From 16c62a97d5dbdaef60ff67c7b1eb5288d8b016c1 Mon Sep 17 00:00:00 2001 From: starainrt Date: Wed, 23 Sep 2026 18:55:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=97=B6=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E5=A4=A9=E8=B1=A1=E5=87=A0=E4=BD=95=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E5=B9=B6=E6=89=A9=E5=B1=95=E8=BE=93=E5=87=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试 --- README.en.md | 2492 +---------------- README.md | 2456 +--------------- astro.go | 81 +- barycentric_time_public_test.go | 69 + basic/ancient_station_regression_test.go | 6 +- basic/apsis.go | 18 +- basic/apsis_test.go | 4 +- basic/barycentric_time.go | 137 + basic/barycentric_time_test.go | 128 + basic/calendar_test.go | 4 +- basic/constellation_regression_test.go | 2 +- basic/constellation_test.go | 6 +- basic/coordinate.go | 50 +- basic/coordinate_topocentric_test.go | 19 +- basic/culmination_anchor_test.go | 12 +- basic/delta_t.go | 203 -- basic/delta_t_model.go | 186 ++ basic/delta_t_model_test.go | 94 + basic/delta_t_test.go | 42 +- basic/diameter.go | 144 +- basic/diameter_test.go | 4 +- basic/eclipse_diagram_test.go | 4 +- basic/greatest_time_contour.go | 4 +- basic/greatest_time_contour_test.go | 2 +- basic/inner_event_window.go | 6 +- basic/inner_planet_event_boundary_test.go | 6 +- basic/inner_planet_truth_test.go | 4 +- basic/julian.go | 46 +- basic/julian_test.go | 10 +- basic/julian_zone_test.go | 28 + basic/jupiter.go | 126 +- basic/jupiter_events.go | 54 +- basic/jupiter_physical.go | 36 +- basic/jupiter_physical_test.go | 6 +- basic/jupiter_satellite_contact_events.go | 12 +- .../jupiter_satellite_contact_events_test.go | 8 +- basic/jupiter_satellite_events.go | 10 +- basic/jupiter_satellite_events_test.go | 10 +- basic/jupiter_satellite_phenomena.go | 10 +- basic/jupiter_satellites.go | 40 +- basic/local_ephemeris.go | 32 +- basic/local_ephemeris_test.go | 32 +- basic/lunar_eclipse.go | 5 +- basic/lunar_eclipse_contact_test.go | 6 +- basic/lunar_eclipse_geometry.go | 6 +- basic/lunar_eclipse_geometry_test.go | 4 +- basic/lunar_eclipse_test.go | 34 +- basic/lunisolar.go | 8 +- basic/mars.go | 126 +- basic/mars_event_helpers_test.go | 2 +- basic/mars_events.go | 54 +- basic/memo_bench_test.go | 2 +- basic/mercury.go | 126 +- basic/mercury_event_helpers_test.go | 2 +- basic/mercury_events.go | 112 +- basic/mercury_retrograde_optimization_test.go | 2 +- basic/mercury_station_regression_test.go | 10 +- basic/moon.go | 80 +- basic/moon_bright_limb.go | 36 +- basic/moon_bright_limb_test.go | 4 +- .../moon_geocentric_apparent_external_test.go | 4 +- basic/moon_geocentric_apparent_test.go | 4 +- basic/moon_horizon.go | 53 +- basic/moon_horizon_test.go | 93 +- basic/moon_max_declination.go | 32 +- basic/moon_max_declination_perf_test.go | 10 +- basic/moon_max_declination_test.go | 50 +- basic/moon_observation.go | 156 +- basic/moon_phase.go | 14 +- basic/moon_physical.go | 48 +- basic/moon_physical_test.go | 2 +- basic/moon_planet_conjunction.go | 2 +- .../moon_planet_conjunction_external_test.go | 66 +- basic/moon_precision.go | 118 +- basic/moon_rise_set_convention_test.go | 12 +- basic/moon_rise_set_external_test.go | 12 +- basic/moon_state_test.go | 17 + basic/moon_test.go | 18 +- basic/moon_topocentric_physical_test.go | 8 +- basic/neptune.go | 126 +- basic/neptune_events.go | 54 +- basic/nutation.go | 4 +- basic/occultation.go | 67 +- basic/occultation_contact_rate_test.go | 80 + basic/occultation_instant.go | 18 +- basic/occultation_isochrone.go | 17 +- basic/occultation_isochrone_test.go | 85 + basic/occultation_limit_separation_test.go | 74 + basic/occultation_path.go | 21 +- basic/occultation_path_ephemeris.go | 2 +- basic/occultation_planet.go | 4 +- basic/occultation_planet_footprint.go | 4 +- basic/occultation_planet_path.go | 17 +- basic/occultation_planet_test.go | 6 +- basic/occultation_rise_set.go | 130 +- basic/occultation_rise_set_vector_test.go | 2 +- basic/occultation_star.go | 96 +- basic/occultation_star_3d_test.go | 339 +++ basic/occultation_star_internal_test.go | 6 +- basic/occultation_station_correction.go | 35 +- basic/orbit_coordinates.go | 88 +- basic/orbit_kepler.go | 16 +- basic/orbit_magnitude.go | 10 +- basic/orbit_observation.go | 46 +- basic/orbit_phase.go | 30 +- basic/orbital_nodes.go | 26 +- basic/outer_planet_event_boundary_test.go | 6 +- basic/outer_planet_event_helpers_test.go | 2 +- basic/outer_planet_truth_test.go | 4 +- basic/parallactic_test.go | 2 +- basic/path_regression_p0_test.go | 14 +- basic/path_regression_p2_test.go | 4 +- basic/planet_apparent.go | 40 +- basic/planet_apparent_external_test.go | 2 +- basic/planet_elongation_objective_test.go | 10 +- basic/planet_event_perf_test.go | 8 +- basic/planet_input_guard_test.go | 12 +- basic/planet_observation_n_test.go | 4 +- basic/planet_phase.go | 98 +- basic/planet_phase_invariant_test.go | 34 +- basic/planet_phase_test.go | 4 +- basic/planet_physical.go | 14 +- basic/planet_physical_test.go | 6 +- basic/planet_rise_set_external_test.go | 4 +- basic/planet_transit.go | 20 +- basic/planet_transit_test.go | 28 +- basic/planet_truncated.go | 226 +- basic/rise_set.go | 2 +- basic/rise_set_curve_test.go | 20 +- basic/rise_set_dynamic_test.go | 14 +- basic/saturn.go | 122 +- basic/saturn_events.go | 54 +- basic/saturn_ring.go | 70 +- basic/sidereal_memo.go | 27 +- basic/sidereal_memo_test.go | 10 +- basic/solar_eclipse.go | 270 +- .../solar_eclipse_11360601_regression_test.go | 20 +- .../solar_eclipse_15001121_regression_test.go | 2 +- .../solar_eclipse_18741010_regression_test.go | 2 +- .../solar_eclipse_43290612_regression_test.go | 2 +- basic/solar_eclipse_band.go | 64 +- basic/solar_eclipse_band_closure_scan.go | 283 ++ basic/solar_eclipse_band_closure_scan_test.go | 108 + basic/solar_eclipse_bessel.go | 257 ++ basic/solar_eclipse_bessel_test.go | 252 ++ basic/solar_eclipse_central_envelope_test.go | 4 +- basic/solar_eclipse_diagram.go | 19 +- ..._eclipse_duration_width_regression_test.go | 4 +- ...solar_eclipse_envelope_containment_test.go | 4 +- basic/solar_eclipse_hybrid_envelope.go | 28 +- basic/solar_eclipse_hybrid_envelope_test.go | 4 +- basic/solar_eclipse_isochrone_test.go | 20 +- basic/solar_eclipse_local.go | 76 +- basic/solar_eclipse_local_test.go | 2 +- basic/solar_eclipse_magnitude.go | 114 +- basic/solar_eclipse_noncentral_band.go | 26 +- basic/solar_eclipse_path.go | 52 +- basic/solar_eclipse_path_geometry.go | 68 +- basic/solar_eclipse_path_test.go | 72 +- .../solar_eclipse_path_width_contract_test.go | 96 + basic/solar_eclipse_perf_bench_test.go | 2 +- basic/solar_eclipse_polar_envelope_test.go | 2 +- basic/solar_eclipse_radius_convention_test.go | 109 + basic/solar_eclipse_review_fixes_test.go | 18 +- basic/solar_eclipse_rise_set.go | 8 +- basic/solar_eclipse_rise_set_arc.go | 12 +- basic/solar_eclipse_rise_set_refine.go | 74 +- basic/solar_eclipse_rise_set_scan_test.go | 4 +- basic/solar_eclipse_rise_set_topology.go | 18 +- basic/solar_eclipse_shadow.go | 42 +- basic/solar_eclipse_shadow_batch_test.go | 2 +- basic/solar_eclipse_shadow_test.go | 22 +- basic/solar_eclipse_test.go | 22 +- basic/solar_eclipse_total_envelope_test.go | 4 +- basic/solar_terms.go | 16 +- basic/star.go | 52 +- basic/star_catalog.go | 60 +- basic/star_catalog_test.go | 2 +- basic/star_motion_3d_test.go | 135 + basic/station_truth_test.go | 14 +- basic/sun.go | 168 +- basic/sun_observation.go | 77 +- basic/sun_observation_n_test.go | 4 +- basic/sun_physical.go | 14 +- basic/sun_physical_test.go | 6 +- basic/sun_test.go | 4 +- basic/timescale.go | 448 +++ basic/timescale_table.go | 334 +++ basic/timescale_test.go | 714 +++++ basic/traditional_calendar.go | 24 +- basic/uranus.go | 126 +- basic/uranus_events.go | 54 +- basic/venus.go | 126 +- basic/venus_event_helpers_test.go | 2 +- basic/venus_events.go | 26 +- calendar/astro.go | 18 +- calendar/chinese.go | 60 +- calendar/chineseAncient.go | 6 +- calendar/chineseCalendricalJieQi.go | 4 +- calendar/chineseQinHan.go | 4 +- calendar/chinese_test.go | 2 +- calendar/julian_only_test.go | 82 +- calendar/reform_roundtrip_test.go | 8 +- calendar/solar_candidates_test.go | 2 +- calendar/time.go | 40 +- civil_event_regression_test.go | 198 ++ coord/coord.go | 45 +- coord/coord_test.go | 47 +- coord/parallactic.go | 2 +- coord/perf_bench_test.go | 7 +- delta_t_model_public_test.go | 81 + .../lunar-eclipse-2026-03-03-detailed-en.svg | 2 + doc/img/lunar-eclipse-2026-03-03-detailed.svg | 2 + doc/{ => img}/lunar-eclipse-2026-03-03-en.svg | 2 +- doc/{ => img}/lunar-eclipse-2026-03-03.svg | 2 +- .../lunar-eclipse-2029-01-01-detailed-en.svg | 2 + doc/img/lunar-eclipse-2029-01-01-detailed.svg | 2 + doc/{ => img}/lunar-eclipse-2029-01-01-en.svg | 2 +- .../lunar-eclipse-2029-01-01-global-en.svg | 1 + doc/img/lunar-eclipse-2029-01-01-global.svg | 1 + doc/{ => img}/lunar-eclipse-2029-01-01.svg | 2 +- ...ultation-hr4799-2025-06-05-detailed-en.svg | 2 +- ...occultation-hr4799-2025-06-05-detailed.svg | 2 +- ...ccultation-hr4799-2025-06-05-global-en.svg | 1 + ...r-occultation-hr4799-2025-06-05-global.svg | 2 +- ...occultation-hr4799-2025-06-05-local-en.svg | 2 +- ...ar-occultation-hr4799-2025-06-05-local.svg | 2 +- ...tation-hr4799-2025-06-05-southpolar-en.svg | 1 + ...cultation-hr4799-2025-06-05-southpolar.svg | 1 + .../solar-eclipse-arctic-2012-global-en.svg | 1 + doc/img/solar-eclipse-arctic-2012-global.svg | 1 + doc/img/solar-eclipse-beijing-2035-en.svg | 1 + .../solar-eclipse-beijing-2035-global-en.svg | 1 + doc/img/solar-eclipse-beijing-2035-global.svg | 1 + doc/img/solar-eclipse-beijing-2035.svg | 1 + ...solar-eclipse-southpolar-2021-12-04-en.svg | 1 + .../solar-eclipse-southpolar-2021-12-04.svg | 1 + doc/img/solar-eclipse-xiamen-2012-en.svg | 1 + .../solar-eclipse-xiamen-2012-global-en.svg | 1 + doc/img/solar-eclipse-xiamen-2012-global.svg | 1 + doc/img/solar-eclipse-xiamen-2012.svg | 1 + doc/img/solar-eclipse-yangshan-2009-en.svg | 1 + .../solar-eclipse-yangshan-2009-global-en.svg | 1 + ...ar-eclipse-yangshan-2009-global-ut1-en.svg | 1 + ...solar-eclipse-yangshan-2009-global-ut1.svg | 1 + .../solar-eclipse-yangshan-2009-global.svg | 1 + .../solar-eclipse-yangshan-2009-globe-en.svg | 1 + doc/img/solar-eclipse-yangshan-2009-globe.svg | 1 + doc/img/solar-eclipse-yangshan-2009.svg | 1 + doc/lunar-eclipse-2026-03-03-detailed-en.svg | 2 - doc/lunar-eclipse-2026-03-03-detailed.svg | 2 - doc/lunar-eclipse-2029-01-01-detailed-en.svg | 2 - doc/lunar-eclipse-2029-01-01-detailed.svg | 2 - doc/lunar-eclipse-2029-01-01-global-en.svg | 1 - doc/lunar-eclipse-2029-01-01-global.svg | 1 - ...ccultation-hr4799-2025-06-05-global-en.svg | 1 - doc/manual/accuracy.md | 93 + doc/manual/calendar.md | 678 +++++ doc/manual/coord.md | 404 +++ doc/manual/eclipse.md | 919 ++++++ doc/manual/en/accuracy.md | 99 + doc/manual/en/calendar.md | 693 +++++ doc/manual/en/coord.md | 424 +++ doc/manual/en/eclipse.md | 992 +++++++ doc/manual/en/formula.md | 341 +++ doc/manual/en/map-geojson.md | 533 ++++ doc/manual/en/occultation.md | 558 ++++ doc/manual/en/orbit.md | 461 +++ doc/manual/en/planets.md | 927 ++++++ doc/manual/en/star.md | 325 +++ doc/manual/en/sun-moon.md | 1057 +++++++ doc/manual/en/sundial.md | 351 +++ doc/manual/en/timescale.md | 219 ++ doc/manual/formula.md | 331 +++ doc/manual/map-geojson.md | 485 ++++ doc/manual/occultation.md | 526 ++++ doc/manual/orbit.md | 441 +++ doc/manual/planets.md | 872 ++++++ doc/manual/star.md | 311 ++ doc/manual/sun-moon.md | 1001 +++++++ doc/manual/sundial.md | 337 +++ doc/manual/timescale.md | 207 ++ doc/solar-eclipse-arctic-2012-global-en.svg | 1 - doc/solar-eclipse-arctic-2012-global.svg | 1 - doc/solar-eclipse-beijing-2035-en.svg | 1 - doc/solar-eclipse-beijing-2035-global-en.svg | 1 - doc/solar-eclipse-beijing-2035-global.svg | 1 - doc/solar-eclipse-beijing-2035.svg | 1 - doc/solar-eclipse-xiamen-2012-en.svg | 1 - doc/solar-eclipse-xiamen-2012-global-en.svg | 1 - doc/solar-eclipse-xiamen-2012-global.svg | 1 - doc/solar-eclipse-xiamen-2012.svg | 1 - doc/solar-eclipse-yangshan-2009-en.svg | 1 - doc/solar-eclipse-yangshan-2009-global-en.svg | 1 - doc/solar-eclipse-yangshan-2009-global.svg | 1 - doc/solar-eclipse-yangshan-2009-globe-en.svg | 1 - doc/solar-eclipse-yangshan-2009-globe.svg | 1 - doc/solar-eclipse-yangshan-2009.svg | 1 - earth/apsis.go | 2 +- earth/apsis_test.go | 8 +- earth/earth.go | 4 +- eclipse/label_ut1.go | 165 ++ eclipse/lunar.go | 16 +- eclipse/lunar_local.go | 139 +- eclipse/lunar_local_test.go | 113 +- eclipse/lunar_panel.go | 2 +- eclipse/lunar_test.go | 4 +- eclipse/observation_helpers.go | 43 +- eclipse/provenance_test.go | 47 + eclipse/saros.go | 6 +- eclipse/saros_anchor_consistency_test.go | 6 +- eclipse/saros_extended.go | 2 +- eclipse/saros_extended_test.go | 3 +- eclipse/saros_generate_test.go | 14 + eclipse/saros_table_extended.go | 4 +- eclipse/search_skip_test.go | 4 +- eclipse/solar.go | 98 +- eclipse/solar_bessel.go | 105 + eclipse/solar_bessel_test.go | 62 + eclipse/solar_local.go | 38 +- eclipse/solar_local_radius_convention_test.go | 32 + eclipse/solar_local_test.go | 4 +- eclipse/solar_panel.go | 58 +- eclipse/solar_panel_test.go | 39 +- eclipse/solar_path.go | 7 + eclipse/solar_path_width_contract_test.go | 55 + eclipse/solar_radius_convention_test.go | 71 + eclipse/solar_shadow.go | 20 +- eclipse/solar_shadow_test.go | 16 +- eclipse/stateless_exports_contract_test.go | 4 +- eclipse/svg/footer_margin_contract_test.go | 232 ++ eclipse/svg/lunar.go | 94 +- eclipse/svg/lunar_detailed.go | 63 +- eclipse/svg/lunar_detailed_test.go | 2 +- eclipse/svg/lunar_geocentric_maximum_test.go | 75 + eclipse/svg/lunar_map.go | 274 +- eclipse/svg/lunar_map_orthographic_test.go | 149 + eclipse/svg/lunar_map_partition_test.go | 457 +++ eclipse/svg/lunar_map_polar_test.go | 2 +- eclipse/svg/lunar_map_test.go | 18 +- eclipse/svg/lunar_model.go | 26 +- eclipse/svg/lunar_penumbral_phase_test.go | 419 +++ eclipse/svg/lunar_timescale_test.go | 191 ++ eclipse/svg/solar.go | 109 +- .../svg/solar_local_footer_balance_test.go | 129 + eclipse/svg/solar_map.go | 76 +- eclipse/svg/solar_map_globe_test.go | 8 +- ...olar_map_isochrone_levels_contract_test.go | 6 +- eclipse/svg/solar_map_labels.go | 3 +- eclipse/svg/solar_map_panel_rows.go | 7 +- eclipse/svg/solar_map_projected_fill_test.go | 198 ++ eclipse/svg/solar_map_timescale_test.go | 55 + eclipse/svg/solar_map_width_contract_test.go | 90 + eclipse/svg/solar_model.go | 13 +- event_boundary_public_test.go | 6 +- formula/distance.go | 22 + formula/distance_test.go | 19 + geojson/eclipse.go | 834 +++++- geojson/eclipse_path_width_defined_test.go | 107 + geojson/geojson.go | 49 +- geojson/geojson_test.go | 148 +- geojson/lunar_horizon_test.go | 295 +- geojson/lunar_penumbra_band_test.go | 188 ++ geojson/lunar_visibility_ring_test.go | 14 +- geojson/occultation.go | 19 +- geojson/occultation_timescale_test.go | 117 + geojson/path_regression_p2_test.go | 4 +- .../solar_eclipse_20120521_regression_test.go | 8 +- ...olar_eclipse_central_shadow_region_test.go | 4 +- ...ar_eclipse_grazing_band_regression_test.go | 8 +- geojson/solar_eclipse_options_test.go | 121 + geojson/solar_isochrone_seam_test.go | 64 + geojson/solar_shadow_instant.go | 2 +- geojson/solar_shadow_region.go | 9 +- geojson/ut1_geometry_test.go | 153 + internal/civiltime/event.go | 36 + internal/civiltime/event_test.go | 55 + internal/geodata/seam_test.go | 5 +- internal/geodata/topology.go | 22 +- internal/lunarhorizon/lunarhorizon.go | 6 +- internal/svgchart/text.go | 95 +- internal/svgchart/text_test.go | 138 + internal/svgmap/map.go | 23 +- internal/svgmap/seam_test.go | 92 + internal/timenote/timenote.go | 120 + internal/timenote/timenote_test.go | 64 + jupiter/diameter.go | 8 +- jupiter/jupiter.go | 129 +- jupiter/nodes.go | 8 +- jupiter/phase.go | 2 +- jupiter/phenomena.go | 2 +- jupiter/phenomena_contact_events.go | 14 +- jupiter/phenomena_contact_events_test.go | 10 +- jupiter/phenomena_events.go | 12 +- jupiter/phenomena_events_test.go | 12 +- jupiter/phenomena_test.go | 2 +- jupiter/physical.go | 14 +- jupiter/physical_test.go | 8 +- jupiter/satellites.go | 2 +- jupiter/satellites_test.go | 2 +- jupiter/truncated.go | 74 +- kml/kml.go | 1477 ++++++++++ kml/kml_test.go | 1221 ++++++++ lite/internal/common.go | 14 +- lite/internal/sun.go | 45 +- lite/moon/moon.go | 43 +- lite/moon/moon_test.go | 2 +- lite/moon/perf_bench_test.go | 2 +- lite/sun/sun.go | 47 +- mars/diameter.go | 8 +- mars/mars.go | 129 +- mars/nodes.go | 8 +- mars/phase.go | 2 +- mars/physical.go | 4 +- mars/physical_test.go | 4 +- mars/truncated.go | 74 +- mercury/diameter.go | 8 +- mercury/mercury.go | 153 +- mercury/nodes.go | 8 +- mercury/phase.go | 2 +- mercury/physical.go | 4 +- mercury/physical_test.go | 4 +- mercury/transit.go | 16 +- mercury/truncated.go | 74 +- moon/apsis.go | 2 +- moon/apsis_test.go | 4 +- moon/conjunction.go | 12 +- moon/conjunction_test.go | 4 +- moon/diameter.go | 8 +- moon/geocentric_apparent_test.go | 2 +- moon/max_declination.go | 4 +- moon/max_declination_test.go | 20 +- moon/moon.go | 157 +- moon/nodes.go | 4 +- moon/occultation_star_test.go | 4 +- moon/occultation_ut1.go | 131 + moon/phase.go | 4 +- moon/physical.go | 4 +- moon/physical_test.go | 4 +- moon/physical_topocentric.go | 2 +- moon/svg/footer_margin_contract_test.go | 107 + moon/svg/occultation.go | 44 +- moon/svg/occultation_detailed.go | 68 +- moon/svg/occultation_detailed_test.go | 2 +- moon/svg/occultation_local.go | 96 +- .../occultation_local_footer_balance_test.go | 176 ++ moon/svg/occultation_local_labels.go | 79 + moon/svg/occultation_local_labels_test.go | 88 + moon/svg/occultation_planet.go | 8 + moon/svg/occultation_planet_local.go | 55 +- moon/svg/occultation_svg_support_test.go | 3 + moon/svg/occultation_timescale_test.go | 151 + neptune/diameter.go | 8 +- neptune/neptune.go | 129 +- neptune/nodes.go | 8 +- neptune/phase.go | 2 +- neptune/physical.go | 4 +- neptune/physical_test.go | 4 +- neptune/truncated.go | 74 +- orbit/orbit.go | 53 +- orbit/orbit_test.go | 8 +- orbit/parallactic.go | 2 +- orbit/parallactic_test.go | 12 +- planet/doc.go | 4 +- planet/moon_low.go | 68 +- planet/planet.go | 10 +- planet/sun_low.go | 40 +- saturn/diameter.go | 8 +- saturn/nodes.go | 8 +- saturn/phase.go | 2 +- saturn/physical.go | 4 +- saturn/physical_test.go | 4 +- saturn/ring.go | 4 +- saturn/saturn.go | 129 +- saturn/truncated.go | 74 +- semantics_regression_test.go | 12 +- star/parallactic.go | 4 +- star/star.go | 67 +- sun/diameter.go | 8 +- sun/parallactic.go | 4 +- sun/physical.go | 4 +- sun/physical_test.go | 4 +- sun/sun.go | 233 +- timescale.go | 87 + timescale_public_test.go | 156 ++ tools/distance.go | 40 + tools/distance_test.go | 49 + uranus/diameter.go | 8 +- uranus/nodes.go | 8 +- uranus/phase.go | 2 +- uranus/physical.go | 4 +- uranus/physical_test.go | 4 +- uranus/truncated.go | 74 +- uranus/uranus.go | 129 +- ut1_cover_test.go | 203 ++ venus/diameter.go | 8 +- venus/nodes.go | 8 +- venus/phase.go | 2 +- venus/physical.go | 4 +- venus/physical_test.go | 4 +- venus/transit.go | 16 +- venus/truncated.go | 74 +- venus/venus.go | 153 +- 503 files changed, 33290 insertions(+), 9471 deletions(-) create mode 100644 barycentric_time_public_test.go create mode 100644 basic/barycentric_time.go create mode 100644 basic/barycentric_time_test.go delete mode 100644 basic/delta_t.go create mode 100644 basic/delta_t_model.go create mode 100644 basic/delta_t_model_test.go create mode 100644 basic/julian_zone_test.go create mode 100644 basic/moon_state_test.go create mode 100644 basic/occultation_contact_rate_test.go create mode 100644 basic/occultation_star_3d_test.go create mode 100644 basic/solar_eclipse_band_closure_scan.go create mode 100644 basic/solar_eclipse_band_closure_scan_test.go create mode 100644 basic/solar_eclipse_bessel.go create mode 100644 basic/solar_eclipse_bessel_test.go create mode 100644 basic/solar_eclipse_path_width_contract_test.go create mode 100644 basic/solar_eclipse_radius_convention_test.go create mode 100644 basic/star_motion_3d_test.go create mode 100644 basic/timescale.go create mode 100644 basic/timescale_table.go create mode 100644 basic/timescale_test.go create mode 100644 civil_event_regression_test.go create mode 100644 delta_t_model_public_test.go create mode 100644 doc/img/lunar-eclipse-2026-03-03-detailed-en.svg create mode 100644 doc/img/lunar-eclipse-2026-03-03-detailed.svg rename doc/{ => img}/lunar-eclipse-2026-03-03-en.svg (68%) rename doc/{ => img}/lunar-eclipse-2026-03-03.svg (68%) create mode 100644 doc/img/lunar-eclipse-2029-01-01-detailed-en.svg create mode 100644 doc/img/lunar-eclipse-2029-01-01-detailed.svg rename doc/{ => img}/lunar-eclipse-2029-01-01-en.svg (68%) create mode 100644 doc/img/lunar-eclipse-2029-01-01-global-en.svg create mode 100644 doc/img/lunar-eclipse-2029-01-01-global.svg rename doc/{ => img}/lunar-eclipse-2029-01-01.svg (68%) rename doc/{ => img}/lunar-occultation-hr4799-2025-06-05-detailed-en.svg (98%) rename doc/{ => img}/lunar-occultation-hr4799-2025-06-05-detailed.svg (98%) create mode 100644 doc/img/lunar-occultation-hr4799-2025-06-05-global-en.svg rename doc/{ => img}/lunar-occultation-hr4799-2025-06-05-global.svg (64%) rename doc/{ => img}/lunar-occultation-hr4799-2025-06-05-local-en.svg (57%) rename doc/{ => img}/lunar-occultation-hr4799-2025-06-05-local.svg (52%) create mode 100644 doc/img/lunar-occultation-hr4799-2025-06-05-southpolar-en.svg create mode 100644 doc/img/lunar-occultation-hr4799-2025-06-05-southpolar.svg create mode 100644 doc/img/solar-eclipse-arctic-2012-global-en.svg create mode 100644 doc/img/solar-eclipse-arctic-2012-global.svg create mode 100644 doc/img/solar-eclipse-beijing-2035-en.svg create mode 100644 doc/img/solar-eclipse-beijing-2035-global-en.svg create mode 100644 doc/img/solar-eclipse-beijing-2035-global.svg create mode 100644 doc/img/solar-eclipse-beijing-2035.svg create mode 100644 doc/img/solar-eclipse-southpolar-2021-12-04-en.svg create mode 100644 doc/img/solar-eclipse-southpolar-2021-12-04.svg create mode 100644 doc/img/solar-eclipse-xiamen-2012-en.svg create mode 100644 doc/img/solar-eclipse-xiamen-2012-global-en.svg create mode 100644 doc/img/solar-eclipse-xiamen-2012-global.svg create mode 100644 doc/img/solar-eclipse-xiamen-2012.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-en.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-global-en.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-global-ut1-en.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-global-ut1.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-global.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-globe-en.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009-globe.svg create mode 100644 doc/img/solar-eclipse-yangshan-2009.svg delete mode 100644 doc/lunar-eclipse-2026-03-03-detailed-en.svg delete mode 100644 doc/lunar-eclipse-2026-03-03-detailed.svg delete mode 100644 doc/lunar-eclipse-2029-01-01-detailed-en.svg delete mode 100644 doc/lunar-eclipse-2029-01-01-detailed.svg delete mode 100644 doc/lunar-eclipse-2029-01-01-global-en.svg delete mode 100644 doc/lunar-eclipse-2029-01-01-global.svg delete mode 100644 doc/lunar-occultation-hr4799-2025-06-05-global-en.svg create mode 100644 doc/manual/accuracy.md create mode 100644 doc/manual/calendar.md create mode 100644 doc/manual/coord.md create mode 100644 doc/manual/eclipse.md create mode 100644 doc/manual/en/accuracy.md create mode 100644 doc/manual/en/calendar.md create mode 100644 doc/manual/en/coord.md create mode 100644 doc/manual/en/eclipse.md create mode 100644 doc/manual/en/formula.md create mode 100644 doc/manual/en/map-geojson.md create mode 100644 doc/manual/en/occultation.md create mode 100644 doc/manual/en/orbit.md create mode 100644 doc/manual/en/planets.md create mode 100644 doc/manual/en/star.md create mode 100644 doc/manual/en/sun-moon.md create mode 100644 doc/manual/en/sundial.md create mode 100644 doc/manual/en/timescale.md create mode 100644 doc/manual/formula.md create mode 100644 doc/manual/map-geojson.md create mode 100644 doc/manual/occultation.md create mode 100644 doc/manual/orbit.md create mode 100644 doc/manual/planets.md create mode 100644 doc/manual/star.md create mode 100644 doc/manual/sun-moon.md create mode 100644 doc/manual/sundial.md create mode 100644 doc/manual/timescale.md delete mode 100644 doc/solar-eclipse-arctic-2012-global-en.svg delete mode 100644 doc/solar-eclipse-arctic-2012-global.svg delete mode 100644 doc/solar-eclipse-beijing-2035-en.svg delete mode 100644 doc/solar-eclipse-beijing-2035-global-en.svg delete mode 100644 doc/solar-eclipse-beijing-2035-global.svg delete mode 100644 doc/solar-eclipse-beijing-2035.svg delete mode 100644 doc/solar-eclipse-xiamen-2012-en.svg delete mode 100644 doc/solar-eclipse-xiamen-2012-global-en.svg delete mode 100644 doc/solar-eclipse-xiamen-2012-global.svg delete mode 100644 doc/solar-eclipse-xiamen-2012.svg delete mode 100644 doc/solar-eclipse-yangshan-2009-en.svg delete mode 100644 doc/solar-eclipse-yangshan-2009-global-en.svg delete mode 100644 doc/solar-eclipse-yangshan-2009-global.svg delete mode 100644 doc/solar-eclipse-yangshan-2009-globe-en.svg delete mode 100644 doc/solar-eclipse-yangshan-2009-globe.svg delete mode 100644 doc/solar-eclipse-yangshan-2009.svg create mode 100644 eclipse/label_ut1.go create mode 100644 eclipse/provenance_test.go create mode 100644 eclipse/solar_bessel.go create mode 100644 eclipse/solar_bessel_test.go create mode 100644 eclipse/solar_local_radius_convention_test.go create mode 100644 eclipse/solar_path_width_contract_test.go create mode 100644 eclipse/solar_radius_convention_test.go create mode 100644 eclipse/svg/footer_margin_contract_test.go create mode 100644 eclipse/svg/lunar_geocentric_maximum_test.go create mode 100644 eclipse/svg/lunar_map_orthographic_test.go create mode 100644 eclipse/svg/lunar_map_partition_test.go create mode 100644 eclipse/svg/lunar_penumbral_phase_test.go create mode 100644 eclipse/svg/lunar_timescale_test.go create mode 100644 eclipse/svg/solar_local_footer_balance_test.go create mode 100644 eclipse/svg/solar_map_projected_fill_test.go create mode 100644 eclipse/svg/solar_map_timescale_test.go create mode 100644 eclipse/svg/solar_map_width_contract_test.go create mode 100644 formula/distance.go create mode 100644 formula/distance_test.go create mode 100644 geojson/eclipse_path_width_defined_test.go create mode 100644 geojson/lunar_penumbra_band_test.go create mode 100644 geojson/occultation_timescale_test.go create mode 100644 geojson/solar_eclipse_options_test.go create mode 100644 geojson/solar_isochrone_seam_test.go create mode 100644 geojson/ut1_geometry_test.go create mode 100644 internal/civiltime/event.go create mode 100644 internal/civiltime/event_test.go create mode 100644 internal/svgmap/seam_test.go create mode 100644 internal/timenote/timenote.go create mode 100644 internal/timenote/timenote_test.go create mode 100644 kml/kml.go create mode 100644 kml/kml_test.go create mode 100644 moon/occultation_ut1.go create mode 100644 moon/svg/footer_margin_contract_test.go create mode 100644 moon/svg/occultation_local_footer_balance_test.go create mode 100644 moon/svg/occultation_local_labels.go create mode 100644 moon/svg/occultation_local_labels_test.go create mode 100644 moon/svg/occultation_timescale_test.go create mode 100644 timescale.go create mode 100644 timescale_public_test.go create mode 100644 tools/distance.go create mode 100644 tools/distance_test.go create mode 100644 ut1_cover_test.go diff --git a/README.en.md b/README.en.md index 5460404..10b3765 100644 --- a/README.en.md +++ b/README.en.md @@ -4,2436 +4,106 @@ [![Go Reference](https://pkg.go.dev/badge/b612.me/astro.svg)](https://pkg.go.dev/b612.me/astro) -A personal astronomy library developed over years for calendrical-astronomy hobby work. +An astronomy library I have used for years for my own interest in astronomy and calendars. -> 📚 This project is mainly for learning and validating astronomical algorithms. The results are intended for serious amateur use. +Based on Jean Meeus's *Astronomical Algorithms*. Solar and planetary calculations use built-in VSOP87 terms; lunar calculations use ELP2000/82-style truncated series. No external ephemeris files are required. -The implementation follows *Astronomical Algorithms*; the covered scope is listed in [Highlights](#highlights) below. - -The Sun and planets use built-in VSOP87-style analytical terms, while the Moon uses a built-in truncated ELP2000/82-style analytical series. No external JPL ephemeris files are required. - -Unless noted otherwise, coordinates are apparent-of-date coordinates. Angles are in degrees, apparent diameters and semidiameters are in arcseconds, and distances use the unit implied by the function name, usually `AU` or `km`. - -## Contents - -- [Install](#install) -- [Highlights](#highlights) -- [Package Overview](#package-overview) -- [Scope And Accuracy](#scope-and-accuracy) -- [Quick Start](#quick-start) - - [Calendar And Solar Terms](#calendar-and-solar-terms) - - [Sun And Moon](#sun-and-moon) - - [Lite Sun And Moon](#lite-sun-and-moon) - - [Lunar Occultations](#lunar-occultations) - - [Event Maps And GeoJSON](#event-maps-and-geojson) - - [Planets](#planets) - - [Stars](#stars) - - [Coordinate Tools](#coordinate-tools) - - [Formula Helpers](#formula-helpers) - - [Generic Small-Body Orbits](#generic-small-body-orbits) - - [Sundial And Apparent Solar Time](#sundial-and-apparent-solar-time) -- [Implemented](#implemented) -- [TODO](#todo) +Intended for calendar calculations, amateur observation and studying astronomical algorithms. ## Install -```bash +```sh go get b612.me/astro ``` ## Highlights -- 📅 Calendar conversion between Gregorian dates and the traditional Chinese lunisolar calendar, from 721 BCE through 3000 CE, including solar terms -- 🌞 Solar position, rise/set, Earth distance, apparent solar time, apparent altitude, parallactic angle, solar `P/B0/L0`, apparent diameter -- 🌙 Lunar position, rise/set, Earth distance, phase, new/full/quarter times, apparent diameter, bright-limb angle, parallactic angle, geocentric/topocentric libration, apsides, nodes, maximum declination -- 🪶 `lite/sun` and `lite/moon` lightweight approximation chains for watches, frontends, mini programs, and other resource-constrained environments, covering sky position, rise/set, and phase -- 🌗 Global and local solar/lunar eclipses, solar central paths, partial footprints, visible local lunar eclipses, Saros metadata, local diagrams, and global visibility-map SVGs -- 🌘 Point-source stellar and finite-disk planetary lunar occultations, with fixed-site contacts, global paths, geometric greatest points, and SVG output -- 🗺️ GeoJSON for solar eclipses, lunar eclipses, and lunar occultations, including timed paths and optional time-marker points; border-free global SVG maps support equirectangular and polar projections -- 🪐 Seven major planets with positions, rise/set, conjunction/opposition/station events, quadratures, elongations, Mercury/Venus geocentric transits, nodes, phase, apparent magnitude, apparent diameter, parallactic angle, and physical ephemerides -- ⭐ A built-in catalog of 9100 stars plus constellation lookup, proper-motion propagation, rise/set, parallactic angle, and apparent altitude -- 🧭 Coordinate transforms, topocentric coordinates, sidereal time, precession, nutation, angular distance, refraction, airmass, parallactic angle, and Galactic coordinates -- 🔭 Standalone formulas for blackbody radiation, synodic periods, photometry, telescope limiting magnitude, stellar radius/temperature/luminosity relations, and airmass models -- ☄️ Generic heliocentric two-body orbit propagation for asteroids, comets, and hypothetical objects, including phase/photometry helpers and visual-binary position solving -- 🕰️ Apparent/mean solar time, solar hour angle, mean-time/zone-time hour-angle helpers, planar-sundial geometry, and equatorial/horizontal/vertical dial helpers +- Civil and Chinese calendar conversion from 721 BCE to 3000 CE, solar terms, sexagenary dates, era names and historical calendars. +- Positions, rise/set, transit, distances, angular diameters and physical ephemerides for the Sun, Moon and seven planets; lightweight Sun and Moon algorithms are also available. +- Solar and lunar eclipses, stellar and planetary lunar occultations, local contacts and global paths. +- SVG charts, GeoJSON data and KML for viewing and time playback in Google Earth. +- A built-in catalog of 9,100 stars, with constellation lookup, coordinate corrections and rise/set calculations. +- Coordinate transformations, sidereal time, precession, nutation, refraction, small-body orbits, sundials and astronomical formulas. + +## Example + +Calculate sunrise in Xi'an, the lunar phase and the Chinese calendar date: + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/calendar" + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 2, 17, 12, 0, 0, 0, cst) + + rise, err := sun.RiseTime(date, 108.93, 34.27, 0, true) + if err != nil { + log.Fatal(err) + } + fmt.Println("Sunrise:", rise.Format("15:04:05")) + fmt.Println("Lunar phase:", moon.PhaseDesc(date)) + + day, err := calendar.SolarToLunar(date) + if err != nil { + log.Fatal(err) + } + fmt.Println("Chinese calendar:", day.Lunar().MonthDay()) +} +``` + +Longitude and latitude are in degrees, positive east and north. The example uses a height of 0 metres and enables refraction with `true`. Rise/set functions return errors for polar conditions or a missing event on that date. + +Calendar and phase descriptions in this example are returned in Chinese. ## Package Overview -| Package | What it provides | +| Package | Purpose and documentation | | --- | --- | -| `calendar` | Gregorian/lunisolar conversion, solar terms, historical era names, old-calendar metadata | -| `coord` | Ecliptic/equatorial/horizontal transforms, sidereal time, precession, nutation, topocentric helpers, refraction, airmass, parallactic angle, Galactic coordinates, and research helpers with manual obliquity/hour angle | -| `sun` | Solar position, rise/set, twilight, equation of time, apparent solar time, apparent altitude, parallactic angle, diameter, solar `P/B0/L0` | -| `moon` | Lunar position, rise/set, phases, new/full/quarter times, apparent altitude, parallactic angle, diameter, bright-limb angle, geocentric/topocentric libration, apsides, nodes, maximum declination, and stellar/planetary lunar occultations with global paths | -| `lite/sun` / `lite/moon` | Lightweight Sun/Moon approximation chains for minute-level rise/set, lightweight sky position, and lunar-phase work | -| `eclipse` / `eclipse/svg` | Global/local solar and lunar eclipses, solar central paths, partial footprints, local visibility filtering, Saros metadata, local diagrams, and global visibility-map SVGs | -| `moon/svg` | Fixed-site stellar/planetary lunar-occultation disk charts and projected global maps with bands, center lines, and time labels | -| `geojson` | RFC 7946 encoding for existing solar-eclipse, lunar-eclipse, and lunar-occultation geographic results | -| `mercury` / `venus` | Positions, rise/set, conjunctions, stations, elongations, geocentric transits, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides | -| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | Positions, rise/set, conjunction/opposition, stations, quadratures, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides | -| `earth` | Earth orbital eccentricity, perihelion, aphelion | -| `star` | Constellation lookup, star catalog, proper motion / precession / nutation correction, stellar rise/set, parallactic angle, apparent altitude | -| `formula` | Date-independent astronomy and olympiad-style formulas, including pure airmass models | -| `orbit` | Generic heliocentric conic propagation for elliptical, near-parabolic, parabolic, and hyperbolic orbits, plus phase/photometry helpers and a lightweight visual-binary solver | -| `sundial` | Apparent/mean solar time, solar hour angle, mean-time/zone-time hour-angle helpers, planar geometry, time-line and declination-curve sampling, equatorial/horizontal/vertical dial helpers | +| `calendar` | [Calendar conversion, solar terms, sexagenary dates and historical calendars](doc/manual/en/calendar.md) | +| `sun` / `moon` / `lite/sun` / `lite/moon` | [Sun and Moon positions, rise/set, phases and physical quantities](doc/manual/en/sun-moon.md) | +| `mercury` / `venus` / `mars` / `jupiter` / `saturn` / `uranus` / `neptune` / `earth` | [Planetary positions, events, Jupiter's satellites and Saturn's rings](doc/manual/en/planets.md) | +| `eclipse` / `eclipse/svg` | [Eclipse searches, local visibility, paths and charts](doc/manual/en/eclipse.md) | +| `moon` / `moon/svg` | [Stellar and planetary lunar occultations, contacts and bands](doc/manual/en/occultation.md) | +| `geojson` / `kml` | [Event maps, GeoJSON and KML](doc/manual/en/map-geojson.md) | +| `star` | [Star catalog, constellations and observing quantities](doc/manual/en/star.md) | +| `coord` | [Coordinates, sidereal time, precession, nutation and refraction](doc/manual/en/coord.md) | +| `orbit` | [Asteroid and comet two-body orbits, visual binaries](doc/manual/en/orbit.md) | +| `sundial` | [Apparent solar time and sundial geometry](doc/manual/en/sundial.md) | +| `formula` | [Radiation, magnitudes, synodic periods and telescope formulas](doc/manual/en/formula.md) | +| `astro` (root) | [UTC, UT1, TT and ΔT models](doc/manual/en/timescale.md) | -Some entry points also provide `...N` truncated variants: +`basic` contains the underlying algorithms, `planet` holds analytical series, and `tools` provides numerical helpers. Most applications can use the packages above. -- `n < 0`: use all built-in terms embedded in this repository -- `n >= 0`: truncate the series, useful for performance comparisons, rough estimates, or algorithm studies +Full signatures are also available in the [Go API documentation](https://pkg.go.dev/b612.me/astro). -"All built-in terms" means the table entries shipped inside this package. It does not mean the complete original external VSOP/ELP long tables. +## Time Scale Conventions + +Most observing APIs accept a civil instant as `time.Time` and handle UTC, UT1 and TT conversions internally. Before 1972 the library treats civil time as UT1, and as UTC thereafter. Orbital epochs and some low-level APIs have separate scale requirements. + +Angles default to degrees, angular diameters to arcseconds, sidereal time to hours, and distances to AU or km as specified by the API. SVG and GeoJSON time labels default to UTC, with explicit UT1 options. + +See [Time scales](doc/manual/en/timescale.md). + +DUT1 and TT−UTC default to extrapolating the current leap-second rule beyond the observed window (through September 2026); `SetTimeScaleFuturePolicy` switches the TT−UTC convention to one of four alternatives: a frozen offset, continued UT1 tracking, the leap-hour rule, or a civil scale permanently identical to UT1. + +## Observer Height Convention + +Observer height is ellipsoidal height in metres. Convert orthometric height `H` using the local geoid undulation `N`: `height = H + N`. See [Observer height](doc/manual/en/coord.md#observer-height). ## Scope And Accuracy -### Sun and planets +The library uses analytical models and truncated series. Accuracy varies with date, body and calculation. Professional occultation predictions and spacecraft navigation require more precise ephemerides and physical models. -The Sun and planets use built-in VSOP87 analytical terms. The current table entries cover roughly 4000 years around J2000. The table below lists truncation errors relative to the complete VSOP87 tables: +`lite/sun` and `lite/moon` suit resource-constrained applications. Some main APIs also provide truncated variants with an `N` suffix. -| Target | Longitude / latitude | Distance | -| --- | --- | --- | -| Sun / Earth | about `0.1"` | about `0.1 x 10^-6 AU` | -| Mercury, Venus | about `0.2"` | about `0.2 x 10^-6 AU` | -| Mars | about `0.5"` | about `1 x 10^-6 AU` | -| Jupiter | about `0.5"` | about `3 x 10^-6 AU` | -| Saturn | about `0.5"` | about `5 x 10^-6 AU` | -| Uranus | about `1"` | about `20 x 10^-6 AU` | -| Neptune | about `1"` | about `40 x 10^-6 AU` | - -This is suitable for ordinary calendrical work, observing support, outreach, and personal research; spacecraft navigation, precise occultation prediction, and strict dynamical integration fall outside that range and usually need a professional ephemeris such as JPL DE. - -### Moon - -The Moon uses a built-in truncated ELP2000/82-style analytical series. The package stays lightweight and does not require external ephemeris files. - -It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, amateur occultation prediction, and ordinary positional work; extremely high-precision lunar laser ranging, long-term physical libration, and professional occultation work fall outside that range and are best served by JPL or a dedicated lunar ephemeris. - -The four principal phases keep the historical pinyin names and also expose English aliases: - -- `ShuoYue` / `NewMoon` -- `WangYue` / `FullMoon` -- `ShangXianYue` / `FirstQuarter` -- `XiaXianYue` / `LastQuarter` - -The matching `Next*`, `Last*`, and `Closest*` helpers are available in both naming styles. - -### Lite lightweight chains - -`lite/sun` and `lite/moon` are independent approximation chains. They do not depend on the VSOP87 or ELP2000/82 series used by `sun` / `moon`, and are intended for CPU- or memory-constrained environments. - -- `lite/sun`: simplified true/apparent solar longitude formulas plus lightweight equatorial conversion -- `lite/moon`: Schlyter-style lunar approximation with about 15 perturbation terms plus lightweight topocentric correction -- rise/set search: fixed-step scanning plus bisection, without the high-precision nutation iteration used by the main chain -- zero heap allocation in the computation path (0 allocs/op); against the main chain, pure evaluation entry points such as position and phase run about `8.3-27.3x` faster, and rise/set entry points about `1.0-3.7x` - -| Package | Position model | Rise/set search | Main use | -| --- | --- | --- | --- | -| `lite/sun` | simplified true/apparent solar longitude plus lightweight equatorial conversion | `30 min` scan plus bisection | sunrise/sunset, solar altitude, watch faces, frontend refresh loops | -| `lite/moon` | Schlyter / vFPS lunar approximation plus lightweight topocentric correction | `15 min` scan plus bisection | moonrise/moonset, lunar phase, lunar age, lightweight lunar observing helpers | - -Error against the `sun` / `moon` packages (year 2026, 8 observing sites; rise/set sampled every 7 or 15 days, phase/age every 6 hours): - -| Capability | Mean absolute error | P95 | Max absolute error | Notes | -| --- | --- | --- | --- | --- | -| `lite/sun` sunrise | `0.02 min` | `0.04 min` | `0.31 min` | no event-existence mismatch in the sample set | -| `lite/sun` sunset | `0.02 min` | `0.06 min` | `0.35 min` | `2` high-latitude samples differ only in day-attribution semantics across midnight | -| `lite/moon` moonrise | `0.28 min` | `0.57 min` | `1.44 min` | no event-existence mismatch in the sample set | -| `lite/moon` moonset | `0.36 min` | `0.86 min` | `1.24 min` | `1` high-latitude sample differs on whether the moonset belongs to the same civil day | -| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | compared with `moon.Phase` | -| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | about 4.3 min mean, 14.4 min P95, 20.2 min max | -| `lite/moon` geocentric longitude | `2.41'` | `6.82'` | `9.91'` | relative to the main lunar chain | -| `lite/moon` geocentric latitude | `0.87'` | `1.83'` | `2.92'` | relative to the main lunar chain | - -`Go testing.Benchmark` reference values (single-machine measurements, for reference only; absolute values vary with hardware): - -The measurements use 2026-01-01 20:00 CST, Shanghai (`121.4737°E, 31.2304°N`), `height=0`, `aero=true`, and take the median of 3 runs; each benchmark is warmed up once, so lazy-loading caches and first-call allocations stay out of the steady-state per-call cost. - -| Entry point | Main chain | `lite` | Speedup | Main-chain allocation | `lite` allocation | -| --- | --- | --- | --- | --- | --- | -| `Sun ApparentRaDec` | `5.888 µs/op` | `215.6 ns/op` | `27.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun Altitude` | `5.955 µs/op` | `625.9 ns/op` | `9.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun RiseTime` | `95.847 µs/op` | `25.648 µs/op` | `3.7x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon ApparentRaDec` | `16.520 µs/op` | `1.006 µs/op` | `16.4x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Phase` | `15.139 µs/op` | `917.7 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Altitude` | `9.533 µs/op` | `1.150 µs/op` | `8.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon RiseTime` | `120.545 µs/op` | `118.037 µs/op` | `1.0x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | - -The main-chain/`lite` gap depends on the scenario: pure evaluation entry points (position, phase) run about `8.3-27.3x` faster in `lite`, while rise/set entry points narrow to `1.0-3.7x` because both sides perform a time search (`Moon RiseTime` is nearly level). The speedup ratios come from a same-machine comparison and are affected by hardware less than the absolute values are. - -Use the main `sun` / `moon` chains for eclipses, physical libration, or high-latitude edge cases. - -### Accuracy references - -The following entry points have been checked against JPL Horizons, NASA GSFC, IMCCE, and other public references; use them to judge the order of magnitude to expect: - -- apparent diameters of the Sun, planets, and Moon: maximum differences from the external baseline range from `0.000002"` to `0.194598"` depending on the body; the Moon is the most sensitive because of parallax and distance changes -- solar physical ephemerides `P/B0/L0`: maximum differences are about `0.003349° / 0.003986° / 0.047394°` -- planetary rise, transit, and set: checked against JPL Horizons Time-Varying Hourly (TVH) events; that baseline is generated at a 1-minute step, and current results align with the Horizons event times at the minute level -- Moon rise/set: `aero=true` uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing. Across 14 sea-level events at 7 sites, the current mean/maximum differences against JPL Horizons DE441 are about `0.30s / 0.75s`. -- Moon rise/set with other conventions: mean/maximum differences are about `38.77s / 76.22s` against MET Norway's fixed `-0.8333°` convention. Against IMCCE Miriade, whose horizon convention is not exposed, the mean is about `2m13.46s`; the grazing `61°N` sample reaches about `6m41.82s`. -- Earth perihelion and aphelion: maximum time difference about `1m28.84s`, maximum distance difference about `0.000000039837 AU` -- main-chain lunar position: the current algorithm is a truncated ELP2000/82-style analytical series; across four JPL/Horizons `JDTT` samples in year `-2000`, the maximum difference from JPL/Horizons is about `219.6"` in longitude, `25.8"` in latitude, and `34.3 km` in distance -- Moon perigee and apogee: maximum time difference about `15m53.45s`, maximum distance difference about `39.758 km` -- maximum lunar declination: maximum time difference about `2.43s`, maximum declination difference about `0.00006431°` - -The README examples are illustrative. The repository tests contain the exact baselines. - -## Quick Start - -### Calendar And Solar Terms - -The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from 721 BCE through 3000 CE; 104 BCE is the calendar-table switch point. The calendar is lunisolar in the strict sense, but public function names use `Lunar` for readability and convention. - -For historical input, Chinese era names stay in Chinese. This is part of the API surface, because historical Chinese dates are normally written that way. - -#### Calendar notes - -- **Default routing**: the package selects by year automatically. The pre-Qin range uses reconstructed Chunqiu and ancient-six-calendar systems, `-220..-104` uses the Qin/Han Zhuanxu calendar, `-103..1912` uses calendar tables, and `1913` onward uses the modern algorithm. -- **Explicit ancient calendars**: use APIs such as `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` when a specific ancient calendar system is required. -- **Data sources**: ancient-calendar support mainly references 《寿星天文历》; [Professor ytliu0's ChineseCalendar data](https://ytliu0.github.io/ChineseCalendar/index_simp.html) is used for validation. The modern range follows GB/T 33661-2017 and uses VSOP87/ELP computations for solar terms and new moons. -- **Solar terms**: `JieQi` returns modern astronomical solar-term instants; `CalendricalJieQi` returns calendar-compatible solar-term dates. - -#### Usage notes - -##### 1. One Gregorian date may map to several lunisolar dates -In periods when several regimes coexisted with different calendars (the Three Kingdoms, for example), one Gregorian date can map to several lunisolar dates. The package returns every conversion it can determine. - -##### 2. One lunisolar date may map to several Gregorian dates -Rival calendars are not the only cause: one regime can produce the same effect during a calendar reform. After Wu Zetian's reform, for example, the third year of Shengli had two twelfth months. - -##### 3. Gregorian calendar rules -All computation goes through Julian Days. The Gregorian side follows these rules: -- after `1582-10-15`: Gregorian calendar -- before `1582-10-04`: Julian calendar -- before year 8 CE: proleptic Julian calendar -- the day after `1582-10-04` is `1582-10-15` -- `1582-10-05` through `1582-10-14` do not exist and the corresponding entry points reject them -- year numbering: year `0` is 1 BCE, year `-1` is 2 BCE, and so on - -##### 4. Time zone -The package targets the Chinese calendar, so solar terms and new moons are computed in Beijing time (UTC+8) by default. Applying Chinese lunisolar rules in another time zone can shift dates. - -For exploration and research, the lower-level `Solar` and `Lunar` methods convert between the Gregorian and lunisolar calendars under a **custom time zone**, following the **current Chinese calendar algorithm (GB/T 33661-2017)**. -For standard conversions in Beijing time, use the wrapped `SolarToLunar` and `LunarToSolar` methods. - -**Example**: the calendar rules require the winter solstice to fall in the eleventh lunisolar month. For the 1984 winter solstice: -```go -ws := calendar.JieQi(1984, 270) -fmt.Println(ws) -fmt.Println(moon.ClosestShuoYue(ws)) -``` - -| Event | UTC+8 | UTC+7 | -| --- | --- | --- | -| Winter solstice | 1984-12-22 | 1984-12-21 | -| New moon | 1984-12-22 | 1984-12-22 | - -In UTC+8 (China), 1984-12-22 is both the winter solstice and the new moon, so it is the first day of the eleventh lunisolar month; in UTC+7 the solstice moves up to 12-21, which makes 12-22 the first day of the twelfth month. - -Chinese New Year shifts the same way. The Gregorian date of the first day of the first lunisolar month of 1985 is February 20 in UTC+8 and January 21 in UTC+7: -```go -fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0)) -fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) -``` - -##### 5. Go-specific note - -⚠️ Go's standard-library `time.Time` differs from this package in calendar handling: - -- Before 1582-10-15 Go uses the proleptic Gregorian calendar rather than the Julian calendar. Without `Add`, it generally works as expected. -- So **before 1582-10-15, `time.Time.Weekday()` does not agree with this package**: - for 1582-10-04 this package reports Thursday, Go reports Monday. - -#### Suggested solutions -To obtain the weekday this package uses: - -```go -// date should be the local midnight of the target day. -weekday := int(calendar.Date2JDE(date)+1.5) % 7 -// 0 means Sunday, 1 means Monday, ..., 6 means Saturday. -``` -Using `Add` or `AddDate` on a `time.Time` before 1582 runs through the proleptic Gregorian calendar and lands one day away from the Julian calendar whenever it crosses a leap day that only the Julian calendar has. -For example, 700 is a leap year in the Julian calendar but not in Go's proleptic Gregorian calendar. - -##### 6. Julian-only leap days (for example 700-02-29) -Before 1582, years divisible by 100 but not 400 (such as 100, 700 or 1500) have a 29 February in the Julian calendar, -while Go's `time.Time` uses the proleptic Gregorian calendar and has no such day (`time.Date(700, 2, 29, ...)` normalises to 700-03-01). The library accepts 700-02-29 as an existing day, with these constraints: - -- `Time.Solar()` / `LunarTime.SolarDate`: the library's canonical label, always the **following day** - (700-03-01 for 700-02-29), matching `basic.JDE2DateByZone`; -- `Time.JulianOnly()`: whether the lunar date exists only in the Julian calendar (JSON field `julianOnly`); -- `Time.JDE()`: the exact Julian day; for a Julian-only leap day it is one day earlier than `Solar()`, - otherwise the two agree (JSON field `jde`). - -```go -julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) -fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JDE(), julian.Lunar().MonthDay()) -// 0700-03-01 true 1.9767915e+06 二月初五 -``` - -> Two kinds of entry point keep this leap day: the integer year/month/day entry points `SolarToLunarByYMD` / `LunarToSolarByYMD`, and a direct -> `basic.JDECalc(700, 2, 29)` call returning the exact Julian day `1976791.5`; building a `time.Time` first is the step that loses it. - -##### 7. Several Gregorian candidates for one lunar date - -The dual-numbering reforms (Wang Mang 9-23 CE, Emperor Ming of Wei 237-240 CE, Wu Zetian 689-700 CE, Emperor Suzong of Tang 761-762 CE) -and the Taichu calendar handoff (104 BCE) give one lunar date two legal Gregorian days. `Solar()` remains the library default, and -`SolarCandidates()` returns every candidate with the first element always equal to `Solar()`: - -```go -res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) -fmt.Println(res.Solar().Format("2006-01-02")) -fmt.Println(len(res.SolarCandidates())) -// 0700-12-15 -// 2 -``` - -Outside the reform windows there is a single candidate and `SolarCandidates()` returns a one-element -slice; a Julian-only leap day also reports only its canonical label, because its single legal day -cannot be expressed as a `time.Time` - use `JDE()` for the exact date. - -#### Calendar conversion - -##### Gregorian to lunar - -- **Input**: a Gregorian date, as `time.Time` -- **Output**: a `calendar.Time` object, holding one or more matching lunisolar dates -- **The result carries**: - - a full lunisolar date description - - sexagenary (ganzhi) year, month, and day - - dynasty, emperor, and era name - - the complete structured lunisolar record - -##### Lunar to Gregorian - -Two calling styles: - -###### Style 1: pass a lunisolar string -Formats: -1. `era name + year + month + day`, for example **`"元丰六年十月十二"`** (prefix a leap month with `闰`; day names read `初一`, `二十`, and so on) -2. `era name + year + month + ganzhi day`, for example **`"元嘉二十七年七月庚午"`** -3. `year + month + day`, for example **`"二零二五年正月初一"`** (prefix a leap month with `闰`; suits modern dates) -4. `year + month + ganzhi day`, for example **`"二零二五年正月戊戌日"`** -5. `Arabic digits + month + day`: Chinese numerals may be written as Arabic digits, for example **`"2025年1月1日"`**, which stands for `二零二五年正月初一` -6. Historical cases: month names could differ from modern usage (under Wu Zetian, `正月` and `一月` denoted different months); such month names are read as Chinese numerals - -> ⚠️ A lunisolar year and a Gregorian year do not line up exactly. 2025-01-28, Chinese New Year's Eve, is the 29th day of the 12th lunisolar month of 2024, so its string is `"二零二四年腊月廿九"`. - -###### Style 2: pass numeric fields -- **Parameters**: year (`int`), month (`int`), day (`int`), leap-month flag (`bool`) -- **Semantics**: locate the date by lunisolar year, month, day, and the leap-month flag; for modern conversions - -One lunisolar day can have several legal Gregorian candidates: `Solar()` takes the first, `SolarCandidates()` returns all of them (see note 7 above). - -##### Code example - -```go -package main - -import ( - "encoding/json" - "fmt" - "time" - - "b612.me/astro/calendar" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - // Example 1: Gregorian to lunisolar. This date is in the Three Kingdoms period, so multiple parallel-calendar results are returned. - date := time.Date(240, 1, 1, 8, 8, 8, 8, cst) - lunar, _ := calendar.SolarToLunar(date) - fmt.Println(lunar.LunarDescWithEmperor()) - - // Structured lunisolar information for each matching historical result. - info := lunar.LunarInfo() - data, _ := json.MarshalIndent(info, "", " ") - fmt.Println(string(data)) - - // Example 2: lunisolar to Gregorian by string. This is the date from Su Shi's 《记承天寺夜游》. - solar, _ := calendar.LunarToSolar("元丰六年十月十二日") - for _, v := range solar { - fmt.Println(v.Time()) - fmt.Println(v.LunarDescWithEmperor()) - } - - // Example 3: lunisolar to Gregorian by numeric fields. 2026 month 1 day 1 is Chinese New Year. - modernDate, _ := calendar.LunarToSolarByYMD(2026, 1, 1, false) - fmt.Println(modernDate.Time()) -} -``` - -Output: - -```text -[魏明帝 景初三年腊月二十 蜀后主 延熙二年冬月十九 吴大帝 赤乌二年冬月二十] // one Gregorian instant maps to parallel Three Kingdoms lunisolar results -[ - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 12, - "lunarDay": 20, - "isLeap": false, - "lunarMonthDayDesc": "腊月二十", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "魏", - "emperor": "魏明帝", - "nianhao": "景初", - "yearOfNianhao": 3, - "eraDesc": "景初三年", - "lunarWithNianhaoDesc": "景初三年腊月二十", - "chineseZodiac": "羊" - }, - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 11, - "lunarDay": 19, - "isLeap": false, - "lunarMonthDayDesc": "冬月十九", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "蜀", - "emperor": "蜀后主", - "nianhao": "延熙", - "yearOfNianhao": 2, - "eraDesc": "延熙二年", - "lunarWithNianhaoDesc": "延熙二年冬月十九", - "chineseZodiac": "羊" - }, - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 11, - "lunarDay": 20, - "isLeap": false, - "lunarMonthDayDesc": "冬月二十", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "吴", - "emperor": "吴大帝", - "nianhao": "赤乌", - "yearOfNianhao": 2, - "eraDesc": "赤乌二年", - "lunarWithNianhaoDesc": "赤乌二年冬月二十", - "chineseZodiac": "羊" - } -] // structured lunisolar records, one object per matching historical result -1083-11-24 00:00:00 +0800 CST // Gregorian date corresponding to 元丰六年十月十二日 -[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] // the same day also matches a Liao calendar result -2026-02-17 00:00:00 +0800 CST // Chinese New Year in 2026 -``` - -#### Solar terms - -`JieQi(year, term)` returns the exact solar-term instant computed by the modern astronomical algorithm. `CalendricalJieQi(year, term)` returns the date on which the solar term falls under the default calendar, fixed at 00:00 Beijing time for that day. Use `CalendricalJieQiWithCalendar(year, term, system)` when a specific ancient calendar system is required. - -```go -package main - -import ( - "fmt" - - "b612.me/astro/calendar" -) - -func main() { - // Beginning of Spring in 2020. Solar-term constants correspond to apparent solar longitude. - fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) - // Winter Solstice in 2020. - fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) - // March Equinox in 2020. - fmt.Println(calendar.JieQi(2020, calendar.JQ_春分)) - // Direct longitude input is also supported; 0 degrees is the March Equinox. - fmt.Println(calendar.JieQi(2020, 0)) -} -``` - -Output: - -```text -2020-02-04 17:03:20.471614301 +0800 CST // Beginning of Spring -2020-12-21 18:02:20.648710727 +0800 CST // Winter Solstice -2020-03-20 11:49:37.149532735 +0800 CST // March Equinox -2020-03-20 11:49:37.149532735 +0800 CST // same result from direct longitude input -``` - -Calendrical solar-term example: - -```go -date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) -fmt.Println(date, err) - -date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan) -fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err) -``` - -Output: - -```text -1582-12-22 00:00:00 +0800 CST --202-12-25 -``` - -### Sun And Moon - -#### Observing-angle semantics - -- `Altitude`: altitude angle; horizon is `0°`, zenith is `+90°` -- `Zenith`: zenith distance; zenith is `0°`, horizon is `90°` -- `Zenith` and `Altitude` are complements; the two add up to `90°` - -#### Sunrise/sunset and moonrise/moonset - -> ⚠️ Moon rise/set times are computed for the queried civil date, so the rise and set instants need not be continuous. -> -> For example, the Moon may set at 01:00 and rise again at noon, in which case the rise time is later than the set time; the evening moonset in that scenario corresponds to the next day's date. -> -> The full rise/set cycle follows from the order of the two instants: check whether the rise time falls after the set time to pick the correct subsequent instants. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" - "b612.me/astro/sun" -) - -func main() { - // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // All "today" semantics are based on this local civil date. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - // Civil morning twilight begins when the Sun is 6 degrees below the horizon. - fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) - // Sunrise: dynamic standard refraction and instantaneous solar semidiameter, upper limb. - fmt.Println(sun.RiseTime(date, lon, lat, height, true)) - // Upper culmination of the Sun in Xi'an. - fmt.Println(sun.CulminationTime(date, lon)) - // Sunset: dynamic standard refraction and instantaneous solar semidiameter, upper limb. - fmt.Println(sun.SetTime(date, lon, lat, height, true)) - // Civil evening twilight ends when the Sun is 6 degrees below the horizon. - fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) - - // Moonrise: dynamic standard refraction and instantaneous lunar semidiameter, upper limb. - fmt.Println(moon.RiseTime(date, lon, lat, height, true)) - // Upper culmination of the Moon in Xi'an. - fmt.Println(moon.CulminationTime(date, lon, lat)) - // Moonset: dynamic standard refraction and instantaneous lunar semidiameter, upper limb. - fmt.Println(moon.SetTime(date, lon, lat, height, true)) -} -``` - -Output: - -```text -2020-01-01 07:22:27.960488498 +0800 CST // civil morning twilight begins -2020-01-01 07:49:52.413689196 +0800 CST // sunrise -2020-01-01 12:47:35.933117866 +0800 CST // solar upper culmination -2020-01-01 17:45:09.188657999 +0800 CST // sunset -2020-01-01 18:12:33.624035418 +0800 CST // civil evening twilight ends -2020-01-01 11:52:49.860912859 +0800 CST // moonrise -2020-01-01 17:36:48.811488747 +0800 CST // lunar upper culmination -2020-01-01 23:26:49.313553571 +0800 CST // moonset -``` - -#### Sun and Moon position - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" - "b612.me/astro/star" - "b612.me/astro/sun" - "b612.me/astro/tools" -) - -func main() { - // Xi'an, China. - var lon, lat float64 = 108.93, 34.27 - cst := time.FixedZone("CST", 8*3600) - // Instant of observation. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - // Apparent ecliptic longitude of the Sun, in degrees. - fmt.Println(sun.ApparentLo(date)) - // True obliquity of the ecliptic at this instant. - fmt.Println(sun.EclipticObliquity(date, true)) - // Apparent right ascension and declination of the Sun. - ra, dec := sun.ApparentRaDec(date) - fmt.Println("RA:", tools.Format(ra/15, 1), "Dec:", tools.Format(dec, 0)) - // English constellation containing the Sun. - fmt.Println(star.ConstellationEN(ra, dec, date)) - // Solar azimuth, altitude, and zenith distance at Xi'an. - fmt.Println("Azimuth:", sun.Azimuth(date, lon, lat), "Altitude:", sun.Altitude(date, lon, lat), "Zenith:", sun.Zenith(date, lon, lat)) - // Sun-Earth distance, in AU. - fmt.Println(sun.EarthDistance(date)) - - // Topocentric apparent right ascension and declination of the Moon. - ra, dec = moon.ApparentRaDec(date, lon, lat) - fmt.Println("RA:", tools.Format(ra/15, 1), "Dec:", tools.Format(dec, 0)) - // English constellation containing the Moon. - fmt.Println(star.ConstellationEN(ra, dec, date)) - // Lunar azimuth, altitude, and zenith distance at Xi'an. - fmt.Println("Azimuth:", moon.Azimuth(date, lon, lat), "Altitude:", moon.Altitude(date, lon, lat), "Zenith:", moon.Zenith(date, lon, lat)) - // Earth-Moon distance, in km. - fmt.Println(moon.EarthDistance(date)) -} -``` - -Output: - -```text -280.01526210031136 // apparent ecliptic longitude of the Sun, degrees -23.4362178391013 // true obliquity of the ecliptic, degrees -RA: 18h43m34.82s Dec: -23°3′30.27″ // apparent RA and Dec of the Sun -Sagittarius // English constellation containing the Sun -Azimuth: 120.19477090015224 Altitude: 2.4014437419430097 Zenith: 87.59855625805699 // solar horizontal coordinates at Xi'an -0.983292937163176 // Sun-Earth distance, AU -RA: 23h18m56.24s Dec: -10°20′54.42″ // topocentric apparent RA and Dec of the Moon -Aquarius // English constellation containing the Moon -Azimuth: 67.63889332004852 Altitude: -45.34916937173283 Zenith: 135.34916937173284 // lunar horizontal coordinates at Xi'an -404238.6096080479 // Earth-Moon distance, km -``` - -`sun.Physical` / `sun.PhysicalN` return: - -- `P`: position angle of the solar north pole, degrees -- `B0`: heliographic latitude of the solar disk center, degrees -- `L0`: Carrington longitude of the solar disk center, degrees - -The Sun, Moon, and seven major planets expose `Diameter` / `Semidiameter` and `N` variants. Units are arcseconds: - -```go -fmt.Println(sun.Diameter(date), sun.Semidiameter(date)) // solar apparent diameter and semidiameter -fmt.Println(sun.Physical(date)) // solar P/B0/L0 -fmt.Println(moon.Diameter(date), moon.Semidiameter(date)) // lunar apparent diameter and semidiameter -fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // Martian apparent diameter and semidiameter -``` - -Earth and Moon distance extrema, maximum lunar declination, and lunar physical parameters: - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/earth" - "b612.me/astro/moon" -) - -func main() { - // Earth perihelion and aphelion in 2026. Time is UTC, distance is AU. - peri := earth.Perihelion(2026) - aphe := earth.Aphelion(2026) - fmt.Printf("earth perihelion=%s distance=%.9fAU\n", peri.Time.Format(time.RFC3339), peri.Distance) - fmt.Printf("earth aphelion=%s distance=%.9fAU\n", aphe.Time.Format(time.RFC3339), aphe.Distance) - - // Lunar perigee and apogee in January 2026. Distance is km. - perigees := moon.PerigeesInMonth(2026, time.January) - apogees := moon.ApogeesInMonth(2026, time.January) - fmt.Printf("moon perigee=%s distance=%.1fkm count=%d\n", perigees[0].Time.Format(time.RFC3339), perigees[0].Distance, len(perigees)) - fmt.Printf("moon apogee=%s distance=%.1fkm count=%d\n", apogees[0].Time.Format(time.RFC3339), apogees[0].Distance, len(apogees)) - - // Maximum northern and southern lunar declinations in January 2026. - north := moon.MaximumNorthDeclinationsInMonth(2026, time.January) - south := moon.MaximumSouthDeclinationsInMonth(2026, time.January) - fmt.Printf("north=%s dec=%.6f\n", north[0].Time.Format(time.RFC3339), north[0].Declination) - fmt.Printf("south=%s dec=%.6f\n", south[0].Time.Format(time.RFC3339), south[0].Declination) - - // Lunar libration and position angle of the rotation axis. - physical := moon.Physical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Printf("libration lon=%.6f lat=%.6f pa=%.6f\n", physical.LibrationLongitude, physical.LibrationLatitude, physical.PositionAngle) - - // Bright-limb position angle; 0 degrees starts at the lunar north point and increases eastward. - fmt.Printf("bright limb=%.6f\n", moon.BrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) - - // Topocentric lunar libration, rotation-axis position angle, and bright-limb angle from Shanghai. - topo := moon.TopocentricPhysical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4) - fmt.Printf("topo libration lon=%.6f lat=%.6f pa=%.6f\n", topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) - fmt.Printf("topo bright limb=%.6f\n", moon.TopocentricBrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4)) -} -``` - -Output: - -```text -earth perihelion=2026-01-03T17:15:35Z distance=0.983302050AU // Earth perihelion time and distance -earth aphelion=2026-07-06T17:31:24Z distance=1.016643936AU // Earth aphelion time and distance -moon perigee=2026-01-01T21:44:24Z distance=360348.1km count=2 // first lunar perigee in January and event count -moon apogee=2026-01-13T20:47:13Z distance=405437.9km count=1 // first lunar apogee in January and event count -north=2026-01-02T08:10:49Z dec=28.266373 // maximum northern lunar declination -south=2026-01-16T05:15:14Z dec=-28.304184 // maximum southern lunar declination -libration lon=-1.278902 lat=-6.531444 pa=-9.967050 // geocentric libration longitude, latitude, and axis position angle -bright limb=267.364849 // geocentric bright-limb position angle -topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 // topocentric libration and axis position angle from Shanghai -topo bright limb=266.038258 // topocentric bright-limb position angle from Shanghai -``` - -Earth orbital eccentricity at an instant: - -```go -fmt.Printf("earth e=%.9f\n", earth.EarthEccentricity(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) // Earth orbital eccentricity -``` - -The Moon also exposes ascending-node and descending-node longitudes: - -```go -nodeDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) -fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate)) // ascending-node and descending-node longitudes, degrees -``` - -Here: - -- `AscendingNode`: ecliptic longitude where the Moon crosses from south of the ecliptic to north of it -- `DescendingNode`: ecliptic longitude where the Moon crosses from north of the ecliptic to south of it -- both values are degrees, and are usually about `180°` apart at the same instant - -Output: - -```text -340.95708624505863 160.9570862450587 // lunar ascending-node and descending-node longitudes, degrees -``` - -#### Lunar phases - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - // Instant of observation. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - // Illuminated fraction of the lunar disk. - fmt.Println(moon.Phase(date)) - // Chinese textual phase description. - fmt.Println(moon.PhaseDesc(date)) - // Next new moon; moon.NextNewMoon(date) is the English alias. - fmt.Println(moon.NextShuoYue(date)) - // Next first quarter; moon.NextFirstQuarter(date) is the English alias. - fmt.Println(moon.NextShangXianYue(date)) - // Next full moon; moon.NextFullMoon(date) is the English alias. - fmt.Println(moon.NextWangYue(date)) - // Next last quarter; moon.NextLastQuarter(date) is the English alias. - fmt.Println(moon.NextXiaXianYue(date)) -} -``` - -Output: - -```text -0.30004130960877884 // about 30% of the lunar disk is illuminated -上峨眉月 // Chinese phase description -2020-01-25 05:41:58.271192908 +0800 CST // next new moon -2020-01-03 12:45:23.229190707 +0800 CST // next first quarter -2020-01-11 03:21:17.159625291 +0800 CST // next full moon -2020-01-17 20:58:23.396406769 +0800 CST // next last quarter -``` - -Phase aliases: - -- `ShuoYue` / `NewMoon` -- `WangYue` / `FullMoon` -- `ShangXianYue` / `FirstQuarter` -- `XiaXianYue` / `LastQuarter` - -The matching `Next*`, `Last*`, and `Closest*` functions are also available. - -#### Lite Sun And Moon - -`lite/sun` and `lite/moon` use the same calling style as the main chain. Error levels are listed in [Lite lightweight chains](#lite-lightweight-chains). - -```go -package main - -import ( - "fmt" - "time" - - litemoon "b612.me/astro/lite/moon" - litesun "b612.me/astro/lite/sun" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2026, 1, 1, 20, 0, 0, 0, cst) - - // Lightweight solar altitude from Shanghai. - fmt.Println(litesun.Altitude(date, 121.4737, 31.2304)) - // Lightweight sunrise time from Shanghai. - fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) - - // Lightweight lunar illuminated fraction. - fmt.Println(litemoon.Phase(date)) - // Lightweight lunar age, in days. - fmt.Println(litemoon.PhaseAge(date)) - // Lightweight moonrise time from Shanghai. - fmt.Println(litemoon.RiseTime(date, 121.4737, 31.2304, 0, true)) -} -``` - -Exported functions: - -- `lite/sun`: `TrueLo`, `ApparentLo`, `Distance`, `TrueRaDec`, `ApparentRaDec`, `HourAngle`, `Azimuth`, `Altitude`, `Zenith`, `RiseTime`, `SetTime` -- `lite/moon`: `TrueLo`, `TrueBo`, `TrueRaDec`, `ApparentRaDec`, `HourAngle`, `Azimuth`, `Altitude`, `Zenith`, `SunMoonLoDiff`, `Phase`, `PhaseAge`, `RiseTime`, `SetTime` - -#### Solar eclipse - -Solar-eclipse calculation lives in `eclipse`; SVG generation lives in `eclipse/svg`. The default lunar-radius convention follows NASA bulletin split-`k`. IAU single-`k` variants are available through same-named `...IAUSingleK` functions. - -Common entry points: - -- `SolarEclipseOnDate`: detect whether a global solar eclipse occurs near a local date -- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`: search global solar eclipses -- `LocalSolarEclipseOnDate`: detect whether a site can see a local solar eclipse on that date -- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`: search locally visible solar eclipses -- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`: search locally visible total solar eclipses, returning `(info, ok)` -- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`: search locally visible annular solar eclipses, returning `(info, ok)` -- `SolarEclipseCentralPath`: compute central line, northern/southern limits, and greatest-eclipse point -- `SolarEclipsePartialFootprints`: compute the partial-eclipse penumbral footprint on Earth, with optional sampled umbral/antumbral outlines -- `eclipse/svg.LocalSolarEclipseSVG`: render a local solar-disk SVG - -`SolarEclipsePartialFootprintsInfo` also reports global shadow contacts. `P1/P4` are the external penumbral contacts and `P2/P3` are the internal penumbral contacts; `U1/U4` are the external umbral or antumbral contacts and `U2/U3` are the internal contacts. Contacts that do not occur remain zero `time.Time` values. `CentralBeginOnEarth` / `CentralEndOnEarth` retain their existing meaning of the shadow axis entering and leaving Earth; they are not aliases for `U1/U4`. - -Set `CentralShadowStep` in `SolarEclipsePartialFootprintOptions` when structured instantaneous central-shadow outlines are needed; samples are returned in `CentralShadowFootprints`. Zero disables this extra calculation in the data API; the SVG entry points follow the same rule and sample only for positive values (rounded up to one minute). - -The same options struct takes `GreatestTimeValues` or `GreatestTimeStep` when the isochrones are wanted straight from the data layer. `GreatestTimeValues []time.Time` holds the **greatest-eclipse time levels** as absolute instants (their `Location` does not affect the computation); at most 64 are kept, duplicates and levels outside the partial-eclipse window are skipped, the rest are sorted, and anything beyond the earliest 64 is dropped; a level with no usable branch produces no entry. When it is empty, `GreatestTimeStep` generates the levels instead; only a positive step applies, and the grid aligns to UTC ticks. A display-timezone grid has to be generated by the caller and passed through `GreatestTimeValues`. - -Contours come back in `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`. `JDE` is the matching TT Julian ephemeris day, `Time` is that level in the input timezone (an explicit level is echoed back unchanged, a step-derived one is converted from `JDE` and rounded to the millisecond so the round trip cannot truncate a whole minute into the previous one), and `Segments` are the isochrone branches at that instant. Isochrones exist only where the solar and lunar disks actually overlap and the Sun is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the partial-visibility boundary, nothing is continued beyond ±88° latitude, and one instant may carry several disconnected branches. When they are not requested, existing output is unchanged. - -`SolarEclipseInfo`, `LocalSolarEclipseInfo`, and the embedded `Eclipse` field in `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` include Saros metadata: - -- `HasSaros`: whether a Saros series was matched -- `Saros.Series`: NASA Saros series number when `Verified=true`, otherwise a provisional derived series number -- `Saros.Member`: 1-based member number within that series -- `Saros.Count`: total member count of that series -- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` - -Saros note: - -- One Saros is about `6585.321` days, that is `223` synodic months or about `18 years 11 days 8 hours`; series members are ordered by this period. -- A Saros series is a sequence of eclipses separated by one Saros period. `Series` identifies the sequence, while `Member` / `Count` describe the event's position in it. -- Saros metadata belongs to the eclipse event, not to the observing site. Global, local, path, and footprint results for the same eclipse should report the same Saros. -- Embedded NASA anchors take precedence. Unmatched events in astronomical years `-3000` through `+6000` use a precomputed extension table, including both end years; year `0` is 1 BCE. Only events outside that interval use live extrapolation. Precomputed and live results have `Verified=false` and are not official NASA assignments. -- Extended numbers follow NASA's [Saros/Inex numbering relations](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html). Members are computed with the Split-K model across the complete series, without clipping at the precomputed year limits. The computed result for `3288-11-15` is series `202`, member `1/71`. -- For example, the `2024-04-08` North American total solar eclipse is member `30/71` of Solar Saros `139`. - -##### Timing checks against NASA material - -Solar-eclipse timing is checked in two forms: - -- **Global eclipses**: greatest-eclipse UT, magnitude, gamma, greatest-eclipse coordinates, and path width. Current regression samples include `2023-04-20`, `2024-04-08`, `2024-10-02`, and `2025-03-29`. -- **Local eclipses**: local first contact, greatest eclipse, last contact, and totality/annularity duration. Current samples include a Chicago partial eclipse, the 2024 total-eclipse greatest point, and the 2024 annular-eclipse greatest point. - -| Check type | Sample | Time fields | Result | -| --- | --- | --- | --- | -| Global solar eclipse | 4 modern eclipses | Greatest-eclipse UT | second-level agreement, current samples are within an `8 s` threshold | -| Local solar eclipse | 3 observing sites | greatest eclipse, first contact, last contact | NASA local-circumstance public values are often rounded to whole minutes; current results match those rounded minute values | -| Local central eclipse | 2 central-eclipse points | totality/annularity duration | second-level agreement, current samples are within a `5 s` threshold | - -Global eclipse references often publish seconds, so second-level checks are meaningful there. Many local-circumstance pages publish contact times only to whole minutes, so minute-level agreement is the correct interpretation for those fields. The 2009 Yangshan and 2012 Xiamen examples below only demonstrate API calls and SVG output and claim no publication-grade accuracy for local contact times; check them item by item against NASA/IMCCE local circumstances when that matters. - -##### 2009 Yangtze River total eclipse near Yangshan - -`2009-07-22` is the Great Yangtze Eclipse. The example below uses a site near Yangshan at the Yangtze River estuary southeast of Shanghai, close to the center line; totality lasts about 5 minutes 57 seconds. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/eclipse" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) - - // Near Yangshan, Shanghai. East longitude and north latitude are positive; elevation is 0 m. - info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) - fmt.Println(ok, info.Type) // whether a local eclipse is found; eclipse type - fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count - fmt.Println(info.PartialStart) // first contact - fmt.Println(info.CentralStart) // totality begins - fmt.Println(info.GreatestEclipse) // greatest eclipse - fmt.Println(info.CentralEnd) // totality ends - fmt.Println(info.PartialEnd) // last contact - fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // totality duration - fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // magnitude, obscuration, solar altitude at greatest eclipse - - // Central path for the same date, including greatest point, center line, and northern/southern limits. - path, _ := eclipse.SolarEclipseCentralPath( - date, - eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 100}, - ) - fmt.Printf("greatest lon=%.4f lat=%.4f width=%.1fkm center=%d\n", - path.Greatest.Longitude, - path.Greatest.Latitude, - path.Greatest.WidthKM, - len(path.CenterLine), - ) -} -``` - -Output: - -```text -true total // Yangshan site has a local total solar eclipse -true {136 37 71 true} // Solar Saros 136, member 37/71, verified -2009-07-22 08:23:54.852366149 +0800 CST // first contact -2009-07-22 09:37:22.978486418 +0800 CST // totality begins -2009-07-22 09:40:20.771366357 +0800 CST // greatest eclipse -2009-07-22 09:43:19.610750377 +0800 CST // totality ends -2009-07-22 11:03:13.974526226 +0800 CST // last contact -5m56.632263959s // totality duration -magnitude=1.076997 obscuration=1.000000 altitude=57.292 // magnitude, obscuration, solar altitude at greatest eclipse -greatest lon=144.1177 lat=24.2193 width=258.3km center=289 // global greatest point, path width, center-line sample count -``` - -##### 2012 Xiamen annular eclipse - -The `2012-05-21` annular eclipse was visible from the southeast coast of China. The Xiamen example has the Sun about 9.6 degrees above the horizon at greatest eclipse, and annularity lasts about 4 minutes 19 seconds. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/eclipse" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst) - - info, ok := eclipse.LocalSolarEclipseOnDate(date, 118.0894, 24.4798, 0) - fmt.Println(ok, info.Type) // whether a local eclipse is found; eclipse type - fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count - fmt.Println(info.PartialStart) // first contact - fmt.Println(info.CentralStart) // annularity begins - fmt.Println(info.GreatestEclipse) // greatest eclipse - fmt.Println(info.CentralEnd) // annularity ends - fmt.Println(info.PartialEnd) // last contact - fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // annularity duration - fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // magnitude, obscuration, solar altitude at greatest eclipse -} -``` - -Output: - -```text -true annular // Xiamen site has a local annular solar eclipse -true {128 58 73 true} // Solar Saros 128, member 58/73, verified -2012-05-21 05:08:12.683024704 +0800 CST // first contact -2012-05-21 06:08:15.570422708 +0800 CST // annularity begins -2012-05-21 06:10:25.156724452 +0800 CST // greatest eclipse -2012-05-21 06:12:34.764188826 +0800 CST // annularity ends -2012-05-21 07:20:55.029536783 +0800 CST // last contact -4m19.193766118s // annularity duration -magnitude=0.933290 obscuration=0.872480 altitude=9.567 // magnitude, obscuration, solar altitude at greatest eclipse -``` - -##### Solar-eclipse SVG - -The modern city example uses the `2035-09-02` total solar eclipse in Beijing. With approximate downtown coordinates (`116.4074E`, `39.9042N`), this event belongs to Solar Saros `145` as member `23/77`, and local totality lasts about `1m33s`. - -The default solar-eclipse SVG header includes Saros metadata and totality/annularity duration. `LocalSolarEclipseSVGOptions` can override: - -- `Title`: main title -- `SummaryText` / `GreatestText` / `MetaText`: three subtitle lines under the title -- `OverviewTitle` / `PhasePanelsTitle` / `ContactsTitle`: section titles -- `DirectionText` / `FooterNote`: footer direction note and extra note - -```go -package main - -import ( - "fmt" - "os" - "time" - - "b612.me/astro/eclipse" - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - // 2009 Yangshan total solar eclipse diagram. - totalSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - time.Date(2009, 7, 22, 12, 0, 0, 0, cst), - 121.9850, 30.6167, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - Language: "en", - }, - ) - fmt.Println(ok, len(totalSVG)) // whether SVG generation succeeded; SVG byte length - if ok { - _ = os.WriteFile("doc/solar-eclipse-yangshan-2009-en.svg", []byte(totalSVG), 0o644) - } - - // 2012 Xiamen annular solar eclipse diagram. - annularSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - time.Date(2012, 5, 21, 12, 0, 0, 0, cst), - 118.0894, 24.4798, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - Language: "en", - }, - ) - fmt.Println(ok, len(annularSVG)) // whether SVG generation succeeded; SVG byte length - if ok { - _ = os.WriteFile("doc/solar-eclipse-xiamen-2012-en.svg", []byte(annularSVG), 0o644) - } - - // 2035 Beijing total solar eclipse diagram, including Saros metadata and totality duration. - beijingDate := time.Date(2035, 9, 2, 12, 0, 0, 0, cst) - beijingInfo, ok := eclipse.LocalSolarEclipseOnDate(beijingDate, 116.4074, 39.9042, 0) - fmt.Println(ok, beijingInfo.Type) // whether a local eclipse is found; eclipse type - fmt.Println(beijingInfo.HasSaros, beijingInfo.Saros) // Saros match flag; series, member number, total count - fmt.Println(beijingInfo.CentralEnd.Sub(beijingInfo.CentralStart)) // totality duration - - beijingSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - beijingDate, - 116.4074, 39.9042, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - Language: "en", - }, - ) - fmt.Println(ok, len(beijingSVG)) // whether SVG generation succeeded; SVG byte length - if ok { - _ = os.WriteFile("doc/solar-eclipse-beijing-2035-en.svg", []byte(beijingSVG), 0o644) - } -} -``` - -Output: - -```text -true 13433 // Yangshan total-eclipse SVG generated, 13433 bytes -true 13362 // Xiamen annular-eclipse SVG generated, 13362 bytes -true total // Beijing site has a local total solar eclipse -true {145 23 77 true} // Solar Saros 145, member 23/77, verified -1m33.329527974s // totality duration near downtown Beijing -true 13394 // Beijing total-eclipse SVG generated, 13394 bytes -``` - -Rendered examples: - -![2009 Yangshan total solar eclipse](doc/solar-eclipse-yangshan-2009-en.svg) - -![2012 Xiamen annular solar eclipse](doc/solar-eclipse-xiamen-2012-en.svg) - -![2035 Beijing total solar eclipse](doc/solar-eclipse-beijing-2035-en.svg) - -#### Lunar eclipse - -Lunar-eclipse detection and search live in `eclipse`; returned times preserve the input `time.Time` location. - -Common entry points: - -- `LunarEclipseOnDate`: detect whether a lunar eclipse occurs on a local date -- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`: search global lunar eclipses -- `LocalLunarEclipseOnDate`: detect whether a visible lunar eclipse is visible from a site on a local date -- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`: search visible local lunar eclipses -- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`: search visible local total lunar eclipses, returning `(info, ok)` -- `GeometricLocalLunarEclipseOnDate`: detect geometric lunar eclipse overlap without filtering by whether the Moon is above the horizon -- `eclipse/svg.LunarEclipseSVG`: render a lunar-eclipse shadow-path SVG - -`LunarEclipseInfo` includes: - -- eclipse type `Type` -- Saros metadata `HasSaros` / `Saros` -- penumbral magnitude `PenumbralMagnitude` -- umbral magnitude `UmbralMagnitude` -- P1, U1, U2, greatest eclipse, U3, U4, P4 contact times - -`Saros` has the same meaning as in the solar-eclipse section: - -- `Saros.Series`: NASA lunar Saros series number when `Verified=true`, otherwise a provisional derived series number -- `Saros.Member`: 1-based member number within that series -- `Saros.Count`: total member count of that series -- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` - -Lunar metadata also uses NASA anchors first, the extension table for astronomical years `-3000` through `+6000`, and live extrapolation outside that interval. Computed members include the union of events detected by Danjon and Chauvenet, so metadata is independent of the requested lunar model and observing site. Very shallow members may differ from the NASA catalog; `Verified` remains `false`. - -Run `go generate ./eclipse` from the repository root to regenerate both extension tables. The generator scans years `-5000` through `+8000` to include complete lifetimes of series at the range limits, checking numbering in both directions from NASA anchors. Ordinary queries and tests do not generate tables. - -For example, the cross-year total lunar eclipse on `2028-12-31 / 2029-01-01` is member `49/72` of Lunar Saros `125`. - -Two shadow-radius conventions are retained: - -- **Danjon** (default): multiplies only the lunar horizontal-parallax term by `1.01`, then combines it with the solar semidiameter and solar parallax. NASA GSFC's current lunar-eclipse catalogs and diagram pages use the same route, as do the library defaults `LunarEclipseOnDate`, `LastLunarEclipse`, `NextLunarEclipse`, and `ClosestLunarEclipse`. -- **Chauvenet**, compatibility convention: starts with `0.99834 x Earth equatorial radius` and then multiplies the full shadow radii by `51/50`. This is closer to older traditional tables and is useful for compatibility checks. - -Differences: - -- `Chauvenet` gives larger penumbral and umbral shadows. Penumbral magnitude is usually about `0.025` larger, and umbral magnitude about `0.005` larger. -- For edge cases, `Chauvenet` can push an eclipse toward a deeper type. -- Against NASA catalogs, modern ephemeris software, or current mainstream lunar-eclipse material, the matching convention is the default `Danjon`. -- For compatibility with existing historical baselines, the matching convention is the explicitly-called `Chauvenet`. - -##### Code example - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/eclipse" -) - -func main() { - date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) - - // Default Danjon model, closer to NASA current material. - info := eclipse.ClosestLunarEclipse(date) - fmt.Println(info.Type) // eclipse type - fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count - fmt.Println(info.Maximum) // greatest-eclipse time - fmt.Println(info.PenumbralMagnitude, info.UmbralMagnitude) // penumbral and umbral magnitudes - fmt.Println(info.PenumbralStart) // P1, penumbral eclipse begins - fmt.Println(info.PartialStart) // U1, partial eclipse begins - fmt.Println(info.TotalStart) // U2, totality begins - fmt.Println(info.TotalEnd) // U3, totality ends - fmt.Println(info.PartialEnd) // U4, partial eclipse ends - fmt.Println(info.PenumbralEnd) // P4, penumbral eclipse ends - - // Chauvenet model for compatibility with older conventions. - legacy := eclipse.ClosestLunarEclipseChauvenet(date) - fmt.Println(legacy.PenumbralMagnitude, legacy.UmbralMagnitude) // magnitudes under Chauvenet - - // Check a local civil date. Output time zone follows the input date. - local := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) - today, ok := eclipse.LunarEclipseOnDate(local) - fmt.Println(ok) // whether this local date overlaps a lunar eclipse - fmt.Println(today.Type) // eclipse type - fmt.Println(today.Maximum) // greatest-eclipse time in the input time zone -} -``` - -Output: - -```text -total // eclipse type -true {125 49 72 true} // Lunar Saros 125, member 49/72, verified -2028-12-31 16:52:05.566135346 +0000 UTC // greatest eclipse -2.2739890433790566 1.2461142882915068 // penumbral and umbral magnitudes -2028-12-31 14:03:54.219463169 +0000 UTC // P1 -2028-12-31 15:07:42.115980684 +0000 UTC // U1 -2028-12-31 16:16:27.24464178 +0000 UTC // U2 -2028-12-31 17:27:46.214954853 +0000 UTC // U3 -2028-12-31 18:36:32.251235246 +0000 UTC // U4 -2028-12-31 19:40:11.52023971 +0000 UTC // P4 -2.2996033397562012 1.2511710895669002 // Chauvenet penumbral and umbral magnitudes -true // local date overlaps an eclipse -total // local eclipse type -2029-01-01 00:52:05.566135346 +0800 CST // greatest eclipse in UTC+8 -``` - -##### Checks against NASA data - -The following values are compared against NASA GSFC lunar-eclipse catalogs and single-eclipse diagram pages. Time errors are in seconds. - -| Sample | Model | Penumbral magnitude error | Umbral magnitude error | Contact-time check | -| --- | --- | --- | --- | --- | -| 2026-03-03 total lunar eclipse | Danjon | -0.000072053 | -0.000065148 | second-level agreement, max error 6.380 s | -| 2026-03-03 total lunar eclipse | Chauvenet | +0.025594905 | +0.004939948 | compatibility model, not the NASA timing baseline | -| 2026-08-28 partial lunar eclipse | Danjon | -0.000118545 | -0.000028773 | second-level agreement, max error 6.179 s | -| 2026-08-28 partial lunar eclipse | Chauvenet | +0.025562714 | +0.004962282 | compatibility model, not the NASA timing baseline | -| 2024-03-25 penumbral lunar eclipse | Danjon | -0.000181657 | see note below | second-level agreement, max error 7.781 s | -| 2024-03-25 penumbral lunar eclipse | Chauvenet | +0.026039769 | see note below | compatibility model, not the NASA timing baseline | - -For the `2026-03-03` total lunar eclipse, current default `Danjon` differences against NASA are: - -- type: both `total` -- penumbral magnitude: `2.183727947` vs NASA `2.1838`, error `-0.000072053` -- umbral magnitude: `1.150634852` vs NASA `1.1507`, error `-0.000065148` -- P1 error: `+3.400 s` -- U1 error: `+5.801 s` -- U2 error: `+6.261 s` -- greatest eclipse error: `+5.897 s` -- U3 error: `+5.776 s` -- U4 error: `+6.328 s` -- P4 error: `+6.380 s` - -For pure penumbral eclipses, NASA may publish negative `umbral magnitude`, meaning the Moon's disk center remains outside the umbral boundary by that amount. This library preserves that negative value, so pure penumbral cases are compared in the same convention. - -##### Lunar-eclipse SVG - -`LunarEclipseSVG`, `LunarEclipseDetailedSVG`, and `LunarEclipseMapSVG` share one default model: Danjon with a Chauvenet fallback for ultra-shallow penumbral cases, matching `LunarEclipseOnDate`; the `Danjon` / `Chauvenet` variants keep forcing their model. - -The default lunar-eclipse SVG header includes Saros metadata. `LunarEclipseSVGOptions` can override: - -- `Title`: main title -- `SummaryText` / `MaximumText` / `CoordinatesText` / `DurationText` / `MetaText`: five information lines under the title -- `ContactsTitle`: contact-time section title -- `DirectionText` / `FooterNote`: footer direction note and extra note - -```go -package main - -import ( - "fmt" - "os" - "time" - - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - // Render the shadow-path diagram for the cross-year total lunar eclipse on 2029-01-01 UTC. - svg, ok := eclipsesvg.LunarEclipseSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC), - eclipsesvg.LunarEclipseSVGOptions{ - Width: 960, - Height: 620, - Step: 10 * time.Minute, - Language: "en", - }, - ) - fmt.Println(ok, len(svg)) // whether SVG generation succeeded; SVG byte length - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-en.svg", []byte(svg), 0o644) - } -} -``` - -Output: - -```text -true 20274 // lunar-eclipse SVG generated, 20274 bytes -``` - -Rendered example: - -![2029 cross-year total lunar eclipse shadow path](doc/lunar-eclipse-2029-01-01-en.svg) - -##### References - -- NASA lunar eclipse decade catalog: -- NASA 2026-03-03 total lunar eclipse diagram: -- NASA 2026-08-28 partial lunar eclipse diagram: -- NASA 2024-03-25 penumbral lunar eclipse diagram: -- NASA lunar eclipse algorithm and history notes: - -### Lunar Occultations - -Lunar-occultation APIs live in `moon` and search only the target supplied by the caller; they never enumerate the star catalog. Fixed-site APIs take `start`, `end`, longitude, latitude, and elevation directly. - -Global-path results contain WGS84 samples suitable for `moon/svg` or `geojson`. - -Targets use two distinct contact models: - -- **Stars** are point sources. Results contain immersion, greatest occultation, and emersion. -- **Planets** are finite disks. C1/C4 are external contacts; a fully covered disk also has C2/C3 internal contacts. Partial and grazing events have no C2/C3. -- The planetary model uses the equatorial body radius and excludes rings, atmospheric extensions, and oblateness. -- `FindBestStarOccultations` and `FindBestPlanetOccultations` return the global sea-level geometric greatest point. They do not score horizon visibility, lunar altitude, duration, or magnitude. -- `VisibleAtGreatest` only reports visibility at the selected point. -- The query window selects events by their greatest instant. Once selected, complete contacts or a complete global path are returned rather than clipped at the query endpoints. -- Contact times solve topocentric geometry between the target and lunar limb without atmospheric refraction. -- `MoonAltitudeAtGreatest` is the true altitude of the lunar center. `VisibleAtGreatest` reports whether it is at or above the geometric horizon. - -#### Stellar occultations - -Callers supply a `StarCoordinate`. `RA` and `Dec` are degrees; `Epoch` and `Frame` are required. Proper motions use `mas/year`; `ProperMotionRACosDecMasPerYear` follows the usual catalog convention `dRA*cos(Dec)`. - -A coordinate can be constructed directly: - -```go -target := moon.StarCoordinate{ - ID: "HR 4799", - RA: 189.1975, - Dec: -5.831944444444, - Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), - Frame: moon.CoordinateFrameJ2000, - ProperMotionRACosDecMasPerYear: -28, - ProperMotionDecMasPerYear: -18, -} -``` - -Alternatively, explicitly load the embedded 9100-star catalog and convert a `StarData` value with `StarCoordinateFromStarData`. - -The occultation search itself does not load the catalog; calls such as `star.InitStarDatabase`, `StarDataByName`, and `StarDataByHR` do. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" - "b612.me/astro/star" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) - end := start.Add(24 * time.Hour) - - _ = star.InitStarDatabase() - data, _ := star.StarDataByHR(4799) - target, _ := moon.StarCoordinateFromStarData(data) - target.ID = "HR 4799" // Optional display label. - - events, _ := moon.FindStarOccultations( - start, end, target, - 121.56601, 6.80706, 0, - moon.OccultationSearchOptions{}, - ) - for _, event := range events { - fmt.Println(event.TargetID, event.Type) - fmt.Println( - event.Immersion.Format("2006-01-02 15:04:05.000 MST"), - event.Greatest.Format("2006-01-02 15:04:05.000 MST"), - event.Emersion.Format("2006-01-02 15:04:05.000 MST"), - ) - fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest) - } - - paths, _ := moon.FindStarOccultationPaths( - start, end, target, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - ) - for _, path := range paths { - fmt.Println( - path.Start.Time.Format("2006-01-02 15:04:05.000 MST"), - path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"), - path.End.Time.Format("2006-01-02 15:04:05.000 MST"), - ) - fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n", - path.Greatest.Longitude, path.Greatest.Latitude, - path.Greatest.WidthKM, len(path.CenterLine)) - } -} -``` - -Output: - -```text -HR 4799 total -2025-06-05 19:14:01.062 CST 2025-06-05 20:02:06.296 CST 2025-06-05 20:50:10.697 CST -altitude=75.561 visible=true -2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST -greatest=121.566140 6.807079 width=3582.4km center=108 -``` - -Zero-valued `OccultationSearchOptions` select the default search step and safety margin; `MaxEvents > 0` limits output. - -`OccultationPathOptions.Step` controls base time sampling, while `TargetSpacingKM` adaptively refines the center line. Requests exceeding the deterministic budget return `ErrOccultationPathSamplingLimit`. `RiseSetStep` independently samples the six boundaries where local start, greatest, or end coincides with moonrise or moonset; zero uses five minutes, and `DisableRiseSet` omits them. `DisableFootprints` omits the much larger dense instantaneous visible-region features and merges sparse support samples into compact bands while retaining the center line, limits, and six rise/set phase curves, which is useful for ordinary GeoJSON maps. `IncludeFootprintTimeline` retains independently sampled instantaneous footprints alongside the compact band, using `FootprintTimelineStep` for time-axis selection of the currently visible region. - -`OccultationPathOptions.Algorithm` selects the stellar/planetary global-path ephemeris branch. Its zero value or `moon.OccultationPathAlgorithmOptimized` uses checked Cartesian interpolation at 30-minute nodes while retaining the station equations, continuous envelopes, and rise/set curves. `moon.OccultationPathAlgorithmExact` retains the original branch: interpolated candidates, with full-term ephemerides for final solving. Failed table checks fall back to the original branch; evaluations outside the interpolation window use exact ephemerides. Checks are sampled safeguards, not a rigorous error bound at every instant. The branches share the geometric definition, but sample points and GeoJSON bytes need not be identical. Event-only searches, fixed-site contacts, independent instant-footprint APIs, and eclipses are unaffected. - -Both branches retain full-term evaluation of global start/end/greatest markers and center-line widths. Render the complete returned path, including visibility contours; discarding those contours invokes the legacy sampled-footprint fallback, whose boundary is not interchangeable with the analytic visible set. - -In a returned path, `BandContours` are the static contact envelopes, `VisibilityContours` are the time envelope where the Moon is above the horizon, and `Footprints` are instantaneous samples for time-axis detail. They serve different geometry layers and should not be used as substitutes for one another. - -`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` request **greatest-occultation time isolines**. Unlike the solar case, `GreatestTimeValues []float64` carries TT Julian ephemeris days; at most 64 are kept — deduplicated, sorted, and cut to the earliest 64 — and a level outside the visibility window or without a usable branch produces no entry. When it is empty, `GreatestTimeStep` takes over, again only for a positive value, aligned to UTC ticks. Contours land in `StarOccultationPath.GreatestTimeContours` (the planetary path has the same field) as `OccultationGreatestTimeContour` values whose `JDE`, `Time`, and `Segments` mean the same as in the solar case: `Time` keeps the original aligned instant for step-derived levels, while an explicit level is converted from `JDE` and rounded to the millisecond; both carry the UTC zone, whereas branch point times use the path timezone (the solar public layer instead reports `Time` in the input timezone). The boundary rules match as well: curves exist only where the target disk truly overlaps the lunar disk and the Moon is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the occultation-visibility boundary, nothing is continued beyond ±88° latitude, one instant may carry several disconnected branches, and output is unchanged when they are not requested. - -```go -options := moon.OccultationPathOptions{ - Algorithm: moon.OccultationPathAlgorithmExact, // Original branch; omit for optimized. - DisableFootprints: true, -} -``` - -#### Planetary occultations - -Planet targets use the constants from `OccultationMercury` through `OccultationNeptune`. This example solves C1-C4 for the `2025-02-01` occultation of Saturn at a site near the global geometric greatest point: - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) - events, _ := moon.FindPlanetOccultations( - start, start.Add(24*time.Hour), moon.OccultationSaturn, - 104.52219613, 55.25401991, 0, - moon.OccultationSearchOptions{}, - ) - for _, event := range events { - fmt.Println(event.TargetID, event.Type, event.HasInternalContacts) - fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1 - fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2 - fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST")) - fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3 - fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4 - } -} -``` - -Output: - -```text -Saturn total true -2025-02-01 11:29:09.710 CST -2025-02-01 11:29:40.069 CST -2025-02-01 12:00:48.747 CST -2025-02-01 12:32:46.415 CST -2025-02-01 12:33:18.312 CST -``` - -Global `FindPlanetOccultationPaths` results contain both the region where any part of the planetary disk overlaps the Moon and the region where the whole planet is hidden. `HasTotalBand` reports whether a total band exists, and `GreatestTotalWidthKM` is its width at greatest occultation; center lines, limits, and enabled instantaneous footprints all carry sample times. - -#### Lunar-occultation SVG - -`moon/svg` provides both search-and-render and render-an-existing-result entry points: - -- `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs`: fixed-site apparent tracks, the lunar path, and contact-stage panels. -- `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs`: global bands, center lines, event points, and time labels. -- `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG`: render an existing fixed-site event. -- `StarOccultationPathSVG` / `PlanetOccultationPathSVG`: render an existing global path. - -```go -localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( - start, end, target, - 121.56601, 6.80706, 0, - moon.OccultationSearchOptions{}, - moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 700, Location: cst}, -) -fmt.Println(err, len(localSVGs)) -``` - -Local charts use the selected observer's topocentric geometry; the diagram below reuses the `2025-06-05` occultation of HR 4799 from the preceding example. The fixed site is `121.56601°E, 6.80706°N`, near the global geometric greatest point. Its immersion, greatest, and emersion times are the actual topocentric contacts at that site. The diagram also shows lunar orientation, the lunar path, Moon altitude, azimuth, and horizon visibility. - -![2025 fixed-site occultation of HR 4799](doc/lunar-occultation-hr4799-2025-06-05-local-en.svg) - -### Event Maps And GeoJSON - -#### Global visibility-map SVG - -`eclipse/svg` can directly render global solar- and lunar-eclipse maps. A solar map includes the partial-visibility sweep, total/annular central band, center line, global stages, and center-line time labels. It also shows the sunrise/sunset lines for eclipse start, greatest eclipse, and eclipse end; the subsolar point; shadow-axis entry/exit; `P1-P4/U1-U4` contacts; and the timed penumbral and umbral/antumbral outlines that are off by default and drawn on request. A lunar map shows P1/P4 visible hemispheres, moonrise/moonset transition regions, and the region that sees the entire event. - -`LunarEclipseDetailedSVG` merges both lunar charts into one detailed page: a centred summary (greatest eclipse, penumbral/umbral magnitude, gamma, penumbral/umbral radius, Moon distance, Saros series), geocentric Sun and Moon blocks on either side, the shadow-path diagram, a three-column row of durations / arc-minute scale bar / contacts, and the world visibility map with its legend underneath. The shadow geometry comes from `basic.LunarEclipseShadowGeometryAt`, where **gamma is in Earth equatorial radii while the penumbral and umbral radii are in degrees** - multiply a radius by the Earth's parallax at the Moon to get Earth radii. - -```go -package main - -import ( - "os" - "time" - - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - solar, ok := eclipsesvg.SolarEclipseMapSVG( - time.Date(2009, 7, 22, 12, 0, 0, 0, cst), - eclipsesvg.SolarEclipseMapSVGOptions{ - Width: 1200, Height: 800, Location: cst, - Language: "en", - TimeLabelStep: 30 * time.Minute, - }, - ) - if ok { - _ = os.WriteFile("doc/solar-eclipse-yangshan-2009-global-en.svg", []byte(solar), 0o644) - } - - lunar, ok := eclipsesvg.LunarEclipseMapSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, cst), - eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: cst}, - ) - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global-en.svg", []byte(lunar), 0o644) - } - - detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, cst), - eclipsesvg.LunarEclipseDetailedSVGOptions{Language: "en", Location: cst}, - ) - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-detailed-en.svg", []byte(detailed), 0o644) - } -} -``` - -The long orange dashes are the six phase lines where eclipse start, greatest eclipse, and eclipse end coincide with sunrise or sunset. **Instantaneous penumbral and umbral outlines are off by default**; a positive `PenumbralOutlineStep` / `CentralShadowStep` turns them on. Short purple dashes are the local greatest-magnitude contours given by `MagnitudeValues` (0.2/0.4/0.6/0.8 by default), and solid blue lines are greatest-eclipse time isochrones. - -**Greatest-eclipse time isochrones** (solid blue lines) are off by default: every place on one line sees greatest eclipse at the same instant, and a positive `GreatestTimeStep` turns them on, with 30 minutes as the NASA world-map spacing. This `GreatestTimeStep` belongs to the SVG layer and aligns to ticks of the **display timezone** (`Location`), unlike the UTC alignment of the data layer; `eclipse/svg` exposes no explicit-level entry point, so call the data layer directly and pass the instants through `GreatestTimeValues` when another alignment is needed. - -```go -solar, _ := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ - Width: 1200, Height: 800, Location: cst, - GreatestTimeStep: 30 * time.Minute, -}) -``` - -They are not produced by evaluating greatest eclipse on a latitude/longitude grid and contouring it. The instant is fixed first and the zero set of `d(centre-separation squared)/dt = 0` is continued along the curve instead, so the cost scales with curve length rather than with the visible area. Isochrones are drawn only where the solar and lunar disks actually overlap and the Sun is above the geometric horizon (no refraction or semidiameter correction), each branch ending at the horizon or the partial-visibility boundary; nothing is continued beyond ±88° latitude, and one instant may carry several disconnected branches. - -A zero `TimeLabelStep` uses 30 minutes, while a negative value disables center-line time labels. A zero or negative `GreatestTimeStep` draws no greatest-eclipse isochrones (they must be requested explicitly, as in the core layer and `moon/svg`); positive values align to the display timezone, values below one minute use one minute, and at most 64 are generated per request. The recommended NASA world-map spacing is 30 minutes. `MagnitudeValues` uses 0.2/0.4/0.6/0.8 when nil, is disabled by an explicitly empty slice, and otherwise draws exactly the given levels. - -`SolarEclipseMapSVGOptions.EventsTitle` replaces the title of the global-phases block that carries the greatest-eclipse coordinates and the earth-wide central begin/end, falling back to a localized default when empty; `MapTitle` and `Title` cover the map section title and the main title. - -The documented minimum solar-map canvas is **800x560**: a width below 800 or a height below 560 falls back to 960x640, because on a narrower landscape canvas the map frame overlaps the right-hand data grid and the panel row spacing collapses below 1 px. A `PartialStep` below two minutes is treated as two minutes: the partial region is a union of instantaneous footprints whose cost grows with the sample count, and that union needs one self-consistent sweep, so a denser request does not change the product (a one-second step measured 36.8 s / 951 MB and becomes 1.1 s / 30 MB, byte-identical to the default request). The detailed lunar chart derives its page stack from `Height`: 640x420 and 800x600 cannot hold the diagram and map minima and return `false`, while 1000x1414 and 1414x1000 render normally. - -The three suffix-free lunar entries (`LunarEclipseSVG`, `LunarEclipseDetailedSVG`, `LunarEclipseMapSVG`) share one default model: Danjon with a Chauvenet fallback for ultra-shallow penumbral cases, matching `LunarEclipseOnDate`; the `Danjon` / `Chauvenet` variants keep forcing their model. - -Degradable layers tag their actual geometry source in `data-source`, using exactly the vocabulary documented in the `eclipse/svg` package comment: `partial-band-union`, `sampled-footprint-sweep`, `partial-band-contours`, `rise-set-phase-lines`, `magnitude-contours`, `greatest-time-isochrones`, `besselian-critical-envelope`, `paired-limit-chords`, `sampled-open-sweep`, `central-path-limits`, `penumbral-outlines`, `central-shadow-outlines`, `p1-p4-visibility-regions`, `p1-p4-horizon-boundaries`. `PenumbralOutlineStep` and `CentralShadowStep` are **off by default** (zero or negative draws no instantaneous penumbral/umbral outlines, which hide the globe and are absent from NASA world maps); a positive value gives the sampling interval, with values below one minute treated as one minute. - -Solar-eclipse and occultation automatic projection can select a north- or south-polar map when appropriate. Lunar-eclipse maps default to equirectangular. Projection affects SVG presentation only, not the underlying WGS84 result. - -Eclipse maps use `EclipseMapProjectionEquirectangular`, `EclipseMapProjectionNorthPolar`, or `EclipseMapProjectionSouthPolar` to force a projection. Lunar-occultation maps use the corresponding `MapProjection...` constants. `EclipseMapProjectionOrthographic` adds a **detailed orthographic globe**: the view point is the greatest-eclipse point, only the facing hemisphere is drawn, and the map boundary is the great circle of that hemisphere. - -The globe needs neither extra data nor a projection library. Land is rebuilt at run time by decoding the embedded equirectangular base map back to longitude/latitude and projecting it orthographically; clipping to the limb intersects great circles in geographic coordinates and closes each cut ring along the limb arc; the graticule is sampled on the sphere and clipped the same way. - -The orthographic projection also switches to the **NASA composition**: the globe is centred and enlarged, a scale bar sits directly below it, the phase information becomes three panels (penumbral contacts / local circumstances at greatest / umbral contacts), and the legend and footer follow underneath. Other projections keep the original composition. The cost is roughly 1.0 s per map (about 0.85 s for the equirectangular view), and a 1000x1414 globe SVG is about 530 KB. - -The following global maps reuse the dates from the earlier local SVG examples. The 2009 Yangtze River total eclipse, 2012 Xiamen annular eclipse, and 2035 Beijing total eclipse use the equirectangular projection: - -![2009 Yangtze River total solar eclipse global visibility](doc/solar-eclipse-yangshan-2009-global-en.svg) - -The same eclipse as an orthographic globe (`EclipseMapProjectionOrthographic`): - -![2009 Yangtze River total solar eclipse orthographic globe](doc/solar-eclipse-yangshan-2009-globe-en.svg) - -![2012 Xiamen annular solar eclipse global visibility](doc/solar-eclipse-xiamen-2012-global-en.svg) - -![2035 Beijing total solar eclipse global visibility](doc/solar-eclipse-beijing-2035-global-en.svg) - -The partial-eclipse visibility region of the `2012-05-21` annular eclipse includes the North Pole. The same event is therefore shown again with a forced north-polar azimuthal-equidistant projection, making its antimeridian-crossing Arctic visibility easier to inspect. The circular outline is the projection boundary, not an administrative or political boundary: - -![2012 annular solar eclipse north-polar global visibility](doc/solar-eclipse-arctic-2012-global-en.svg) - -The lunar-eclipse example reuses the cross-year total eclipse on `2029-01-01`, separating entire-event visibility, moonrise during eclipse, moonset during eclipse, and unavailable regions: - -![2029 cross-year total lunar eclipse global visibility](doc/lunar-eclipse-2029-01-01-global-en.svg) - -The lunar-eclipse map also has a detailed layout that merges the two charts above into one page: a centred summary (greatest eclipse, penumbral/umbral magnitude, gamma, penumbral/umbral radius, Moon distance, Saros series), geocentric Sun and Moon blocks on either side, the shadow-path diagram, a three-column row of durations / arc-minute scale bar / contacts, and the world visibility map with its legend underneath, on one `1000x1414` page: - -![2029 cross-year total lunar eclipse detailed layout](doc/lunar-eclipse-2029-01-01-detailed-en.svg) - -#### Lunar-occultation detailed SVG - -`moon/svg` composes a whole occultation into one `1000x1414` page through `StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG`: a centred summary, geocentric/topocentric data blocks for the Sun, Moon, and target body, an **orthographic globe** of the global band (northern and southern limits, visible/geometric center lines, the greatest point, immersion/greatest/emersion stage points, and 30-minute time labels), and a footer note. The globe view point is the event centre and only the facing hemisphere is drawn; this layout is fixed to the orthographic sphere and accepts no other projection, and the standalone global band map is `StarOccultationPathSVG` / `FindStarOccultationSVGs` from the "Lunar-occultation SVG" section above. Page data falls into six blocks: geocentric Moon coordinates, target body, band path points, contact times, ephemeris and constants, and libration. A landscape canvas places the blocks to the right of the map in two columns by three rows; a portrait canvas places them below the globe in three columns by two rows. The diagram below is the `2025-06-05` occultation of HR 4799: - -```go -package main - -import ( - "os" - "time" - - "b612.me/astro/moon" - moonsvg "b612.me/astro/moon/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - star := moon.StarCoordinate{ - ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, - Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, - ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, - } - paths, err := moon.FindStarOccultationPaths( - time.Date(2025, 6, 5, 0, 0, 0, 0, cst), - time.Date(2025, 6, 6, 0, 0, 0, 0, cst), - star, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - ) - if err == nil && len(paths) > 0 { - detailed, renderErr := moonsvg.StarOccultationDetailedSVG( - paths[0], star, - moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Language: "en", Location: cst}, - ) - if renderErr == nil { - _ = os.WriteFile("doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg", []byte(detailed), 0o644) - } - } -} -``` - -![2025 detailed layout of the HR 4799 lunar occultation](doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg) - -The globe on this page uses Natural Earth `1:50m` coastlines without administrative boundaries. Setting `GreatestTimeStep` on `moon.OccultationPathOptions` additionally requests **greatest-occultation time isochrones**, with the same meaning as the blue isochrones on solar-eclipse maps: the instant is fixed and the zero set of the separation derivative is continued along the curve. They are opt-in too, and output is unchanged when the option is not set. An occultation is visible worldwide for only a few hours (the HR 4799 sample on this page spans 4 h 33 m), so the spacing is usually tighter than for a solar eclipse, in the 15-30 minute range, and a larger spacing leaves fewer lines on the band; point-source stars define greatest by center separation while finite planetary disks use the outer-contact metric, matching `StarOccultationInfo.Greatest` and `PlanetOccultationInfo.Greatest` respectively. `moon/svg` has no isochrone switch of its own; it only draws the `GreatestTimeContours` already present in the path, so the request has to be made through `OccultationPathOptions` while the path is computed, and line placement follows the core rules (`GreatestTimeStep` aligns to UTC ticks). For a display-timezone grid, convert the instants to TT Julian ephemeris days and pass them as `GreatestTimeValues`. Global immersion and emersion are the instants when the lunar shadow first touches and finally leaves Earth; they are not the fixed site's contact times. - -- Canvas and errors: the detailed layout needs at least `480x320` and derives its layout from the canvas, returning `ErrInvalidOccultationDetailedSVGOptions` when the map and data blocks cannot fit (`800x600`, `1000x1414`, and `1414x1000` render, while `640x420` and `900x400` are rejected); the standalone global band map needs at least `640x480` and returns `ErrInvalidStarOccultationSVGOptions` on a smaller canvas. - -The "band width" label on the diagram is the ground separation of the northern and southern limits at greatest occultation, `GreatestLimitSeparationKM` (about `3666.6 km` here), which is a different convention from the across-center-line width `Greatest.WidthKM` (about `3582.4 km`); the two are not interchangeable. Greatest-time isochrones must be requested explicitly at the path layer through `OccultationPathOptions.GreatestTimeStep`; a compact band using `DisableFootprints` merges once on the first render and then caches for the same path. See the "Lunar-occultation SVG" section above for the detailed layout and the fixed-site charts. - -#### GeoJSON - -`geojson` accepts an already computed solar-eclipse, lunar-eclipse, or lunar-occultation result and returns `[]byte`. Those bytes are one complete UTF-8 RFC 7946 `FeatureCollection`, not an image or compressed payload: they can be written to `.geojson`, passed to `encoding/json`, or served directly to a map client. - -```go -package main - -import ( - "encoding/json" - "fmt" - "time" - - "b612.me/astro/eclipse" - "b612.me/astro/geojson" -) - -func main() { - date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) - partial, ok := eclipse.SolarEclipsePartialFootprints( - date, - eclipse.SolarEclipsePartialFootprintOptions{ - Step: 10 * time.Minute, BoundaryPoints: 180, - }, - ) - if !ok { - return - } - central, hasCentral := eclipse.SolarEclipseCentralPath( - date, - eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}, - ) - var centralPath *eclipse.SolarEclipsePath - if hasCentral { - centralPath = ¢ral - } - - data, err := geojson.MarshalSolarEclipseWithTimeMarkers( - partial, centralPath, - geojson.TimeMarkerOptions{ - Step: 30 * time.Minute, - Location: time.FixedZone("CST", 8*3600), - }, - ) - fmt.Println(err, json.Valid(data)) -} -``` - -Each event type has plain and time-marker variants: - -- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` -- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` -- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers` -- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers` - -Coordinates are WGS84 longitude and latitude; lines and polygons are split at the antimeridian. Timed paths have `times` arrays aligned point-for-point with coordinate segments; `WithTimeMarkers` additionally adds Point Features with `role=time-marker`, whose localized `label` is for display while `time` stays UTC RFC 3339. - -Single-instant primitives (timeline dragging, "exact the moment you stop") and station search horizons: - -- `eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{...})` returns a reusable handle. `ShadowAt(time.Time)` (interpreted as UTC) or `ShadowAtJDE(jdeTT)` (TT) returns the **global umbral footprint** at that instant, and `StationStateAt` / `StationStateAtJDE` returns the **topocentric Sun/Moon geometry** for one station (magnitude, obscuration, center separation, apparent radii, solar altitude/azimuth, total/annular phase flags). Both compute only that: no visibility band, magnitude contours, rise/set boundaries, limits or center line, and an instant without an umbra yields an empty result instead of an error. -- `geojson.MarshalSolarEclipseShadowInstant(instant)` exports only that instant's shadow region plus its physical boundary when the horizon cuts it, with `time`, `source_boundary_closed`, `geometry_role`, `closure`, `delta_t_seconds`, `model` and `interp_signature` properties. The umbra uses `central-shadow-footprint` + `central-shadow-boundary`; setting `Kind` to `SolarEclipseShadowPenumbra` exports the penumbra (partial-eclipse region) as `partial-footprint` + `partial-footprint-boundary` with the same defaults as the packaged partial sampling (96 points plus 200 km refinement), so the same instant matches the sample to about 1e-12 degrees. No shadow yields an empty FeatureCollection. -- Packaged `partial-footprint` features also carry `source_boundary_closed`, `geometry_role`, `closure` and `interp_signature`, and their horizon-cut endpoints are extended to the horizon grazing points, where an endpoint would otherwise deviate by on the order of `36–41 km`. The partial-band fill hints keep the previous closure so the polar face selection does not move. -- Measured cost (native build, single-machine reference values; absolute timings vary with hardware): about **64 µs** per 96-point instant footprint and **20 µs** per station state; constructing the public handle only clamps options (≈0), the first query builds the internal state for the nearest new moon in 39 µs with a 130 µs anchor lookup, cached per event afterwards; batches run at about 75 µs per instant. -- ΔT: an explicit `DeltaTSeconds` applies to that handle only and only changes Earth rotation — the TT geometry stays fixed while the ground footprint shifts by `0.4651 * |ΔΔT| * cos(latitude)` km (see `basic.DeltaTGroundShiftKM`); `<=0` uses the process-wide model. Either way the result reports the ΔT actually used. The library ships no ΔT uncertainty model; use that helper to convert an external σ into a geometric uncertainty. -- Interpolation: both the packaged `central-shadow-footprint` features and the single-instant export carry `interp_signature` (for example `umbra-closed-seg1-pt97`, derived from the physical boundary's vertex count, segment count, closure flag and pole flag); neighbours may be interpolated vertex-wise only while it is identical; a flipped `closed` flag, a changed segment count (antimeridian), a changed vertex count, or empty↔non-empty (near U1/U4) all require the exact instant instead. Measured over two-minute steps, the centroid moves 78–232 km mid-event and up to about 520 km near the contacts. -- Batches: `ShadowBetween(start, end, step)` and `StationStatesBetween(...)` return a timeline-aligned slice whose entries are empty where no shadow exists. -- Single-instant occultation footprints (`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` through `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`) also carry `delta_t_seconds`, `source_boundary_closed`, `geometry_role` and `interp_signature`; when the Moon's horizon cuts them the `closure` has `kind` `target-horizon`, `body` `moon` and a `sublunar` reference instead of the subsolar point. The occultation subsystem keeps the process-wide ΔT and only reports the value used. -- Station searches: `SearchLocalCentralSolarEclipse(date, lon, lat, height, eclipse.SolarEclipseLocalSearchOptions{Kind, MaxYears, Backward, Geometric, Model})` returns `(info, status)` where `status.Exhausted` separates "none within the horizon" from "found"; `MaxYears<=0` uses the legacy-equivalent default horizon (6000 candidate steps, about 992 years because candidates skip non-eclipse seasons). `SolarEclipseCandidates(start, end, options)` returns a geometry-free timetable (greatest time, type, centrality, magnitude, gamma, optional Saros). - -The central-shadow roles in solar-eclipse GeoJSON are a stable contract: `role=central-shadow-footprint` is either absent or a `Polygon`/`MultiPolygon` and never a line. When the horizon cuts the shadow it still describes the full region covered on the ground that instant: the physical boundary is extended to both horizon grazing points and closed by the horizon arc between them, `source_boundary_closed` is `false`, and `closure` (`kind`, `time`, `subsolar`) declares that synthetic arc. The physical boundary alone is exported as `role=central-shadow-boundary`, so callers can stroke it and fill the region without drawing a fake horizon edge. A footprint that has shrunk to nothing at U1/U4 is omitted entirely instead of degrading into a line, and `source_boundary_closed=true` means the ring is closed by the shadow itself and carries no synthetic segment. - -A zero `TimeMarkerOptions.Step` means 30 minutes, a positive step must be at least one minute, and one export is limited to 1440 markers. GeoJSON contains no basemap, national boundaries, styling, or projection; Web Mercator, polar views, tile selection, and political-boundary policy belong to the application. - -### Planets - -#### Inner planets - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/mercury" - "b612.me/astro/venus" -) - -func main() { - // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // Instant of observation. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - // Previous inferior conjunction of Mercury. - fmt.Println(mercury.LastInferiorConjunction(date)) - // Next superior conjunction of Venus. - fmt.Println(venus.NextSuperiorConjunction(date)) - // Previous Mercury station from prograde to retrograde. - fmt.Println(mercury.LastProgradeToRetrograde(date)) - // Next Venus station from retrograde to prograde. - fmt.Println(venus.NextRetrogradeToPrograde(date)) - // Previous greatest eastern elongation of Mercury. - fmt.Println(mercury.LastGreatestElongationEast(date)) - // Next greatest western elongation of Venus. - fmt.Println(venus.NextGreatestElongationWest(date)) - // Venus rise and set times in Xi'an. - fmt.Println(venus.RiseTime(date, lon, lat, height, true)) - fmt.Println(venus.SetTime(date, lon, lat, height, true)) - // Current apparent magnitude of Venus. - fmt.Println(venus.ApparentMagnitude(date)) - // Venus phase angle, illuminated fraction, and bright-limb position angle. - fmt.Println(venus.PhaseAngle(date)) - fmt.Println(venus.Phase(date)) - fmt.Println(venus.BrightLimbPositionAngle(date)) - // Earth-Venus distance. - fmt.Println(venus.EarthDistance(date)) - // Sun-Venus distance. - fmt.Println(venus.SunDistance(date)) -} -``` - -Output: - -```text -2019-11-11 23:21:41.971051096 +0800 CST // previous inferior conjunction of Mercury -2021-03-26 14:57:42.052354216 +0800 CST // next superior conjunction of Venus -2019-11-01 04:31:49.749019145 +0800 CST // previous Mercury station from prograde to retrograde -2020-06-25 02:07:41.599749326 +0800 CST // next Venus station from retrograde to prograde -2019-10-20 12:01:37.740152478 +0800 CST // previous greatest eastern elongation of Mercury -2020-08-13 08:14:46.304587125 +0800 CST // next greatest western elongation of Venus -2020-01-01 10:02:34.172435402 +0800 CST // Venus rise time in Xi'an; no error -2020-01-01 20:25:37.36411482 +0800 CST // Venus set time in Xi'an; no error --4 // Venus apparent magnitude -49.98145049145023 // Venus phase angle, degrees -0.8215177914415865 // illuminated fraction of Venus -255.63802053541346 // bright-limb position angle of Venus, degrees -1.2778819631550336 // Earth-Venus distance, AU -0.7262651056423838 // Sun-Venus distance, AU -``` - -Inner and outer planets also expose `Diameter` / `Semidiameter` and `N` variants, returning geocentric apparent diameter/semidiameter in arcseconds. - -Planet apparent diameter and orbital nodes can be queried directly: - -```go -fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // Martian apparent diameter and semidiameter, arcseconds -fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date)) // Venus ascending-node and descending-node ecliptic longitudes, degrees -``` - -Ascending node / descending node here means the two intersections of the body's orbital plane with the ecliptic: - -- `AscendingNode`: ecliptic longitude where the body crosses from south of the ecliptic to north of it -- `DescendingNode`: ecliptic longitude where the body crosses from north of the ecliptic to south of it -- return values are degrees; for the same instant, descending node is usually about `180°` from ascending node - -For `date := 2020-01-01 08:08:08 CST`, the output is: - -```text -4.287299886569956 2.143649943284978 // Mars apparent diameter and semidiameter, arcseconds -76.86008484515058 256.8600848451506 // Venus ascending-node and descending-node longitudes, degrees -``` - -Mercury and Venus also expose `NextTransit` / `LastTransit` / `ClosestTransit` for geocentric planetary transits. "Geocentric" means the planet disk crosses the solar disk as seen from Earth's center; it does not test whether the Sun is above the horizon at a particular observing site. For observing plans, combine this with local solar altitude and weather. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/mercury" - "b612.me/astro/venus" -) - -func main() { - // Next geocentric Mercury transit after the beginning of 2019. - mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Println(mercuryTransit.Valid) - fmt.Println(mercuryTransit.Start) - fmt.Println(mercuryTransit.InternalStart) - fmt.Println(mercuryTransit.Greatest) - fmt.Println(mercuryTransit.InternalEnd) - fmt.Println(mercuryTransit.End) - fmt.Println(mercuryTransit.Duration) - fmt.Println(mercuryTransit.MinimumSeparationArcsec) - fmt.Println(mercuryTransit.SunSemidiameterArcsec) - fmt.Println(mercuryTransit.PlanetSemidiameterArcsec) - - // Next geocentric Venus transit after the beginning of 2012. - venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Println(venusTransit.Valid) - fmt.Println(venusTransit.Start) - fmt.Println(venusTransit.InternalStart) - fmt.Println(venusTransit.Greatest) - fmt.Println(venusTransit.InternalEnd) - fmt.Println(venusTransit.End) - fmt.Println(venusTransit.Duration) -} -``` - -Output: - -```text -true // a valid geocentric Mercury transit was found -2019-11-11 12:35:31.567597389 +0000 UTC // first contact: Mercury externally enters the solar disk -2019-11-11 12:37:12.817581295 +0000 UTC // second contact: Mercury is fully inside the solar disk -2019-11-11 15:19:48.36056292 +0000 UTC // greatest transit: Mercury center is closest to the Sun center -2019-11-11 18:02:29.176982045 +0000 UTC // third contact: Mercury starts leaving the solar disk -2019-11-11 18:04:10.637948513 +0000 UTC // fourth contact: Mercury externally leaves the solar disk -5h28m39.070351124s // geocentric transit duration from first to fourth contact -75.92400059923187 // minimum Mercury-Sun center separation at greatest transit, arcseconds -968.8881519533047 // solar semidiameter at greatest transit, arcseconds -4.978442871670873 // Mercury semidiameter at greatest transit, arcseconds -true // a valid geocentric Venus transit was found -2012-06-05 22:09:47.466886639 +0000 UTC // first contact: Venus externally enters the solar disk -2012-06-05 22:27:35.865356326 +0000 UTC // second contact: Venus is fully inside the solar disk -2012-06-06 01:29:35.572371482 +0000 UTC // greatest transit: Venus center is closest to the Sun center -2012-06-06 04:31:35.068444311 +0000 UTC // third contact: Venus starts leaving the solar disk -2012-06-06 04:49:23.25597167 +0000 UTC // fourth contact: Venus externally leaves the solar disk -6h39m35.789085031s // geocentric transit duration from first to fourth contact -``` - -#### Outer planets - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/jupiter" - "b612.me/astro/mars" - "b612.me/astro/neptune" - "b612.me/astro/saturn" - "b612.me/astro/uranus" -) - -func main() { - // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // Instant of observation. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - // Next opposition of Mars. - fmt.Println(mars.NextOpposition(date)) - // Next conjunction of Jupiter. - fmt.Println(jupiter.NextConjunction(date)) - // Previous Saturn station from prograde to retrograde. - fmt.Println(saturn.LastProgradeToRetrograde(date)) - // Saturn ring observing parameters. - ring := saturn.Ring(date) - fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f dU=%.6f major=%.6f minor=%.6f\n", - ring.EarthLatitude, - ring.SunLatitude, - ring.PositionAngle, - ring.DeltaU, - ring.MajorAxis, - ring.MinorAxis, - ) - // Next Uranus station from retrograde to prograde. - fmt.Println(uranus.NextRetrogradeToPrograde(date)) - // Previous eastern quadrature of Neptune. - fmt.Println(neptune.LastEasternQuadrature(date)) - // Next western quadrature of Mars. - fmt.Println(mars.NextWesternQuadrature(date)) - // Mars rise and set times in Xi'an. - fmt.Println(mars.RiseTime(date, lon, lat, height, true)) - fmt.Println(mars.SetTime(date, lon, lat, height, true)) - // Current apparent magnitude of Mars. - fmt.Println(mars.ApparentMagnitude(date)) - // Earth-Mars distance. - fmt.Println(mars.EarthDistance(date)) - // Sun-Mars distance. - fmt.Println(mars.SunDistance(date)) -} -``` - -Output: - -```text -2020-10-14 07:25:50.441412627 +0800 CST // next opposition of Mars -2021-01-29 09:39:33.697994649 +0800 CST // next conjunction of Jupiter -2019-04-30 10:28:00.187439918 +0800 CST // previous Saturn station from prograde to retrograde -saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // Saturn ring B, B', P, dU, major axis, minor axis -2020-01-11 15:23:23.360308706 +0800 CST // next Uranus station from retrograde to prograde -2019-12-08 17:00:15.517960488 +0800 CST // previous eastern quadrature of Neptune -2020-06-07 03:11:00.026179254 +0800 CST // next western quadrature of Mars -2020-01-01 04:41:29.621566236 +0800 CST // Mars rise time in Xi'an; no error -2020-01-01 14:55:32.963508367 +0800 CST // Mars set time in Xi'an; no error -1.57 // Mars apparent magnitude -2.1844284956325937 // Earth-Mars distance, AU -1.5897860004265403 // Sun-Mars distance, AU -``` - -`saturn.Ring` returns `RingInfo`: `EarthLatitude` is ring opening angle B, `SunLatitude` is B', `PositionAngle` is the position angle of the northern semiminor axis, `DeltaU` is the Saturnicentric longitude difference between the Sun and Earth in the ring plane, and `MajorAxis` / `MinorAxis` are the apparent outer major/minor axes in arcseconds. - -#### Planetary physical ephemerides - -All seven major planets provide `Physical` / `PhysicalN` for disk orientation, sub-Earth/sub-Sun coordinates, and north-pole position angle. Jupiter additionally exposes System I/II/III central meridians, and Saturn exposes ring parameters. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/jupiter" - "b612.me/astro/saturn" -) - -func main() { - date := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) - - // Jupiter: DS and DE are planetocentric declinations of the Sun and Earth relative to Jupiter's equator. - // CMI/CMII/CMIII are Jupiter System I/II/III central meridians, degrees. - j := jupiter.Physical(date) - fmt.Printf("jupiter DS=%.6f DE=%.6f CMI=%.6f CMII=%.6f CMIII=%.6f\n", - j.DS, - j.DE, - j.CentralMeridianSystemI, - j.CentralMeridianSystemII, - j.CentralMeridianSystemIII, - ) - - // Saturn ring: B/B' are ring-plane latitudes seen from Earth and Sun; P is the position angle of the ring minor axis. - ring := saturn.Ring(date) - fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f major=%.6f minor=%.6f\n", - ring.EarthLatitude, - ring.SunLatitude, - ring.PositionAngle, - ring.MajorAxis, - ring.MinorAxis, - ) -} -``` - -Output: - -```text -jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // Jupiter DS/DE and System I/II/III central meridians, degrees -saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // Saturn ring B, B', minor-axis position angle, outer major/minor axes -``` - -If only Jupiter central meridians are needed: - -```go -cm := jupiter.CentralMeridians(date) -fmt.Printf("CMI=%.6f CMII=%.6f CMIII=%.6f\n", cm.SystemI, cm.SystemII, cm.SystemIII) // Jupiter System I/II/III central meridians -``` - -Saturn and Uranus also retain explicit `System III` semantic aliases: - -```go -sat3 := saturn.PhysicalSystemIII(date) -ura3 := uranus.PhysicalSystemIII(date) -fmt.Printf("saturn systemIII lon=%.6f lat=%.6f P=%.6f\n", sat3.SubEarthLongitude, sat3.SubEarthLatitude, sat3.NorthPolePositionAngle) // Saturn sub-Earth longitude/latitude and north-pole position angle -fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude, ura3.SubEarthLatitude, ura3.NorthPolePositionAngle) // Uranus sub-Earth longitude/latitude and north-pole position angle -``` - -#### Galilean satellites of Jupiter - -The `jupiter` package provides apparent positions, instantaneous phenomena, and event searches for the four Galilean satellites. - -Common entry points: - -- `Satellites`: instantaneous apparent positions relative to Jupiter's disk -- `SatellitePhenomena`: instantaneous transit, occultation, eclipse, and shadow-transit flags -- `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`: search whole phenomenon intervals -- `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`: search IMCCE-style D/F contact events - -Two conventions matter: - -- `GalileanPhenomenonEvent` treats the satellite as a point and checks when its center enters or leaves Jupiter's disk. It is suitable for fast phenomenon search and internal state checks. -- `GalileanPhenomenonContactEvent` includes the finite disk of the satellite and splits disappearance and reappearance contact windows. It is the better match for IMCCE tables such as `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F`. - -The two conventions may differ by up to about 7 minutes in duration. This is a definition difference, not a timing-accuracy failure. Use `GalileanPhenomenonContactEvent` for observing predictions and direct comparison with public almanacs. - -##### Code example - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/jupiter" -) - -func main() { - date := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) - - // Instantaneous positions of the four satellites relative to Jupiter's center. - sats := jupiter.Satellites(date) - fmt.Printf("io x=%.6f y=%.6f front=%v\n", sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) - fmt.Printf("europa ra=%.6f dec=%.6f\n", sats.Europa.ApparentRA, sats.Europa.ApparentDec) - - // Instantaneous phenomenon flags. - ph := jupiter.SatellitePhenomena(date) - fmt.Printf("io transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Io.Transit, ph.Io.Occultation, ph.Io.Eclipse, ph.Io.ShadowTransit) - fmt.Printf("europa transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Europa.Transit, ph.Europa.Occultation, ph.Europa.Eclipse, ph.Europa.ShadowTransit) - - // Next full Io transit event. - event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteIo, jupiter.GalileanPhenomenonTransit) - fmt.Printf("event valid=%v sat=%d type=%s\n", event.Valid, event.Satellite, event.Type) - fmt.Println(event.Start) - fmt.Println(event.Greatest) - fmt.Println(event.End) - fmt.Println(event.Duration) - - // Next IMCCE-style contact window for a Europa occultation. - contact := jupiter.NextGalileanPhenomenonContactEvent(date, jupiter.GalileanSatelliteEuropa, jupiter.GalileanPhenomenonOccultation) - fmt.Printf("contact valid=%v sat=%d type=%s\n", contact.Valid, contact.Satellite, contact.Type) - fmt.Println(contact.Disappearance.Start) - fmt.Println(contact.Disappearance.ModelCrossing) - fmt.Println(contact.Disappearance.End) - fmt.Println(contact.Greatest) - fmt.Println(contact.Reappearance.Start) - fmt.Println(contact.Reappearance.ModelCrossing) - fmt.Println(contact.Reappearance.End) -} -``` - -Output: - -```text -io x=-0.675026 y=-0.032798 front=true // Io X/Y offset from Jupiter center, in Jupiter radii; in front of Jupiter -europa ra=110.769133 dec=22.335828 // Europa apparent RA and Dec, degrees -io transit=true occultation=false eclipse=false shadow=true // Io is transiting, and its shadow is also transiting -europa transit=false occultation=false eclipse=false shadow=false // Europa has no transit, occultation, eclipse, or shadow transit at this instant -event valid=true sat=1 type=transit // next valid event is an Io transit -2026-01-16 16:32:47.552742362 +0000 UTC // Io transit begins -2026-01-16 17:40:44.189371168 +0000 UTC // midpoint of the Io transit -2026-01-16 18:48:40.287077128 +0000 UTC // Io transit ends -2h15m52.734334766s // Io transit duration -contact valid=true sat=2 type=occultation // next valid contact event is a Europa occultation -2026-01-17 01:00:34.99533087 +0000 UTC // Europa occultation disappearance starts -2026-01-17 01:02:31.714070141 +0000 UTC // model center crossing during disappearance -2026-01-17 01:04:28.432809412 +0000 UTC // disappearance ends -2026-01-17 02:27:37.807798683 +0000 UTC // deepest occultation -2026-01-17 03:50:48.120300471 +0000 UTC // reappearance starts -2026-01-17 03:52:43.901527225 +0000 UTC // model center crossing during reappearance -2026-01-17 03:54:39.68275398 +0000 UTC // reappearance ends -``` - -##### External baselines - -The Galilean-satellite implementation was checked against two external baselines: - -- **JPL Horizons**: apparent positions of the four satellites relative to Jupiter's center, and shadow-center offsets from Jupiter's disk during shadow transits. -- **IMCCE 2026 tables**: transits, occultations, Jupiter eclipses, shadow transits, and D/F contact windows. - -Current test results in summary: - -- `Satellites` positions relative to Jupiter center: maximum sample difference against JPL Horizons about `X=0.054"`, `Y=0.048"`. -- `SatellitePhenomena` shadow-transit shadow-center offsets: maximum sample difference against JPL Horizons about `X=0.051"`, `Y=0.016"`; boolean phenomenon flags match in the samples. -- `GalileanPhenomenonContactEvent` against IMCCE 2026 tables (8 samples, all four D1/D2/F1/F2 contacts compared): maximum contact-time difference about `79 s`, maximum contact-duration difference about `17 s`, pinned by a regression test with `120 s` / `25 s` ceilings. -- `GalileanPhenomenonEvent` is not the IMCCE D/F contact convention. Direct comparison of its start/end times with IMCCE contact tables can differ by up to about `7 min` because the event definitions are different. - -### Stars - -The built-in star database holds 9100 stars (BSC / HR numbers `1–9110`, apparent magnitudes `-1.46` to `7.96`) and propagates proper motion automatically. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/star" - "b612.me/astro/tools" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - // Instant of observation. - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - // Initialize the star catalog. - star.InitStarDatabase() - sirius, _ := star.StarDataByHR(2491) // Sirius - ra, dec := sirius.RaDecByDate(date) - // Rise time of Sirius. - riseDate, _ := star.RiseTime(date, ra, dec, 115, 40, 0, true) - fmt.Println(riseDate) - // Set time of Sirius. - setDate, _ := star.SetTime(date, ra, dec, 115, 40, 0, true) - fmt.Println(setDate) - // English constellation containing Sirius. - fmt.Println(star.ConstellationEN(ra, dec, date)) - - // Vega. - vega, _ := star.StarDataByHR(7001) // Vega - ra, dec = vega.RaDecByDate(time.Date(13600, 1, 1, 0, 0, 0, 0, time.Local)) - // Right ascension of Vega in year 13600. - fmt.Println(tools.Format(ra/15, 1)) - // Declination of Vega in year 13600. - fmt.Println(tools.Format(dec, 0)) - - // First entry in the brightest-star list. - bright, _ := star.TopBrightStars() - fmt.Println(bright[0].CommonName, bright[0].HR, bright[0].Mag) -} -``` - -Output: - -```text -2019-12-31 19:22:56.144202053 +0800 CST // rise time of Sirius -2020-01-01 05:30:39.802506566 +0800 CST // set time of Sirius -Canis Major // English constellation containing Sirius -6h3m46.61s // right ascension of Vega in year 13600 -84°18′27.15″ // declination of Vega in year 13600 -Sirius 2491 -1.46 // first brightest-star entry: common English name, HR number, apparent magnitude -``` - -### Coordinate Tools - -`coord` is the user-facing coordinate wrapper. Unless noted otherwise, angles are degrees, sidereal time is in hours, and `time.Time` is treated as an absolute instant and internally converted to UTC. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/coord" -) - -func main() { - date := time.Date(2026, 4, 27, 10, 30, 45, 0, time.FixedZone("CST", 8*3600)) - - eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) - fmt.Println(eq.RA, eq.Dec) - - hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) - fmt.Println(hz.Azimuth, hz.Altitude, hz.Zenith) - - top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) - fmt.Println(top.RA, top.Dec) - - // Manual local sidereal time; the library does not compute sidereal time from date here. - manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, 83.6331, 22.0145, 31.2) - fmt.Printf("manual az=%.6f alt=%.6f zen=%.6f ha=%.6f\n", - manual.Azimuth, - manual.Altitude, - manual.Zenith, - manual.HourAngle, - ) - - // ICRS/J2000 equatorial coordinates to Galactic coordinates. - gal := coord.EquatorialToGalactic(266.4051, -28.936175) - fmt.Printf("gal lon=%.6f lat=%.6f\n", gal.Lon, gal.Lat) - - // Atmospheric refraction: estimate apparent altitude from true altitude. - fmt.Printf("apparent alt=%.6f\n", coord.ApparentAltitude(10, 1010, 0)) -} -``` - -Output: - -```text -143.72223158223719 19.53512536790277 // RA and Dec converted from ecliptic coordinates -43.46959597099446 -17.686623571613737 107.68662357161374 // azimuth, altitude, zenith distance -144.2551242046188 18.790254631841993 // topocentric RA and Dec -manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900 // manual-LST horizontal result and hour angle -gal lon=0.000047 lat=-0.000079 // Galactic longitude and latitude -apparent alt=10.093428 // apparent altitude after refraction estimate -``` - -Research-style `coord` helpers do not automatically substitute the current obliquity or sidereal time. They are useful for experiments with custom axial tilts or manually specified hour angles. For ordinary observing calculations, use the `time.Time` based APIs such as `EclipticToEquatorial` and `EquatorialToHorizontal`. - -Observing helpers: - -- `ParallacticAngle` / `ParallacticAngleByHourAngle`: parallactic angle, or the direction angle of the zenith at the target -- `Airmass...FromApparentAltitude`: apply empirical airmass formula directly when apparent altitude is already known -- `Airmass...FromTrueAltitude`: estimate refraction from pressure/temperature, convert true altitude to apparent altitude, then compute airmass - -```go -// Parallactic angle of the target, useful for camera rotation, spectrograph slit direction, and field orientation. -q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) - -// With true altitude as input, estimate refraction first and then compute empirical airmass. -x := coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0) -fmt.Printf("q=%.6f airmass=%.6f\n", q, x) -``` - -The same observing helpers are also exposed in `sun`, `moon`, `star`, and the seven major-planet packages. If apparent altitude is already available and only the raw formula is needed, use `formula.Airmass...`. - -### Formula Helpers - -`formula` contains common formulas that do not depend on a specific date or ephemeris. They are useful for estimates, teaching, and lightweight research. - -```go -package main - -import ( - "fmt" - - "b612.me/astro/formula" -) - -func main() { - // Empirical limiting magnitude for a 70 mm refractor at a site with naked-eye limit 6. - fmt.Printf("limiting=%.6f\n", formula.LimitingMagnitudeEmpirical(70, 6)) - - // Synodic period of Earth and Venus. Inputs and output are days. - fmt.Printf("synodic=%.6f\n", formula.SynodicPeriod(365.25636, 224.70069)) - - // Apparent magnitude of a Sun-like absolute-magnitude object at 100 pc. - fmt.Printf("apparent=%.6f\n", formula.ApparentMagnitudeFromAbsolute(4.83, 100)) - - // Treat the Sun as a 5772 K blackbody; compute peak wavelength and total radiant exitance. - fmt.Printf("peak=%.9em flux=%.6e\n", - formula.WienPeakWavelength(5772), - formula.StefanBoltzmannFlux(5772), - ) -} -``` - -Output: - -```text -limiting=11.000000 // empirical telescope limiting magnitude -synodic=583.920635 // Earth-Venus synodic period, days -apparent=9.830000 // apparent magnitude at 100 pc -peak=5.020394932e-07m flux=6.293859e+07 // blackbody peak wavelength and radiant exitance -``` - -For raw airmass formulas without coordinate-layer refraction correction: - -- `AirmassPlaneParallel`: true altitude input, equivalent to the geometric `sec(z)` approximation -- `AirmassPlaneParallelByZenithDistance`: zenith-distance input -- `AirmassKastenYoung` / `AirmassPickering`: apparent-altitude input, no automatic refraction correction - -```go -fmt.Println(formula.AirmassPlaneParallel(30)) // plane-parallel airmass from true altitude -fmt.Println(formula.AirmassKastenYoung(5)) // Kasten-Young airmass from apparent altitude -fmt.Println(formula.AirmassPickering(5)) // Pickering airmass from apparent altitude -fmt.Println(formula.AirmassPlaneParallelByZenithDistance(60)) // plane-parallel airmass from zenith distance -``` - -### Generic Small-Body Orbits - -`orbit` propagates heliocentric two-body positions from orbital elements. It supports asteroids, comets, dwarf planets, and custom hypothetical orbits. The seven major planets are still computed by their own packages using built-in VSOP87 analytical terms. - -`orbit.Elements` supports two common forms: - -- classical elliptical elements: `A/E/I/Omega/W/M0` -- perihelion form: `Q/E/I/Omega/W/TpJD`, useful for comets and high-eccentricity orbits - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/orbit" -) - -func main() { - // Classical elliptical elements for 1 Ceres, referenced to J2000 mean ecliptic/equinox. - ceres := orbit.Elements{ - EpochJD: 2461000.5, - A: 2.765615651508659, - E: 0.07957631994408416, - I: 10.58788658206854, - Omega: 80.24963090816965, - W: 73.29975464616518, - M0: 231.5397330043706, - } - ceresPos := orbit.ApparentGeocentricEquatorial( - time.Date(2025, 11, 12, 0, 0, 0, 0, time.UTC), - ceres, - ) - fmt.Printf("ceres ra=%.6f dec=%.6f distance=%.6f\n", ceresPos.RA, ceresPos.Dec, ceresPos.Distance) - - // Halley's Comet example using perihelion distance Q and perihelion passage time TpJD. - halley := orbit.Elements{ - Q: 0.5870992, - E: 0.9671429, - I: 162.26269, - Omega: 58.42008, - W: 111.33249, - TpJD: 2446467.395, - } - halleyPos := orbit.ApparentGeocentricEquatorial( - time.Date(1986, 2, 9, 0, 0, 0, 0, time.UTC), - halley, - ) - fmt.Printf("halley ra=%.6f dec=%.6f distance=%.6f\n", halleyPos.RA, halleyPos.Dec, halleyPos.Distance) -} -``` - -Output: - -```text -ceres ra=7.739532 dec=-10.625981 distance=2.164391 // apparent geocentric RA, Dec, and distance of Ceres -halley ra=312.112360 dec=-11.826451 distance=1.533936 // apparent geocentric RA, Dec, and distance of Halley's Comet -``` - -Orbital elements have epochs. The farther the target date is from the epoch, the more static-element error can grow. If the source provides long-term linear rates such as `ADot/EDot/IDot/OmegaDot/WDot/MDot`, they can be filled into `Elements` to reduce medium- and long-term drift. - -Common observing geometry and lightweight photometry helpers: - -```go -r := orbit.SunDistance(when, ceres) // heliocentric distance -delta := orbit.EarthDistance(when, ceres) // geocentric distance -elong := orbit.Elongation(when, ceres) // solar elongation -phase := orbit.PhaseAngle(when, ceres) // phase angle -k := orbit.IlluminatedFraction(when, ceres) // illuminated fraction -mag := orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12) // H-G asteroid magnitude -q := orbit.ParallacticAngle(when, ceres, 121.4737, 31.2304, 20) // parallactic angle from a site - -fmt.Printf("r=%.6f delta=%.6f elong=%.6f phase=%.6f k=%.6f mag=%.3f q=%.6f\n", - r, delta, elong, phase, k, mag, q) -``` - -Treat an orbit as an observable target for topocentric pointing: - -```go -site := time.FixedZone("CST", 8*3600) -when := time.Date(2025, 11, 21, 20, 0, 0, 0, site) - -alt := orbit.Altitude(when, ceres, 121.4737, 31.2304, 20) // topocentric altitude -az := orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20) // topocentric azimuth -rise, _ := orbit.RiseTime(time.Date(2025, 11, 21, 0, 0, 0, 0, site), ceres, 121.4737, 31.2304, 20, true) // rise time - -fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) -``` - -These observing helpers work on topocentric apparent coordinates and suit rise/set and pointing support for asteroids, comets, or custom two-body targets. - -`orbit` also includes a lightweight visual-binary solver using the classical apparent-orbit formula from chapter 55 of *Astronomical Algorithms*: - -```go -gammaVir := orbit.VisualBinaryElements{ - PeriodYears: 171.37, - PeriastronYear: 1836.433, - Eccentricity: 0.8808, - SemiMajorAxis: 3.746, - Inclination: 146.05, - AscendingNode: 31.78, - PeriastronArgument: 252.88, -} -vb := orbit.VisualBinary(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), gammaVir) -fmt.Printf("theta=%.6f rho=%.6f\n", vb.PositionAngle, vb.Separation) // position angle and separation -``` - -### Sundial And Apparent Solar Time - -`sundial` groups apparent solar time, solar hour angle, and the geometry needed to draw sundials. It uses the same underlying `sun` APIs and does not introduce a separate solar algorithm. - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/sundial" -) - -func main() { - date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) - lon, lat := 121.4737, 31.2304 - - trueSolar := sundial.TrueSolarTime(date, lon) - hourAngle := sundial.HourAngle(date, lon) - lineAngle := sundial.HorizontalHourLineAngle(lat, -45) - lineAngleNow := sundial.HorizontalHourLineAngleAt(date, lon, lat) - - fmt.Println(trueSolar) - fmt.Printf("hour angle=%.6f line@9am=%.6f line@now=%.6f\n", hourAngle, lineAngle, lineAngleNow) -} -``` - -Meaning: - -- `TrueSolarTime`: local apparent solar time at the specified longitude -- `MeanSolarTime`: local mean solar time at the specified longitude -- `HourAngle`: signed solar hour angle, negative before noon and positive after noon -- `MeanSolarHourAngle` / `ZoneTimeHourAngle`: convert local mean solar time or zone-clock time to apparent solar hour angle -- `PlanarDial` / `Geometry` / `ShadowPointByHourAngleDeclination`: general geometric core for a planar sundial -- `PlaneIlluminatedHourAngleIntervals` / `IlluminatedHourAngleIntervals`: analytic hour-angle intervals for plane illumination and usable sunlight -- `DeclinationCurve` / `DeclinationCurveAt`: segmented sundial curve samples by declination or date -- `MeanSolarTimePoint` / `ZoneTimePoint` / `MeanSolarTimeLine` / `ZoneTimeLine`: attach mean-time or zone-time lines directly to the dial geometry -- `EquatorialNorthDial` / `EquatorialSouthDial` / `HorizontalDial` / `VerticalDial`: special cases for equatorial, horizontal, and vertical dials -- `HorizontalHourLineAngle`: hour-line angle on a horizontal sundial for a given latitude and hour angle -- `HorizontalHourLineAngleAt`: current horizontal hour-line angle from date, longitude, and latitude - -Notes: - -- `date` passed to `MeanSolarTimePoint` / `MeanSolarTimeLine` should be in the target site's local mean-solar-time zone. The most direct source is `MeanSolarTime(...)`. -- `ZoneTimePoint` / `ZoneTimeLine` ignore the original clock fields in `date`; they use only the calendar date and time zone, then replace the clock reading with `zoneTimeHours`. - -## Implemented - -- ✅ Sun position, altitude, zenith distance, azimuth, culmination, twilight, rise/set, solar terms, solar eclipses, solar physical ephemerides -- ✅ Moon position, altitude, zenith distance, azimuth, culmination, rise/set, phases, lunar eclipses, libration, apsides, maximum declination, and stellar/planetary lunar occultations -- ✅ Global projected SVG maps for solar eclipses, lunar eclipses, and lunar occultations; fixed-site occultation charts; GeoJSON with optional time markers -- ✅ `lite/sun` and `lite/moon` lightweight Sun/Moon chains for minute-level rise/set, lightweight position, and lunar-phase work -- ✅ Earth eccentricity, Sun-Earth distance, perihelion, aphelion -- ✅ Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates -- ✅ Seven major-planet coordinates, Sun/body and Earth/body distances, special events, Mercury/Venus geocentric transits, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes -- ✅ Chinese lunisolar calendar conversion from 721 BC to AD 3000 -- ✅ 9100-star catalog -- ✅ Generic small-body orbit propagation, H-G apparent magnitude, visual-binary position angle and separation -- ✅ Blackbody radiation, synodic periods, magnitudes, telescope formulas, airmass formulas -- ✅ Apparent solar time, planar-sundial geometry, horizontal sundial hour-line angle - -## TODO - -- 🔄 Code normalization and performance optimization -- 🔄 More external baselines and fuller notes on physical-ephemeris conventions -- 🔄 More stellar and deep-sky helper functionality +Model ranges, measured differences and benchmarks are documented under [Accuracy and performance](doc/manual/en/accuracy.md). diff --git a/README.md b/README.md index 6ac29b8..4e4ce4f 100644 --- a/README.md +++ b/README.md @@ -6,2406 +6,94 @@ 自用多年的天文算法库,用于个人天文历法爱好。 ->📚 本项目主要用于天文算法学习与验证,计算结果满足业余爱好级别需求。 - -基于《天文算法》(Astronomical Algorithms)一书实现,覆盖范围见下方[功能概览](#功能概览)。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP2000/82 风格截断解析级数,不依赖外部 JPL 星历文件。 - -没有特殊标注时,本程序所提供的坐标均为瞬时天球坐标;角度单位默认是度,视直径/视半径单位是角秒,距离单位按函数名使用 AU 或 km。 - - -## 目录 - -- [安装](#安装) -- [功能概览](#功能概览) -- [包概览](#包概览) -- [适用范围与精度](#适用范围与精度) -- [快速开始](#快速开始) - - [历法转换与节气](#历法转换与节气) - - [太阳与月亮](#太阳与月亮) - - [Lite 轻量太阳与月亮](#lite-轻量太阳与月亮) - - [月掩](#月掩) - - [天象地图与 GeoJSON](#天象地图与-geojson) - - [行星](#行星) - - [恒星](#恒星) - - [坐标工具](#坐标工具) - - [研究公式](#研究公式) - - [通用小天体轨道](#通用小天体轨道) - - [日晷与真太阳时](#日晷与真太阳时) -- [已实现](#已实现) -- [TODO](#todo) +基于 Jean Meeus《天文算法》(Astronomical Algorithms)实现。太阳和行星使用内置 VSOP87 解析项,月球使用 ELP2000/82 风格截断级数,不依赖外部星历文件。适合历法计算、业余观测和天文算法学习。 ## 安装 -```bash +```sh go get b612.me/astro ``` ## 功能概览 -- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年)、节气时刻 -- 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等 -- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等 -- 🪶 **轻量链路**:`lite/sun` 与 `lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相 -- 🌗 **日月食**:全局日食、站心日食、中心线/偏食足迹、月食、地方可见月食,以及局地示意图和全球见食图 SVG -- 🌘 **月掩**:按指定赤经赤纬搜索恒星月掩,按有限圆盘计算行星月掩,支持指定地点接触时刻、全球掩带、几何掩甚点和 SVG -- 🗺️ **地理输出**:日食、月食和月掩结果可编码为带时间数据与可选时间标记的 GeoJSON;全球 SVG 使用无行政边界海岸线,并支持等经纬和南北极投影 -- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历 -- ⭐ **恒星计算**:指定天球坐标所属星座;同时内置 9100 颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息 -- 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标 -- 🔭 **研究公式**:黑体辐射、会合周期、星等距离换算、望远镜极限星等、恒星半径/温度/光度换算、大气质量模型 -- ☄️ **通用轨道**:给定小行星、彗星或假想天体轨道根数,计算日心/地心位置和站心视位置,并提供距日/距地距离、日距角、相位角、照明比例、H-G 视星等和轻量视双星位置角/角距计算 -- 🕰️ **日晷**:真/平太阳时换算、太阳时角、平太阳时/区时时角、平面日晷几何、赤道/水平/垂直日晷特例 +- 公历与农历互转,支持公元前 721 年至公元 3000 年;节气、干支、年号与古历。 +- 太阳、月亮、七大行星的位置、升落、中天、距离、视直径与物理星历;另有轻量日月算法。 +- 日月食、恒星与行星月掩的事件搜索、地方接触时刻和全球路径。 +- SVG 天象图、GeoJSON 地理数据和可供 Google Earth 查看及按时间播放的 KML。 +- 内置 9100 颗恒星,支持星座判定、坐标修正与升落计算。 +- 天球坐标转换、恒星时、岁差章动、折射、通用小天体轨道、日晷和常用天文公式。 + +## 使用示例 + +计算西安某日的日出时刻、月相,以及当天的农历日期: + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/calendar" + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 2, 17, 12, 0, 0, 0, cst) + + rise, err := sun.RiseTime(date, 108.93, 34.27, 0, true) + if err != nil { + log.Fatal(err) + } + fmt.Println("日出:", rise.Format("15:04:05")) + fmt.Println("月相:", moon.PhaseDesc(date)) + + day, err := calendar.SolarToLunar(date) + if err != nil { + log.Fatal(err) + } + fmt.Println("农历:", day.Lunar().MonthDay()) +} +``` + +经纬度以东经、北纬为正,单位为度;示例高度为 0 米,`true` 表示升落计算考虑大气折射。极昼、极夜或当天没有升落事件时,升落接口会返回错误。 ## 包概览 -| 包 | 主要能力 | +| 包 | 用途与文档 | | --- | --- | -| `calendar` | 公历/农历互转、节气、历史朝代年号、古代历法信息 | -| `coord` | 黄道/赤道/地平互转、恒星时、岁差、章动、站心坐标、大气折射、大气质量、视差角、银道坐标、手动黄赤交角和手动时角的研究接口 | -| `sun` | 太阳位置、日出日落、晨昏朦影、均时差、真太阳时、视高度角、视差角、视直径、日面 `P/B0/L0` | -| `moon` | 月亮位置、月出月落、月相、朔望弦、视高度角、视差角、视直径、亮边位置角、地心/站心天平动、近远地点、交点、最大赤纬,以及恒星/行星月掩和全球掩带 | -| `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 | -| `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、局地示意图与全球见食图 SVG | -| `moon/svg` | 指定地点恒星/行星月掩视圆图,以及带掩带、中心线和时间标记的全球投影 SVG | -| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON | -| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 | -| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 | -| `earth` | 地球轨道偏心率、近日点、远日点 | -| `star` | 星座判定、恒星数据库、恒星自行/岁差/章动修正、恒星升落、视差角、视高度角 | -| `formula` | 与具体日期无关的常用研究公式和天文奥赛公式,含大气质量模型 | -| `orbit` | 通用日心二体圆锥曲线轨道传播,支持椭圆、近抛物、抛物和双曲轨道;另含相位/测光辅助和轻量视双星计算 | -| `sundial` | 真/平太阳时换算、太阳时角、平太阳时/区时时角、平面日晷几何、时间线/赤纬曲线采样、赤道/水平/垂直日晷特例 | +| `calendar` | [历法转换、节气、干支与古历](doc/manual/calendar.md) | +| `sun` / `moon` / `lite/sun` / `lite/moon` | [太阳与月亮的位置、升落、月相和物理量](doc/manual/sun-moon.md) | +| `mercury` / `venus` / `mars` / `jupiter` / `saturn` / `uranus` / `neptune` / `earth` | [行星位置、特殊天象、木星卫星与土星环](doc/manual/planets.md) | +| `eclipse` / `eclipse/svg` | [日月食查询、地方可见性、路径与出图](doc/manual/eclipse.md) | +| `moon` / `moon/svg` | [恒星及行星月掩、接触时刻与掩带](doc/manual/occultation.md) | +| `geojson` / `kml` | [天象地图、GeoJSON 与 KML](doc/manual/map-geojson.md) | +| `star` | [恒星数据库、星座与观测量](doc/manual/star.md) | +| `coord` | [坐标转换、恒星时、岁差、章动与折射](doc/manual/coord.md) | +| `orbit` | [小行星、彗星等二体轨道与视双星](doc/manual/orbit.md) | +| `sundial` | [真太阳时与日晷几何](doc/manual/sundial.md) | +| `formula` | [辐射、星等、会合周期与望远镜公式](doc/manual/formula.md) | +| `astro`(根包) | [UTC、UT1、TT 换算与 ΔT 模型](doc/manual/timescale.md) | -一些接口额外提供 `...N` 截断版本: +`basic` 是底层算法包,`planet` 保存解析级数,`tools` 提供数值辅助函数。一般使用上表中的包即可;完整函数签名也可查 [Go API 文档](https://pkg.go.dev/b612.me/astro)。 -- `n < 0`:使用本仓库当前内置的全部解析项 -- `n >= 0`:截断解析项,适合性能对比、粗算或算法研究 +## 时标约定 -这里的“全部解析项”指package中已经内置的表项,不等同于外部发行版 VSOP/ELP 长表的全部原始数据。 +一般观测接口接收表示民用时刻的 `time.Time`,内部处理 UTC、UT1 与 TT 换算。1972 年以前,库将民用时间按 UT1 处理,之后按UTC处理;轨道历元和部分底层接口另有时标要求。 + +角度默认用度,视直径用角秒,恒星时用小时,距离按接口使用 AU 或 km。SVG 和 GeoJSON 默认输出 UTC 时间标签,也可显式选择 UT1。具体约定见[时标手册](doc/manual/timescale.md)。 + +DUT1 与 TT−UTC 在实测窗口(截至 2026 年 9 月)之后默认沿用现行闰秒规则外推,可用 `SetTimeScaleFuturePolicy` 调整 TT−UTC 口径到"冻结偏移量"、"继续跟随 UT1"、"闰时规则"、"持续等价UT1"四个规则。 + +## 观测点高度约定 + +观测点高度为椭球高,单位米。若只有海拔(正高)`H`,应结合当地大地水准面差距 `N` 换算:`height = H + N`。详见[观测点高度](doc/manual/coord.md#观测点高度)。 ## 适用范围与精度 -### 太阳与行星 +本库使用解析模型和截断级数。精度随年代、天体与计算项目变化;专业掩星、航天导航等用途需要更精确的星历和物理模型。 -太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。下表列出相对完整 VSOP87 的截断误差量级: - -| 目标 | 黄经/黄纬 | 距离 | -| --- | --- | --- | -| 太阳/地球 | 约 `0.1"` | 约 `0.1 × 10^-6 AU` | -| 水星、金星 | 约 `0.2"` | 约 `0.2 × 10^-6 AU` | -| 火星 | 约 `0.5"` | 约 `1 × 10^-6 AU` | -| 木星 | 约 `0.5"` | 约 `3 × 10^-6 AU` | -| 土星 | 约 `0.5"` | 约 `5 × 10^-6 AU` | -| 天王星 | 约 `1"` | 约 `20 × 10^-6 AU` | -| 海王星 | 约 `1"` | 约 `40 × 10^-6 AU` | - -这类精度适合常规天文历法、观测辅助、科普展示和个人研究;航天导航、精确掩星预报和严格动力学积分不在该范围内,这类用途通常需要 JPL DE 等专业星历。 - -### 月球 - -月球使用内置的 ELP2000/82 风格截断解析级数,库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;极高精度月球测距、长期物理天平动和专业掩星超出该范围,这类用途以 JPL 星历或专门月球星历为准。 - -### Lite 轻量链路 - -`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或主链的 ELP2000/82 级数,适合 CPU / 内存受限环境。 - -- `lite/sun`:简化太阳真黄经 / 视黄经公式 + 轻量赤道坐标转换 -- `lite/moon`:Schlyter 风格月球近似(约 15 个摄动项)+ 轻量站心修正 -- 升落搜索:固定步长扫描 + 二分,不走主链的高精度章动迭代 -- 计算链路零堆分配(0 allocs/op);相对主链,位置与月相等纯求值接口约快 `8.3–27.3x`,升落接口约 `1.0–3.7x` - -能力边界: - -| 包 | 位置模型 | 升落搜索 | 主要用途 | -| --- | --- | --- | --- | -| `lite/sun` | 简化太阳真/视黄经 + 轻量赤道坐标转换 | `30` 分钟步长扫描 + 二分 | 日出日落、太阳高度角、表盘/前端周期刷新 | -| `lite/moon` | Schlyter / vFPS 月球近似 + 轻量站心修正 | `15` 分钟步长扫描 + 二分 | 月出月落、月相、月龄、轻量月球观测辅助 | - -与 `sun` / `moon` package的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时): - -| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 | -| --- | --- | --- | --- |-----------------------------------| -| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 | -| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜日期归属上有语义差异 | -| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 | -| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 | -| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 与 `moon.Phase` 对比的结果 | -| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | 约平均 4.3 分钟、P95 14.4 分钟、最大 20.2 分钟 | -| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 | -| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 | - -`Go testing.Benchmark` 参考值(单机实测,仅供参考;绝对值因机器而异): - -口径为 2026-01-01 20:00 CST、上海(`121.4737°E, 31.2304°N`)、`height=0`、`aero=true`,表中取 3 次中位数;每项先预热一次,懒加载缓存与首次分配不计入稳态单次开销。 - -| 接口 | 主链 | `lite` | 加速倍数 | 主链分配 | `lite` 分配 | -| --- | --- | --- | --- | --- | --- | -| `Sun ApparentRaDec` | `5.888 µs/op` | `215.6 ns/op` | `27.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun Altitude` | `5.955 µs/op` | `625.9 ns/op` | `9.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun RiseTime` | `95.847 µs/op` | `25.648 µs/op` | `3.7x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon ApparentRaDec` | `16.520 µs/op` | `1.006 µs/op` | `16.4x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Phase` | `15.139 µs/op` | `917.7 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Altitude` | `9.533 µs/op` | `1.150 µs/op` | `8.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon RiseTime` | `120.545 µs/op` | `118.037 µs/op` | `1.0x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | - -主链与 `lite` 的差距随场景变化:位置、月相等纯求值接口约 `8.3–27.3x`;升落接口两边都要做时间搜索,差距缩小到 `1.0–3.7x`(`Moon RiseTime` 已接近持平)。加速倍数来自同一台机器上的对照,受机器影响小于绝对值。 - -日月食、物理天平动或高纬边界判定使用主链 `sun` / `moon`。 - -### 精度校验参考 - -下面这些函数曾与 JPL Horizons、NASA GSFC 等资料对照,可作为使用时判断结果量级的参考: - -- 太阳/行星/月亮视直径:各天体与外部基线的最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感 -- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°` -- 行星升/中天/落:已用 JPL Horizons 电视事件(TVH, Time-Varying Hourly)做对比校验;该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐 -- 月出/月落:`aero=true` 按动态标准折射和实时月球视半径计算上缘过地平线。7 个地点、14 个海平面事件相对 JPL Horizons DE441 的平均/最大差异约 `0.30s / 0.75s` -- 月出/月落的其他口径:相对固定 `-0.8333°` 的 MET Norway(Skyfield 1.53 + DE440s)约 `38.77s / 76.22s`;相对未公开地平线口径的 IMCCE Miriade 平均约 `2m13.46s`,`61°N` 低仰角样本最大约 `6m41.82s` -- 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU` -- 月球主链位置:当前算法为 ELP2000/82 风格截断解析级数;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km` -- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km` -- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°` - -## 快速开始 - -### 历法转换与节气 - -本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(公元前104年为历法表切换点)。 -农历本质上是阴阳合历(Lunisolar Calendar),但为兼顾大众习惯与代码简洁性,相关函数命名采用 `Lunar` 而非更学术的 `Lunisolar`。 - -#### 历法说明 - -- **默认路由**:按年份自动选择,先秦段使用春秋/古六历重建,`-220..-104` 使用秦汉颛顼历,`-103..1912` 使用历表,`1913` 年后使用现代算法。 -- **显式古历**:如果需要指定某一古历系统,请使用 `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` 这类 API。 -- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证校验;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。 -- **节气**:`JieQi` 返回现代天文计算的节气时刻;`CalendricalJieQi` 返回历法相符节气日期。 - ---- - -#### 使用须知 - -##### 1. 同一公历日期可能对应多个农历日期 -在多个政权并存的历史时期(如三国时期),不同政权可能使用不同历法,造成同一公历日期对应多个农历日期。本程序尽可能提供所有可能的转换结果。 - -##### 2. 同一农历日期可能对应多个公历日期 -不仅因多个政权历法不同,同一政权在历法改革中也可能出现此类情况。例如,武则天改历后,圣历三年出现了两个腊月。 - -##### 3. 公历历法处理规则 -本程序基于儒略日进行计算,公历部分处理规则如下: -- 1582年10月15日之后:使用格里高利历 -- 1582年10月4日之前:使用儒略历 -- 公元8年之前:使用逆推儒略历 -- 1582年10月4日的下一天为1582年10月15日 -- 1582年10月5日到10月14日这10个公历日期不存在,相关接口会直接拒绝 -- 年份表示:0年表示公元前1年,-1年表示公元前2年,以此类推 - -##### 4. 时区说明 - -本 package 主要面向中国历法,因此定气和定朔的计算默认采用北京时间(UTC+8)。对于使用其他时区的地区,若直接套用中国农历的编排规则,可能会产生日期偏差。 - -为方便探索与研究,本 package 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)** 进行公历与农历的相互转换。 -如果只需北京时间下的标准转换,请直接使用封装好的 `SolarToLunar` 和 `LunarToSolar` 方法。 - -**示例**:农历规则要求冬至必须落在农历十一月。以1984年冬至为例,计算可得: -```go -ws := calendar.JieQi(1984, 270) -fmt.Println(ws) -fmt.Println(moon.ClosestShuoYue(ws)) -``` - -| 节令 | 东八区 (UTC+8) | 东七区 (UTC+7) | -|------|----------------|----------------| -| 冬至 | 1984-12-22 | 1984-12-21 | -| 朔日 | 1984-12-22 | 1984-12-22 | - -可见,对于东八区(中国),1984年12月22日既是冬至又是朔日,因此该日为农历十一月初一;而在东七区,冬至提前至12月21日,导致12月22日已成为腊月初一。 - -类似地,春节的日期也会相差一天。1985年正月初一对应的公历日期,在东八区为2月20日,在东七区则为1月21日: -```go -fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0)) -fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) -``` - -##### 5. Go 语言特别注意 - -⚠️ Go 标准库 `time.Time` 在历法处理上与本程序存在差异: - -- Go 语言在1582年10月15日之前使用逆推格里高利历,而非儒略历。若不使用 `Add` 方法,一般可正常使用。 -- 因此,**在1582年10月15日之前,`time.Time.Weekday()` 返回结果与本程序计算结果不一致**。 - 例如:1582年10月4日,本程序为星期四,Go 语言判断为星期一。 - -#### 建议解决方案: -如需获得与本程序一致的星期数,可使用如下方法: - -```go -// date 应为当日0时的 time.Time -weekday := int(calendar.Date2JDE(date)+1.5) % 7 -// 0表示星期日,1表示星期一,……,6表示星期六 -``` -在 1582 年之前使用 `time.Time` 的 `Add` 或 `AddDate` 会经过逆推格里高利历,跨过儒略历独有的闰日时与儒略历相差一天。 -例如:700年儒略历为闰年,而 Go 使用的逆推格里高利历中700年不是闰年。 - -##### 6. 儒略历独有的闰日(如 700-02-29) - -1582 年以前"能被 100 整除但不能被 400 整除"的年份(如 100、700、1500 年)在儒略历中有 2 月 29 日, -而 Go 的`time.Time`使用逆推格里高利历,没有这一天(`time.Date(700, 2, 29, ...)` 会被规范化成 700-03-01)。本库承认 700-02-29 这一天存在,对应的约束如下: - -- `Time.JulianOnly()`:该农历日是否只存在于儒略历(对应 JSON 字段 `julianOnly`); -- `Time.JDE()`:该日精确的儒略日;儒略历闰日比 `Solar()` 早一天,其余情况两者一致(对应 JSON 字段 `jde`)。 -- `Time.Solar()` / `LunarTime.SolarDate`:库内标准输出,对于700-02-29Go标准库表示不出来的日期,固定返回为**后一天**(700-02-29 的后一天是 700-03-01),与 `basic.JDE2DateByZone` 的约定一致; - -```go -julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) -fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JDE(), julian.Lunar().MonthDay()) -// 0700-03-01 true 1.9767915e+06 二月初五 -``` - -> 涉及这类日期时,不丢闰日的入口有两类:整型年月日入口 `SolarToLunarByYMD` / `LunarToSolarByYMD`,以及直接调用 -> `basic.JDECalc(700, 2, 29)` 得到精确儒略日 `1976791.5`;先构造 `time.Time` 的那一步就会丢掉闰日。 - -##### 7. 同一农历日的多个公历候选 - -改历双纪年(王莽 9–23 年、魏明帝 237–240 年、武则天 689–700 年、唐肃宗 761–762 年)与太初改历交接 -(公元前 104 年)会让同一个农历日对应两个合法公历日。`Solar()` 仍是库内默认选择,`SolarCandidates()` -返回全部候选、首个恒等于 `Solar()`: - -```go -res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) -fmt.Println(res.Solar().Format("2006-01-02")) -fmt.Println(len(res.SolarCandidates())) -// 0700-12-15 -// 2 -``` - -非改历年份的农历日只有一个候选,`SolarCandidates()` 返回仅含 `Solar()` 的slice;儒略历独有的闰日也只返回 -标准输出,因为它唯一合法的那一天无法表示成 `time.Time`,精确日期见 `JDE()`。 - -#### 历法转换 - -##### 公历转农历 - -- **输入**:公历日期 (`time.Time`) -- **输出**:`calendar.Time` 对象,可能包含多个对应的农历日期 -- **功能**:可从返回对象中获取: - - 农历日期的详细描述 - - 年、月、日的天干地支 - - 所属朝代、皇帝、年号等信息 - - 完整的结构化农历信息 - -##### 农历转公历 - -支持两种调用方式: - -###### 方式一:传入农历字符串 -支持以下格式(示例): -1. `年号+年+月+日`:如 **`"元丰六年十月十二"`**(闰月前加"闰",日期格式为"初一"、"二十"等) -2. `年号+年+月+干支日`:如 **`"元嘉二十七年七月庚午"`** -3. `年份+月+日`:如 **`"二零二五年正月初一"`**(闰月前加"闰",适用于现代日期) -4. `年份+月+干支日`:如 **`"二零二五年正月戊戌日"`** -5. `阿拉伯数字+月+日`:可以将中文数字替换为阿拉伯数字,如 **`"2025年1月1日"`**,代表`二零二五年正月初一` -6. 历史场景:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),这类场景下月份名称按汉字数字解释 - -> ⚠️ 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,对应的字符串是 `"二零二四年腊月廿九"`。 - -###### 方式二:传入数字参数 -- **参数**:年份 (`int`)、月份 (`int`)、日期 (`int`)、是否闰月 (`bool`) -- **语义**:按农历年、月、日与闰月标志定位日期,适用于现代农历日期转换 - -##### 代码示例 - -```go -package main - -import ( - "encoding/json" - "fmt" - "b612.me/astro/calendar" - "time" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - // 示例1:公历转农历;这里故意选三国时期,会返回多个政权并行历法结果。 - date := time.Date(240, 1, 1, 8, 8, 8, 8, cst) - lunar, _ := calendar.SolarToLunar(date) - fmt.Println(lunar.LunarDescWithEmperor()) - - info := lunar.LunarInfo() - data, _ := json.MarshalIndent(info, "", " ") - fmt.Println(string(data)) - - // 示例2:农历转公历(字符串格式);这里用苏轼《记承天寺夜游》的日期。 - solar, _ := calendar.LunarToSolar("元丰六年十月十二日") - for _, v := range solar { - fmt.Println(v.Time()) - fmt.Println(v.LunarDescWithEmperor()) - } - - // 示例3:农历转公历(数字参数格式);2026 年正月初一,也就是春节。 - modernDate, _ := calendar.LunarToSolarByYMD(2026, 1, 1, false) - fmt.Println(modernDate.Time()) -} -``` - -输出结果: - -```text -// 同一公历时刻在三国并立时期会映射到多个政权各自的农历结果 -[魏明帝 景初三年腊月二十 蜀后主 延熙二年冬月十九 吴大帝 赤乌二年冬月二十] -// 结构化农历信息输出;每个对象对应一个政权口径下的结果 -[ - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 12, - "lunarDay": 20, - "isLeap": false, - "lunarMonthDayDesc": "腊月二十", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "魏", - "emperor": "魏明帝", - "nianhao": "景初", - "yearOfNianhao": 3, - "eraDesc": "景初三年", - "lunarWithNianhaoDesc": "景初三年腊月二十", - "chineseZodiac": "羊" - }, - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 11, - "lunarDay": 19, - "isLeap": false, - "lunarMonthDayDesc": "冬月十九", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "蜀", - "emperor": "蜀后主", - "nianhao": "延熙", - "yearOfNianhao": 2, - "eraDesc": "延熙二年", - "lunarWithNianhaoDesc": "延熙二年冬月十九", - "chineseZodiac": "羊" - }, - { - "solarDate": "0240-01-01T08:08:08.000000008+08:00", - "lunarYear": 239, - "lunarYearChn": "二三九", - "lunarMonth": 11, - "lunarDay": 20, - "isLeap": false, - "lunarMonthDayDesc": "冬月二十", - "ganzhiYear": "己未", - "ganzhiMonth": "丙子", - "ganzhiDay": "辛未", - "calendarSystem": "", - "calendarName": "", - "jde": 1808717.8389814815, - "dynasty": "吴", - "emperor": "吴大帝", - "nianhao": "赤乌", - "yearOfNianhao": 2, - "eraDesc": "赤乌二年", - "lunarWithNianhaoDesc": "赤乌二年冬月二十", - "chineseZodiac": "羊" - } -] -// “元丰六年十月十二日”对应的公历日期 -1083-11-24 00:00:00 +0800 CST -// 同一天在并行政权下还会命中辽道宗大康九年十月十二 -[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] -// 现代农历日期转换结果;2026 年正月初一对应 2026-02-17 -2026-02-17 00:00:00 +0800 CST -``` - -#### 节气 - -`JieQi(year, term)` 返回现代天文算法计算出的节气精确时刻;`CalendricalJieQi(year, term)` 返回默认历法下节气落在的日期,时间固定为北京时间当天 0 点。需要指定古历系统时,使用 `CalendricalJieQiWithCalendar(year, term, system)`。 - -```go -package main - -import ( - "fmt" - - "b612.me/astro/calendar" -) - -func main() { - // 计算 2020 年立春时刻;节气常量本质上对应太阳视黄经。 - fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) - // 计算 2020 年冬至时刻。 - fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) - // 计算 2020 年春分时刻。 - fmt.Println(calendar.JieQi(2020, calendar.JQ_春分)) - // 也可直接传入黄经数值;春分对应太阳视黄经 0°。 - fmt.Println(calendar.JieQi(2020, 0)) -} -``` - -输出结果 - -``` -2020-02-04 17:03:20.471614301 +0800 CST -2020-12-21 18:02:20.648710727 +0800 CST -2020-03-20 11:49:37.149532735 +0800 CST -2020-03-20 11:49:37.149532735 +0800 CST - -``` - -历法相符节气示例: - -```go -date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) -fmt.Println(date, err) - -date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan) -fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err) -``` - -输出结果 - -``` -1582-12-22 00:00:00 +0800 CST --202-12-25 -``` - - -### 太阳与月亮 - -#### 观测角语义 - -- `Altitude`:高度角,地平线为 `0°`,天顶为 `+90°` -- `Zenith`:天顶距,天顶为 `0°`,地平线为 `90°` -- `Zenith` 与 `Altitude` 互补,两者相加为 `90°` - -#### 日出日落/月出月落 - -> ⚠️ 月球升降时间按当天日期计算,升降时间点之间不一定具有连续性。 -> -> 例如月亮可能在凌晨1点落下、中午12点再次升起,此时升起时间会晚于降落时间;这一场景晚上的月落时间对应次日日期。 -> -> 完整的升降周期由升起时间与降落时间的先后关系决定:判断升起时间是否在降落时间之后,即可确定后续的正确时间点。 - -```go -package main - -import ( - "fmt" - "b612.me/astro/moon" - "b612.me/astro/sun" - "time" -) - -func main() { - // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // 指定 2020-01-01 08:08:08 CST,所有"今日"语义都以这个本地自然日为基准。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - // 西安市2020年1月1日民用晨朦影开始时间 - // 民用朦影,太阳位于地平线下6度,航海朦影=地平线下12度,天文朦影=地平线下18度 - fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) - // 西安市2020年1月1日日出时间,按动态标准折射和实时太阳视半径计算上缘过地平线 - fmt.Println(sun.RiseTime(date, lon, lat, height, true)) - // 西安市2020年1月1日太阳上中天时间 - fmt.Println(sun.CulminationTime(date, lon)) - // 西安市2020年1月1日日落时间,按动态标准折射和实时太阳视半径计算上缘过地平线 - fmt.Println(sun.SetTime(date, lon, lat, height, true)) - // 西安市2020年1月1日民用昏朦影结束时间 - fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) - - // 西安市2020年1月1日月出时间,按动态标准折射和实时月球视半径计算上缘过地平线 - fmt.Println(moon.RiseTime(date, lon, lat, height, true)) - // 西安市2020年1月1日月亮上中天时间 - fmt.Println(moon.CulminationTime(date, lon, lat)) - // 西安市2020年1月1日月落时间,按动态标准折射和实时月球视半径计算上缘过地平线 - fmt.Println(moon.SetTime(date, lon, lat, height, true)) -} -``` - - -输出结果 - -``` -2020-01-01 07:22:27.960488498 +0800 CST -2020-01-01 07:49:52.413689196 +0800 CST -2020-01-01 12:47:35.933117866 +0800 CST -2020-01-01 17:45:09.188657999 +0800 CST -2020-01-01 18:12:33.624035418 +0800 CST -2020-01-01 11:52:49.860912859 +0800 CST -2020-01-01 17:36:48.811488747 +0800 CST -2020-01-01 23:26:49.313553571 +0800 CST - - -``` - -#### 日月位置 - - -```go -package main - -import ( - "fmt" - "b612.me/astro/moon" - "b612.me/astro/star" - "b612.me/astro/sun" - "b612.me/astro/tools" - "time" -) - -func main() { - // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 - var lon, lat float64 = 108.93, 34.27 - cst := time.FixedZone("CST", 8*3600) - // 指定观测时刻。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - // 太阳此刻的视黄经,单位度。 - fmt.Println(sun.ApparentLo(date)) - // 此刻黄赤交角,第二个参数 true 表示使用真黄赤交角。 - fmt.Println(sun.EclipticObliquity(date, true)) - //太阳此刻视赤经、视赤纬 - ra, dec := sun.ApparentRaDec(date) - fmt.Println("赤经:", tools.Format(ra/15, 1), "赤纬:", tools.Format(dec, 0)) - //太阳当前所在星座 - fmt.Println(star.Constellation(ra, dec, date)) - //此刻西安市的太阳方位角、高度角、天顶距 - fmt.Println("方位角:", sun.Azimuth(date, lon, lat), "高度角:", sun.Altitude(date, lon, lat), "天顶距:", sun.Zenith(date, lon, lat)) - //此刻日地距离,单位为天文单位(AU) - fmt.Println(sun.EarthDistance(date)) - - //月亮此刻站心视赤经、视赤纬 - ra, dec = moon.ApparentRaDec(date, lon, lat) - fmt.Println("赤经:", tools.Format(ra/15, 1), "赤纬:", tools.Format(dec, 0)) - //月亮当前所在星座 - fmt.Println(star.Constellation(ra, dec, date)) - //此刻西安市的月亮方位角、高度角、天顶距 - fmt.Println("方位角:", moon.Azimuth(date, lon, lat), "高度角:", moon.Altitude(date, lon, lat), "天顶距:", moon.Zenith(date, lon, lat)) - //此刻地月距离,单位为千米 - fmt.Println(moon.EarthDistance(date)) -} -``` - -输出结果: - -``` -280.01526210031136 -23.4362178391013 -赤经: 18h43m34.82s 赤纬: -23°3′30.27″ -人马座 -方位角: 120.19477090015224 高度角: 2.4014437419430097 天顶距: 87.59855625805699 -0.983292937163176 -赤经: 23h18m56.24s 赤纬: -10°20′54.42″ -宝瓶座 -方位角: 67.63889332004852 高度角: -45.34916937173283 天顶距: 135.34916937173284 -404238.6096080479 -``` - -太阳还提供 `sun.Physical` / `sun.PhysicalN`,返回: - -- `P`:太阳北极位置角,单位度 -- `B0`:日面中心太阳纬度,单位度 -- `L0`:日面中心卡林顿经度,单位度 - -日月与七大行星还提供统一的 `Diameter` / `Semidiameter`(以及 `N` 版),单位均为角秒: - -```go -fmt.Println(sun.Diameter(date), sun.Semidiameter(date)) -fmt.Println(sun.Physical(date)) -fmt.Println(moon.Diameter(date), moon.Semidiameter(date)) -fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) -``` - -地球和月球还提供轨道距离极值、月球最大赤纬和月球物理观测参数: - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/earth" - "b612.me/astro/moon" -) - -func main() { - // 2026 年地球近日点、远日点,时间为 UTC,距离单位 AU。 - peri := earth.Perihelion(2026) - aphe := earth.Aphelion(2026) - fmt.Printf("earth perihelion=%s distance=%.9fAU\n", peri.Time.Format(time.RFC3339), peri.Distance) - fmt.Printf("earth aphelion=%s distance=%.9fAU\n", aphe.Time.Format(time.RFC3339), aphe.Distance) - - // 2026 年 1 月的月球近地点、远地点,距离单位 km。 - perigees := moon.PerigeesInMonth(2026, time.January) - apogees := moon.ApogeesInMonth(2026, time.January) - fmt.Printf("moon perigee=%s distance=%.1fkm count=%d\n", perigees[0].Time.Format(time.RFC3339), perigees[0].Distance, len(perigees)) - fmt.Printf("moon apogee=%s distance=%.1fkm count=%d\n", apogees[0].Time.Format(time.RFC3339), apogees[0].Distance, len(apogees)) - - // 2026 年 1 月的月球最大北/南赤纬。 - north := moon.MaximumNorthDeclinationsInMonth(2026, time.January) - south := moon.MaximumSouthDeclinationsInMonth(2026, time.January) - fmt.Printf("north=%s dec=%.6f\n", north[0].Time.Format(time.RFC3339), north[0].Declination) - fmt.Printf("south=%s dec=%.6f\n", south[0].Time.Format(time.RFC3339), south[0].Declination) - - // 月球天平动和自转轴位置角。 - physical := moon.Physical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Printf("libration lon=%.6f lat=%.6f pa=%.6f\n", physical.LibrationLongitude, physical.LibrationLatitude, physical.PositionAngle) - - // 月亮明亮边缘位置角;0° 从月面北点起,向东增加。 - fmt.Printf("bright limb=%.6f\n", moon.BrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) - - // 上海站心看到的月球天平动、自转轴位置角和亮边位置角。 - topo := moon.TopocentricPhysical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4) - fmt.Printf("topo libration lon=%.6f lat=%.6f pa=%.6f\n", topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) - fmt.Printf("topo bright limb=%.6f\n", moon.TopocentricBrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4)) -} -``` - -输出结果: - -```text -earth perihelion=2026-01-03T17:15:35Z distance=0.983302050AU -earth aphelion=2026-07-06T17:31:24Z distance=1.016643936AU -moon perigee=2026-01-01T21:44:24Z distance=360348.1km count=2 -moon apogee=2026-01-13T20:47:13Z distance=405437.9km count=1 -north=2026-01-02T08:10:49Z dec=28.266373 -south=2026-01-16T05:15:14Z dec=-28.304184 -libration lon=-1.278902 lat=-6.531444 pa=-9.967050 -bright limb=267.364849 -topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 -topo bright limb=266.038258 -``` - -如果只关心某一时刻地球轨道偏心率,也可以直接调用: - -```go -fmt.Printf("earth e=%.9f\n", earth.EarthEccentricity(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) -``` - -月球也提供升交点和降交点黄经,适合做食季、轨道几何和月球轨道研究: - -```go -nodeDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) -fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate)) -``` - -这里的“升交点 / 降交点”与行星章节中的定义相同: - -- `AscendingNode`:月球轨道从黄道南侧穿到黄道北侧时的黄经 -- `DescendingNode`:月球轨道从黄道北侧穿到黄道南侧时的黄经 -- 单位都是度;同一时刻两者通常相差约 `180°` - -以上面 `nodeDate := 2026-01-01 00:00:00 UTC` 的示例来说,输出结果是: - -```text -340.95708624505863 160.9570862450587 -``` - -#### 月相 - -```go -package main - -import ( - "fmt" - "b612.me/astro/moon" - "time" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - // 指定观测时刻。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - //月亮此刻被照亮的比例(月相) - fmt.Println(moon.Phase(date)) - //月相具体描述 - fmt.Println(moon.PhaseDesc(date)) - //下次朔月时间;也可用 moon.NextNewMoon(date) - fmt.Println(moon.NextShuoYue(date)) - //下次上弦月时间;也可用 moon.NextFirstQuarter(date) - fmt.Println(moon.NextShangXianYue(date)) - //下次望月时间;也可用 moon.NextFullMoon(date) - fmt.Println(moon.NextWangYue(date)) - //下次下弦月时间;也可用 moon.NextLastQuarter(date) - fmt.Println(moon.NextXiaXianYue(date)) -} -``` - -输出结果: - -``` -0.30004130960877884 // 月面约有 30% 被太阳照亮 -上峨眉月 // 当前月相描述 -2020-01-25 05:41:58.271192908 +0800 CST // 下一次朔月 -2020-01-03 12:45:23.229190707 +0800 CST // 下一次上弦 -2020-01-11 03:21:17.159625291 +0800 CST // 下一次望月,也就是满月 -2020-01-17 20:58:23.396406769 +0800 CST // 下一次下弦 -``` - -月相四个相位同时提供拼音名和英文 alias,例如: - -- `ShuoYue` / `NewMoon` -- `WangYue` / `FullMoon` -- `ShangXianYue` / `FirstQuarter` -- `XiaXianYue` / `LastQuarter` - -对应的 `Next*`、`Last*`、`Closest*` 也都成组提供。 - -#### Lite 轻量太阳与月亮 - -`lite/sun` 和 `lite/moon` 的用法与主链相同。误差量级见[适用范围与精度](#lite-轻量链路)。 - -```go -package main - -import ( - "fmt" - litemoon "b612.me/astro/lite/moon" - litesun "b612.me/astro/lite/sun" - "time" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2026, 1, 1, 20, 0, 0, 0, cst) - - fmt.Println(litesun.Altitude(date, 121.4737, 31.2304)) - fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) - - fmt.Println(litemoon.Phase(date)) - fmt.Println(litemoon.PhaseAge(date)) - fmt.Println(litemoon.RiseTime(date, 121.4737, 31.2304, 0, true)) -} -``` - -导出函数: - -- `lite/sun`:`TrueLo`、`ApparentLo`、`Distance`、`TrueRaDec`、`ApparentRaDec`、`HourAngle`、`Azimuth`、`Altitude`、`Zenith`、`RiseTime`、`SetTime` -- `lite/moon`:`TrueLo`、`TrueBo`、`TrueRaDec`、`ApparentRaDec`、`HourAngle`、`Azimuth`、`Altitude`、`Zenith`、`SunMoonLoDiff`、`Phase`、`PhaseAge`、`RiseTime`、`SetTime` - -#### 日食 - -日食计算统一放在 `eclipse` 包;SVG 生成功能放在 `eclipse/svg` 包。默认采用 `NASA bulletin Split-K` 的月亮半径口径;如果需要 IAU 单一 `k` 值,也可以调用同名的 `...IAUSingleK` 接口。 - -常用接口: - -- `SolarEclipseOnDate`:判断某个当地日期附近是否有全局日食 -- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`:搜索全局日食 -- `LocalSolarEclipseOnDate`:判断某地当天是否能看到站心日食 -- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`:搜索某地可见的站心日食 -- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`:搜索某地可见的日全食,返回 `(info, ok)` -- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`:搜索某地可见的日环食,返回 `(info, ok)` -- `SolarEclipseCentralPath`:计算中心线、南北界和食甚点 -- `SolarEclipsePartialFootprints`:计算偏食半影在地球表面的足迹;可选采样本影/反本影瞬时轮廓 -- `eclipse/svg.LocalSolarEclipseSVG`:生成某地的日面视圆 SVG - -`SolarEclipsePartialFootprintsInfo` 还给出影锥与地球的全球接触:`P1/P4` 是半影外切,`P2/P3` 是半影内切;`U1/U4` 是本影或反本影外切,`U2/U3` 是内切。某次日食不存在的接触保持 `time.Time` 零值。`CentralBeginOnEarth` / `CentralEndOnEarth` 仍表示影轴进入和离开地球,不等同于 `U1/U4`。 - -需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算;SVG 入口同样只在正值时采样(小于一分钟按一分钟),零值或负值都不画。 - -需要在数据层直接取等时线时,可在同一个 `SolarEclipsePartialFootprintOptions` 中设置 `GreatestTimeValues` 或 `GreatestTimeStep`。`GreatestTimeValues []time.Time` 是**食甚时刻取值**,按绝对时刻使用(其 `Location` 不参与换算),最多保留 64 条:重复的时刻取值与偏食可见窗口之外的时刻取值会被跳过,其余按时间先后排序,超出时保留最早的 64 条;没有可用支路的时刻取值不会出现在结果里。它为空时改用 `GreatestTimeStep` 按间隔生成,间隔只在为正值时生效,且对齐到 UTC 整刻度;要按展示时区对齐,请自行生成时刻后传给 `GreatestTimeValues`。 - -结果写入 `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`:`JDE` 是对应的力学时儒略日,`Time` 是该时刻取值在输入时区下的时刻(显式传入的时刻取值原样回显,按步长生成时由 `JDE` 换算并抹到毫秒,避免往返把整分截断成前一分钟),`Segments` 是该时刻的等时线支路。等时线只出现在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。不请求时既有输出完全不变。 - -日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息: - -- `HasSaros`:是否成功匹配到沙罗序列 -- `Saros.Series`:`Verified=true` 时为 NASA 沙罗系列号,否则为推算的暂定系列号 -- `Saros.Member`:这次日食在该系列中的第几个成员,从 `1` 开始 -- `Saros.Count`:该沙罗系列的总成员数 -- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` - -说明: - -- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月、约 `18 年 11 天 8 小时`;系列成员按此周期排列。 -- 沙罗系列是一组按沙罗周期连续排列的日食事件;`Series` 标识该组,`Member` / `Count` 表示当前事件在该组中的序号和总数。 -- 沙罗序列属于整场日食事件,不随观测地点改变,所以全局日食、站心日食、中心路径和偏食足迹中的对应值应当一致。 -- 内置 NASA 锚点优先使用正式编号;天文年份 `-3000` 至 `+6000` 年内(含首尾年,`0` 年为公元前 1 年)未被锚点覆盖的事件使用预计算扩展表,范围外才实时演算外推。预计算与实时推算结果的 `Verified` 都是 `false`,不应视为实际已发布编号。 -- 扩展编号沿用 NASA 的 [Saros/Inex 编号关系](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html),成员按 Split-K 模型计算,计数覆盖完整系列,不在预计算年份边界截断。`3288-11-15` 的推算结果为系列 `202`、第 `1/71` 个成员。 -- 例如 `2024-04-08` 北美日全食属于 `日食沙罗序列139` 的第 `30/71` 个成员。 - -##### 与 NASA 资料的时间对照 - -日食时间分两类看: - -- **全局日食**:关注整次日食的食甚 UT、食分、Gamma、食甚点经纬度和食带宽度。当前用 NASA GSFC 的日食搜索/贝塞尔根数资料对照了 `2023-04-20` 全环食、`2024-04-08` 日全食、`2024-10-02` 日环食、`2025-03-29` 日偏食。 -- **站心日食**:关注某个观测点看到的初亏、食甚、复圆和中心食持续时间。当前用 NASA GSFC 的 local circumstances / Google map 日食资料对照了芝加哥偏食、2024 日全食食甚点、2024 日环食食甚点。 - -当前回归样例的对照口径如下: - -| 对照类型 | 样例 | 时间项 | 对照结果 | -| --- | --- | --- | --- | -| 全局日食 | 4 次现代日食 | 食甚 UT | 秒级对齐,当前样例在 `8 s` 阈值内 | -| 站心日食 | 3 个本地观测点 | 食甚、初亏、复圆 | NASA local circumstances 公开值多为整分钟,当前结果与公开分钟值对齐 | -| 站心日食 | 2 个中心食点 | 全食/环食持续时间 | 秒级对齐,当前样例在 `5 s` 阈值内 | - -说明: - -- 全局日食资料通常给到秒,适合直接做秒级对照。 -- 很多站心日食页面的初亏、复圆和本地食甚只公开到整分钟,因此这类资料只按分钟级核对,公开资料舍入造成的残差不按秒级误差解读。 -- 下面的 2009 洋山和 2012 厦门示例只展示接口调用与 SVG 输出,未承诺地方接触时刻的发布级精度;需要逐项核对时可与 NASA/IMCCE local circumstances 比对。 - -##### 2009 年长江大日食:长江口洋山附近 - -2009-07-22 “长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,全食持续约 5 分 57 秒。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/eclipse" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) - - // 上海洋山附近,东经为正,北纬为正,海拔取 0 米。 - info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) - fmt.Println(ok, info.Type) // 是否命中本地日食;食型 - fmt.Println(info.HasSaros, info.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 - fmt.Println(info.PartialStart) // 初亏 - fmt.Println(info.CentralStart) // 全食开始 - fmt.Println(info.GreatestEclipse) // 食甚 - fmt.Println(info.CentralEnd) // 全食结束 - fmt.Println(info.PartialEnd) // 复圆 - fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // 全食阶段持续时间 - fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // 食分、遮掩比例、食甚太阳高度 - - // 同一天的中心路径,包含食甚点、中心线和南北界。 - path, _ := eclipse.SolarEclipseCentralPath( - date, - eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 100}, - ) - fmt.Printf("greatest lon=%.4f lat=%.4f width=%.1fkm center=%d\n", - path.Greatest.Longitude, - path.Greatest.Latitude, - path.Greatest.WidthKM, - len(path.CenterLine), - ) -} -``` - -输出结果: - -```text -true total // 洋山站点当天命中日食,食型为日全食 -true {136 37 71 true} // Solar Saros 136,第 37/71 个成员,已核验 -2009-07-22 08:23:54.852366149 +0800 CST // 初亏 -2009-07-22 09:37:22.978486418 +0800 CST // 全食开始 -2009-07-22 09:40:20.771366357 +0800 CST // 食甚 -2009-07-22 09:43:19.610750377 +0800 CST // 全食结束 -2009-07-22 11:03:13.974526226 +0800 CST // 复圆 -5m56.632263959s // 全食持续时间 -magnitude=1.076997 obscuration=1.000000 altitude=57.292 // 食分、遮掩比例、食甚太阳高度 -greatest lon=144.1177 lat=24.2193 width=258.3km center=289 // 全局食甚点经纬度、食带宽度、中心线采样点数 -``` - -##### 2012 年日环食:厦门示例 - -2012-05-21 日环食在中国东南沿海可见。下面用厦门做一个本地日环食示例,食甚时太阳高度约 9.6 度,环食阶段持续约 4 分 19 秒。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/eclipse" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst) - - info, ok := eclipse.LocalSolarEclipseOnDate(date, 118.0894, 24.4798, 0) - fmt.Println(ok, info.Type) // 是否命中本地日食;食型 - fmt.Println(info.HasSaros, info.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 - fmt.Println(info.PartialStart) // 初亏 - fmt.Println(info.CentralStart) // 环食开始 - fmt.Println(info.GreatestEclipse) // 食甚 - fmt.Println(info.CentralEnd) // 环食结束 - fmt.Println(info.PartialEnd) // 复圆 - fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // 环食阶段持续时间 - fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // 食分、遮掩比例、食甚太阳高度 -} -``` - -输出结果: - -```text -true annular // 厦门站点当天命中日食,食型为日环食 -true {128 58 73 true} // Solar Saros 128,第 58/73 个成员,已核验 -2012-05-21 05:08:12.683024704 +0800 CST // 初亏 -2012-05-21 06:08:15.570422708 +0800 CST // 环食开始 -2012-05-21 06:10:25.156724452 +0800 CST // 食甚 -2012-05-21 06:12:34.764188826 +0800 CST // 环食结束 -2012-05-21 07:20:55.029536783 +0800 CST // 复圆 -4m19.193766118s // 环食持续时间 -magnitude=0.933290 obscuration=0.872480 altitude=9.567 // 食分、遮掩比例、食甚太阳高度 -``` - -##### 生成日食 SVG - -现代城市观测示例可以使用 `2035-09-02` 北京日全食。按北京市区近似坐标(东经 `116.4074`,北纬 `39.9042`)计算,这次事件属于 `Solar Saros 145` 的第 `23/77` 个成员,全食阶段持续约 `1m33s`。 - -默认日食 SVG 头部会自动带上沙罗序列和全食/环食历时;更多文案可通过 `LocalSolarEclipseSVGOptions` 覆写: - -- `Title`:主标题 -- `SummaryText` / `GreatestText` / `MetaText`:标题下三行摘要 -- `OverviewTitle` / `PhasePanelsTitle` / `ContactsTitle`:总览、阶段视圆、接触时刻三个分区标题 -- `DirectionText` / `FooterNote`:底部方向说明和补充说明 - -```go -package main - -import ( - "fmt" - "os" - "time" - - "b612.me/astro/eclipse" - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - // 2009 长江口洋山附近日全食图。 - totalSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - time.Date(2009, 7, 22, 12, 0, 0, 0, cst), - 121.9850, 30.6167, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - }, - ) - fmt.Println(ok, len(totalSVG)) // 是否生成成功;SVG 字节长度 - if ok { - _ = os.WriteFile("doc/solar-eclipse-yangshan-2009.svg", []byte(totalSVG), 0o644) - } - - // 2012 厦门日环食图。 - annularSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - time.Date(2012, 5, 21, 12, 0, 0, 0, cst), - 118.0894, 24.4798, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - }, - ) - fmt.Println(ok, len(annularSVG)) // 是否生成成功;SVG 字节长度 - if ok { - _ = os.WriteFile("doc/solar-eclipse-xiamen-2012.svg", []byte(annularSVG), 0o644) - } - - // 2035 北京日全食图,同时打印沙罗序列号和全食持续时间。 - beijingDate := time.Date(2035, 9, 2, 12, 0, 0, 0, cst) - beijingInfo, ok := eclipse.LocalSolarEclipseOnDate(beijingDate, 116.4074, 39.9042, 0) - fmt.Println(ok, beijingInfo.Type) // 是否命中本地日食;食型 - fmt.Println(beijingInfo.HasSaros, beijingInfo.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 - fmt.Println(beijingInfo.CentralEnd.Sub(beijingInfo.CentralStart)) // 全食持续时间 - - beijingSVG, ok := eclipsesvg.LocalSolarEclipseSVG( - beijingDate, - 116.4074, 39.9042, 0, - eclipsesvg.LocalSolarEclipseSVGOptions{ - Width: 920, - Height: 720, - Step: 5 * time.Minute, - Location: cst, - }, - ) - fmt.Println(ok, len(beijingSVG)) // 是否生成成功;SVG 字节长度 - if ok { - _ = os.WriteFile("doc/solar-eclipse-beijing-2035.svg", []byte(beijingSVG), 0o644) - } -} -``` - -输出结果: - -```text -true 13460 // 洋山日全食 SVG 生成成功,长度 13460 字节 -true 13377 // 厦门日环食 SVG 生成成功,长度 13377 字节 -true total // 北京站点当天命中日食,食型为日全食 -true {145 23 77 true} // Solar Saros 145,第 23/77 个成员,已核验 -1m33.329527974s // 北京市区近似坐标下的全食持续时间 -true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节 -``` - -生成效果: - -![2009 长江口洋山日全食](doc/solar-eclipse-yangshan-2009.svg) - -![2012 厦门日环食](doc/solar-eclipse-xiamen-2012.svg) - -![2035 北京日全食](doc/solar-eclipse-beijing-2035.svg) - -#### 月食 - -本库的月食判断与搜索能力统一放在 `eclipse` 包,返回结果会保持传入 `time.Time` 的时区。 -常用接口: - -- `LunarEclipseOnDate`:判断某个当地日期是否有月食 -- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`:搜索全局月食 -- `LocalLunarEclipseOnDate`:判断某地当天是否能看到可见月食 -- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`:搜索某地可见月食 -- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`:搜索某地可见月全食,返回 `(info, ok)` -- `GeometricLocalLunarEclipseOnDate`:判断某地当天是否发生几何月食,不做“月亮在地平线上方”的可见性过滤 -- `eclipse/svg.LunarEclipseSVG`:生成月食穿影图 SVG - -返回结果 `LunarEclipseInfo` 包含: - -- 月食类型 `Type` -- 沙罗序列信息 `HasSaros` / `Saros` -- 半影食分 `PenumbralMagnitude` -- 本影食分 `UmbralMagnitude` -- 半影始、初亏、食既、食甚、生光、复圆、半影终等时刻 - -其中 `Saros` 的含义与日食部分相同: - -- `Saros.Series`:`Verified=true` 时为 NASA 月食沙罗系列号,否则为推算的暂定系列号 -- `Saros.Member`:这次月食在该系列中的第几个成员,从 `1` 开始 -- `Saros.Count`:该沙罗系列的总成员数 -- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` - -月食同样优先使用 NASA 锚点,天文年份 `-3000` 至 `+6000` 年内查扩展表,范围外才实时演算。推算成员按 Danjon 与 Chauvenet 检出的事件并集计数,因此不随调用的月食模型或观测地点改变;极浅成员可能与 NASA 目录不同,`Verified` 保持 `false`。 -例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `月食沙罗序列125` 的第 `49/72` 个成员。 - -当前同时保留两套地影放大口径: - -- **Danjon(默认)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。 -- **Chauvenet(兼容口径)**:先取 `0.99834 × 地球赤道半径`,再把整组影半径统一乘 `51/50`。这与传统旧历表口径更接近,适合做兼容性回归和旧结果对照。 - -两者的直接差异通常表现为: - -- `Chauvenet` 给出的半影和本影都更大,半影食分通常比 `Danjon` 多约 `0.025`,本影食分通常多约 `0.005` -- 对边界月食而言,`Chauvenet` 更容易把结果推向“更深”的食型 -- 与 NASA 目录、现代星历软件或当前主流月食资料对照时,对应的是默认的 `Danjon` -- 兼容既有历史基线时,对应的是显式调用的 `Chauvenet` - -##### 代码示例 - -```go -package main - -import ( - "fmt" - "b612.me/astro/eclipse" - "time" -) - -func main() { - date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) - - // 默认使用 Danjon,更接近 NASA - info := eclipse.ClosestLunarEclipse(date) - fmt.Println(info.Type) - fmt.Println(info.HasSaros, info.Saros) - fmt.Println(info.Maximum) - fmt.Println(info.PenumbralMagnitude, info.UmbralMagnitude) - fmt.Println(info.PenumbralStart) - fmt.Println(info.PartialStart) - fmt.Println(info.TotalStart) - fmt.Println(info.TotalEnd) - fmt.Println(info.PartialEnd) - fmt.Println(info.PenumbralEnd) - - // 如需兼容旧口径,可显式使用 Chauvenet - legacy := eclipse.ClosestLunarEclipseChauvenet(date) - fmt.Println(legacy.PenumbralMagnitude, legacy.UmbralMagnitude) - - // 判断某个本地自然日是否发生月食,返回时区与输入保持一致 - local := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) - today, ok := eclipse.LunarEclipseOnDate(local) - fmt.Println(ok) - fmt.Println(today.Type) - fmt.Println(today.Maximum) -} -``` - -输出结果: - -```text -total -true {125 49 72 true} -2028-12-31 16:52:05.566135346 +0000 UTC -2.2739890433790566 1.2461142882915068 -2028-12-31 14:03:54.219463169 +0000 UTC -2028-12-31 15:07:42.115980684 +0000 UTC -2028-12-31 16:16:27.24464178 +0000 UTC -2028-12-31 17:27:46.214954853 +0000 UTC -2028-12-31 18:36:32.251235246 +0000 UTC -2028-12-31 19:40:11.52023971 +0000 UTC -2.2996033397562012 1.2511710895669002 -true -total -2029-01-01 00:52:05.566135346 +0800 CST -``` - -##### 与 NASA 数据对照 - -以下对照值均来自 NASA GSFC 的月食目录和单次月食图页。结果基于当前库实现直接计算,时间误差单位为秒。 - -| 样例 | 模型 | 半影食分误差 | 本影食分误差 | 接触时刻对照 | -|------|------|--------------|--------------|----------------| -| 2026-03-03 月全食 | Danjon | -0.000072053 | -0.000065148 | 秒级对齐,最大误差 6.380 s | -| 2026-03-03 月全食 | Chauvenet | +0.025594905 | +0.004939948 | 兼容旧口径,不作为 NASA 时间对齐基准 | -| 2026-08-28 月偏食 | Danjon | -0.000118545 | -0.000028773 | 秒级对齐,最大误差 6.179 s | -| 2026-08-28 月偏食 | Chauvenet | +0.025562714 | +0.004962282 | 兼容旧口径,不作为 NASA 时间对齐基准 | -| 2024-03-25 半影月食 | Danjon | -0.000181657 | 见下说明 | 秒级对齐,最大误差 7.781 s | -| 2024-03-25 半影月食 | Chauvenet | +0.026039769 | 见下说明 | 兼容旧口径,不作为 NASA 时间对齐基准 | - -以 `2026-03-03` 月全食为例,当前默认 `Danjon` 与 NASA 的逐项差异为: - -- 食型:一致,都是 `total` -- 半影食分:`2.183727947` vs NASA `2.1838`,误差 `-0.000072053` -- 本影食分:`1.150634852` vs NASA `1.1507`,误差 `-0.000065148` -- 半影始:误差 `+3.400 s` -- 初亏:误差 `+5.801 s` -- 食既:误差 `+6.261 s` -- 食甚:误差 `+5.897 s` -- 生光:误差 `+5.776 s` -- 复圆:误差 `+6.328 s` -- 半影终:误差 `+6.380 s` - -同一例中,`Chauvenet` 的结果为: - -- 食型:一致,都是 `total` -- 半影食分:`2.209394905` vs NASA `2.1838`,误差 `+0.025594905` -- 本影食分:`1.155639948` vs NASA `1.1507`,误差 `+0.004939948` - -`Chauvenet` 是保留给旧历表/旧口径兼容的影半径模型,半影和本影都会比默认 `Danjon` 更大;与 NASA 当前目录对照时,接触时刻会出现分钟量级偏移。这是模型口径差异,不代表默认月食接口的时间精度。 - -> 说明:纯半影月食时,NASA 会给出负的 `umbral magnitude`,表示月面中心距本影边界还有余量;本库也保留这个负值,因此纯半影月食与 NASA 的本影食分已经属于同口径比较。 - -##### 月食 SVG - -`LunarEclipseSVG`、`LunarEclipseDetailedSVG` 与 `LunarEclipseMapSVG` 的默认模型与后缀入口口径见下文[全球见食图 SVG](#全球见食图-svg)。 - -默认月食 SVG 头部会自动带上沙罗序列;如果需要自定义更多文字,可以通过 `LunarEclipseSVGOptions` 覆写: - -- `Title`:主标题 -- `SummaryText` / `MaximumText` / `CoordinatesText` / `DurationText` / `MetaText`:标题下五行信息 -- `ContactsTitle`:接触时刻区标题 -- `DirectionText` / `FooterNote`:底部方向说明和补充说明 - -```go -package main - -import ( - "fmt" - "os" - "time" - - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - // 生成 2029-01-01 这次跨年月全食的穿影图。 - svg, ok := eclipsesvg.LunarEclipseSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC), - eclipsesvg.LunarEclipseSVGOptions{ - Width: 960, - Height: 620, - Step: 10 * time.Minute, - }, - ) - fmt.Println(ok, len(svg)) - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01.svg", []byte(svg), 0o644) - } -} -``` - -输出结果: - -```text -true 19671 -``` - -生成效果: - -![2029 跨年月全食穿影图](doc/lunar-eclipse-2029-01-01.svg) - -##### 参考资料 - -- NASA 月食 decade 目录: -- NASA 2026-03-03 月全食图页: -- NASA 2026-08-28 月偏食图页: -- NASA 2024-03-25 半影月食图页: -- NASA 月食算法与历史说明: - - - -### 月掩 - -月掩接口位于 `moon`,按用户给定的目标搜索,不会遍历恒星表。固定地点接口直接接收 `start`、`end`、经度、纬度和海拔;全球路径接口返回 WGS84 经纬度采样,可继续交给 `moon/svg` 或 `geojson`。 - -目标和接触语义分为两类: - -- **恒星**按点光源处理,返回掩始 `Immersion`、掩甚 `Greatest` 和掩终 `Emersion`。 -- **行星**按有限圆盘处理,外切为 C1/C4,完全被月面覆盖时另有内切 C2/C3;偏掩和擦掩没有 C2/C3。行星半径取赤道本体半径,不含行星环、大气延伸和扁率。 -- `FindBestStarOccultations` / `FindBestPlanetOccultations` 返回全球海平面几何掩甚点,不按地平线、月高、可见时长或食分评分;`VisibleAtGreatest` 仅报告该点的可见性。 -- 搜索时间窗按掩甚时刻选择事件。命中后会返回完整接触时刻或完整全球路径,不会把结果裁剪到查询端点。 -- 接触时刻按目标与月面边缘的站心几何求解,不加入大气折射。`MoonAltitudeAtGreatest` 是月心真高度,`VisibleAtGreatest` 表示它是否不低于几何地平线。 - -#### 恒星月掩 - -恒星由调用者传入 `StarCoordinate`。`RA` / `Dec` 单位为度,`Epoch` 和 `Frame` 必填;自行单位为 `mas/year`,其中 `ProperMotionRACosDecMasPerYear` 使用星表常见的 `dRA*cos(Dec)` 口径。 - -可以直接构造坐标: - -```go -target := moon.StarCoordinate{ - ID: "HR 4799", - RA: 189.1975, - Dec: -5.831944444444, - Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), - Frame: moon.CoordinateFrameJ2000, - ProperMotionRACosDecMasPerYear: -28, - ProperMotionDecMasPerYear: -18, -} -``` - -也可以显式加载内置 9100 星表,再用 `StarCoordinateFromStarData` 转换。月掩搜索本身不会加载星表;只有调用 `star.InitStarDatabase`、`StarDataByName`、`StarDataByHR` 等星表接口时才会加载。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" - "b612.me/astro/star" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) - end := start.Add(24 * time.Hour) - - _ = star.InitStarDatabase() - data, _ := star.StarDataByName("进贤增九") - target, _ := moon.StarCoordinateFromStarData(data) - - events, _ := moon.FindStarOccultations( - start, end, target, - 121.56601, 6.80706, 0, - moon.OccultationSearchOptions{}, - ) - for _, event := range events { - fmt.Println(event.TargetID, event.Type) - fmt.Println( - event.Immersion.Format("2006-01-02 15:04:05.000 MST"), - event.Greatest.Format("2006-01-02 15:04:05.000 MST"), - event.Emersion.Format("2006-01-02 15:04:05.000 MST"), - ) - fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest) - } - - paths, _ := moon.FindStarOccultationPaths( - start, end, target, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - ) - for _, path := range paths { - fmt.Println( - path.Start.Time.Format("2006-01-02 15:04:05.000 MST"), - path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"), - path.End.Time.Format("2006-01-02 15:04:05.000 MST"), - ) - fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n", - path.Greatest.Longitude, path.Greatest.Latitude, - path.Greatest.WidthKM, len(path.CenterLine)) - } -} -``` - -输出结果: - -```text -进贤增九 total -2025-06-05 19:14:01.062 CST 2025-06-05 20:02:06.296 CST 2025-06-05 20:50:10.697 CST -altitude=75.561 visible=true -2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST -greatest=121.566140 6.807079 width=3582.4km center=108 -``` - -`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。`RiseSetStep` 独立控制初掩、掩甚、终掩分别发生在月升/月落时的六类阶段线,零值使用 5 分钟;`DisableRiseSet` 可跳过这些阶段线。`DisableFootprints` 跳过体积较大的密集瞬时可见区要素,改用稀疏支撑样本合并成紧凑掩带;中心线、边界和六类升落阶段线仍保留,适合普通 GeoJSON 地图(首次渲染需合并一次,重复渲染走缓存)。`GreatestLimitSeparationKM` 是掩甚处南北限的地面间距,掩星图与详细版的"掩带宽"用它标注,与 `Greatest.WidthKM` 口径不同、不可互换。`IncludeFootprintTimeline` 可在紧凑掩带之外保留按 `FootprintTimelineStep` 采样的瞬时足迹,供时间轴选择当前时刻的可见区域。 - -`OccultationPathOptions.Algorithm` 控制恒星和行星全球路径的星历分支:零值或 `moon.OccultationPathAlgorithmOptimized` 默认使用经抽检的 30 分钟节点矢量插值,保留现有站心方程、连续包络和升落曲线;`moon.OccultationPathAlgorithmExact` 保留原有分支,候选可使用插值,最终求解仍使用全项星历。优化分支在抽检不合格时回退到原分支,超出插值时间窗时使用精确星历。抽检不是全时段严格误差证明;两个分支的几何目标相同,但不保证采样点或 GeoJSON 字节完全相同。此选项不影响仅查询事件、指定站点接触或独立单时刻月影接口,也不影响日月食。 - -两个分支的全球起止、掩甚标记和中心线宽度均保留全项星历计算。绘图时应传入完整返回路径,包括可见性轮廓;丢弃该轮廓会调用历史瞬时足迹回退逻辑,其边界不能替代完整解析可见集。 - -路径中的 `BandContours` 是静态掩带的接触包络,`VisibilityContours` 是月亮处于地平线以上时的可见时间包络;两者与 `Footprints` 的瞬时采样分别承担静态边界、可见性边界和时间轴细节,不应互相替代。 - -`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` 请求**掩甚时刻等时线**。与日食不同,`GreatestTimeValues []float64` 给的是力学时儒略日,最多保留 64 条(先去掉重复的时刻取值,按时间先后排序,超出时保留最早的 64 条),掩可见窗口之外或没有可用支路的时刻取值不会出现在结果里;它为空时改用 `GreatestTimeStep`,同样只在为正值时生效,且对齐到 UTC 整刻度。结果写入 `StarOccultationPath.GreatestTimeContours`(行星路径是同名字段),元素类型 `OccultationGreatestTimeContour` 的 `JDE`、`Time`、`Segments` 与日食同义:`Time` 在按步长生成时是原始对齐时刻,显式给出的时刻取值则由 `JDE` 换算并抹到毫秒,两者都落在 UTC 时区,而支路点的时刻仍按路径时区;日食公共层的 `Time` 则直接落在输入时区。边界口径同样一致:只出现在目标盘面与月面确有重叠且月亮在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或掩可见域边界,纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路;不请求时既有输出不变。 - -```go -options := moon.OccultationPathOptions{ - Algorithm: moon.OccultationPathAlgorithmExact, // 显式选择原分支;省略时使用优化分支 - DisableFootprints: true, -} -``` - -#### 行星月掩 - -行星目标使用 `OccultationMercury` 到 `OccultationNeptune` 常量。下面以 `2025-02-01` 月掩土星为例,在靠近全球几何掩甚点的位置求 C1-C4: - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/moon" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) - events, _ := moon.FindPlanetOccultations( - start, start.Add(24*time.Hour), moon.OccultationSaturn, - 104.52219613, 55.25401991, 0, - moon.OccultationSearchOptions{}, - ) - for _, event := range events { - fmt.Println(event.TargetID, event.Type, event.HasInternalContacts) - fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1 - fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2 - fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST")) - fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3 - fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4 - } -} -``` - -输出结果: - -```text -Saturn total true -2025-02-01 11:29:09.710 CST -2025-02-01 11:29:40.069 CST -2025-02-01 12:00:48.747 CST -2025-02-01 12:32:46.415 CST -2025-02-01 12:33:18.312 CST -``` - -`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和启用时的瞬时足迹都带采样时刻。 - -#### 月掩 SVG - -`moon/svg` 同时提供“搜索并渲染”和“渲染已计算结果”两组入口: - -- `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs`:指定地点的实际月面轨迹、白道和接触阶段图。 -- `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs`:全球掩带、中心线、阶段点和时间标记图。 -- `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG`:渲染已有的固定地点事件。 -- `StarOccultationPathSVG` / `PlanetOccultationPathSVG`:渲染已有的全球路径。 - -```go -localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( - start, end, target, - 121.56601, 6.80706, 0, - moon.OccultationSearchOptions{}, - moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 700, Location: cst}, -) -fmt.Println(err, len(localSVGs)) -``` - -本地图按指定观测者的站心几何绘制,下图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。 - -![2025 月掩进贤增九指定地点见掩图](doc/lunar-occultation-hr4799-2025-06-05-local.svg) - -### 天象图与 GeoJSON - -#### 全球见食图 SVG - -`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示初亏/食甚/复圆的日升日落线、太阳直射点、影轴进出地球点、`P1-P4/U1-U4` 接触点,以及默认关闭、按需打开的定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。 - -`LunarEclipseDetailedSVG` 把上述两类月食图合成详细版式的一页:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例。地影几何由 `basic.LunarEclipseShadowGeometryAt` 给出,其中 **Gamma 用地球赤道半径、半影/本影半径用度**,换成地球半径要乘以月球处的地球视差。 - -```go -package main - -import ( - "os" - "time" - - eclipsesvg "b612.me/astro/eclipse/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - - solar, ok := eclipsesvg.SolarEclipseMapSVG( - time.Date(2009, 7, 22, 12, 0, 0, 0, cst), - eclipsesvg.SolarEclipseMapSVGOptions{ - Width: 1200, Height: 800, Location: cst, - TimeLabelStep: 30 * time.Minute, - }, - ) - if ok { - _ = os.WriteFile("doc/solar-eclipse-yangshan-2009-global.svg", []byte(solar), 0o644) - } - - lunar, ok := eclipsesvg.LunarEclipseMapSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, cst), - eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}, - ) - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global.svg", []byte(lunar), 0o644) - } - - detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( - time.Date(2029, 1, 1, 0, 0, 0, 0, cst), - eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, - ) - if ok { - _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-detailed.svg", []byte(detailed), 0o644) - } -} -``` - -图上的橙色长虚线是初亏/食甚/复圆分别发生在日出和日落时的六类阶段线;**瞬时半影与本影轮廓默认不画**,需要时用正的 `PenumbralOutlineStep` / `CentralShadowStep` 打开。紫色短虚线是 `MagnitudeValues` 指定的地方最大食分等值线(默认 0.2/0.4/0.6/0.8),蓝色实线是食甚时刻等时线。 - -**食甚时刻等时线**(蓝色实线)默认不画:同一条线上的地点在同一时刻看到食甚,需要时用正的 `GreatestTimeStep` 打开,NASA 全球图的间隔是 30 分钟。这里的 `GreatestTimeStep` 属于 SVG 层,按**展示时区**(`Location`)对齐整刻度,与数据层的 UTC 对齐不同;`eclipse/svg` 不提供显式时刻取值入口,需要别的对齐刻度时请直接调用数据层并把时刻传给 `GreatestTimeValues`。 - -```go -solar, _ := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ - Width: 1200, Height: 800, Location: cst, - GreatestTimeStep: 30 * time.Minute, -}) -``` - -它不是在经纬度网格上逐点求食甚再描等值线,而是固定时刻后求解 `∂(日月中心角距²)/∂t = 0` 的零集,再沿曲线延拓,因此成本正比于曲线长度而不是可见域面积。等时线只画在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,每条支路止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。 - -`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。`GreatestTimeStep` 的零值与负值都不画食甚时刻等时线(与核心层、`moon/svg` 一样必须显式请求),正值按展示时区对齐、小于一分钟时按一分钟处理,单次最多生成 64 条;30 分钟是 NASA 全球图的推荐间隔。`MagnitudeValues` 为 nil 时使用 0.2/0.4/0.6/0.8,显式空切片关闭,非空切片按给定电平绘制。 - -`SolarEclipseMapSVGOptions.EventsTitle` 覆盖“全球阶段”数据块的标题(该块给出食甚经纬度与地球范围的中心食始/终),为空时使用本地化默认标题;`MapTitle` 与 `Title` 分别覆盖地图分区标题与主标题。 - -日食图的画布下限是 **800×560**:宽度小于 800 或高度小于 560 时按文档回落到 960×640,更窄的横版画布上地图框会与右栏数据网格水平重叠、面板行距压到 1 px 以下。`PartialStep` 小于两分钟时按两分钟处理:偏食区填充是瞬时足迹的并集,成本随采样数成倍增长,而并集必须由一整条自洽的扫描序列生成,更密的请求不改变产物(1 秒步长实测 36.8 s / 951 MB,夹取后为 1.1 s / 30 MB,与默认请求逐字节相同)。月食详细版式按 `Height` 推导版面:640×420 与 800×600 容不下示意图与底图的下限而返回 `false`,1000×1414 与 1414×1000 正常出图。 - -`eclipse/svg` 的三个无后缀月食入口(`LunarEclipseSVG`、`LunarEclipseDetailedSVG`、`LunarEclipseMapSVG`)使用同一个默认模型:以 Danjon 为主,极浅半影按核心默认口径回退 Chauvenet,与 `LunarEclipseOnDate` 一致;带 `Danjon` / `Chauvenet` 后缀的入口强制指定模型。 - -可降级的图层用 `data-source` 标注实际几何来源,取值词表见 `eclipse/svg` 包注释:`partial-band-union`、`sampled-footprint-sweep`、`partial-band-contours`、`rise-set-phase-lines`、`magnitude-contours`、`greatest-time-isochrones`、`besselian-critical-envelope`、`paired-limit-chords`、`sampled-open-sweep`、`central-path-limits`、`penumbral-outlines`、`central-shadow-outlines`、`p1-p4-visibility-regions`、`p1-p4-horizon-boundaries`。`PenumbralOutlineStep` 与 `CentralShadowStep` **默认关闭**(零值或负值都不画瞬时半影/本影轮廓,它们会把地球盖住,NASA 全球图也没有这两族),正值给出采样间隔,小于一分钟时按一分钟。 - -日食和月掩的自动投影会在适合时选择北极或南极图;月食默认使用等经纬投影。投影仅影响 SVG 表达,不改变底层 WGS84 地理结果。 - -日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。`EclipseMapProjectionOrthographic` 给出 NASA 版式的**正射球面图**:视点取食甚点,只画朝向视点的半个地球,投影边界就是可见半球的大圆。 - -球面图不需要新增数据,也不需要第三方投影库:陆地由内置的等经纬底图在运行时反解回经纬度再正射投影,视界裁剪在地理坐标上按大圆求交、并沿视界弧补齐被切断的环;经纬网按球面采样后同样裁剪。 - -正射投影同时切换成 **NASA 摆法**的版式:球面居中放大,比例尺排在球面正下方,阶段信息改为三栏面板(半影接触 / 食甚点地方情况 / 本影接触),图例与页脚依次向下;其他投影保持原有版式。代价是 `1000×1414` 画布下整幅图约 1.0 s(等经纬图约 0.85 s),一个同尺寸的球面图 SVG 约 530 KB;耗时为单机实测参考值,绝对值因机器而异。 - -下面的全球图沿用前文局地 SVG 的事件日期。2009 长江大日食、2012 厦门日环食和 2035 北京日全食使用等经纬投影: - -![2009 长江大日食全球见食图](doc/solar-eclipse-yangshan-2009-global.svg) - -同一场日食的正射球面版式(`EclipseMapProjectionOrthographic`): - -![2009 长江大日食正射球面图](doc/solar-eclipse-yangshan-2009-globe.svg) - -![2012 厦门日环食全球见食图](doc/solar-eclipse-xiamen-2012-global.svg) - -![2035 北京日全食全球见食图](doc/solar-eclipse-beijing-2035-global.svg) - -`2012-05-21` 日环食的偏食可见区覆盖北极点。下面把同一事件强制切换为北极方位等距投影,以便查看跨反经线的北极区见食范围;圆形边界是投影范围,不是行政或政治边界: - -![2012 日环食北极区全球见食图](doc/solar-eclipse-arctic-2012-global.svg) - -月食沿用前文 `2029-01-01` 跨年月全食,显示全程可见、带食月出、带食月落和不可见区域: - -![2029 跨年月全食全球可见图](doc/lunar-eclipse-2029-01-01-global.svg) - -月食还有把上面两类图合二为一的详细版式:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例,一页 `1000x1414`: - -![2029 跨年月全食详细版式](doc/lunar-eclipse-2029-01-01-detailed.svg) - -#### 月掩详细版式 SVG - -`moon/svg` 的详细版式把整场月掩合成一页 `1000x1414`:居中摘要、日月与目标天体的地心/站心数据块、一张**正射球面**的全球掩带图(南北限、可见/几何中心线、掩甚点、初掩/掩甚/终掩阶段点与 30 分钟时间标记),以及页脚说明。球面视点取事件中心,只画朝向视点的半球,这一版式固定用正射球面,不接受其它投影;只需要单独的全球掩带地图时用前文“月掩 SVG”的 `StarOccultationPathSVG` / `FindStarOccultationSVGs`。页内数据分为月亮地心坐标、目标天体、掩带路径点、接触时刻、历表与常数、天平动六块;横版画布把数据块排在地图右侧两栏三行,竖版把数据块排在球面下方三栏两行。下图是 `2025-06-05` 月掩 HR 4799: - -```go -package main - -import ( - "os" - "time" - - "b612.me/astro/moon" - moonsvg "b612.me/astro/moon/svg" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - star := moon.StarCoordinate{ - ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, - Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, - ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, - } - paths, err := moon.FindStarOccultationPaths( - time.Date(2025, 6, 5, 0, 0, 0, 0, cst), - time.Date(2025, 6, 6, 0, 0, 0, 0, cst), - star, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - ) - if err == nil && len(paths) > 0 { - detailed, renderErr := moonsvg.StarOccultationDetailedSVG( - paths[0], star, - moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst}, - ) - if renderErr == nil { - _ = os.WriteFile("doc/lunar-occultation-hr4799-2025-06-05-detailed.svg", []byte(detailed), 0o644) - } - } -} -``` - -![2025 月掩 HR 4799 详细版式](doc/lunar-occultation-hr4799-2025-06-05-detailed.svg) - -页内的球面掩带图使用 Natural Earth `1:50m` 海岸线,不含行政边界。在 `moon.OccultationPathOptions` 上设置 `GreatestTimeStep` 会额外请求**掩甚时刻等时线**,含义与日食图上的蓝色等时线相同:固定时刻后求角距导数的零集并沿曲线延拓。它同样是可选项,不设置时输出不变。掩星全球可见窗口通常只有数小时(本页 HR 4799 样例为 4 小时 33 分),常用间隔比日食更密,为 15–30 分钟量级,间隔越大掩带上的等时线越少;点源恒星按日月中心角距定食甚,有限盘面行星按外接触度量,分别与库内 `StarOccultationInfo.Greatest`、`PlanetOccultationInfo.Greatest` 同口径。`moon/svg` 自己不提供等时线开关,只绘制路径结果里已有的 `GreatestTimeContours`,因此请求必须在计算路径时通过 `OccultationPathOptions` 提出,线的位置也由核心口径决定(`GreatestTimeStep` 对齐 UTC 整刻度);按展示时区对齐的入口,是把时刻换算成力学时儒略日后传给 `GreatestTimeValues`。全球掩始和掩终表示月影首次接触和最后离开地球,与指定地点的接触时刻无关。 - -- 画布与错误:详细版最小 `480x320`,并按画布推导版式,地图与数据块放不下时返回 `ErrInvalidOccultationDetailedSVGOptions`(`800x600`、`1000x1414`、`1414x1000` 均可出图,`640x420`、`900x400` 会被拒绝);单独的全球掩带地图最小 `640x480`,更小的画布返回 `ErrInvalidStarOccultationSVGOptions`。 - -图上标注的“掩带宽”是掩甚处南北限的地面间距 `GreatestLimitSeparationKM`(本例约 `3666.6 km`),与中心线横向宽度 `Greatest.WidthKM`(约 `3582.4 km`)口径不同、不可互换。掩甚时刻等时线要在路径层显式请求 `OccultationPathOptions.GreatestTimeStep`;使用 `DisableFootprints` 的紧凑掩带首次渲染会合并一次,之后同一路径走缓存。详细版式与固定地点图见前文“月掩 SVG”。 - -#### GeoJSON - -`geojson` 接收已经计算好的日食、月食或月掩结果,返回 `[]byte`。这段字节是完整的 UTF-8 RFC 7946 `FeatureCollection` JSON,不是图片,也不是压缩数据,可以直接写入 `.geojson`、交给 `encoding/json`,或发送给前端地图组件。 - -```go -package main - -import ( - "encoding/json" - "fmt" - "time" - - "b612.me/astro/eclipse" - "b612.me/astro/geojson" -) - -func main() { - date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) - partial, ok := eclipse.SolarEclipsePartialFootprints( - date, - eclipse.SolarEclipsePartialFootprintOptions{ - Step: 10 * time.Minute, BoundaryPoints: 180, - }, - ) - if !ok { - return - } - central, hasCentral := eclipse.SolarEclipseCentralPath( - date, - eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}, - ) - var centralPath *eclipse.SolarEclipsePath - if hasCentral { - centralPath = ¢ral - } - - data, err := geojson.MarshalSolarEclipseWithTimeMarkers( - partial, centralPath, - geojson.TimeMarkerOptions{ - Step: 30 * time.Minute, - Location: time.FixedZone("CST", 8*3600), - }, - ) - fmt.Println(err, json.Valid(data)) -} -``` - -对应的无时间标记和带时间标记入口包括: - -- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` -- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` -- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers` -- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers` - -坐标统一为 WGS84 经度、纬度,跨反经线的线和面会拆分。带时路径的 `times` 属性与各段坐标逐点对齐;`WithTimeMarkers` 另加 `role=time-marker` 的 Point Feature,本地化 `label` 用于显示,`time` 始终是 UTC RFC 3339。 - -单时刻原语(拖动时间轴、"停下即精确")与站心搜索跨度: - -- `eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{...})` 返回可复用句柄;`ShadowAt(time.Time)`(按 UTC 解释)或 `ShadowAtJDE(jdeTT)`(TT 语义)取该时刻的**全球本影足迹**,`StationStateAt` / `StationStateAtJDE` 取该时刻、该站点的**站心日月几何**(食分、遮蔽率、站心角距、日月视半径、太阳高度/方位、是否处于全食/环食)。两者都只算这一件事,不产生可见带、食分线、升落边界、南北界或中心线;本影不在地球上时返回空/零值而不是错误。 -- `geojson.MarshalSolarEclipseShadowInstant(instant)` 只输出该时刻的阴影区域,以及被地平线切断时的物理边界;属性含 `time`、`source_boundary_closed`、`geometry_role`、`closure`、`delta_t_seconds`、`model`、`interp_signature`。本影用 `central-shadow-footprint` + `central-shadow-boundary`;把 `Kind` 设为 `SolarEclipseShadowPenumbra` 则输出半影(偏食区),角色为 `partial-footprint` + `partial-footprint-boundary`,默认参数与整包偏食采样一致(96 点 + 200 km 加密),因此同一时刻的结果与采样逐点一致(约 1e-12 度差)。没有阴影时返回空 FeatureCollection。 -- 采样的 `partial-footprint` 也带 `source_boundary_closed`、`geometry_role`、`closure` 与 `interp_signature`;被地平线切断的序列端点补到地平圈擦地点,未补齐时该处的端点偏差为 `36–41 km` 量级。掩带的填充提示仍沿用旧封口,避免端点外扩改变极区面归属。 -- 实测成本(原生构建,单机参考值,绝对值因机器而异):单时刻足迹(96 点)约 **64 µs**,站心瞬时约 **20 µs**;公开句柄构造只是夹取选项(≈0),首次查询时按最近朔月构造内部状态约 39 µs、锚点查询约 130 µs,随后按事件缓存;批处理约 75 µs/时刻。 -- ΔT:`DeltaTSeconds` 显式指定时只作用于该句柄,且只改变地球自转相位——同一 TT 的几何不变,地面足迹沿经度平移 `0.4651·|ΔΔT|·cos(纬度)` 千米(见 `basic.DeltaTGroundShiftKM`);`<=0` 时使用进程级模型。两种情况下结果都回传实际使用的 ΔT。库不附带 ΔT 不确定度模型,请用该函数把外部的 ΔT 标准差换算成几何不确定度。 -- 插值:整包的 `central-shadow-footprint` 与该时刻的单时刻导出都带 `interp_signature`(形如 `umbra-closed-seg1-pt97`,由物理边界的顶点数/分段数/闭合标志与绕极标志给出),**相同**的相邻时刻才适合按顶点插值;`closed` 翻转、段数变化(换日线拆分)、顶点数变化、空↔非空(U1/U4 附近)时必须改取精确几何。实测 2 分钟步长下中段质心移动 78–232 km,端点附近可达约 520 km。 -- 批量:`ShadowBetween(start, end, step)` / `StationStatesBetween(...)` 按时间轴对齐返回整段,没有阴影的时刻是空条目。 -- 掩星的单时刻足迹(`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` → `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`)同样带 `delta_t_seconds`、`source_boundary_closed`、`geometry_role`、`interp_signature`;被月球地平切断时 `closure` 的 `kind` 是 `target-horizon`、`body` 是 `moon`,参照 `sublunar` 月下点而不是日下点。掩星子系统沿用进程级 ΔT,只回传实际用值,不提供显式覆盖。 -- 站心搜索:`SearchLocalCentralSolarEclipse(date, lon, lat, height, eclipse.SolarEclipseLocalSearchOptions{Kind, MaxYears, Backward, Geometric, Model})` 返回 `(info, status)`,`status.Exhausted` 把"跨度内确实没有"与"找到了"分开;`MaxYears<=0` 使用与旧入口等价的默认跨度(6000 次候选步进 ≈ 992 年,因为候选会跳过非食季)。`SolarEclipseCandidates(start, end, options)` 只回时刻表(食甚时刻、食型、中心食类型、食分、伽马、可选沙罗序列),不含任何几何。 - -日食 GeoJSON 的中心影相关 role 是稳定契约:`role=central-shadow-footprint` 要么缺省、要么是 `Polygon`/`MultiPolygon`,永不出现线类型;被地平线切断时它仍输出该时刻地面本影(或反本影)覆盖的完整区域——物理边界延伸到两个地平擦地点,再由两擦地点之间的地平弧闭合,此时 `source_boundary_closed=false`,并由 `closure`(`kind`、`time`、`subsolar`)声明那段人工弧。只含物理边界曲线的折线另由 `role=central-shadow-boundary` 输出,调用方描它、填上面那个面即可,不会描出假的地平线边界。足迹收缩到零(U1/U4)时整条缺省,也不会退化成线。`source_boundary_closed=true` 表示边界由本影自身闭合,环上没有任何人工段。 - -`TimeMarkerOptions.Step` 的零值为 30 分钟,正值至少 1 分钟,每次导出最多 1440 个标记。GeoJSON 不携带底图、国家边界、样式或投影;Web Mercator、极区图、瓦片选择和政治边界由应用自行决定。 - -### 行星 - -#### 内行星 - -```go -package main - -import ( - "fmt" - "b612.me/astro/mercury" - "b612.me/astro/venus" - "time" -) - -func main() { - // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // 指定观测时刻。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - //水星上次下合时间 - fmt.Println(mercury.LastInferiorConjunction(date)) - //金星下次上合时间 - fmt.Println(venus.NextSuperiorConjunction(date)) - //水星上次留(顺转逆)时间(水逆) - fmt.Println(mercury.LastProgradeToRetrograde(date)) - //金星下次留(逆转顺)时间 - fmt.Println(venus.NextRetrogradeToPrograde(date)) - //水星上次东大距时间 - fmt.Println(mercury.LastGreatestElongationEast(date)) - //金星下次西大距时间 - fmt.Println(venus.NextGreatestElongationWest(date)) - //西安市今日金星升起,降落时间 - fmt.Println(venus.RiseTime(date, lon, lat, height, true)) - fmt.Println(venus.SetTime(date, lon, lat, height, true)) - //金星当前视星等 - fmt.Println(venus.ApparentMagnitude(date)) - //金星相位角、被照亮比例、亮面中心位置角 - fmt.Println(venus.PhaseAngle(date)) - fmt.Println(venus.Phase(date)) - fmt.Println(venus.BrightLimbPositionAngle(date)) - //金地距离 - fmt.Println(venus.EarthDistance(date)) - //金日距离 - fmt.Println(venus.SunDistance(date)) -} -``` - -输出结果: - -``` -2019-11-11 23:21:41.971051096 +0800 CST // 水星上次下合 -2021-03-26 14:57:42.052354216 +0800 CST // 金星下次上合 -2019-11-01 04:31:49.749019145 +0800 CST // 水星上次由顺行转逆行的留 -2020-06-25 02:07:41.599749326 +0800 CST // 金星下次由逆行转顺行的留 -2019-10-20 12:01:37.740152478 +0800 CST // 水星上次东大距 -2020-08-13 08:14:46.304587125 +0800 CST // 金星下次西大距 -2020-01-01 10:02:34.172435402 +0800 CST // 西安当天金星升起时刻;无错误 -2020-01-01 20:25:37.36411482 +0800 CST // 西安当天金星落下时刻;无错误 --4 // 金星视星等 -49.98145049145023 // 金星相位角,单位度 -0.8215177914415865 // 金星被照亮比例 -255.63802053541346 // 金星亮面中心位置角,单位度 -1.2778819631550336 // 金地距离,单位 AU -0.7262651056423838 // 金日距离,单位 AU -``` - -内外行星同样提供 `Diameter` / `Semidiameter`(以及 `N` 版),返回地心视直径/视半径,单位为角秒。 - -行星视直径或轨道节点也可以单独查询: - -```go -fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) -fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date)) -``` - -这里的“升交点 / 降交点”指天体轨道面与黄道面的两个交点: - -- `AscendingNode`:天体从黄道南侧穿到黄道北侧时对应的黄经 -- `DescendingNode`:天体从黄道北侧穿到黄道南侧时对应的黄经 -- 返回值单位都是度;对同一时刻而言,降交点通常与升交点相差约 `180°` - -以上面 `date := 2020-01-01 08:08:08 CST` 的示例来说,输出结果是: - -```text -4.287299886569956 2.143649943284978 // 火星视直径、视半径,单位角秒 -76.86008484515058 256.8600848451506 // 金星升交点、降交点黄经,单位度 -``` - -水星和金星还提供 `NextTransit` / `LastTransit` / `ClosestTransit` 地心凌日查询。这里的“地心”指从地球中心看到的行星圆面经过太阳圆面,不判断某个地点当时太阳是否在地平线上;如果要做观测计划,还需要结合本地太阳高度角和天气条件。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/mercury" - "b612.me/astro/venus" -) - -func main() { - // 查询 2019 年之后下一次地心水星凌日。 - mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Println(mercuryTransit.Valid) - fmt.Println(mercuryTransit.Start) - fmt.Println(mercuryTransit.InternalStart) - fmt.Println(mercuryTransit.Greatest) - fmt.Println(mercuryTransit.InternalEnd) - fmt.Println(mercuryTransit.End) - fmt.Println(mercuryTransit.Duration) - fmt.Println(mercuryTransit.MinimumSeparationArcsec) - fmt.Println(mercuryTransit.SunSemidiameterArcsec) - fmt.Println(mercuryTransit.PlanetSemidiameterArcsec) - - // 查询 2012 年之后下一次地心金星凌日。 - venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC)) - fmt.Println(venusTransit.Valid) - fmt.Println(venusTransit.Start) - fmt.Println(venusTransit.InternalStart) - fmt.Println(venusTransit.Greatest) - fmt.Println(venusTransit.InternalEnd) - fmt.Println(venusTransit.End) - fmt.Println(venusTransit.Duration) -} -``` - -输出结果: - -```text -true // 找到一次有效的地心水星凌日 -2019-11-11 12:35:31.567597389 +0000 UTC // 一触:水星外切进入太阳圆面 -2019-11-11 12:37:12.817581295 +0000 UTC // 二触:水星完全进入太阳圆面 -2019-11-11 15:19:48.36056292 +0000 UTC // 凌甚:水星中心最接近太阳中心 -2019-11-11 18:02:29.176982045 +0000 UTC // 三触:水星开始离开太阳圆面 -2019-11-11 18:04:10.637948513 +0000 UTC // 四触:水星外切离开太阳圆面 -5h28m39.070351124s // 一触到四触的地心凌日持续时间 -75.92400059923187 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒 -968.8881519533047 // 凌甚时太阳视半径,单位角秒 -4.978442871670873 // 凌甚时水星视半径,单位角秒 -true // 找到一次有效的地心金星凌日 -2012-06-05 22:09:47.466886639 +0000 UTC // 一触:金星外切进入太阳圆面 -2012-06-05 22:27:35.865356326 +0000 UTC // 二触:金星完全进入太阳圆面 -2012-06-06 01:29:35.572371482 +0000 UTC // 凌甚:金星中心最接近太阳中心 -2012-06-06 04:31:35.068444311 +0000 UTC // 三触:金星开始离开太阳圆面 -2012-06-06 04:49:23.25597167 +0000 UTC // 四触:金星外切离开太阳圆面 -6h39m35.789085031s // 一触到四触的地心凌日持续时间 -``` - -#### 外行星 - -```go -package main - -import ( - "fmt" - "b612.me/astro/jupiter" - "b612.me/astro/mars" - "b612.me/astro/neptune" - "b612.me/astro/saturn" - "b612.me/astro/uranus" - "time" -) - -func main() { - // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 - var lon, lat, height float64 = 108.93, 34.27, 0 - cst := time.FixedZone("CST", 8*3600) - // 指定观测时刻。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - //火星下次冲日时间 - fmt.Println(mars.NextOpposition(date)) - //木星下次合日时间 - fmt.Println(jupiter.NextConjunction(date)) - //土星上次留(顺转逆)时间(土逆) - fmt.Println(saturn.LastProgradeToRetrograde(date)) - //土星环观测参数 - ring := saturn.Ring(date) - fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f dU=%.6f major=%.6f minor=%.6f\n", - ring.EarthLatitude, - ring.SunLatitude, - ring.PositionAngle, - ring.DeltaU, - ring.MajorAxis, - ring.MinorAxis, - ) - //天王星下次留(逆转顺)时间 - fmt.Println(uranus.NextRetrogradeToPrograde(date)) - //海王星上次东方照时间 - fmt.Println(neptune.LastEasternQuadrature(date)) - //火星下次西方照时间 - fmt.Println(mars.NextWesternQuadrature(date)) - //西安市今日火星升起,降落时间 - fmt.Println(mars.RiseTime(date, lon, lat, height, true)) - fmt.Println(mars.SetTime(date, lon, lat, height, true)) - //火星当前视星等 - fmt.Println(mars.ApparentMagnitude(date)) - //地火距离 - fmt.Println(mars.EarthDistance(date)) - //日火距离 - fmt.Println(mars.SunDistance(date)) -} - -``` - -输出结果: - -``` -2020-10-14 07:25:50.441412627 +0800 CST // 火星下次冲日 -2021-01-29 09:39:33.697994649 +0800 CST // 木星下次合日 -2019-04-30 10:28:00.187439918 +0800 CST // 土星上次由顺行转逆行的留 -saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴 -2020-01-11 15:23:23.360308706 +0800 CST // 天王星下次由逆行转顺行的留 -2019-12-08 17:00:15.517960488 +0800 CST // 海王星上次东方照 -2020-06-07 03:11:00.026179254 +0800 CST // 火星下次西方照 -2020-01-01 04:41:29.621566236 +0800 CST // 西安当天火星升起时刻;无错误 -2020-01-01 14:55:32.963508367 +0800 CST // 西安当天火星落下时刻;无错误 -1.57 // 火星视星等 -2.1844284956325937 // 地火距离,单位 AU -1.5897860004265403 // 日火距离,单位 AU - -``` - -`saturn.Ring` 返回 `RingInfo`:`EarthLatitude` 是土星环张角 B,`SunLatitude` 是 B',`PositionAngle` 是北半短轴位置角,`DeltaU` 是太阳与地球在环面内的土星心黄经差,`MajorAxis` / `MinorAxis` 是土星环外缘长短轴,单位为角秒。 - -#### 行星物理星历 - -七大行星都提供 `Physical` / `PhysicalN`,用于查看盘面朝向、子地/子日经纬度和北极位置角等物理观测参数。木星额外提供 System I/II/III 中央经线,土星额外提供土星环参数。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/jupiter" - "b612.me/astro/saturn" -) - -func main() { - date := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) - - // 木星:DS/DE 分别是太阳、地球相对木星赤道的行星中心赤纬。 - // CMI/CMII/CMIII 是木星 System I/II/III 中央经线,单位度。 - j := jupiter.Physical(date) - fmt.Printf("jupiter DS=%.6f DE=%.6f CMI=%.6f CMII=%.6f CMIII=%.6f\n", - j.DS, - j.DE, - j.CentralMeridianSystemI, - j.CentralMeridianSystemII, - j.CentralMeridianSystemIII, - ) - - // 土星环:B/B' 是地球、太阳看到的环面纬度,P 是环面短轴位置角。 - ring := saturn.Ring(date) - fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f major=%.6f minor=%.6f\n", - ring.EarthLatitude, - ring.SunLatitude, - ring.PositionAngle, - ring.MajorAxis, - ring.MinorAxis, - ) -} -``` - -输出结果: - -```text -jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // 木星子日/子地赤纬,System I/II/III 中央经线,单位度 -saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒 -``` - -只需要中央经线时,可以单独调用 `CentralMeridians`: - -```go -cm := jupiter.CentralMeridians(date) -fmt.Printf("CMI=%.6f CMII=%.6f CMIII=%.6f\n", cm.SystemI, cm.SystemII, cm.SystemIII) // 木星 System I/II/III 中央经线 -``` - -土星和天王星则额外保留了显式的 `System III` 语义别名,便于按行星自转系统来写调用代码: - -```go -sat3 := saturn.PhysicalSystemIII(date) -ura3 := uranus.PhysicalSystemIII(date) -fmt.Printf("saturn systemIII lon=%.6f lat=%.6f P=%.6f\n", sat3.SubEarthLongitude, sat3.SubEarthLatitude, sat3.NorthPolePositionAngle) // 土星子地经纬度与北极位置角 -fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude, ura3.SubEarthLatitude, ura3.NorthPolePositionAngle) // 天王星子地经纬度与北极位置角 -``` - -#### 木星伽利略卫星 - -`jupiter` 包提供四颗伽利略卫星的视位置、瞬时现象和事件搜索。 - -常用接口: - -- `Satellites`:四颗卫星相对木星盘面的瞬时视位置 -- `SatellitePhenomena`:瞬时凌日、掩蔽、食、影凌状态 -- `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`:搜索整场现象区间 -- `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`:搜索 IMCCE 风格的 D/F 接触事件 - -两个口径的区别如下,以木卫一凌日为例: - -- `GalileanPhenomenonEvent` 把卫星看作一个点,判断“卫星圆心是否进入/离开木星圆面”。它返回整段凌日的起止区间,适合快速搜索现象和程序内部状态判断。 -- `GalileanPhenomenonContactEvent` 把卫星自身的有限圆盘考虑进去,区分初亏到复圆的完整接触过程。它返回消失阶段(D)和再现阶段(R)各自的接触起止与模型中心穿越时刻,适合和 IMCCE 年表中的 `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` 逐项对照。 - -两个口径的差异在持续时间上最多约 7 分钟,差异来自模型定义不同。用于观测预报或与公开年表逐项核对时,取 `GalileanPhenomenonContactEvent`。 - -##### 代码示例 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/jupiter" -) - -func main() { - date := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) - - // 四颗卫星相对木星中心的瞬时位置。 - sats := jupiter.Satellites(date) - fmt.Printf("io x=%.6f y=%.6f front=%v\n", sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) - fmt.Printf("europa ra=%.6f dec=%.6f\n", sats.Europa.ApparentRA, sats.Europa.ApparentDec) - - // 瞬时现象标志。 - ph := jupiter.SatellitePhenomena(date) - fmt.Printf("io transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Io.Transit, ph.Io.Occultation, ph.Io.Eclipse, ph.Io.ShadowTransit) - fmt.Printf("europa transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Europa.Transit, ph.Europa.Occultation, ph.Europa.Eclipse, ph.Europa.ShadowTransit) - - // 下一次木卫一凌日整场事件。 - event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteIo, jupiter.GalileanPhenomenonTransit) - fmt.Printf("event valid=%v sat=%d type=%s\n", event.Valid, event.Satellite, event.Type) - fmt.Println(event.Start) - fmt.Println(event.Greatest) - fmt.Println(event.End) - fmt.Println(event.Duration) - - // 下一次木卫二掩蔽的 IMCCE 风格接触窗口。 - contact := jupiter.NextGalileanPhenomenonContactEvent(date, jupiter.GalileanSatelliteEuropa, jupiter.GalileanPhenomenonOccultation) - fmt.Printf("contact valid=%v sat=%d type=%s\n", contact.Valid, contact.Satellite, contact.Type) - fmt.Println(contact.Disappearance.Start) - fmt.Println(contact.Disappearance.ModelCrossing) - fmt.Println(contact.Disappearance.End) - fmt.Println(contact.Greatest) - fmt.Println(contact.Reappearance.Start) - fmt.Println(contact.Reappearance.ModelCrossing) - fmt.Println(contact.Reappearance.End) -} -``` - -输出结果: - -```text -io x=-0.675026 y=-0.032798 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方 -europa ra=110.769133 dec=22.335828 // 木卫二视赤经、视赤纬,单位度 -io transit=true occultation=false eclipse=false shadow=true // 木卫一正在凌日,且影子正在凌日 -europa transit=false occultation=false eclipse=false shadow=false // 木卫二此刻无凌日、掩蔽、木星食或影凌 -event valid=true sat=1 type=transit // 下一次有效事件为木卫一凌日 -2026-01-16 16:32:47.552742362 +0000 UTC // 木卫一凌日开始 -2026-01-16 17:40:44.189371168 +0000 UTC // 木卫一凌日中点 -2026-01-16 18:48:40.287077128 +0000 UTC // 木卫一凌日结束 -2h15m52.734334766s // 木卫一凌日持续时间 -contact valid=true sat=2 type=occultation // 下一次有效接触事件为木卫二掩蔽 -2026-01-17 01:00:34.99533087 +0000 UTC // 木卫二掩蔽消失阶段开始 -2026-01-17 01:02:31.714070141 +0000 UTC // 木卫二掩蔽消失阶段模型中心穿越 -2026-01-17 01:04:28.432809412 +0000 UTC // 木卫二掩蔽消失阶段结束 -2026-01-17 02:27:37.807798683 +0000 UTC // 木卫二掩蔽最深时刻 -2026-01-17 03:50:48.120300471 +0000 UTC // 木卫二掩蔽再现阶段开始 -2026-01-17 03:52:43.901527225 +0000 UTC // 木卫二掩蔽再现阶段模型中心穿越 -2026-01-17 03:54:39.68275398 +0000 UTC // 木卫二掩蔽再现阶段结束 -``` - -##### 与外部资料对照 - -木卫能力主要对照了两类外部基线: - -- **JPL Horizons**:用于四颗卫星相对木星中心的视位置,以及影凌时影心相对木星盘面的偏移。 -- **IMCCE 2026 年表**:用于凌日、掩蔽、木星食、影凌等事件和 D/F 接触窗口。 - -当前测试结果可概括为: - -- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.054"`、`Y=0.048"`。 -- `SatellitePhenomena` 的影凌影心偏移,对 JPL Horizons 的样例最大偏差约为 `X=0.051"`、`Y=0.016"`,现象布尔标志在样例中一致。 -- `GalileanPhenomenonContactEvent` 对 IMCCE 2026 年表(8 个样例、D1/D2/F1/F2 四个接触逐值对拍):接触时刻最大偏差约 `79 s`,接触持续时间最大偏差约 `17 s`,回归测试按 `120 s` / `25 s` 上限钉住。 -- `GalileanPhenomenonEvent` 不是 IMCCE 的 D/F 接触口径;与 IMCCE 起止时刻直接对照时,当前样例的最大差异约 `7` 分钟,来源是事件定义不同。 - -### 恒星 - -1. 本程序自带 9100 颗恒星的数据库(BSC / HR 编号 `1–9110`,视星等 `-1.46`~`7.96`),能够自动计算自行 - -```go -package main - -import ( - "fmt" - "b612.me/astro/star" - "b612.me/astro/tools" - "time" -) - -func main() { - cst := time.FixedZone("CST", 8*3600) - // 指定观测时刻。 - date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) - - //初始化恒星数据库 - star.InitStarDatabase() - sirius, _ := star.StarDataByName("天狼") - ra, dec := sirius.RaDecByDate(date) - //天狼星升起时间 - riseDate, _ := star.RiseTime(date, ra, dec, 115, 40, 0, true) - fmt.Println(riseDate) - //天狼星降落时间 - setDate, _ := star.SetTime(date, ra, dec, 115, 40, 0, true) - fmt.Println(setDate) - fmt.Println(star.Constellation(ra, dec, date)) - - //织女星 - vega, _ := star.StarDataByName("织女一") - ra, dec = vega.RaDecByDate(time.Date(13600, 01, 01, 00, 00, 00, 00, time.Local)) - //织女星在公元13600年的赤经 - fmt.Println(tools.Format(ra/15, 1)) - //织女星在公元13600年的赤纬 - fmt.Println(tools.Format(dec, 0)) - - bright, _ := star.TopBrightStars() - fmt.Println(bright[0].ChineseName, bright[0].CommonName, bright[0].Mag) -} -``` - -``` -2019-12-31 19:22:56.144202053 +0800 CST // 天狼星升起时刻 -2020-01-01 05:30:39.802506566 +0800 CST // 天狼星落下时刻 -大犬座 // 天狼星所在星座 -6h3m46.61s // 织女一在公元 13600 年的赤经 -84°18′27.15″ // 织女一在公元 13600 年的赤纬 -天狼 Sirius -1.46 // 最亮恒星表第一项:中文名、英文常用名、视星等 -``` - -### 坐标工具 - -`coord` package 提供面向用户的坐标薄封装。没有特殊说明时,角度单位为度;恒星时单位为小时;`time.Time` 按绝对时刻使用,内部转换为 UTC 后计算。 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/coord" -) - -func main() { - date := time.Date(2026, 4, 27, 10, 30, 45, 0, time.FixedZone("CST", 8*3600)) - - eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) - fmt.Println(eq.RA, eq.Dec) - - hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) - fmt.Println(hz.Azimuth, hz.Altitude, hz.Zenith) - - top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) - fmt.Println(top.RA, top.Dec) - - // 手动给地方恒星时,不让库自动计算恒星时。 - manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, 83.6331, 22.0145, 31.2) - fmt.Printf("manual az=%.6f alt=%.6f zen=%.6f ha=%.6f\n", - manual.Azimuth, - manual.Altitude, - manual.Zenith, - manual.HourAngle, - ) - - // ICRS/J2000 赤道坐标转银道坐标。 - gal := coord.EquatorialToGalactic(266.4051, -28.936175) - fmt.Printf("gal lon=%.6f lat=%.6f\n", gal.Lon, gal.Lat) - - // 大气折射:由真高度角估算视高度角。 - fmt.Printf("apparent alt=%.6f\n", coord.ApparentAltitude(10, 1010, 0)) -} -``` - -输出结果: - -```text -143.72223158223719 19.53512536790277 -43.46959597099446 -17.686623571613737 107.68662357161374 -144.2551242046188 18.790254631841993 -manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900 -gal lon=0.000047 lat=-0.000079 -apparent alt=10.093428 -``` - -`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规计算可用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。 - -观测辅助方面,`coord` 还提供了两类高频小工具: - -- `ParallacticAngle` / `ParallacticAngleByHourAngle`:视差角(天顶方向角) -- `Airmass...FromApparentAltitude`:已经有视高度角时,直接套经验式 -- `Airmass...FromTrueAltitude`:先按给定气压/气温估算折射,把真高度角换成视高度角后再算 - -```go -// 目标的视差角,常用于旋转相机、光谱缝方向和视场姿态判断。 -q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) - -// 已知真高度角时,可先估算折射,再按经验模型求大气质量。 -x := coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0) -fmt.Printf("q=%.6f airmass=%.6f\n", q, x) -``` - -同样的观测辅助接口在 `sun`、`moon`、`star` 以及七大行星包中都有提供。已有视高度角且只需要纯公式时,`formula.Airmass...` 更直接。 - -### 研究公式 - -`formula` 包放的是和具体日期、星历表无关的常用公式,适合科普估算、小说设定和教学演示。 - -```go -package main - -import ( - "fmt" - - "b612.me/astro/formula" -) - -func main() { - // 70mm 小折射镜,观测地裸眼极限取 6 等。 - fmt.Printf("limiting=%.6f\n", formula.LimitingMagnitudeEmpirical(70, 6)) - - // 地球和金星的会合周期,输入周期单位都是天,输出也是天。 - fmt.Printf("synodic=%.6f\n", formula.SynodicPeriod(365.25636, 224.70069)) - - // 太阳这样的绝对星等天体放到 100pc 处的视星等。 - fmt.Printf("apparent=%.6f\n", formula.ApparentMagnitudeFromAbsolute(4.83, 100)) - - // 把太阳近似为 5772K 黑体,计算峰值波长和单位面积总辐射出射度。 - fmt.Printf("peak=%.9em flux=%.6e\n", - formula.WienPeakWavelength(5772), - formula.StefanBoltzmannFlux(5772), - ) -} -``` - -输出结果: - -```text -limiting=11.000000 -synodic=583.920635 -apparent=9.830000 -peak=5.020394932e-07m flux=6.293859e+07 -``` - -如果不需要坐标层的折射修正,`formula` 也直接提供三种大气质量模型,输入语义更直接: - -- `AirmassPlaneParallel`:输入真高度角,等价于 `sec(z)` 几何近似 -- `AirmassPlaneParallelByZenithDistance`:直接输入天顶距 -- `AirmassKastenYoung` / `AirmassPickering`:输入视高度角,不会自动做折射修正 - -```go -fmt.Println(formula.AirmassPlaneParallel(30)) -fmt.Println(formula.AirmassKastenYoung(5)) -fmt.Println(formula.AirmassPickering(5)) -fmt.Println(formula.AirmassPlaneParallelByZenithDistance(60)) -``` - -### 通用小天体轨道 - -`orbit` 包用于按日心二体轨道根数传播天体位置,支持小行星、彗星、矮行星和自定义假想轨道。七大行星仍由各行星包使用内置 VSOP87 解析项计算。 - -`orbit.Elements` 支持两种常见写法: - -- 经典椭圆根数:`A/E/I/Omega/W/M0` -- 近日点形式:`Q/E/I/Omega/W/TpJD`,适合彗星和高偏心率轨道 - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/orbit" -) - -func main() { - // 1 Ceres 的一组经典椭圆根数,参考系为 J2000 平黄道/平春分点。 - ceres := orbit.Elements{ - EpochJD: 2461000.5, - A: 2.765615651508659, - E: 0.07957631994408416, - I: 10.58788658206854, - Omega: 80.24963090816965, - W: 73.29975464616518, - M0: 231.5397330043706, - } - ceresPos := orbit.ApparentGeocentricEquatorial( - time.Date(2025, 11, 12, 0, 0, 0, 0, time.UTC), - ceres, - ) - fmt.Printf("ceres ra=%.6f dec=%.6f distance=%.6f\n", ceresPos.RA, ceresPos.Dec, ceresPos.Distance) - - // 哈雷彗星示例:用近日点距离 Q 和近日点通过时刻 TpJD 描述。 - halley := orbit.Elements{ - Q: 0.5870992, - E: 0.9671429, - I: 162.26269, - Omega: 58.42008, - W: 111.33249, - TpJD: 2446467.395, - } - halleyPos := orbit.ApparentGeocentricEquatorial( - time.Date(1986, 2, 9, 0, 0, 0, 0, time.UTC), - halley, - ) - fmt.Printf("halley ra=%.6f dec=%.6f distance=%.6f\n", halleyPos.RA, halleyPos.Dec, halleyPos.Distance) -} -``` - -输出结果: - -```text -ceres ra=7.739532 dec=-10.625981 distance=2.164391 -halley ra=312.112360 dec=-11.826451 distance=1.533936 -``` - -轨道根数本身有历元,离历元越远,静态根数误差越明显。若数据源提供 `ADot/EDot/IDot/OmegaDot/WDot/MDot` 这类长期线性变化率,也可以填入 `Elements`,用于减轻中长期漂移。 - -`orbit` 也提供了常见观测几何量和轻量测光接口: - -```go -r := orbit.SunDistance(when, ceres) -delta := orbit.EarthDistance(when, ceres) -elong := orbit.Elongation(when, ceres) -phase := orbit.PhaseAngle(when, ceres) -k := orbit.IlluminatedFraction(when, ceres) -mag := orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12) -q := orbit.ParallacticAngle(when, ceres, 121.4737, 31.2304, 20) - -fmt.Printf("r=%.6f delta=%.6f elong=%.6f phase=%.6f k=%.6f mag=%.3f q=%.6f\n", - r, delta, elong, phase, k, mag, q) -``` - -已有轨道根数时,也可以把它当作一个“可观测目标”来求站心观测量: - -```go -site := time.FixedZone("CST", 8*3600) -when := time.Date(2025, 11, 21, 20, 0, 0, 0, site) - -alt := orbit.Altitude(when, ceres, 121.4737, 31.2304, 20) -az := orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20) -rise, _ := orbit.RiseTime(time.Date(2025, 11, 21, 0, 0, 0, 0, site), ceres, 121.4737, 31.2304, 20, true) - -fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) -``` - -这些观测接口基于站心视坐标计算,适合直接拿去做小行星、彗星或自定义二体目标的升落和指向辅助。 - -`orbit` 里还带了一个视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距: - -```go -gammaVir := orbit.VisualBinaryElements{ - PeriodYears: 171.37, - PeriastronYear: 1836.433, - Eccentricity: 0.8808, - SemiMajorAxis: 3.746, - Inclination: 146.05, - AscendingNode: 31.78, - PeriastronArgument: 252.88, -} -vb := orbit.VisualBinary(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), gammaVir) -fmt.Printf("theta=%.6f rho=%.6f\n", vb.PositionAngle, vb.Separation) -``` - -### 日晷与真太阳时 - -`sundial` 把 `sun` 包的真太阳时、太阳时角与日晷绘制所需的几何量集中在一起,不引入另一套算法: - -```go -package main - -import ( - "fmt" - "time" - - "b612.me/astro/sundial" -) - -func main() { - date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) - lon, lat := 121.4737, 31.2304 - - trueSolar := sundial.TrueSolarTime(date, lon) - hourAngle := sundial.HourAngle(date, lon) - lineAngle := sundial.HorizontalHourLineAngle(lat, -45) - lineAngleNow := sundial.HorizontalHourLineAngleAt(date, lon, lat) - - fmt.Println(trueSolar) - fmt.Printf("hour angle=%.6f line@9am=%.6f line@now=%.6f\n", hourAngle, lineAngle, lineAngleNow) -} -``` - -其中: - -- `TrueSolarTime`:返回该绝对时刻在指定经度上的地方真太阳时 -- `MeanSolarTime`:返回该绝对时刻在指定经度上的地方平太阳时 -- `HourAngle`:返回带符号的太阳时角,上午为负,下午为正 -- `MeanSolarHourAngle` / `ZoneTimeHourAngle`:把地方平太阳时或区时钟面读数换成视太阳时角 -- `PlanarDial` / `Geometry` / `ShadowPointByHourAngleDeclination`:任意平面日晷的通用几何核心 -- `PlaneIlluminatedHourAngleIntervals` / `IlluminatedHourAngleIntervals`:按太阳赤纬解析盘面受光区间与最终可用时角区间 -- `DeclinationCurve` / `DeclinationCurveAt`:按赤纬或日期生成分段的日晷曲线采样点列 -- `MeanSolarTimePoint` / `ZoneTimePoint` / `MeanSolarTimeLine` / `ZoneTimeLine`:把平太阳时线或区时线直接接到日晷几何 -- `EquatorialNorthDial` / `EquatorialSouthDial` / `HorizontalDial` / `VerticalDial`:赤道、水平、垂直日晷特例 -- `HorizontalHourLineAngle`:给定纬度和时角,计算水平日晷相对午线的时线角 -- `HorizontalHourLineAngleAt`:直接用时刻和经纬度求当前时线角 - -- `MeanSolarTimePoint` / `MeanSolarTimeLine` 的 `date` 位于目标地点的地方平太阳时区,通常是 `MeanSolarTime(...)` 的返回值。 -- `ZoneTimePoint` / `ZoneTimeLine` 会忽略传入 `date` 的原有时分秒,只使用它的年月日与时区,再把钟面时间替换成参数 `zoneTimeHours`。 - -## 已实现 - -- ✅ 太阳位置、高度角、天顶距、方位角、中天、晨昏朦影、日出日落、节气、日食、日面物理参数 -- ✅ 月亮位置、高度角、天顶距、方位角、中天、升落、月相、月食、天平动、近远地点、最大赤纬,以及恒星/行星月掩 -- ✅ 日食、月食和月掩的全球 SVG 投影图、指定地点月掩图、GeoJSON 与可选时间标记 -- ✅ `lite/sun`、`lite/moon` 轻量太阳/月亮链路:面向分钟级升落、轻量位置和月相计算 -- ✅ 地球偏心率、日地距离、近日点、远日点 -- ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标 -- ✅ 七大行星坐标、距日距地距离、特殊天象、水星/金星地心凌日、物理星历、视直径、相位、视差角与节点 -- ✅ 公农历转换(公元前721年至公元3000年) -- ✅ 9100 颗恒星数据库 -- ✅ 通用小天体轨道传播、H-G 视星等、视双星位置角/角距 -- ✅ 黑体辐射、会合周期、星等、望远镜、大气质量等研究公式 -- ✅ 真太阳时、平面日晷几何、水平日晷时线角 - -## TODO - -- 🔄 代码规范化与性能优化 -- 🔄 继续补充外部基线和更完整的物理星历口径说明 -- 🔄 增强恒星计算功能和更多深空天体辅助能力 +`lite/sun`、`lite/moon` 适合资源受限环境;部分主接口也提供带 `N` 后缀的截断版本。模型范围、误差样本和性能数据见[精度与性能](doc/manual/accuracy.md)。 diff --git a/astro.go b/astro.go index ce29c56..c310a9c 100644 --- a/astro.go +++ b/astro.go @@ -1,17 +1,94 @@ -// Package astro 进程级 ΔT 模型的读取、替换与恢复默认:DeltaT、SetDeltaT、DefaultDeltaT。 -// Package astro exposes the process-wide DeltaT model: read the current function, install a custom one, or restore the built-in default. +// Package astro 提供进程级时标配置与时标换算 / process-wide time-scale configuration and conversions. package astro import "b612.me/astro/basic" +// DeltaT 返回当前的 TT−UT1(ΔT)模型 / the active TT−UT1 (DeltaT) model. func DeltaT() func(float64, bool) float64 { return basic.GetDeltaTFn() } +// SetDeltaT 替换 ΔT 模型(入参为数值与“该数值是否为儒略日”,为假时是十进制年),传 nil 恢复默认 / replaces the DeltaT model; nil restores the default. func SetDeltaT(deltaT func(float64, bool) float64) { basic.SetDeltaTFn(deltaT) } +// DefaultDeltaT 返回内置默认 ΔT 模型 / the built-in default DeltaT model. func DefaultDeltaT() func(float64, bool) float64 { return basic.DefaultDeltaTv2 } + +// TTMinusUTC 返回当前 TT−UTC 覆盖函数,未设置时为 nil / current TT−UTC override, or nil. +func TTMinusUTC() func(float64) float64 { + return basic.GetTTMinusUTCFn() +} + +// SetTTMinusUTC 替换 TT−UTC 模型,nil 恢复内置表与政策 / overrides TT−UTC; nil restores the built-in table and policy. +func SetTTMinusUTC(ttMinusUTC func(float64) float64) { + basic.SetTTMinusUTCFn(ttMinusUTC) +} + +// DefaultTTMinusUTC 返回内置闰秒表模型,忽略覆盖与未来政策 / built-in leap-table model, ignoring overrides and future policy. +func DefaultTTMinusUTC() func(float64) float64 { + return basic.TTMinusUTCSecondsDefault +} + +// TimeScaleFuturePolicy 民用时标换算政策,与输出用的 TimeScale 无关 / civil-time conversion policy, separate from output TimeScale. +type TimeScaleFuturePolicy = basic.TimeScaleFuturePolicy + +const ( + // TimeScaleLeapSecond 默认按 ΔT 越限施加整数秒校正,不代表闰秒公告 / default integer-second corrections driven by extrapolated ΔT, not announcements. + TimeScaleLeapSecond = basic.TimeScaleLeapSecond + // TimeScaleAssumeUT1Tracking 窗口外固定末端 DUT1,平滑跟随 UT1 / holds the last observed DUT1 beyond the window. + TimeScaleAssumeUT1Tracking = basic.TimeScaleAssumeUT1Tracking + // TimeScaleFreezeUTCOffset 假设 UTC 偏移冻结在精确窗口末端 / freezes the UTC offset at the window end. + TimeScaleFreezeUTCOffset = basic.TimeScaleFreezeUTCOffset + // TimeScaleLeapHour 按 ΔT 越限施加整小时校正,仅作情景演算 / applies hour-sized corrections as a scenario assumption. + TimeScaleLeapHour = basic.TimeScaleLeapHour + // TimeScaleUT1Civil 全时轴民用时标等同 UT1,TT−UTC 覆盖仍优先 / uses UT1 as civil time everywhere unless TT−UTC is overridden. + TimeScaleUT1Civil = basic.TimeScaleUT1Civil +) + +// SetTimeScaleFuturePolicy 设置与 basic 共享的民用时标换算政策 / sets the civil-time conversion policy shared with basic. +func SetTimeScaleFuturePolicy(policy TimeScaleFuturePolicy) { + basic.SetTimeScaleFuturePolicy(policy) +} + +// GetTimeScaleFuturePolicy 返回当前民用时标换算政策 / current civil-time conversion policy. +func GetTimeScaleFuturePolicy() TimeScaleFuturePolicy { + return basic.GetTimeScaleFuturePolicy() +} + +// DeltaTModel 标识可选的已发布 ΔT 模型 / a selectable published ΔT model. +type DeltaTModel = basic.DeltaTModel + +const ( + // DeltaTModelDefault 内置默认:SMH2016 + Morrison 2021 / the built-in SMH2016 + Morrison 2021 model. + DeltaTModelDefault = basic.DeltaTModelDefault + // DeltaTModelSMH2016 显式选择内置模型 / selects the built-in model explicitly. + DeltaTModelSMH2016 = basic.DeltaTModelSMH2016 + // DeltaTModelMS2004 Morrison & Stephenson 2004 长期抛物线 / the M&S2004 long-term parabola. + DeltaTModelMS2004 = basic.DeltaTModelMS2004 + // DeltaTModelEspenakMeeus2006 Espenak & Meeus 2006 分段多项式 / the Espenak & Meeus 2006 piecewise polynomial. + DeltaTModelEspenakMeeus2006 = basic.DeltaTModelEspenakMeeus2006 + // DeltaTModelNASACanon2006 上式再加配对修正 c,即 NASA 五千年目录的印刷口径 / plus the pairing term c, the NASA canon convention. + DeltaTModelNASACanon2006 = basic.DeltaTModelNASACanon2006 + // DeltaTModelManual 表示当前生效的是 SetDeltaT 注入的任意函数 / an arbitrary function injected with SetDeltaT. + DeltaTModelManual = basic.DeltaTModelManual +) + +// SetDeltaTModel 安装命名 ΔT 模型;keepObserved 为真时逐月实测段优先(推荐),未知模型返回 false 且不改动现状。 +// SetDeltaTModel installs a named ΔT model; with keepObserved the monthly observed span wins wherever it covers the instant. +func SetDeltaTModel(model DeltaTModel, keepObserved bool) bool { + return basic.SetDeltaTModel(model, keepObserved) +} + +// GetDeltaTModel 返回当前生效的模型与是否保留实测段 / the active model and whether the observed span is kept. +func GetDeltaTModel() (DeltaTModel, bool) { + return basic.GetDeltaTModel() +} + +// DeltaTModelSeconds 按命名模型求 ΔT(秒,入参为 UT 儒略日),不改变进程状态,便于对照 / evaluates a named model without touching process state. +func DeltaTModelSeconds(model DeltaTModel, jd float64, keepObserved bool) float64 { + return basic.DeltaTModelSeconds(model, jd, keepObserved) +} diff --git a/barycentric_time_public_test.go b/barycentric_time_public_test.go new file mode 100644 index 0000000..93c09c1 --- /dev/null +++ b/barycentric_time_public_test.go @@ -0,0 +1,69 @@ +package astro + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +// time.Time 往返经 JD 舍入,容差为 0.2 ms。 +func TestBarycentricTimeFacade(t *testing.T) { + tt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if got := TCGMinusTT(tt); math.Abs(got-1.0777) > 5e-3 { + t.Errorf("TCG−TT=%v 秒, want ≈1.078", got) + } + if got := TCBMinusTT(tt); math.Abs(got-23.9757) > 5e-3 { + t.Errorf("TCB−TT=%v 秒, want ≈23.976", got) + } + if got := TDBMinusTT(tt); math.Abs(got) > 2e-3 { + t.Errorf("TDB−TT=%v 秒, want |·| ≤ 1.7 ms", got) + } + for _, tc := range []struct { + name string + fwd func(time.Time) time.Time + back func(time.Time) time.Time + }{ + {"TCG", TCGFromTT, TTFromTCG}, + {"TCB", TCBFromTT, TTFromTCB}, + {"TDB", TDBFromTT, TTFromTDB}, + } { + round := tc.back(tc.fwd(tt)) + if delta := math.Abs(round.Sub(tt).Seconds()); delta > 2e-4 { + t.Errorf("%s 往返差 %.6f 秒", tc.name, delta) + } + if forward := tc.fwd(tt); !forward.After(tt.Add(-time.Second)) || !forward.Before(tt.Add(time.Minute)) { + t.Errorf("%s 结果 %v 离开合理区间", tc.name, forward) + } + } + tcb := TCBFromTT(tt) + if diff := tcb.Sub(TDBFromTCB(tcb)).Seconds(); math.Abs(diff-23.9757) > 0.1 { + t.Errorf("TCB−TDB=%v 秒, want ≈23.976", diff) + } + if delta := math.Abs(TTFromTCB(tcb).Sub(tt).Seconds()); delta > 2e-4 { + t.Errorf("TT→TCB 往返差 %.6f 秒", delta) + } +} + +func TestBarycentricTimeFacadePreservesScaleAndInstant(t *testing.T) { + tt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, date := range []time.Time{tt, tt.In(time.FixedZone("UTC+08", 8*3600))} { + jd := basic.Date2JD(date.UTC()) + for name, pair := range map[string][2]float64{ + "TCG": {TCGMinusTT(date), basic.TCGMinusTTSeconds(jd)}, + "TCB": {TCBMinusTT(date), basic.TCBMinusTTSeconds(jd)}, + "TDB": {TDBMinusTT(date), basic.TDBMinusTTSeconds(jd)}, + } { + if pair[0] != pair[1] { + t.Errorf("%s facade=%v, basic=%v", name, pair[0], pair[1]) + } + } + if direct, composed := TDBFromTT(date), TDBFromTCB(TCBFromTT(date)); math.Abs(direct.Sub(composed).Seconds()) > 1e-4 { + t.Errorf("direct TDB=%v, composed=%v", direct, composed) + } + if got := TCGFromTT(date); got.Location() != time.UTC || !got.Equal(TCGFromTT(tt)) { + t.Errorf("TCG depends on input Location: %v", got) + } + } +} diff --git a/basic/ancient_station_regression_test.go b/basic/ancient_station_regression_test.go index 96b1013..92058e4 100644 --- a/basic/ancient_station_regression_test.go +++ b/basic/ancient_station_regression_test.go @@ -9,11 +9,11 @@ import ( const ancientStationTolerance = 10.0 / 1440.0 func ancientStationTT(year int, month time.Month, day int) float64 { - return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC))) } func ancientStationUT(year int, month time.Month, day, hour, minute int) float64 { - return Date2JDE(time.Date(year, month, day, hour, minute, 0, 0, time.UTC)) + return Date2JD(time.Date(year, month, day, hour, minute, 0, 0, time.UTC)) } func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) { @@ -23,7 +23,7 @@ func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) { } if diff := math.Abs(gotUT - wantUT); diff > ancientStationTolerance { t.Fatalf("station differs by %.3f minutes: got %s, want %s", diff*1440, - JDE2DateByZone(gotUT, time.UTC, false), JDE2DateByZone(wantUT, time.UTC, false)) + JD2DateByZone(gotUT, time.UTC, false), JD2DateByZone(wantUT, time.UTC, false)) } } diff --git a/basic/apsis.go b/basic/apsis.go index 6149599..398f2e1 100644 --- a/basic/apsis.go +++ b/basic/apsis.go @@ -32,8 +32,8 @@ const ( // ApsisEvent 轨道极值事件 / orbital distance extremum event. type ApsisEvent struct { - // JDE 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day. - JDE float64 + // JD 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day. + JD float64 // Distance 是极值距离;地球相关事件单位 AU,月球相关事件单位 km / extremum distance. Distance float64 } @@ -79,7 +79,7 @@ func earthApsis(year int, aphelion bool) ApsisEvent { } eventTT, distanceAU := refineDistanceExtremum(seedTT, cfg, EarthAway) return ApsisEvent{ - JDE: TD2UT(eventTT, false), + JD: TT2UTC(eventTT), Distance: distanceAU, } } @@ -87,8 +87,8 @@ func earthApsis(year int, aphelion bool) ApsisEvent { func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent { startUTC := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) endUTC := startUTC.AddDate(0, 1, 0) - startTT := TD2UT(Date2JDE(startUTC), true) - endTT := TD2UT(Date2JDE(endUTC), true) + startTT := UTC2TT(Date2JD(startUTC)) + endTT := UTC2TT(Date2JD(endUTC)) kStart := int(math.Floor((startTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) - 1 kEnd := int(math.Ceil((endTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) + 1 @@ -110,19 +110,19 @@ func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent { for k := kStart; k <= kEnd; k++ { seedTT := moonApsisSeedTT(float64(k) + phase) eventTT, distanceKM := refineDistanceExtremum(seedTT, cfg, HMoonAway) - eventUT := TD2UT(eventTT, false) - eventTimeUTC := JDE2DateByZone(eventUT, time.UTC, false) + eventUT := TT2UTC(eventTT) + eventTimeUTC := JD2DateByZone(eventUT, time.UTC, false) if eventTimeUTC.Before(startUTC) || !eventTimeUTC.Before(endUTC) { continue } events = append(events, ApsisEvent{ - JDE: eventUT, + JD: eventUT, Distance: distanceKM, }) } sort.Slice(events, func(i, j int) bool { - return events[i].JDE < events[j].JDE + return events[i].JD < events[j].JD }) return events } diff --git a/basic/apsis_test.go b/basic/apsis_test.go index a2dd568..3a16a7b 100644 --- a/basic/apsis_test.go +++ b/basic/apsis_test.go @@ -62,7 +62,7 @@ func TestEarthApsisMatchesHorizonsBaseline(t *testing.T) { t.Fatalf("unknown earth apsis kind %q", sample.Kind) } - gotTime := JDE2DateByZone(got.JDE, time.UTC, false) + gotTime := JD2DateByZone(got.JD, time.UTC, false) timeDiff := gotTime.Sub(wantTime) if timeDiff < 0 { timeDiff = -timeDiff @@ -139,7 +139,7 @@ func TestMoonApsisMatchesHorizonsBaseline(t *testing.T) { t.Fatalf("unknown moon apsis kind %q", sample.Kind) } - gotTime := JDE2DateByZone(got.JDE, time.UTC, false) + gotTime := JD2DateByZone(got.JD, time.UTC, false) timeDiff := gotTime.Sub(wantTime) if timeDiff < 0 { timeDiff = -timeDiff diff --git a/basic/barycentric_time.go b/basic/barycentric_time.go new file mode 100644 index 0000000..139d7d6 --- /dev/null +++ b/basic/barycentric_time.go @@ -0,0 +1,137 @@ +package basic + +import "math" + +const ( + // 线性时标关系的参考历元,不表示各时标读数在此相等。 + barycentricT0JDE = 2443144.5003725 + lgRate = 6.969290134e-10 + lbRate = 1.550519768e-8 + tdb0Seconds = -65.5e-6 +) + +// tdbPeriodicGroups 按 t 的幂次分组的 TDB−TT 周期项(振幅秒、频率 rad/儒略千年、相位 rad)。 +var tdbPeriodicGroups = [5][][3]float64{ + { + {0.001656674564, 6283.075849991, 6.240054195}, + {2.2417471e-05, 5753.384884897, 4.296977442}, + {1.3839792e-05, 12566.151699983, 6.19690441}, + {4.770086e-06, 529.690965095, 0.444401603}, + {4.67674e-06, 6069.776754553, 4.021195093}, + {2.256707e-06, 213.299095438, 5.543113262}, + {1.694205e-06, -3.523118349, 5.025132748}, + {1.554905e-06, 77713.77146792, 5.19846709}, + {1.276839e-06, 7860.419392439, 5.988822341}, + {1.193379e-06, 5223.693919802, 3.64982373}, + {1.115322e-06, 3930.20969622, 1.422745069}, + {7.94185e-07, 11506.769769794, 2.322313077}, + {4.47061e-07, 26.2983198, 3.615796498}, + {4.35206e-07, -398.149003408, 4.349338347}, + {6.00309e-07, 1577.343542448, 2.678271909}, + {4.96817e-07, 6208.294251424, 5.696701824}, + {4.86306e-07, 5884.926846583, 0.520007179}, + {4.32392e-07, 74.781598567, 2.435898309}, + {4.68597e-07, 6244.942814354, 5.866398759}, + {3.7551e-07, 5507.553238667, 4.103476804}, + {2.43085e-07, -775.522611324, 3.651837925}, + {1.73435e-07, 18849.227549974, 6.153743485}, + {2.30685e-07, 5856.477659115, 4.773852582}, + {2.03747e-07, 12036.460734888, 4.333987818}, + }, + { + {0.000102156724, 6283.075849991, 4.249032005}, + {1.706807e-06, 12566.151699983, 4.205904248}, + {2.69668e-07, 213.299095438, 3.400290479}, + {2.65919e-07, 529.690965095, 5.836047367}, + {2.10568e-07, -3.523118349, 6.262738348}, + {7.7996e-08, 5223.693919802, 4.670344204}, + {5.4764e-08, 1577.343542448, 4.53480017}, + {5.9146e-08, 26.2983198, 1.083044735}, + }, + { + {4.32299e-06, 6283.075849991, 2.642893748}, + {4.06495e-07, 0.0, 4.71238898}, + {1.22605e-07, 12566.151699983, 2.438140634}, + {1.9476e-08, 213.299095438, 1.642186981}, + }, + { + {1.43388e-07, 6283.075849991, 1.131453581}, + {6.671e-09, 12566.151699983, 0.775148887}, + }, + { + {3.826e-09, 6283.075849991, 5.705257275}, + {3.03e-10, 12566.151699983, 5.407132842}, + }, +} + +// 地心近似,不包含观测者位置引起的日周项。 +func tdbPeriodicSeconds(jd float64) float64 { + t := (jd - 2451545.0) / 365250.0 + sum := 0.0 + for i := len(tdbPeriodicGroups) - 1; i >= 0; i-- { + group := 0.0 + for _, term := range tdbPeriodicGroups[i] { + group += term[0] * math.Sin(term[1]*t+term[2]) + } + sum = sum*t + group + } + return sum + 0.00065e-6*math.Sin(6069.776754*t+4.021194) + + 0.00033e-6*math.Sin(213.299095*t+5.543132) - + 0.00196e-6*math.Sin(6208.294251*t+5.696701) - + 0.00173e-6*math.Sin(74.781599*t+2.435900) + + 0.03638e-6*t*t +} + +// TCGMinusTTSeconds 返回 TT 时刻的 TCG−TT(秒)/ TCG−TT in seconds at a TT instant. +func TCGMinusTTSeconds(jd float64) float64 { + return lgRate / (1 - lgRate) * (jd - barycentricT0JDE) * 86400 +} + +// TCBMinusTTSeconds 返回 TT 时刻的地心 TCB−TT 近似值(秒)/ geocentric TCB−TT approximation in seconds at a TT instant. +func TCBMinusTTSeconds(jd float64) float64 { + return (lbRate*(jd-barycentricT0JDE)*86400 + tdbPeriodicSeconds(jd) - tdb0Seconds) / (1 - lbRate) +} + +// TDBMinusTTSeconds 返回 TT 时刻的地心 TDB−TT 近似值(秒)/ geocentric TDB−TT approximation in seconds at a TT instant. +func TDBMinusTTSeconds(jd float64) float64 { return tdbPeriodicSeconds(jd) } + +// TT2TCG 地球时转地心坐标时 / converts TT to TCG. +func TT2TCG(ttJDE float64) float64 { return ttJDE + TCGMinusTTSeconds(ttJDE)/86400 } + +// TCG2TT 地心坐标时转地球时 / converts TCG to TT. +func TCG2TT(tcgJDE float64) float64 { return tcgJDE - lgRate*(tcgJDE-barycentricT0JDE) } + +// TT2TCB 地球时转太阳系质心坐标时,采用地心近似 / converts TT to TCB using a geocentric approximation. +func TT2TCB(ttJDE float64) float64 { return ttJDE + TCBMinusTTSeconds(ttJDE)/86400 } + +// TCB2TT 是 TT2TCB 的逆 / inverts TT2TCB. +func TCB2TT(tcbJDE float64) float64 { + // TDB 与 TT 相差不超过 2 ms,拿 TDB 读数当初值可少迭代两轮。 + tt := TCB2TDB(tcbJDE) + for i := 0; i < 3; i++ { + tt = tcbJDE - TCBMinusTTSeconds(tt)/86400 + } + return tt +} + +// TT2TDB 地球时转太阳系质心力学时,采用地心近似 / converts TT to TDB using a geocentric approximation. +func TT2TDB(ttJDE float64) float64 { return ttJDE + TDBMinusTTSeconds(ttJDE)/86400 } + +// TDB2TT 是 TT2TDB 的逆 / inverts TT2TDB. +func TDB2TT(tdbJDE float64) float64 { + tt := tdbJDE - TDBMinusTTSeconds(tdbJDE)/86400 + for i := 0; i < 3; i++ { + tt = tdbJDE - TDBMinusTTSeconds(tt)/86400 + } + return tt +} + +// TCB2TDB 太阳系质心坐标时转质心力学时 / converts TCB to TDB. +func TCB2TDB(tcbJDE float64) float64 { + return tcbJDE + (tdb0Seconds/86400 - lbRate*(tcbJDE-barycentricT0JDE)) +} + +// TDB2TCB 太阳系质心力学时转质心坐标时 / converts TDB to TCB. +func TDB2TCB(tdbJDE float64) float64 { + return tdbJDE + (lbRate*(tdbJDE-barycentricT0JDE)-tdb0Seconds/86400)/(1-lbRate) +} diff --git a/basic/barycentric_time_test.go b/basic/barycentric_time_test.go new file mode 100644 index 0000000..c63239c --- /dev/null +++ b/basic/barycentric_time_test.go @@ -0,0 +1,128 @@ +package basic + +import ( + "math" + "testing" +) + +func TestBarycentricScaleDefinitions(t *testing.T) { + for _, jd := range []float64{JDCalc(-3000, 1, 1), barycentricT0JDE, JDCalc(2026, 1, 1), JDCalc(6000, 1, 1)} { + tcgOffset := TCGMinusTTSeconds(jd) + if diff := tcgOffset*(1-6.969290134e-10) - 6.969290134e-10*(jd-2443144.5003725)*86400; math.Abs(diff) > 1e-12 { + t.Errorf("jd=%v TCG defining relation residual=%g s", jd, diff) + } + tcbOffset := TCBMinusTTSeconds(jd) + if diff := tcbOffset*(1-1.550519768e-8) - 1.550519768e-8*(jd-2443144.5003725)*86400 - TDBMinusTTSeconds(jd) - 65.5e-6; math.Abs(diff) > 1e-12 { + t.Errorf("jd=%v TCB defining relation residual=%g s", jd, diff) + } + } + if got := TCGMinusTTSeconds(barycentricT0JDE); got != 0 { + t.Errorf("TCG-TT at T0=%v, want 0", got) + } + if got, want := TCB2TDB(barycentricT0JDE), barycentricT0JDE-65.5e-6/86400; got != want { + t.Errorf("TDB at TCB T0=%.12f, want %.12f", got, want) + } +} + +func TestBarycentricLinearConversionAnchors(t *testing.T) { + for _, tc := range []struct { + name string + fn func(float64) float64 + jd float64 + want float64 + }{ + {"TCB2TDB", TCB2TDB, 2453750.5 + 0.893019599, 2453750.5 + 0.8928551362746343397}, + {"TDB2TCB", TDB2TCB, 2453750.5 + 0.892855137, 2453750.5 + 0.8930195997253656716}, + {"TCG2TT", TCG2TT, 2453750.5 + 0.892862531, 2453750.5 + 0.8928551387488816828}, + {"TT2TCG", TT2TCG, 2453750.5 + 0.892482639, 2453750.5 + 0.8924900312508587113}, + } { + ulp := math.Nextafter(tc.want, math.Inf(1)) - tc.want + if got := tc.fn(tc.jd); math.Abs(got-tc.want) > ulp { + t.Errorf("%s=%.12f, want %.12f", tc.name, got, tc.want) + } + } +} + +func TestBarycentricPeriodicTermsMatchFullSeries(t *testing.T) { + for _, tc := range []struct { + jd float64 + want float64 + }{ + {625307.5, 0.001558488628712037}, + {766574.5, -0.000924800103328196}, + {1721057.5, 0.0008624527814561311}, + {2433282.5, -7.069829559472634e-05}, + {2443144.5, -6.551401857064118e-05}, + {2451544.5, -0.0001137630988927298}, + {2461041.5, -8.20152430051247e-05}, + {2461222.5, 0.0001186656922342686}, + {2469807.5, -8.01882947792431e-05}, + {3182029.5, -0.0009618304620565844}, + {3912514.5, -0.00138271667055309}, + } { + if got := TDBMinusTTSeconds(tc.jd); math.Abs(got-tc.want) > 2.1e-6 { + t.Errorf("jd=%v TDB-TT=%.9f us, want %.9f us", tc.jd, got*1e6, tc.want*1e6) + } + } +} + +func TestBarycentricScaleRoundTrips(t *testing.T) { + for _, jd := range []float64{ + JDCalc(-3000, 1, 1), JDCalc(0, 1, 1), JDCalc(1900, 1, 1), JDCalc(1950, 6, 1), + barycentricT0JDE, JDCalc(2000, 1, 1), JDCalc(2026, 4, 1), JDCalc(2050, 7, 1), + JDCalc(2100, 1, 1), JDCalc(6000, 1, 1), + } { + ulp := math.Nextafter(jd, math.Inf(1)) - jd + for _, tc := range []struct { + name string + fwd func(float64) float64 + back func(float64) float64 + }{ + {"TT-TCG", TT2TCG, TCG2TT}, + {"TT-TCB", TT2TCB, TCB2TT}, + {"TT-TDB", TT2TDB, TDB2TT}, + {"TCB-TDB", TCB2TDB, TDB2TCB}, + } { + if got := tc.back(tc.fwd(jd)); math.Abs(got-jd) > ulp { + t.Errorf("jd=%v %s round-trip error=%g s", jd, tc.name, (got-jd)*86400) + } + if got := tc.fwd(tc.back(jd)); math.Abs(got-jd) > ulp { + t.Errorf("jd=%v %s inverse round-trip error=%g s", jd, tc.name, (got-jd)*86400) + } + } + // 组合路径分别舍入,允许两次 JD 舍入误差。 + if diff := TCB2TDB(TT2TCB(jd)) - TT2TDB(jd); math.Abs(diff) > 2*ulp { + t.Errorf("jd=%v TT-TCB-TDB differs from TT-TDB by %g s", jd, diff*86400) + } + if diff := (TT2TDB(jd)-jd)*86400 - TDBMinusTTSeconds(jd); math.Abs(diff) > ulp*86400 { + t.Errorf("jd=%v TDB seconds and JD conversions differ by %g s", jd, diff) + } + } +} + +func TestBarycentricScaleMagnitudes(t *testing.T) { + jd := JDCalc(2026, 1, 1) + if got := TCGMinusTTSeconds(jd); math.Abs(got-1.0777) > 5e-3 { + t.Errorf("TCG-TT=%v s, want about 1.078", got) + } + if got := TCBMinusTTSeconds(jd); math.Abs(got-23.9757) > 5e-3 { + t.Errorf("TCB-TT=%v s, want about 23.976", got) + } + if got := TCGMinusTTSeconds(barycentricT0JDE+365.25) - TCGMinusTTSeconds(barycentricT0JDE); math.Abs(got-0.02204) > 5e-5 { + t.Errorf("TCG-TT annual increase=%v s, want about 0.02204", got) + } + if got := TCBMinusTTSeconds(barycentricT0JDE+365.25) - TCBMinusTTSeconds(barycentricT0JDE); math.Abs(got-0.48934) > 5e-4 { + t.Errorf("TCB-TT annual increase=%v s, want about 0.48934", got) + } + if got := TDBMinusTTSeconds(JDCalc(2100, 1, 1)) - TDBMinusTTSeconds(JDCalc(2000, 1, 1)); math.Abs(got) > 2e-4 { + t.Errorf("TDB-TT century difference=%v s, want within 0.2 ms", got) + } + min, max := math.Inf(1), math.Inf(-1) + for month := 1; month <= 12; month++ { + value := TDBMinusTTSeconds(JDCalc(2026, month, 1)) + min, max = math.Min(min, value), math.Max(max, value) + } + if amplitude := max - min; amplitude < 3.0e-3 || amplitude > 3.6e-3 { + t.Errorf("TDB-TT annual range=%v s, want 3.0-3.6 ms", amplitude) + } +} diff --git a/basic/calendar_test.go b/basic/calendar_test.go index e1d724d..05fd9db 100644 --- a/basic/calendar_test.go +++ b/basic/calendar_test.go @@ -26,7 +26,7 @@ func TestGetJQTime(t *testing.T) { month -= 12 } - jd := JDECalc(year, int(month), float64(day)) + jd := JDCalc(year, int(month), float64(day)) if angle == 0 { angle = 360 } @@ -43,7 +43,7 @@ func TestGetJQTime(t *testing.T) { jd -= 0.001 } jd += 0.001 - return TD2UT(jd, false) + return TT2UTC(jd) } testCases := []struct { diff --git a/basic/constellation_regression_test.go b/basic/constellation_regression_test.go index 9c34eb8..15e4351 100644 --- a/basic/constellation_regression_test.go +++ b/basic/constellation_regression_test.go @@ -65,7 +65,7 @@ func TestConstellationNameLookups(t *testing.T) { } func TestConstellationNameEN(t *testing.T) { - jde := Date2JDE(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) + jde := Date2JD(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) if en := ConstellationNameEN(88.792939, 7.407064, jde); en != "Orion" { t.Fatalf("ConstellationNameEN() = %q, want %q", en, "Orion") } diff --git a/basic/constellation_test.go b/basic/constellation_test.go index cd2fc45..f3124f5 100644 --- a/basic/constellation_test.go +++ b/basic/constellation_test.go @@ -5,7 +5,7 @@ import ( ) func TestConstellationNameZH(t *testing.T) { - now := GetNowJDE() + now := GetNowJD() //finish on 30s for i := 0.00; i <= 360.00; i += 0.5 { for j := -90.00; j <= 90.00; j += 0.5 { @@ -15,9 +15,9 @@ func TestConstellationNameZH(t *testing.T) { } func BenchmarkConstellationNameZH(b *testing.B) { - jde := GetNowJDE() + jde := GetNowJD() for i := 0; i < b.N; i++ { - //GetNowJDE() + //GetNowJD() ConstellationNameZH(11.11, 12.12, jde) } diff --git a/basic/coordinate.go b/basic/coordinate.go index 7c9f009..f27e26b 100644 --- a/basic/coordinate.go +++ b/basic/coordinate.go @@ -86,7 +86,7 @@ func psini(lat, h float64) float64 { } func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) { - return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(jd)*15, au, h) + return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(UTC2UT1(jd))*15, au, h) } func topocentricRaDecWithSidereal(ra, dec, lat, lon, siderealDegrees, au, h float64) (float64, float64) { @@ -109,43 +109,41 @@ func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格 return topocentricDec } -func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时 - c := pcosi(lat, h) - s := psini(lat, h) - sinpi := Sin(0.0024427777777) / au - ra := LoToRa(jd, lo, bo) - tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra) - n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH) - nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi +// TopocentricLoBo 一次求值给出站心黄经与黄纬 / topocentric ecliptic longitude and latitude in one solve. +// +// 先解站心赤道坐标再转黄道:黄道版公式的分母在黄经 90°–270° 时变号,直接 atan2 会切到对顶象限。 +func TopocentricLoBo(lo, bo, lat, lon, jde, au, h float64) (float64, float64) { + ra, dec := LoBoToRaDec(jde, lo, bo) + topRA, topDec := TopocentricRaDec(ra, dec, lat, lon, jde, au, h) + return RaDecToLoBo(jde, topRA, topDec) +} + +// TopocentricLo 站心黄经 / topocentric ecliptic longitude. +func TopocentricLo(lo, bo, lat, lon, jde, au, h float64) float64 { + nlo, _ := TopocentricLoBo(lo, bo, lat, lon, jde, au, h) return nlo } -func TopocentricBo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时 - c := pcosi(lat, h) - s := psini(lat, h) - sinpi := Sin(0.0024427777777) / au - ra := LoToRa(jd, lo, bo) - tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra) - n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH) - nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi - nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(s*Cos(TrueObliquity(jd))-c*Sin(TrueObliquity(jd))*Sin(tH))), n) * 180 / math.Pi +// TopocentricBo 站心黄纬 / topocentric ecliptic latitude. +func TopocentricBo(lo, bo, lat, lon, jde, au, h float64) float64 { + _, nbo := TopocentricLoBo(lo, bo, lat, lon, jde, au, h) return nbo } -func GXCLo(lo, bo, jd float64) float64 { //光行差修正 +func GXCLo(lo, bo, jde float64) float64 { //光行差修正 k := 20.49552 - sunlo := SunTrueLo(jd) - e := Earthe(jd) - epi := EarthPI(jd) + sunlo := SunTrueLo(jde) + e := Earthe(jde) + epi := EarthPI(jde) tmp := (-k*Cos(sunlo-lo) + e*k*Cos(epi-lo)) / Cos(bo) return tmp } -func GXCBo(lo, bo, jd float64) float64 { +func GXCBo(lo, bo, jde float64) float64 { k := 20.49552 - sunlo := SunTrueLo(jd) - e := Earthe(jd) - epi := EarthPI(jd) + sunlo := SunTrueLo(jde) + e := Earthe(jde) + epi := EarthPI(jde) tmp := -k * Sin(bo) * (Sin(sunlo-lo) - e*Sin(epi-lo)) return tmp } diff --git a/basic/coordinate_topocentric_test.go b/basic/coordinate_topocentric_test.go index efa0e25..52bf90c 100644 --- a/basic/coordinate_topocentric_test.go +++ b/basic/coordinate_topocentric_test.go @@ -9,27 +9,28 @@ import ( ) func TestTopocentricRaDecUsesUTJulianDateForSiderealTime(t *testing.T) { - ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC)) + ut := Date2JD(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC)) ra := 189.527817246 dec := -5.973400893 lat := 6.79657 lon := 121.55381 - distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7 + distanceAU := HMoonAwayN(UTC2TT(ut), -1) / 149597870.7 gotRA, gotDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0) wantRA, wantDec := independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0) - if delta := angularDistanceArcsec(gotRA, gotDec, wantRA, wantDec); delta > 1e-6 { - t.Fatalf("TopocentricRaDec differs from independent formula by %.9f arcsec", delta) + // 逐分量比较:零距离处 acos 度规的病态下限会把 1 ulp 放大成毫角秒。 + if deltaRA, deltaDec := math.Abs(gotRA-wantRA), math.Abs(gotDec-wantDec); deltaRA > 1e-12 || deltaDec > 1e-12 { + t.Fatalf("TopocentricRaDec differs from independent formula: dRA=%.3g dDec=%.3g deg", deltaRA, deltaDec) } } func TestTopocentricRaAndDecMatchCombinedResult(t *testing.T) { - ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC)) + ut := Date2JD(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC)) ra := 189.527817246 dec := -5.973400893 lat := 6.79657 lon := 121.55381 - distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7 + distanceAU := HMoonAwayN(UTC2TT(ut), -1) / 149597870.7 wantRA, wantDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0) if got := TopocentricRa(ra, dec, lat, lon, ut, distanceAU, 0); got != wantRA { @@ -41,11 +42,11 @@ func TestTopocentricRaAndDecMatchCombinedResult(t *testing.T) { } func TestHMoonHeightUsesUTForTopocentricCorrection(t *testing.T) { - ut := Date2JDE(time.Date(2026, 4, 28, 16, 1, 30, 0, time.UTC)) + ut := Date2JD(time.Date(2026, 4, 28, 16, 1, 30, 0, time.UTC)) longitude := 0.0 latitude := 51.4779 ra, dec := HMoonApparentRaDecN(ut, longitude, latitude, 0, -1) - hourAngle := Limit360(ApparentSiderealTime(ut)*15 + longitude - ra) + hourAngle := Limit360(ApparentSiderealTime(UTC2UT1(ut))*15 + longitude - ra) want := ArcSin(Sin(latitude)*Sin(dec) + Cos(dec)*Cos(latitude)*Cos(hourAngle)) got := HMoonHeightN(ut, longitude, latitude, 0, -1) if difference := math.Abs(got - want); difference > 1e-10 { @@ -62,7 +63,7 @@ func independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, height float rhoCos := math.Cos(u) + height/6378140.0*Cos(lat) rhoSin := polarRadiusKM/equatorialRadiusKM*math.Sin(u) + height/6378140.0*Sin(lat) sinParallax := Sin(0.0024427777777) / distanceAU - hourAngle := Limit360(ApparentSiderealTime(ut)*15 + lon - ra) + hourAngle := Limit360(ApparentSiderealTime(UTC2UT1(ut))*15 + lon - ra) deltaRA := math.Atan2( -rhoCos*sinParallax*Sin(hourAngle), Cos(dec)-rhoCos*sinParallax*Cos(hourAngle), diff --git a/basic/culmination_anchor_test.go b/basic/culmination_anchor_test.go index 3e02809..75625a3 100644 --- a/basic/culmination_anchor_test.go +++ b/basic/culmination_anchor_test.go @@ -10,7 +10,7 @@ import ( // 调用方按各自约定补偿后,两者都必须落在同一个本地民用日,并接近当日本地日的真实中天。 // // 框架约定(由各自调用方固定下来): -// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JDE(本地 0 时) 起算; +// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JD(本地 0 时) 起算; // - basic.CulminationTime 的返回值是本地民用时框架,sun/sun.go 减 tz/24 得到 UT; // - basic.MoonCulminationTime 的返回值就是本地民用时框架,moon/moon.go 按 byZone=true 使用。 @@ -33,7 +33,7 @@ var culminationAnchorSites = []culminationAnchorSite{ func culminationAnchorLocalMidnight(site culminationAnchorSite, timestamp time.Time) (time.Time, float64) { location := time.FixedZone("anchor", int(site.tz*3600)) local := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, location) - return local, Date2JDE(local) + return local, Date2JD(local) } func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) { @@ -42,7 +42,7 @@ func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) { local, midnightJD := culminationAnchorLocalMidnight(site, timestamp) location := local.Location() // sun/sun.go 的补偿:本地 0 时 JD 再加半天,使 floor 落在同一本地日;随后减 tz/24 得 UT。 - got := JDE2DateByZone(CulminationTime(midnightJD+0.5, site.lon, site.tz)-site.tz/24, location, false) + got := JD2DateByZone(CulminationTime(midnightJD+0.5, site.lon, site.tz)-site.tz/24, location, false) truthJD, truthAltitude := 0.0, -999.0 for minute := 0; minute <= 24*60; minute++ { jd := midnightJD + float64(minute)/1440.0 @@ -50,7 +50,7 @@ func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) { truthAltitude, truthJD = altitude, jd } } - truth := JDE2DateByZone(truthJD-site.tz/24, location, false) + truth := JD2DateByZone(truthJD-site.tz/24, location, false) if got.Day() != local.Day() || got.Month() != local.Month() { t.Fatalf("%s: sun culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02")) } @@ -65,7 +65,7 @@ func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) { for _, site := range culminationAnchorSites { local, midnightJD := culminationAnchorLocalMidnight(site, timestamp) location := local.Location() - got := JDE2DateByZone(MoonCulminationTime(midnightJD, site.lon, site.lat, site.tz), location, true) + got := JD2DateByZone(MoonCulminationTime(midnightJD, site.lon, site.lat, site.tz), location, true) truthJD, truthAltitude := 0.0, -999.0 for minute := 0; minute <= 24*60; minute++ { jd := midnightJD + float64(minute)/1440.0 @@ -73,7 +73,7 @@ func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) { truthAltitude, truthJD = altitude, jd } } - truth := JDE2DateByZone(truthJD, location, true) + truth := JD2DateByZone(truthJD, location, true) if got.Day() != local.Day() || got.Month() != local.Month() { t.Fatalf("%s: moon culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02")) } diff --git a/basic/delta_t.go b/basic/delta_t.go deleted file mode 100644 index 6aef852..0000000 --- a/basic/delta_t.go +++ /dev/null @@ -1,203 +0,0 @@ -package basic - -import ( - "math" - "sync" -) - -var defDeltaTFn = DefaultDeltaTv2 -var deltaTFnMu sync.RWMutex - -// deltaTGeneration 随每次 ΔT 覆盖递增,供依赖 ΔT 的只读记忆表判断自身是否过期。 -// 起始为 1,使零值缓存条目(世代 0)天然视为未命中。 -// deltaTGeneration increments on every ΔT override so ΔT-dependent memo tables can detect -// staleness. It starts at 1 so a zero-valued cache entry (generation 0) is never a hit. -var deltaTGeneration uint64 = 1 - -func DeltaT(date float64, isJDE bool) float64 { - deltaTFnMu.RLock() - fn := defDeltaTFn - deltaTFnMu.RUnlock() - return fn(date, isJDE) -} - -func SetDeltaTFn(fn func(float64, bool) float64) { - if fn != nil { - deltaTFnMu.Lock() - defDeltaTFn = fn - deltaTGeneration++ - deltaTFnMu.Unlock() - } -} - -// deltaTGenerationValue 返回当前 ΔT 世代,用于让只读记忆表在 ΔT 改变后整体失效。 -// deltaTGenerationValue returns the current ΔT generation so memo tables can be invalidated. -func deltaTGenerationValue() uint64 { - deltaTFnMu.RLock() - value := deltaTGeneration - deltaTFnMu.RUnlock() - return value -} - -func GetDeltaTFn() func(float64, bool) float64 { - deltaTFnMu.RLock() - fn := defDeltaTFn - deltaTFnMu.RUnlock() - return fn -} - -func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日,传出为秒 - if math.IsNaN(date) || math.IsInf(date, 0) { - return math.NaN() - } - if !isJd { - year := math.Floor(date) - start := JDECalc(int(year), 1, 1) - end := JDECalc(int(year)+1, 1, 1) - date = start + (date-year)*(end-start) - } - return DeltaTv2(date) -} - -// 使用Stephenson等人(2016)和Morrison等人(2021)的拟合和外推公式计算Delta T -// http://astro.ukho.gov.uk/nao/lvm/ -// 2010年后的系数已修改以包含2019年后的数据 -// 返回Delta T,单位为秒 -func DeltaTSplineY(y float64) float64 { - if math.IsNaN(y) || math.IsInf(y, 0) { - return math.NaN() - } - // 积分lod(平均太阳日偏离86400秒的偏差)方程: - // 来自 http://astro.ukho.gov.uk/nao/lvm/: - // lod = 1.72 t − 3.5 sin(2*pi*(t+0.75)/14) 单位ms/day,其中 t = (y - 1825)/100 - // 是从1825年开始的世纪数 - // 使用 1ms = 1e-3s 和 1儒略年 = 365.25天, - // lod = 6.2823e-3 * Delta y - 1.278375*sin(2*pi/14*(Delta y /100 + 0.75) 单位s/year - // 其中 Delta y = y - 1825。积分该方程得到 - // Integrate[lod, y] = 3.14115e-3*(Delta y)^2 + 894.8625/pi*cos(2*pi/14*(Delta y /100 + 0.75) - // 单位为秒。积分常数设为0。 - integratedLod := func(x float64) float64 { - u := x - 1825 - return 3.14115e-3*u*u + 284.8435805251424*math.Cos(0.4487989505128276*(0.01*u+0.75)) - } - - if y < -720 { - // 使用积分lod + 常数 - const c = 1.007739546148514 - return integratedLod(y) + c - } - if y > 2025 { - // 使用积分lod + 常数 - const c = -150.56787057979514 - return integratedLod(y) + c - } - - // 使用三次样条拟合 - y0 := []float64{-720, -100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022} - y1 := []float64{-100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022, 2025} - a0 := []float64{20371.848, 11557.668, 6535.116, 1650.393, 1056.647, 681.149, 292.343, 109.127, 43.952, 12.068, 18.367, 15.678, 16.516, 10.804, 7.634, 9.338, 10.357, 9.04, 8.255, 2.371, -1.126, -3.21, -4.388, -3.884, -5.017, -1.977, 4.923, 11.142, 17.479, 21.617, 23.789, 24.418, 24.164, 24.426, 27.05, 28.932, 30.002, 30.76, 32.652, 33.621, 35.093, 37.956, 40.951, 44.244, 47.291, 50.361, 52.936, 54.984, 56.373, 58.453, 60.678, 62.898, 64.083, 64.553, 65.197, 66.061, 66.919, 68.130, 69.250, 69.296} - a1 := []float64{-9999.586, -5822.27, -5671.519, -753.21, -459.628, -421.345, -192.841, -78.697, -68.089, 2.507, -3.481, 0.021, -2.157, -6.018, -0.416, 1.642, -0.486, -0.591, -3.456, -5.593, -2.314, -1.893, 0.101, -0.531, 0.134, 5.715, 6.828, 6.33, 5.518, 3.02, 1.333, 0.052, -0.419, 1.645, 2.499, 1.127, 0.737, 1.409, 1.577, 0.868, 2.275, 3.035, 3.157, 3.199, 3.069, 2.878, 2.354, 1.577, 1.648, 2.235, 2.324, 1.804, 0.674, 0.466, 0.804, 0.839, 1.005, 1.348, 0.594, -0.227} - a2 := []float64{776.247, 1303.151, -298.291, 184.811, 108.771, 61.953, -6.572, 10.505, 38.333, 41.731, -1.126, 4.629, -6.806, 2.944, 2.658, 0.261, -2.389, 2.284, -5.148, 3.011, 0.269, 0.152, 1.842, -2.474, 3.138, 2.443, -1.329, 0.831, -1.643, -0.856, -0.831, -0.449, -0.022, 2.086, -1.232, 0.22, -0.61, 1.282, -1.115, 0.406, 1.002, -0.242, 0.364, -0.323, 0.193, -0.384, -0.14, -0.637, 0.708, -0.121, 0.21, -0.729, -0.402, 0.194, 0.144, -0.109, 0.275, 0.068, -0.822, 0.001} - a3 := []float64{409.16, -503.433, 1085.087, -25.346, -24.641, -29.414, 16.197, 3.018, -2.127, -37.939, 1.918, -3.812, 3.25, -0.096, -0.539, -0.883, 1.558, -2.477, 2.72, -0.914, -0.039, 0.563, -1.438, 1.871, -0.232, -1.257, 0.72, -0.825, 0.262, 0.008, 0.127, 0.142, 0.702, -1.106, 0.614, -0.277, 0.631, -0.799, 0.507, 0.199, -0.414, 0.202, -0.229, 0.172, -0.192, 0.081, -0.165, 0.448, -0.276, 0.11, -0.313, 0.109, 0.199, -0.017, -0.084, 0.128, -0.069, -0.297, 0.274, 0.086} - - n := len(y0) - var i int - for i = n - 1; i >= 0; i-- { - if y >= y0[i] { - break - } - } - t := (y - y0[i]) / (y1[i] - y0[i]) - dT := a0[i] + t*(a1[i]+t*(a2[i]+t*a3[i])) - return dT -} - -func DeltaTv2(jd float64) float64 { - if math.IsNaN(jd) || math.IsInf(jd, 0) { - return math.NaN() - } - if jd > 2461041.5 || jd < 2441317.5 { - var y float64 - if jd >= 2299160.5 { - y = (jd-2451544.5)/365.2425 + 2000 - } else { - y = (jd+0.5)/365.25 - 4712 - } - return DeltaTSplineY(y) - } - - // 闰秒JD值 - jdLeaps := []float64{2457754.5, 2457204.5, 2456109.5, 2454832.5, - 2453736.5, 2451179.5, 2450630.5, 2450083.5, - 2449534.5, 2449169.5, 2448804.5, 2448257.5, - 2447892.5, 2447161.5, 2446247.5, 2445516.5, - 2445151.5, 2444786.5, 2444239.5, 2443874.5, - 2443509.5, 2443144.5, 2442778.5, 2442413.5, - 2442048.5, 2441683.5, 2441499.5, 2441133.5} - n := len(jdLeaps) - deltaTSeconds := 42.184 - for i := 0; i < n; i++ { - if jd >= jdLeaps[i] { - deltaTSeconds += float64(n - i - 1) - break - } - } - return deltaTSeconds -} - -// DeltaTSecondsAt 返回某个 TT 时刻实际使用的 ΔT(秒):overrideSeconds 是有限值时直接采用 -// (含 0,可显式要求 ΔT=0),为 NaN/±Inf 时改用进程级模型。模型按 UT 键控,因此这里先解 -// TT−ΔT(UT) 再求值,不把 TT 直接当作 UT 送进模型(差约 2e-6 s)。 -// DeltaTSecondsAt returns the ΔT in seconds used at one TT instant: a finite override wins -// (including 0, which requests ΔT = 0 explicitly), while NaN or ±Inf selects the process-wide -// model. The model is keyed by UT, so the equation TT - ΔT(UT) is solved instead of feeding TT. -func DeltaTSecondsAt(jdeTT, overrideSeconds float64) float64 { - if !math.IsNaN(overrideSeconds) && !math.IsInf(overrideSeconds, 0) { - return overrideSeconds - } - return deltaTModelSecondsAtTT(jdeTT) -} - -func deltaTModelSecondsAtTT(jdeTT float64) float64 { - ut := jdeTT - DeltaT(jdeTT, true)/86400.0 - for iteration := 0; iteration < 4; iteration++ { - next := jdeTT - DeltaT(ut, true)/86400.0 - if next == ut { - break - } - ut = next - } - return DeltaT(ut, true) -} - -// DeltaTGroundShiftKM 把 ΔT 误差换算为站点相对影子的地面横移距离(千米)。 -// 地球赤道自转线速度 465.1 m/s,因此 ΔT 相差 Δ 秒时,地面点相对影子横移 -// 0.4651·|Δ|·cos(纬度) 千米;±400 年跨度上 ΔT 外推差几百到几千秒,足以挪动本影 -// 边界数百千米,调用方可用本函数把外部给出的 ΔT 不确定度换算成几何不确定度。 -// DeltaTGroundShiftKM converts a ΔT error into the ground displacement of a station -// relative to the shadow, in kilometres: 0.4651 * |ΔT| * cos(latitude). -func DeltaTGroundShiftKM(deltaTSeconds, latitudeDeg float64) float64 { - if math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) || - math.IsNaN(latitudeDeg) || math.IsInf(latitudeDeg, 0) { - return math.NaN() - } - return solarEclipseEarthEquatorialRotationKMPerSecond * math.Abs(deltaTSeconds) * math.Cos(latitudeDeg*rad) -} - -func TD2UT(jde float64, utToTD bool) float64 { // true 世界时转力学时CC,false 力学时转世界时VV - deltaTSeconds := DeltaT(jde, true) - if utToTD { - return jde + deltaTSeconds/3600/24 - } - // Delta T is evaluated at UT in the forward conversion. Solve the same - // equation in reverse so distant-epoch contact times survive a round trip. - ut := jde - deltaTSeconds/3600/24 - for iteration := 0; iteration < 4; iteration++ { - next := jde - DeltaT(ut, true)/3600/24 - if next == ut { - break - } - ut = next - } - return ut -} diff --git a/basic/delta_t_model.go b/basic/delta_t_model.go new file mode 100644 index 0000000..aaadd04 --- /dev/null +++ b/basic/delta_t_model.go @@ -0,0 +1,186 @@ +package basic + +import "math" + +// DeltaTModel 标识一个可选的已发布 ΔT 模型 / a selectable published ΔT model. +type DeltaTModel string + +const ( + // DeltaTModelDefault 内置默认模型:Stephenson–Morrison–Hohenkerk 2016 + Morrison 2021 的样条与积分 lod。 + // DeltaTModelDefault is the built-in model: the SMH2016 + Morrison 2021 spline and integrated lod. + DeltaTModelDefault DeltaTModel = "" + // DeltaTModelSMH2016 显式选择内置的 SMH2016 + Morrison 2021 模型。 + // DeltaTModelSMH2016 selects the built-in SMH2016 + Morrison 2021 model explicitly. + DeltaTModelSMH2016 DeltaTModel = "smh2016" + // DeltaTModelMS2004 Morrison & Stephenson 2004 的长期抛物线 ΔT = −20 + 32u²(u = (y−1820)/100),单位秒。 + // DeltaTModelMS2004 is the Morrison & Stephenson 2004 long-term parabola in seconds. + DeltaTModelMS2004 DeltaTModel = "ms2004" + // DeltaTModelEspenakMeeus2006 Espenak & Meeus 2006 的分段多项式,基于 M&S2004,未加配对修正 c。 + // DeltaTModelEspenakMeeus2006 is the Espenak & Meeus 2006 piecewise polynomial without the pairing term c. + DeltaTModelEspenakMeeus2006 DeltaTModel = "espenak_meeus_2006" + // DeltaTModelNASACanon2006 是 Espenak–Meeus 再加配对修正 c = −0.000012932(y−1955)²(y < 1955), + // 即 NASA 五千年目录实际印刷的 ΔT 口径,配对的是 ELP2000/82 的 −25.858″/cy²。 + // DeltaTModelNASACanon2006 adds the pairing term c for y < 1955, the convention printed by NASA's canon. + DeltaTModelNASACanon2006 DeltaTModel = "nasa_canon_2006" + // DeltaTModelManual 表示当前生效的是 SetDeltaTFn 注入的任意函数,不是命名模型。 + // DeltaTModelManual reports that an arbitrary function injected with SetDeltaTFn is active. + DeltaTModelManual DeltaTModel = "manual" +) + +// deltaTModelFunction 把命名模型包成 ΔT 钩子的签名;isJulianDay 为假时入参是十进制年。 +func deltaTModelFunction(model DeltaTModel, keepObserved bool) func(float64, bool) float64 { + return func(date float64, isJulianDay bool) float64 { + if math.IsNaN(date) || math.IsInf(date, 0) { + return math.NaN() + } + if isJulianDay { + return DeltaTModelSeconds(model, date, keepObserved) + } + if keepObserved { + year := math.Floor(date) + start := JDCalc(int(year), 1, 1) + end := JDCalc(int(year)+1, 1, 1) + jd := start + (date-year)*(end-start) + if seconds, ok := deltaTMonthlyAt(jd); ok { + return seconds + } + } + return deltaTModelSecondsAtYear(model, date) + } +} + +// DeltaTModelSeconds 按命名模型求 ΔT(秒),入参为 UT 儒略日。 +// +// keepObserved 为真时,落在逐月实测表覆盖段内的时刻一律返回实测值,模型只接管表外; +// 这是"实测优先"的推荐用法,也是内置默认模型的结构。为假时全程使用模型,仅用于口径对照。 +// DeltaTModelSeconds evaluates a named model. With keepObserved the monthly observed table wins +// wherever it covers the instant and the model only takes over outside it. +func DeltaTModelSeconds(model DeltaTModel, jd float64, keepObserved bool) float64 { + if math.IsNaN(jd) || math.IsInf(jd, 0) { + return math.NaN() + } + switch model { + case DeltaTModelDefault, DeltaTModelSMH2016: + if keepObserved { + // 与内置默认逐位一致(含实测段与样条表尾的常值锚定)。 + return deltaTModelSecondsAtUT(jd) + } + return deltaTSplineAtJDE(jd) + } + if keepObserved { + if seconds, ok := deltaTMonthlyAt(jd); ok { + return seconds + } + } + return deltaTModelSecondsAtYear(model, deltaTYearAtJDE(jd)) +} + +// SetDeltaTModel 安装命名模型;keepObserved 为真时保留实测表覆盖段(推荐),未知模型返回 false 且不改动现状。 +// SetDeltaTModel installs a named model, keeping the observed span when keepObserved is true; +// an unknown model returns false and leaves the current model untouched. +func SetDeltaTModel(model DeltaTModel, keepObserved bool) bool { + if !knownDeltaTModel(model) { + return false + } + if model == DeltaTModelDefault || model == DeltaTModelSMH2016 { + if keepObserved { + // 内置模型本身就是"实测优先",直接用它可以避免表尾锚定出现差异。 + installDeltaTFn(DefaultDeltaTv2, model, true) + return true + } + installDeltaTFn(deltaTModelFunction(model, false), model, false) + return true + } + installDeltaTFn(deltaTModelFunction(model, keepObserved), model, keepObserved) + return true +} + +// GetDeltaTModel 返回当前生效的模型与是否保留实测段;SetDeltaTFn 注入的任意函数报 DeltaTModelManual。 +// GetDeltaTModel returns the active model and whether the observed span is kept; an arbitrary +// function injected with SetDeltaTFn reports DeltaTModelManual. +func GetDeltaTModel() (DeltaTModel, bool) { + deltaTFnMu.RLock() + defer deltaTFnMu.RUnlock() + return activeDeltaTModel, activeDeltaTKeepObserved +} + +func knownDeltaTModel(model DeltaTModel) bool { + switch model { + case DeltaTModelDefault, DeltaTModelSMH2016, DeltaTModelMS2004, + DeltaTModelEspenakMeeus2006, DeltaTModelNASACanon2006: + return true + } + return false +} + +// deltaTModelSecondsAtYear 按十进制年求模型值;实测段是否介入由调用方决定。 +func deltaTModelSecondsAtYear(model DeltaTModel, year float64) float64 { + switch model { + case DeltaTModelMS2004: + u := (year - 1820) / 100 + return -20 + 32*u*u + case DeltaTModelEspenakMeeus2006: + return espenakMeeus2006Seconds(year) + case DeltaTModelNASACanon2006: + value := espenakMeeus2006Seconds(year) + if year < 1955 { + // 配对修正:把 −26.0″/cy² 口径的 ΔT 换到 −25.858″/cy²(1955 起原子时使其与月球历表无关)。 + d := year - 1955 + value += -0.000012932 * d * d + } + return value + } + return DeltaTSplineY(year) +} + +// espenakMeeus2006Seconds 是 Espenak & Meeus 2006 对 −1999..+3000 的分段多项式(秒), +// 自变量 y = year + (month−0.5)/12;分段边界处两段取值一致(−500 处由 17203.7 强制连续)。 +func espenakMeeus2006Seconds(y float64) float64 { + switch { + case y < -500: + u := (y - 1820) / 100 + return -20 + 32*u*u + case y < 500: + u := y / 100 + return 10583.6 + u*(-1014.41+u*(33.78311+u*(-5.952053+u*(-0.1798452+u*(0.022174192+u*0.0090316521))))) + case y < 1600: + u := (y - 1000) / 100 + return 1574.2 + u*(-556.01+u*(71.23472+u*(0.319781+u*(-0.8503463+u*(-0.005050998+u*0.0083572073))))) + case y < 1700: + t := y - 1600 + return 120 - 0.9808*t - 0.01532*t*t + t*t*t/7129 + case y < 1800: + t := y - 1700 + return 8.83 + t*(0.1603+t*(-0.0059285+t*(0.00013336-t/1174000))) + case y < 1860: + t := y - 1800 + return 13.72 + t*(-0.332447+t*(0.0068612+t*(0.0041116+t*(-0.00037436+ + t*(0.0000121272+t*(-0.0000001699+t*0.000000000875)))))) + case y < 1900: + t := y - 1860 + return 7.62 + t*(0.5737+t*(-0.251754+t*(0.01680668+t*(-0.0004473624+t/233174)))) + case y < 1920: + t := y - 1900 + return -2.79 + t*(1.494119+t*(-0.0598939+t*(0.0061966-t*0.000197))) + case y < 1941: + t := y - 1920 + return 21.20 + t*(0.84493+t*(-0.076100+t*0.0020936)) + case y < 1961: + t := y - 1950 + return 29.07 + t*(0.407-t/233+t*t/2547) + case y < 1986: + t := y - 1975 + return 45.45 + t*(1.067-t/260-t*t/718) + case y < 2005: + t := y - 2000 + return 63.86 + t*(0.3345+t*(-0.060374+t*(0.0017275+t*(0.000651814+t*0.00002373599)))) + case y < 2050: + t := y - 2000 + return 62.92 + t*(0.32217+t*0.005589) + case y < 2150: + u := (y - 1820) / 100 + return -20 + 32*u*u - 0.5628*(2150-y) + } + u := (y - 1820) / 100 + return -20 + 32*u*u +} diff --git a/basic/delta_t_model_test.go b/basic/delta_t_model_test.go new file mode 100644 index 0000000..593e37b --- /dev/null +++ b/basic/delta_t_model_test.go @@ -0,0 +1,94 @@ +package basic + +import ( + "math" + "testing" +) + +// ΔT 命名模型的契约:已发表锚点、分段连续性、实测段优先的混合语义与模型标记。 + +func TestDeltaTModelPublishedAnchors(t *testing.T) { + resetTimeScaleState(t) + check := func(name string, year, want, tolerance float64) { + t.Helper() + if got := DeltaT(year, false); math.Abs(got-want) > tolerance { + t.Fatalf("%s ΔT(%.5f)=%.6f, want %.6f±%g", name, year, got, want, tolerance) + } + } + + if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, false) { + t.Fatal("Espenak–Meeus 模型应可安装") + } + check("E-M", 2017.64, 70.34, 0.01) + check("E-M", 2024.27, 74.03, 0.01) + check("E-M", 2100, 202.7, 0.05) + + if !SetDeltaTModel(DeltaTModelMS2004, false) { + t.Fatal("M&S2004 模型应可安装") + } + check("M&S2004", -500, 17203.7, 0.05) + check("M&S2004", 2100, 230.9, 0.05) + + if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, false) { + t.Fatal("Espenak–Meeus 模型应可安装") + } + // 分段边界:原表各段是独立拟合的,边界处本身留有台阶(实测最大 ≤0.3 s,1600 处约 0.25 s), + // 这里只守住这个量级,用来抓 Horner 展开之类的结构性错误。 + for _, knot := range []float64{-500, 500, 1600, 1700, 1800, 1860, 1900, 1920, 1941, 1961, 1986, 2005, 2050, 2150} { + before, after := DeltaT(knot-1e-6, false), DeltaT(knot+1e-6, false) + if diff := math.Abs(after - before); diff > 0.3 { + t.Errorf("分段边界 %.0f 台阶过大:%.6f vs %.6f(差 %g)", knot, before, after, diff) + } + } +} + +func TestDeltaTModelKeepsObservedSpan(t *testing.T) { + resetTimeScaleState(t) + jd := JDCalc(2026, 3, 1) + observed := DeltaT(jd, true) + + if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, true) { + t.Fatal("Espenak–Meeus 模型应可安装") + } + if got := DeltaT(jd, true); math.Abs(got-observed) > 1e-12 { + t.Fatalf("实测段应优先:got %.9f, want %.9f", got, observed) + } + pure := DeltaTModelSeconds(DeltaTModelEspenakMeeus2006, jd, false) + if math.Abs(pure-observed) < 5 { + t.Fatalf("纯模型值 %.3f 应与实测 %.3f 明显不同(否则测不到混合语义)", pure, observed) + } + if model, keep := GetDeltaTModel(); model != DeltaTModelEspenakMeeus2006 || !keep { + t.Fatalf("模型标记 = (%q, %v)", model, keep) + } + future := JDCalc(2100, 1, 1) + if got, want := DeltaT(future, true), DeltaTModelSeconds(DeltaTModelEspenakMeeus2006, future, false); math.Abs(got-want) > 1e-9 { + t.Fatalf("表外应走模型:got %.9f, want %.9f", got, want) + } + + // SMH2016 混合档必须与内置默认逐位一致。 + if !SetDeltaTModel(DeltaTModelSMH2016, true) { + t.Fatal("SMH2016 模型应可安装") + } + if got := DeltaT(jd, true); got != observed { + t.Fatalf("SMH2016 混合档 %.9f 与内置默认 %.9f 不一致", got, observed) + } + if got := DeltaT(future, true); got != DefaultDeltaTv2(future, true) { + t.Fatalf("SMH2016 混合档表外 %.9f 与内置默认 %.9f 不一致", got, DefaultDeltaTv2(future, true)) + } + + // 手工注入的任意函数标记为 manual;恢复 nil 回到内置默认。 + SetDeltaTFn(func(float64, bool) float64 { return 0 }) + if model, keep := GetDeltaTModel(); model != DeltaTModelManual || keep { + t.Fatalf("手工注入标记 = (%q, %v)", model, keep) + } + SetDeltaTFn(nil) + if model, keep := GetDeltaTModel(); model != DeltaTModelDefault || !keep { + t.Fatalf("恢复默认标记 = (%q, %v)", model, keep) + } + if SetDeltaTModel(DeltaTModel("nope"), true) { + t.Fatal("未知模型应被拒绝") + } + if model, _ := GetDeltaTModel(); model != DeltaTModelDefault { + t.Fatalf("被拒绝后模型不应改动,got %q", model) + } +} diff --git a/basic/delta_t_test.go b/basic/delta_t_test.go index 0666715..ce16cb1 100644 --- a/basic/delta_t_test.go +++ b/basic/delta_t_test.go @@ -8,8 +8,8 @@ import ( func TestDefaultDeltaTFractionalYear(t *testing.T) { for _, year := range []float64{-700.5, -0.5, 0, 1000.5, 1582.75, 1800.5, 2000.5, 2026.5} { whole := math.Floor(year) - start := JDECalc(int(whole), 1, 1) - end := JDECalc(int(whole)+1, 1, 1) + start := JDCalc(int(whole), 1, 1) + end := JDCalc(int(whole)+1, 1, 1) want := DefaultDeltaTv2(start+(year-whole)*(end-start), true) got := DefaultDeltaTv2(year, false) if math.IsNaN(got) || math.Abs(got-want) > 1e-10 { @@ -28,36 +28,42 @@ func TestDefaultDeltaTInvalidInput(t *testing.T) { } } -func TestDeltaTLeapSecondBoundary(t *testing.T) { +// 闰秒只改 TT−UTC,不改 TT−UT1:ΔT 在闰秒两侧连续,TT−UTC 才跳 1 秒。 +func TestDeltaTStaysContinuousAcrossLeapSecond(t *testing.T) { boundary := 2457754.5 - if got := DeltaTv2(boundary); got != 69.184 { - t.Fatalf("DeltaT at leap-second boundary=%v, want 69.184", got) + before := DeltaTv2(boundary - 1e-6) + after := DeltaTv2(boundary + 1e-6) + if math.Abs(after-before) > 1e-3 { + t.Fatalf("ΔT should stay continuous at a leap second: %v → %v", before, after) + } + if step := TTMinusUTCSeconds(boundary+1e-6) - TTMinusUTCSeconds(boundary-1e-6); math.Abs(step-1) > 1e-9 { + t.Fatalf("TT−UTC should step by one second: %v", step) } if got := DeltaTSplineY(math.NaN()); !math.IsNaN(got) { t.Fatalf("DeltaTSplineY(NaN)=%v, want NaN", got) } } -func TestTD2UTRoundTrip(t *testing.T) { +func TestUTC2TTRoundTrip(t *testing.T) { for _, year := range []int{-2000, -720, 0, 26, 1426, 2025, 2027, 3627, 4026, 5000} { - ut := JDECalc(year, 9, 20.123456) - tt := TD2UT(ut, true) - if got := TD2UT(tt, false); math.Abs(got-ut) > math.Nextafter(ut, math.Inf(1))-ut { - t.Errorf("year=%d UT round trip differs by %.9f seconds", year, (got-ut)*86400) + utc := JDCalc(year, 9, 20.123456) + tt := UTC2TT(utc) + if got := TT2UTC(tt); math.Abs(got-utc) > math.Nextafter(utc, math.Inf(1))-utc { + t.Errorf("year=%d UTC round trip differs by %.9f seconds", year, (got-utc)*86400) } - if got := TD2UT(TD2UT(tt, false), true); got != tt { + if got := UTC2TT(TT2UTC(tt)); got != tt { t.Errorf("year=%d TT round trip differs by %.9f seconds", year, (got-tt)*86400) } } for _, seconds := range []float64{-70, -1, -0.1, 0, 0.1, 1, 70} { - ut := 2457754.5 + seconds/86400 - if got := TD2UT(TD2UT(ut, true), false); got != ut { - t.Errorf("leap second offset=%g round trip differs by %.9f seconds", seconds, (got-ut)*86400) + utc := 2457754.5 + seconds/86400 + if got := TT2UTC(UTC2TT(utc)); got != utc { + t.Errorf("leap second offset=%g round trip differs by %.9f seconds", seconds, (got-utc)*86400) } } } -func TestTD2UTCustomDeltaT(t *testing.T) { +func TestUTC2TTRoundTripUnderCustomDeltaT(t *testing.T) { original := GetDeltaTFn() t.Cleanup(func() { SetDeltaTFn(original) }) for _, slope := range []float64{0, 0.01} { @@ -67,9 +73,9 @@ func TestTD2UTCustomDeltaT(t *testing.T) { } return 10000 + slope*(jd-2451545) }) - ut := 3000000.123456 - if got := TD2UT(TD2UT(ut, true), false); got != ut { - t.Errorf("custom slope=%g round trip differs by %.9f seconds", slope, (got-ut)*86400) + utc := 3000000.123456 + if got := TT2UTC(UTC2TT(utc)); got != utc { + t.Errorf("custom slope=%g round trip differs by %.9f seconds", slope, (got-utc)*86400) } } } diff --git a/basic/diameter.go b/basic/diameter.go index 5e34c9f..2d5b9ea 100644 --- a/basic/diameter.go +++ b/basic/diameter.go @@ -26,181 +26,181 @@ func angularSemidiameterFromAU(radiusKM, distanceAU float64) float64 { } // SunSemidiameter 太阳视半径,单位角秒 / apparent solar semidiameter in arcseconds. -func SunSemidiameter(jd float64) float64 { - return SunSemidiameterN(jd, -1) +func SunSemidiameter(jde float64) float64 { + return SunSemidiameterN(jde, -1) } // SunSemidiameterN 太阳视半径(截断版),单位角秒 / truncated apparent solar semidiameter in arcseconds. -func SunSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayN(jd, n)) +func SunSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayN(jde, n)) } // SunDiameter 太阳视直径,单位角秒 / apparent solar diameter in arcseconds. -func SunDiameter(jd float64) float64 { - return SunDiameterN(jd, -1) +func SunDiameter(jde float64) float64 { + return SunDiameterN(jde, -1) } // SunDiameterN 太阳视直径(截断版),单位角秒 / truncated apparent solar diameter in arcseconds. -func SunDiameterN(jd float64, n int) float64 { - return 2 * SunSemidiameterN(jd, n) +func SunDiameterN(jde float64, n int) float64 { + return 2 * SunSemidiameterN(jde, n) } // MoonSemidiameter 月亮视半径,单位角秒 / apparent lunar semidiameter in arcseconds. -func MoonSemidiameter(jd float64) float64 { - return MoonSemidiameterN(jd, -1) +func MoonSemidiameter(jde float64) float64 { + return MoonSemidiameterN(jde, -1) } // MoonSemidiameterN 月亮视半径(截断版),单位角秒 / truncated apparent lunar semidiameter in arcseconds. -func MoonSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayN(jd, n)) +func MoonSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayN(jde, n)) } // MoonDiameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds. -func MoonDiameter(jd float64) float64 { - return MoonDiameterN(jd, -1) +func MoonDiameter(jde float64) float64 { + return MoonDiameterN(jde, -1) } // MoonDiameterN 月亮视直径(截断版),单位角秒 / truncated apparent lunar diameter in arcseconds. -func MoonDiameterN(jd float64, n int) float64 { - return 2 * MoonSemidiameterN(jd, n) +func MoonDiameterN(jde float64, n int) float64 { + return 2 * MoonSemidiameterN(jde, n) } // MercurySemidiameter 水星视半径,单位角秒 / apparent Mercury semidiameter in arcseconds. -func MercurySemidiameter(jd float64) float64 { - return MercurySemidiameterN(jd, -1) +func MercurySemidiameter(jde float64) float64 { + return MercurySemidiameterN(jde, -1) } // MercurySemidiameterN 水星视半径(截断版),单位角秒 / truncated apparent Mercury semidiameter in arcseconds. -func MercurySemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayN(jd, n)) +func MercurySemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayN(jde, n)) } // MercuryDiameter 水星视直径,单位角秒 / apparent Mercury diameter in arcseconds. -func MercuryDiameter(jd float64) float64 { - return MercuryDiameterN(jd, -1) +func MercuryDiameter(jde float64) float64 { + return MercuryDiameterN(jde, -1) } // MercuryDiameterN 水星视直径(截断版),单位角秒 / truncated apparent Mercury diameter in arcseconds. -func MercuryDiameterN(jd float64, n int) float64 { - return 2 * MercurySemidiameterN(jd, n) +func MercuryDiameterN(jde float64, n int) float64 { + return 2 * MercurySemidiameterN(jde, n) } // VenusSemidiameter 金星视半径,单位角秒 / apparent Venus semidiameter in arcseconds. -func VenusSemidiameter(jd float64) float64 { - return VenusSemidiameterN(jd, -1) +func VenusSemidiameter(jde float64) float64 { + return VenusSemidiameterN(jde, -1) } // VenusSemidiameterN 金星视半径(截断版),单位角秒 / truncated apparent Venus semidiameter in arcseconds. -func VenusSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayN(jd, n)) +func VenusSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayN(jde, n)) } // VenusDiameter 金星视直径,单位角秒 / apparent Venus diameter in arcseconds. -func VenusDiameter(jd float64) float64 { - return VenusDiameterN(jd, -1) +func VenusDiameter(jde float64) float64 { + return VenusDiameterN(jde, -1) } // VenusDiameterN 金星视直径(截断版),单位角秒 / truncated apparent Venus diameter in arcseconds. -func VenusDiameterN(jd float64, n int) float64 { - return 2 * VenusSemidiameterN(jd, n) +func VenusDiameterN(jde float64, n int) float64 { + return 2 * VenusSemidiameterN(jde, n) } // MarsSemidiameter 火星视半径,单位角秒 / apparent Mars semidiameter in arcseconds. -func MarsSemidiameter(jd float64) float64 { - return MarsSemidiameterN(jd, -1) +func MarsSemidiameter(jde float64) float64 { + return MarsSemidiameterN(jde, -1) } // MarsSemidiameterN 火星视半径(截断版),单位角秒 / truncated apparent Mars semidiameter in arcseconds. -func MarsSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayN(jd, n)) +func MarsSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayN(jde, n)) } // MarsDiameter 火星视直径,单位角秒 / apparent Mars diameter in arcseconds. -func MarsDiameter(jd float64) float64 { - return MarsDiameterN(jd, -1) +func MarsDiameter(jde float64) float64 { + return MarsDiameterN(jde, -1) } // MarsDiameterN 火星视直径(截断版),单位角秒 / truncated apparent Mars diameter in arcseconds. -func MarsDiameterN(jd float64, n int) float64 { - return 2 * MarsSemidiameterN(jd, n) +func MarsDiameterN(jde float64, n int) float64 { + return 2 * MarsSemidiameterN(jde, n) } // JupiterSemidiameter 木星视半径,单位角秒 / apparent Jupiter semidiameter in arcseconds. -func JupiterSemidiameter(jd float64) float64 { - return JupiterSemidiameterN(jd, -1) +func JupiterSemidiameter(jde float64) float64 { + return JupiterSemidiameterN(jde, -1) } // JupiterSemidiameterN 木星视半径(截断版),单位角秒 / truncated apparent Jupiter semidiameter in arcseconds. -func JupiterSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayN(jd, n)) +func JupiterSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayN(jde, n)) } // JupiterDiameter 木星视直径,单位角秒 / apparent Jupiter diameter in arcseconds. -func JupiterDiameter(jd float64) float64 { - return JupiterDiameterN(jd, -1) +func JupiterDiameter(jde float64) float64 { + return JupiterDiameterN(jde, -1) } // JupiterDiameterN 木星视直径(截断版),单位角秒 / truncated apparent Jupiter diameter in arcseconds. -func JupiterDiameterN(jd float64, n int) float64 { - return 2 * JupiterSemidiameterN(jd, n) +func JupiterDiameterN(jde float64, n int) float64 { + return 2 * JupiterSemidiameterN(jde, n) } // SaturnSemidiameter 土星视半径,单位角秒 / apparent Saturn semidiameter in arcseconds. -func SaturnSemidiameter(jd float64) float64 { - return SaturnSemidiameterN(jd, -1) +func SaturnSemidiameter(jde float64) float64 { + return SaturnSemidiameterN(jde, -1) } // SaturnSemidiameterN 土星视半径(截断版),单位角秒 / truncated apparent Saturn semidiameter in arcseconds. -func SaturnSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayN(jd, n)) +func SaturnSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayN(jde, n)) } // SaturnDiameter 土星视直径,单位角秒 / apparent Saturn diameter in arcseconds. -func SaturnDiameter(jd float64) float64 { - return SaturnDiameterN(jd, -1) +func SaturnDiameter(jde float64) float64 { + return SaturnDiameterN(jde, -1) } // SaturnDiameterN 土星视直径(截断版),单位角秒 / truncated apparent Saturn diameter in arcseconds. -func SaturnDiameterN(jd float64, n int) float64 { - return 2 * SaturnSemidiameterN(jd, n) +func SaturnDiameterN(jde float64, n int) float64 { + return 2 * SaturnSemidiameterN(jde, n) } // UranusSemidiameter 天王星视半径,单位角秒 / apparent Uranus semidiameter in arcseconds. -func UranusSemidiameter(jd float64) float64 { - return UranusSemidiameterN(jd, -1) +func UranusSemidiameter(jde float64) float64 { + return UranusSemidiameterN(jde, -1) } // UranusSemidiameterN 天王星视半径(截断版),单位角秒 / truncated apparent Uranus semidiameter in arcseconds. -func UranusSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayN(jd, n)) +func UranusSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayN(jde, n)) } // UranusDiameter 天王星视直径,单位角秒 / apparent Uranus diameter in arcseconds. -func UranusDiameter(jd float64) float64 { - return UranusDiameterN(jd, -1) +func UranusDiameter(jde float64) float64 { + return UranusDiameterN(jde, -1) } // UranusDiameterN 天王星视直径(截断版),单位角秒 / truncated apparent Uranus diameter in arcseconds. -func UranusDiameterN(jd float64, n int) float64 { - return 2 * UranusSemidiameterN(jd, n) +func UranusDiameterN(jde float64, n int) float64 { + return 2 * UranusSemidiameterN(jde, n) } // NeptuneSemidiameter 海王星视半径,单位角秒 / apparent Neptune semidiameter in arcseconds. -func NeptuneSemidiameter(jd float64) float64 { - return NeptuneSemidiameterN(jd, -1) +func NeptuneSemidiameter(jde float64) float64 { + return NeptuneSemidiameterN(jde, -1) } // NeptuneSemidiameterN 海王星视半径(截断版),单位角秒 / truncated apparent Neptune semidiameter in arcseconds. -func NeptuneSemidiameterN(jd float64, n int) float64 { - return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayN(jd, n)) +func NeptuneSemidiameterN(jde float64, n int) float64 { + return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayN(jde, n)) } // NeptuneDiameter 海王星视直径,单位角秒 / apparent Neptune diameter in arcseconds. -func NeptuneDiameter(jd float64) float64 { - return NeptuneDiameterN(jd, -1) +func NeptuneDiameter(jde float64) float64 { + return NeptuneDiameterN(jde, -1) } // NeptuneDiameterN 海王星视直径(截断版),单位角秒 / truncated apparent Neptune diameter in arcseconds. -func NeptuneDiameterN(jd float64, n int) float64 { - return 2 * NeptuneSemidiameterN(jd, n) +func NeptuneDiameterN(jde float64, n int) float64 { + return 2 * NeptuneSemidiameterN(jde, n) } diff --git a/basic/diameter_test.go b/basic/diameter_test.go index a1f2255..94aee4b 100644 --- a/basic/diameter_test.go +++ b/basic/diameter_test.go @@ -51,7 +51,7 @@ func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) for _, tc := range cases { want := sample.Values[tc.baselineKey] got := tc.diameter(jd) @@ -75,7 +75,7 @@ func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) { } func TestAngularDiameterNFullMatchesDefault(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) cases := []struct { name string diff --git a/basic/eclipse_diagram_test.go b/basic/eclipse_diagram_test.go index bc46d0c..8f969f4 100644 --- a/basic/eclipse_diagram_test.go +++ b/basic/eclipse_diagram_test.go @@ -7,7 +7,7 @@ import ( ) func TestLunarEclipseDiagramIncludesContacts(t *testing.T) { - diagram := LunarEclipseDiagram(JDECalc(2026, 3, 3), LunarEclipseDiagramOptions{StepDays: 10.0 / 1440.0}) + diagram := LunarEclipseDiagram(JDCalc(2026, 3, 3), LunarEclipseDiagramOptions{StepDays: 10.0 / 1440.0}) if diagram.Eclipse.Type != LunarEclipseTotal { t.Fatalf("unexpected eclipse type: got %s want %s", diagram.Eclipse.Type, LunarEclipseTotal) } @@ -38,7 +38,7 @@ func TestLunarEclipseDiagramIncludesContacts(t *testing.T) { func TestLocalSolarEclipseDiagramIncludesContacts(t *testing.T) { diagram := LocalSolarEclipseDiagram( - TD2UT(Date2JDE(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)), true), + UTC2TT(Date2JD(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC))), -96.7970, 32.7767, 0, diff --git a/basic/greatest_time_contour.go b/basic/greatest_time_contour.go index 96dd51e..bfd8d21 100644 --- a/basic/greatest_time_contour.go +++ b/basic/greatest_time_contour.go @@ -94,12 +94,12 @@ func greatestTimeContourAlignedLevels(startTT, endTT float64, step time.Duration // greatestTimeContourTTToUTC 把 TT 儒略日换成对应的 UTC 时刻。 func greatestTimeContourTTToUTC(tt float64) time.Time { - return JDE2DateByZone(TD2UT(tt, false), time.UTC, false) + return JD2DateByZone(TT2UTC(tt), time.UTC, false) } // greatestTimeContourUTCToTT 把 UTC 时刻换成对应的 TT 儒略日。 func greatestTimeContourUTCToTT(value time.Time) float64 { - return TD2UT(Date2JDE(value.UTC()), true) + return UTC2TT(Date2JD(value.UTC())) } // greatestTimeContourPointSegmentKM 返回点到折线段的距离,单位 km。 diff --git a/basic/greatest_time_contour_test.go b/basic/greatest_time_contour_test.go index facde8f..a795ea6 100644 --- a/basic/greatest_time_contour_test.go +++ b/basic/greatest_time_contour_test.go @@ -8,7 +8,7 @@ import ( // 对齐网格是导出行为(等时线按整刻度取值)的契约,取值本身写成字面量以免与实际生成函数互相自证。 func TestGreatestTimeContourAlignedLevelsGridContracts(t *testing.T) { tt := func(hour, minute int) float64 { - return TD2UT(Date2JDE(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC))) } cases := []struct { name string diff --git a/basic/inner_event_window.go b/basic/inner_event_window.go index be512e4..03f9df9 100644 --- a/basic/inner_event_window.go +++ b/basic/inner_event_window.go @@ -9,7 +9,7 @@ const ( ) func eventQueryTTAsUT(queryTT float64) float64 { - return TD2UT(queryTT, false) + return TT2UTC(queryTT) } func eventUTQueryTTDelta(eventUT, queryTT float64) float64 { @@ -25,11 +25,11 @@ func eventUTQueryAfterOrEqual(eventUT, queryTT float64) bool { } func eventUTNextQueryTT(eventUT float64) float64 { - return TD2UT(eventUT, true) + 1.0 + return UTC2TT(eventUT) + 1.0 } func eventUTLastQueryTT(eventUT float64) float64 { - return TD2UT(eventUT, true) - 1.0 + return UTC2TT(eventUT) - 1.0 } func innerNextCycleOffset(delta, period float64) float64 { diff --git a/basic/inner_planet_event_boundary_test.go b/basic/inner_planet_event_boundary_test.go index 8eeb02a..2aee299 100644 --- a/basic/inner_planet_event_boundary_test.go +++ b/basic/inner_planet_event_boundary_test.go @@ -34,7 +34,7 @@ func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - queryTT := TD2UT(tc.seed, true) + queryTT := UTC2TT(tc.seed) last := tc.lastFn(queryTT) next := tc.nextFn(queryTT) if !sameEventJD(last, tc.seed) { @@ -64,7 +64,7 @@ func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { first := tc.next(tc.seed) - query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true) + query := UTC2TT(Date2JD(JD2DateByZone(first, time.UTC, false).Add(time.Second))) next := tc.next(query) if !eventUTQueryAfterOrEqual(next, query) { t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next) @@ -91,7 +91,7 @@ func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - queryTT := TD2UT(tc.seed, true) + queryTT := UTC2TT(tc.seed) last := tc.last(queryTT) next := tc.next(queryTT) if !sameEventJD(last, tc.seed) { diff --git a/basic/inner_planet_truth_test.go b/basic/inner_planet_truth_test.go index 1409f33..d5e7b37 100644 --- a/basic/inner_planet_truth_test.go +++ b/basic/inner_planet_truth_test.go @@ -152,8 +152,8 @@ func assertInnerBaselineEvent(t *testing.T, event innerBaselineEvent, lastFn, ne when := parseInnerBaselineTime(t, event.VerifiedJST) before := when.Add(-24 * time.Hour) after := when.Add(24 * time.Hour) - next := JDE2DateByZone(nextFn(toUTJD(before)), when.Location(), false) - last := JDE2DateByZone(lastFn(toUTJD(after)), when.Location(), false) + next := JD2DateByZone(nextFn(toUTJD(before)), when.Location(), false) + last := JD2DateByZone(lastFn(toUTJD(after)), when.Location(), false) tolerance := innerBaselineTolerance(event) if diff := next.Sub(when); diff < -tolerance || diff > tolerance { diff --git a/basic/julian.go b/basic/julian.go index cf042aa..3edefaf 100644 --- a/basic/julian.go +++ b/basic/julian.go @@ -9,10 +9,10 @@ import ( var ErrInvalidCivilDate = errors.New("invalid civil date") var timeNow = time.Now -// Date2JDE 日期转儒略日 -func Date2JDE(date time.Time) float64 { +// Date2JD 日期转儒略日 +func Date2JD(date time.Time) float64 { day := float64(date.Day()) + float64(date.Hour())/24.0 + float64(date.Minute())/24.0/60.0 + float64(date.Second())/24.0/3600.0 + float64(date.Nanosecond())/1000000000.0/3600.0/24.0 - return JDECalc(date.Year(), int(date.Month()), day) + return JDCalc(date.Year(), int(date.Month()), day) } func ValidateCivilDate(year, month int, day float64) error { @@ -68,12 +68,10 @@ func isCivilLeapYear(year, month int, day float64) bool { return year%4 == 0 } -/* -@name: 儒略日计算 -@dec: 计算给定时间的儒略日,1582年改力后为格里高利历,之前为儒略历 -@ 请注意,传入的时间在天文计算中一般为力学时,应当注意和世界时的转化 -*/ -func JDECalc(year, month int, day float64) float64 { +// JDCalc 由公历年月日(day 可含小数)计算儒略日 / Julian day from a civil year, month and fractional day. +// 1582 年 10 月 15 日起按格里高利历,之前按儒略历;日期非法时返回 NaN。 +// Gregorian from 1582-10-15 onward and Julian before it; an invalid civil date yields NaN. +func JDCalc(year, month int, day float64) float64 { if err := ValidateCivilDate(year, month, day); err != nil { return math.NaN() } @@ -92,17 +90,20 @@ func JDECalc(year, month int, day float64) float64 { return (math.Floor(365.25*(float64(year)+4716.0)) + math.Floor(30.6001*float64(month+1)) + day + float64(gregorianCorrection) - 1524.5) } -/* -@name: 获得当前儒略日时间:当地世界时,非格林尼治时间 -*/ -func GetNowJDE() (nowJDE float64) { +// GetNowJD 按当前时区的日历字段取儒略日 / Julian day from the current clock's calendar fields. +// 读的是当前时区的年月日时分秒,不做时区归算。 +// It reads the calendar fields in the current location without any zone conversion. +func GetNowJD() (nowJD float64) { now := timeNow() dayFraction := float64(now.Second())/3600.0/24.0 + float64(now.Minute())/60.0/24.0 + float64(now.Hour())/24.0 - nowJDE = JDECalc(now.Year(), int(now.Month()), float64(now.Day())+dayFraction) + nowJD = JDCalc(now.Year(), int(now.Month()), float64(now.Day())+dayFraction) return } -func JDE2Date(jd float64) time.Time { +// JD2Date 儒略日转 Local 时区的时刻 / Local-zone instant from a Julian day. +// 儒略日的日历字段按 Local 时区解释,1582 年 10 月 15 日之前按儒略历。 +// The calendar fields are read in the Local zone, and dates before 1582-10-15 are read in the Julian calendar. +func JD2Date(jd float64) time.Time { jd = jd + 0.5 z := float64(int(jd)) f := jd - z @@ -137,12 +138,12 @@ func JDE2Date(jd float64) time.Time { return time.Unix(dates.Unix()+int64(tms), int64((tms-math.Floor(tms))*1000000000)) } -// JDE2DateByZone JDE(儒略日)转日期 +// JD2DateByZone 儒略日转日期 // jd: 儒略日 // tz: 目标时区 // byZone: (true: 传入的儒略日视为目标时区当地时间的儒略日,false: 传入的儒略日视为UTC时间的儒略日) // 回参:转换后的日期,时区始终为目标时区 -func JDE2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time { +func JD2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time { jd = jd + 0.5 z := float64(int(jd)) f := jd - z @@ -172,10 +173,13 @@ func JDE2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time { } tms := (days - math.Floor(days)) * 24 * 3600 days = math.Floor(days) - var transTz = tz + date := time.Date(int(years), time.Month(int(months)), int(days), 0, 0, 0, 0, time.UTC). + Add(time.Duration(int64(1000000000 * tms))) if !byZone { - transTz = time.UTC + return date.In(tz) } - return time.Date(int(years), time.Month(int(months)), int(days), 0, 0, 0, 0, transTz). - Add(time.Duration(int64(1000000000 * tms))).In(tz) + // 当地 JD 的小数部分是钟表读数,不能按夏令时午夜后的实际时长累加。 + year, month, day := date.Date() + hour, minute, second := date.Clock() + return time.Date(year, month, day, hour, minute, second, date.Nanosecond(), tz) } diff --git a/basic/julian_test.go b/basic/julian_test.go index d81f543..38d27b5 100644 --- a/basic/julian_test.go +++ b/basic/julian_test.go @@ -6,7 +6,7 @@ import ( "time" ) -func TestGetNowJDEUsesSingleTimestamp(t *testing.T) { +func TestGetNowJDUsesSingleTimestamp(t *testing.T) { oldTimeNow := timeNow defer func() { timeNow = oldTimeNow @@ -23,12 +23,12 @@ func TestGetNowJDEUsesSingleTimestamp(t *testing.T) { return second } - got := GetNowJDE() - want := Date2JDE(first) + got := GetNowJD() + want := Date2JD(first) if calls != 1 { - t.Fatalf("GetNowJDE should read current time once, got %d calls", calls) + t.Fatalf("GetNowJD should read current time once, got %d calls", calls) } if math.Float64bits(got) != math.Float64bits(want) { - t.Fatalf("GetNowJDE mismatch: got %.15f want %.15f", got, want) + t.Fatalf("GetNowJD mismatch: got %.15f want %.15f", got, want) } } diff --git a/basic/julian_zone_test.go b/basic/julian_zone_test.go new file mode 100644 index 0000000..6a59b52 --- /dev/null +++ b/basic/julian_zone_test.go @@ -0,0 +1,28 @@ +package basic + +import ( + "testing" + "time" +) + +func TestLocalJulianDayAcrossDST(t *testing.T) { + for _, name := range []string{"America/New_York", "Australia/Lord_Howe"} { + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatal(err) + } + for _, md := range [][2]int{{3, 8}, {11, 1}, {4, 5}, {10, 4}} { + for _, hour := range []int{0, 3, 12, 23} { + want := time.Date(2026, time.Month(md[0]), md[1], hour, 17, 23, 125000000, loc) + got := JD2DateByZone(Date2JD(want), loc, true) + if delta := got.Sub(want); delta < -100*time.Microsecond || delta > 100*time.Microsecond { + t.Errorf("local %s: got %s, delta=%s", want, got, delta) + } + got = JD2DateByZone(Date2JD(want.UTC()), loc, false) + if delta := got.Sub(want); delta < -100*time.Microsecond || delta > 100*time.Microsecond { + t.Errorf("UTC %s: got %s, delta=%s", want, got, delta) + } + } + } + } +} diff --git a/basic/jupiter.go b/basic/jupiter.go index dde6176..309e560 100644 --- a/basic/jupiter.go +++ b/basic/jupiter.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func JupiterL(jd float64) float64 { - return planet.WherePlanet(4, 0, jd) +func JupiterL(jde float64) float64 { + return planet.WherePlanet(4, 0, jde) } -func JupiterB(jd float64) float64 { - return planet.WherePlanet(4, 1, jd) +func JupiterB(jde float64) float64 { + return planet.WherePlanet(4, 1, jde) } -func JupiterR(jd float64) float64 { - return planet.WherePlanet(4, 2, jd) +func JupiterR(jde float64) float64 { + return planet.WherePlanet(4, 2, jde) } -func AJupiterX(jd float64) float64 { - l := JupiterL(jd) - b := JupiterB(jd) - r := JupiterR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AJupiterX(jde float64) float64 { + l := JupiterL(jde) + b := JupiterB(jde) + r := JupiterR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func AJupiterY(jd float64) float64 { +func AJupiterY(jde float64) float64 { - l := JupiterL(jd) - b := JupiterB(jd) - r := JupiterR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := JupiterL(jde) + b := JupiterB(jde) + r := JupiterR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func AJupiterZ(jd float64) float64 { - //l := JupiterL(jd) - b := JupiterB(jd) - r := JupiterR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AJupiterZ(jde float64) float64 { + //l := JupiterL(jde) + b := JupiterB(jde) + r := JupiterR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func AJupiterXYZ(jd float64) (float64, float64, float64) { - l := JupiterL(jd) - b := JupiterB(jd) - r := JupiterR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AJupiterXYZ(jde float64) (float64, float64, float64) { + l := JupiterL(jde) + b := JupiterB(jde) + r := JupiterR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func JupiterApparentRa(jd float64) float64 { - lo, bo := JupiterApparentLoBo(jd) - eps := TrueObliquity(jd) +func JupiterApparentRa(jde float64) float64 { + lo, bo := JupiterApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func JupiterApparentDec(jd float64) float64 { - lo, bo := JupiterApparentLoBo(jd) - eps := TrueObliquity(jd) +func JupiterApparentDec(jde float64) float64 { + lo, bo := JupiterApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func JupiterApparentRaDec(jd float64) (float64, float64) { - lo, bo := JupiterApparentLoBo(jd) - eps := TrueObliquity(jd) +func JupiterApparentRaDec(jde float64) (float64, float64) { + lo, bo := JupiterApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -105,22 +105,22 @@ func JupiterApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func JupiterMag(jd float64) float64 { - sunDistance := JupiterR(jd) - earthDistance := EarthJupiterAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func JupiterMag(jde float64) float64 { + sunDistance := JupiterR(jde) + earthDistance := EarthJupiterAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -9.40 + 5*math.Log10(sunDistance*earthDistance) + 0.0005*i return FloatRound(mag, 2) } -func JupiterHeight(jde, lon, lat, timezone float64) float64 { +func JupiterHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := JupiterApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -129,12 +129,12 @@ func JupiterHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func JupiterAzimuth(jde, lon, lat, timezone float64) float64 { +func JupiterAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := JupiterApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -153,21 +153,21 @@ func JupiterAzimuth(jde, lon, lat, timezone float64) float64 { } func JupiterHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - JupiterApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - JupiterApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func JupiterCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func JupiterCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-JupiterHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := JupiterHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-JupiterHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := JupiterHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/jupiter_events.go b/basic/jupiter_events.go index ba220d5..919e723 100644 --- a/basic/jupiter_events.go +++ b/basic/jupiter_events.go @@ -74,15 +74,15 @@ func jupiterConjunctionFull(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -90,7 +90,7 @@ func jupiterConjunctionFull(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func jupiterConjunction(jde, degree float64, next uint8) float64 { @@ -105,15 +105,15 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := jupiterSunLongitudeDeltaN(prevJD, degree, true, jupiterEventSearchN) - longitudeSlope := (jupiterSunLongitudeDeltaN(prevJD+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJD-0.000005, degree, true, jupiterEventSearchN)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= jupiterPhaseCoarseTolerance { + prevJDE := estimateJDE + longitudeDelta := jupiterSunLongitudeDeltaN(prevJDE, degree, true, jupiterEventSearchN) + longitudeSlope := (jupiterSunLongitudeDeltaN(prevJDE+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJDE-0.000005, degree, true, jupiterEventSearchN)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= jupiterPhaseCoarseTolerance { converged = true break } @@ -123,12 +123,12 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 { } converged = false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -136,7 +136,7 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func LastJupiterConjunction(jde float64) float64 { @@ -175,22 +175,22 @@ func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit if !isFiniteFloat(oppositionJD) { return math.NaN() } - oppositionTT := TD2UT(oppositionJD, true) + oppositionTT := UTC2TT(oppositionJD) startTT := oppositionTT endTT := oppositionTT if searchBeforeOpposition { easternQuadratureUT := jupiterConjunction(oppositionTT, 90, 0) - startTT = TD2UT(easternQuadratureUT, true) + startTT = UTC2TT(easternQuadratureUT) } else { westernQuadratureUT := jupiterConjunction(oppositionTT, 270, 1) - endTT = TD2UT(westernQuadratureUT, true) + endTT = UTC2TT(westernQuadratureUT) } - bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN) }, func(jd float64) float64 { return jupiterRADerivative(jd, stationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func NextJupiterRetrogradeToPrograde(jde float64) float64 { diff --git a/basic/jupiter_physical.go b/basic/jupiter_physical.go index 1b3e739..062a0fc 100644 --- a/basic/jupiter_physical.go +++ b/basic/jupiter_physical.go @@ -25,14 +25,14 @@ type JupiterCentralMeridianInfo struct { } // JupiterCentralMeridians 木星 System I/II/III 中央经线 / Jupiter System I/II/III central meridians. -func JupiterCentralMeridians(jd float64) JupiterCentralMeridianInfo { - return JupiterCentralMeridiansN(jd, -1) +func JupiterCentralMeridians(jde float64) JupiterCentralMeridianInfo { + return JupiterCentralMeridiansN(jde, -1) } // JupiterCentralMeridiansN 木星 System I/II/III 中央经线(截断版) / truncated Jupiter System I/II/III central meridians. -func JupiterCentralMeridiansN(jd float64, n int) JupiterCentralMeridianInfo { - observations := jupiterPhysicalObservationsN(jd, n) - physical := JupiterPhysicalN(jd, n) +func JupiterCentralMeridiansN(jde float64, n int) JupiterCentralMeridianInfo { + observations := jupiterPhysicalObservationsN(jde, n) + physical := JupiterPhysicalN(jde, n) return JupiterCentralMeridianInfo{ SystemI: observations.SystemI, SystemII: observations.SystemII, @@ -41,18 +41,18 @@ func JupiterCentralMeridiansN(jd float64, n int) JupiterCentralMeridianInfo { } // JupiterDSDE 木星 DS/DE 行星中心赤纬 / Jupiter planetocentric declinations of Sun and Earth. -func JupiterDSDE(jd float64) (ds, de float64) { - return JupiterDSDEN(jd, -1) +func JupiterDSDE(jde float64) (ds, de float64) { + return JupiterDSDEN(jde, -1) } // JupiterDSDEN 木星 DS/DE 行星中心赤纬(截断版) / truncated Jupiter planetocentric declinations of Sun and Earth. -func JupiterDSDEN(jd float64, n int) (ds, de float64) { - observations := jupiterPhysicalObservationsN(jd, n) +func JupiterDSDEN(jde float64, n int) (ds, de float64) { + observations := jupiterPhysicalObservationsN(jde, n) return observations.DS, observations.DE } -func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationInfo { - days := jd - 2433282.5 +func jupiterPhysicalObservationsN(jde float64, n int) jupiterPhysicalObservationInfo { + days := jde - 2433282.5 julianCentury := days / 36525.0 poleRA := (268.0 + 0.1061*julianCentury) * rad @@ -60,9 +60,9 @@ func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationI w1 := (17.71 + 877.90003539*days) * rad w2 := (16.838 + 870.27003539*days) * rad - earthLon := planet.WherePlanetN(-1, 0, jd, n) - earthLat := planet.WherePlanetN(-1, 1, jd, n) - earthRadius := planet.WherePlanetN(-1, 2, jd, n) + earthLon := planet.WherePlanetN(-1, 0, jde, n) + earthLat := planet.WherePlanetN(-1, 1, jde, n) + earthRadius := planet.WherePlanetN(-1, 2, jde, n) delta := 4.0 var jupiterLon float64 @@ -73,16 +73,16 @@ func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationI var z float64 for i := 0; i < 2; i++ { lightTimeDays := astronomicalUnitLightTimeDays * delta - jupiterLon = planet.WherePlanetN(4, 0, jd-lightTimeDays, n) - jupiterLat = planet.WherePlanetN(4, 1, jd-lightTimeDays, n) - jupiterRadius = planet.WherePlanetN(4, 2, jd-lightTimeDays, n) + jupiterLon = planet.WherePlanetN(4, 0, jde-lightTimeDays, n) + jupiterLat = planet.WherePlanetN(4, 1, jde-lightTimeDays, n) + jupiterRadius = planet.WherePlanetN(4, 2, jde-lightTimeDays, n) x = jupiterRadius*Cos(jupiterLat)*Cos(jupiterLon) - earthRadius*Cos(earthLat)*Cos(earthLon) y = jupiterRadius*Cos(jupiterLat)*Sin(jupiterLon) - earthRadius*Cos(earthLat)*Sin(earthLon) z = jupiterRadius*Sin(jupiterLat) - earthRadius*Sin(earthLat) delta = math.Sqrt(x*x + y*y + z*z) } - meanObliquity := EclipticObliquity(jd, false) + meanObliquity := EclipticObliquity(jde, false) sinMeanObliquity, cosMeanObliquity := math.Sincos(meanObliquity) sinJupiterLat, cosJupiterLat := math.Sincos(jupiterLat * rad) sinJupiterLon, cosJupiterLon := math.Sincos(jupiterLon * rad) diff --git a/basic/jupiter_physical_test.go b/basic/jupiter_physical_test.go index 89345f1..1101a0f 100644 --- a/basic/jupiter_physical_test.go +++ b/basic/jupiter_physical_test.go @@ -37,14 +37,14 @@ func TestJupiterCentralMeridianSystemIIIMatchesHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) got := JupiterCentralMeridians(jd) assertPlanetPhaseClose(t, "Jupiter."+sample.InputUTC+".CMIII", got.SystemIII, sample.SubEarthLongitude, 0.02) } } func TestJupiterCentralMeridiansNFullMatchesDefault(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) got := JupiterCentralMeridians(jd) gotN := JupiterCentralMeridiansN(jd, -1) ds, de := JupiterDSDE(jd) @@ -76,7 +76,7 @@ func TestJupiterCentralMeridianAndDSDESampleSweepFiniteAndInRange(t *testing.T) } for _, date := range dates { - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) meridians := JupiterCentralMeridians(jd) ds, de := JupiterDSDE(jd) physical := JupiterPhysical(jd) diff --git a/basic/jupiter_satellite_contact_events.go b/basic/jupiter_satellite_contact_events.go index 0c2589a..2a45a7e 100644 --- a/basic/jupiter_satellite_contact_events.go +++ b/basic/jupiter_satellite_contact_events.go @@ -464,8 +464,8 @@ func jupiterGalileanContactGeometryAt( if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) { return jupiterGalileanContactGeometry{}, false } - evaluationJD := TD2UT(jd, true) - context := newJupiterGalileanObservationContext(evaluationJD) + evaluationJDE := UTC2TT(jd) + context := newJupiterGalileanObservationContext(evaluationJDE) if context.jupiterDistance == 0 { return jupiterGalileanContactGeometry{}, false } @@ -527,8 +527,8 @@ func jupiterGalileanEclipseSignedDistanceAt(jd float64, satellite int, penumbra if !isFinite(jd) || satellite < 1 || satellite > 4 { return math.NaN(), false } - evaluationJD := TD2UT(jd, true) - context := newJupiterGalileanObservationContext(evaluationJD) + evaluationJDE := UTC2TT(jd) + context := newJupiterGalileanObservationContext(evaluationJDE) if context.jupiterDistance == 0 { return math.NaN(), false } @@ -590,8 +590,8 @@ func jupiterGalileanShadowLimbMetricAt(jd float64, satellite int, penumbra bool) if !isFinite(jd) || satellite < 1 || satellite > 4 { return math.NaN(), false } - evaluationJD := TD2UT(jd, true) - context := newJupiterGalileanObservationContext(evaluationJD) + evaluationJDE := UTC2TT(jd) + context := newJupiterGalileanObservationContext(evaluationJDE) if context.jupiterDistance == 0 { return math.NaN(), false } diff --git a/basic/jupiter_satellite_contact_events_test.go b/basic/jupiter_satellite_contact_events_test.go index 9c837d1..a2470c0 100644 --- a/basic/jupiter_satellite_contact_events_test.go +++ b/basic/jupiter_satellite_contact_events_test.go @@ -18,13 +18,13 @@ func TestJupiterGalileanPhenomenonContactEventsAgainstIMCCEBaseline(t *testing.T queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2) phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type) - event := ClosestJupiterGalileanPhenomenonContactEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType) + event := ClosestJupiterGalileanPhenomenonContactEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType) if !event.Valid { t.Fatalf("%s invalid contact event", record.Label) } - gotStart := JDE2DateByZone(event.Disappearance.Start, time.UTC, false) - gotEnd := JDE2DateByZone(event.Reappearance.Start, time.UTC, false) + gotStart := JD2DateByZone(event.Disappearance.Start, time.UTC, false) + gotEnd := JD2DateByZone(event.Reappearance.Start, time.UTC, false) startDiff := math.Abs(gotStart.Sub(startUTC).Seconds()) endDiff := math.Abs(gotEnd.Sub(endUTC).Seconds()) startDurationDiff := math.Abs((event.Disappearance.End-event.Disappearance.Start)*86400 - record.StartDurationMinutes*60) @@ -61,7 +61,7 @@ func TestJupiterGalileanPhenomenonContactEventsAgainstIMCCEBaseline(t *testing.T if !(event.Reappearance.Start <= event.Reappearance.ModelCrossing && event.Reappearance.ModelCrossing <= event.Reappearance.End) { t.Fatalf("%s reappearance ordering invalid", record.Label) } - fullEvent := ClosestJupiterGalileanPhenomenonEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType) + fullEvent := ClosestJupiterGalileanPhenomenonEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType) if phenomenonType != JupiterGalileanShadowTransit { if math.Abs(event.Disappearance.ModelCrossing-fullEvent.Start)*86400 > 2 { t.Fatalf("%s disappearance model crossing mismatch", record.Label) diff --git a/basic/jupiter_satellite_events.go b/basic/jupiter_satellite_events.go index fe88ee7..d827367 100644 --- a/basic/jupiter_satellite_events.go +++ b/basic/jupiter_satellite_events.go @@ -58,7 +58,7 @@ type jupiterGalileanShadowPoint struct { // LastJupiterGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event. // -// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 +// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。 func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true) return event @@ -66,7 +66,7 @@ func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonTyp // NextJupiterGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event. // -// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 +// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。 func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false) return event @@ -74,7 +74,7 @@ func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonTyp // ClosestJupiterGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event. // -// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 +// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。 func ClosestJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true) next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false) @@ -428,8 +428,8 @@ func jupiterGalileanPhenomenonMetricAt( } } - evaluationJD := TD2UT(jd, true) - context := newJupiterGalileanObservationContext(evaluationJD) + evaluationJDE := UTC2TT(jd) + context := newJupiterGalileanObservationContext(evaluationJDE) if context.jupiterDistance == 0 { return jupiterGalileanMetricSample{ metric: math.Inf(1), diff --git a/basic/jupiter_satellite_events_test.go b/basic/jupiter_satellite_events_test.go index e5c2bba..5e98ccf 100644 --- a/basic/jupiter_satellite_events_test.go +++ b/basic/jupiter_satellite_events_test.go @@ -33,9 +33,9 @@ func TestJupiterGalileanPhenomenonEventsAgainstIMCCEBaseline(t *testing.T) { queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2) phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type) - next := NextJupiterGalileanPhenomenonEvent(Date2JDE(queryBefore.UTC()), record.Satellite, phenomenonType) - last := LastJupiterGalileanPhenomenonEvent(Date2JDE(queryAfter.UTC()), record.Satellite, phenomenonType) - closest := ClosestJupiterGalileanPhenomenonEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType) + next := NextJupiterGalileanPhenomenonEvent(Date2JD(queryBefore.UTC()), record.Satellite, phenomenonType) + last := LastJupiterGalileanPhenomenonEvent(Date2JD(queryAfter.UTC()), record.Satellite, phenomenonType) + closest := ClosestJupiterGalileanPhenomenonEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType) assertGalileanEventMatchesBaseline(t, record.Label+" next", next, record, startUTC, endUTC, &maxStartDiff, &maxEndDiff) assertGalileanEventMatchesBaseline(t, record.Label+" last", last, record, startUTC, endUTC, &maxStartDiff, &maxEndDiff) @@ -62,8 +62,8 @@ func assertGalileanEventMatchesBaseline( if string(event.Type) != record.Type { t.Fatalf("%s type mismatch: got %q want %q", name, event.Type, record.Type) } - gotStart := JDE2DateByZone(event.Start, time.UTC, false) - gotEnd := JDE2DateByZone(event.End, time.UTC, false) + gotStart := JD2DateByZone(event.Start, time.UTC, false) + gotEnd := JD2DateByZone(event.End, time.UTC, false) startDiff := math.Abs(gotStart.Sub(startUTC).Seconds()) endDiff := math.Abs(gotEnd.Sub(endUTC).Seconds()) if startDiff > *maxStartDiff { diff --git a/basic/jupiter_satellite_phenomena.go b/basic/jupiter_satellite_phenomena.go index 622d9d7..7966930 100644 --- a/basic/jupiter_satellite_phenomena.go +++ b/basic/jupiter_satellite_phenomena.go @@ -22,18 +22,18 @@ type JupiterGalileanPhenomenon struct { } // JupiterGalileanSatellitePhenomenon 单颗伽利略卫星瞬时现象 / instantaneous phenomena of one Galilean satellite. -func JupiterGalileanSatellitePhenomenon(jd float64, satellite int) JupiterGalileanPhenomenon { - if satellite < 1 || satellite > 4 || !isFinite(jd) { +func JupiterGalileanSatellitePhenomenon(jde float64, satellite int) JupiterGalileanPhenomenon { + if satellite < 1 || satellite > 4 || !isFinite(jde) { return invalidJupiterGalileanPhenomenon() } - context := newJupiterGalileanObservationContext(jd) + context := newJupiterGalileanObservationContext(jde) return context.phenomenonForSatellite(satellite - 1) } // JupiterGalileanSatellitePhenomena 四颗伽利略卫星瞬时现象 / instantaneous phenomena of the four Galilean satellites. -func JupiterGalileanSatellitePhenomena(jd float64) [4]JupiterGalileanPhenomenon { +func JupiterGalileanSatellitePhenomena(jde float64) [4]JupiterGalileanPhenomenon { var phenomena [4]JupiterGalileanPhenomenon - context := newJupiterGalileanObservationContext(jd) + context := newJupiterGalileanObservationContext(jde) for i := range phenomena { phenomena[i] = context.phenomenonForSatellite(i) } diff --git a/basic/jupiter_satellites.go b/basic/jupiter_satellites.go index 76430cc..a3e76cb 100644 --- a/basic/jupiter_satellites.go +++ b/basic/jupiter_satellites.go @@ -35,7 +35,7 @@ type jupiterGalileanL1Term struct { // JupiterGalileanState 木星伽利略卫星原始状态 / raw Galilean-satellite state. // // 输入 jd 使用 TT/TDB 对应的儒略日;返回值为 IMCCE L1 理论的木心 J2000 平赤道直角坐标与速度,单位 AU / AU/day。 -// The input jd is a TT/TDB Julian day (convert a UT query with TD2UT(jd, true)). Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day. +// The input jd is a TT/TDB Julian day (convert a civil query with UTC2TT(jd)). Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day. type JupiterGalileanState struct { X float64 Y float64 @@ -97,11 +97,11 @@ func JupiterGalileanSatelliteStates(jd float64) [4]JupiterGalileanState { // // jd 为 TT/TDB 对应儒略日;返回卫星的天球视赤道坐标,以及相对木星中心的东/北平面偏移。 // jd is a TT/TDB Julian day. The result contains the satellite's astrometric equatorial coordinates and its east/north sky-plane offsets relative to Jupiter's center. -func JupiterGalileanSatelliteObservation(jd float64, satellite int) JupiterGalileanObservation { - if satellite < 1 || satellite > 4 || !isFinite(jd) { +func JupiterGalileanSatelliteObservation(jde float64, satellite int) JupiterGalileanObservation { + if satellite < 1 || satellite > 4 || !isFinite(jde) { return invalidJupiterGalileanObservation() } - context := newJupiterGalileanObservationContext(jd) + context := newJupiterGalileanObservationContext(jde) return context.observationForSatellite(satellite - 1) } @@ -109,9 +109,9 @@ func JupiterGalileanSatelliteObservation(jd float64, satellite int) JupiterGalil // // 返回次序固定为 Io、Europa、Ganymede、Callisto。 // The returned order is Io, Europa, Ganymede, Callisto. -func JupiterGalileanSatelliteObservations(jd float64) [4]JupiterGalileanObservation { +func JupiterGalileanSatelliteObservations(jde float64) [4]JupiterGalileanObservation { var observations [4]JupiterGalileanObservation - context := newJupiterGalileanObservationContext(jd) + context := newJupiterGalileanObservationContext(jde) for i := range observations { observations[i] = context.observationForSatellite(i) } @@ -141,14 +141,14 @@ type jupiterGalileanObservationContext struct { bodyZ Vector3 } -func newJupiterGalileanObservationContext(jd float64) jupiterGalileanObservationContext { - context := jupiterGalileanObservationContext{jd: jd} - if !isFinite(jd) { +func newJupiterGalileanObservationContext(jde float64) jupiterGalileanObservationContext { + context := jupiterGalileanObservationContext{jd: jde} + if !isFinite(jde) { return context } - context.earthHelioJ2000 = rotateEclipticToEquatorial(earthHeliocentricVectorJ2000(jd), orbitJ2000Obliquity) - context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jd, context.earthHelioJ2000) - context.targetJD = jd - context.jupiterLightTime + context.earthHelioJ2000 = rotateEclipticToEquatorial(earthHeliocentricVectorJ2000(jde), orbitJ2000Obliquity) + context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jde, context.earthHelioJ2000) + context.targetJD = jde - context.jupiterLightTime context.jupiterDistance = vectorMagnitude(context.jupiterGeoJ2000) if context.jupiterDistance == 0 { return context @@ -220,9 +220,9 @@ func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTi result := Vector3{} includeSolarLongPeriod := jupiterGalileanUseSolarLongPeriod(jd) for i := 0; i < 8; i++ { - targetJD := jd - lightTime - jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(targetJD), orbitJ2000Obliquity) - state = jupiterGalileanSatelliteStateAtET(targetJD-jupiterGalileanReferenceJD, index, includeSolarLongPeriod) + targetJDE := jd - lightTime + jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(targetJDE), orbitJ2000Obliquity) + state = jupiterGalileanSatelliteStateAtET(targetJDE-jupiterGalileanReferenceJD, index, includeSolarLongPeriod) result = Vector3{ jupiterHelio[0] + state.X - earthHelioJ2000[0], jupiterHelio[1] + state.Y - earthHelioJ2000[1], @@ -256,14 +256,14 @@ func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3 return result, lightTime } -func jupiterHeliocentricVectorJ2000(jd float64) Vector3 { +func jupiterHeliocentricVectorJ2000(jde float64) Vector3 { return eclipticVectorAtReferenceEpoch( eclipticCartesian( - planet.WherePlanet(4, 0, jd), - planet.WherePlanet(4, 1, jd), - planet.WherePlanet(4, 2, jd), + planet.WherePlanet(4, 0, jde), + planet.WherePlanet(4, 1, jde), + planet.WherePlanet(4, 2, jde), ), - jd, + jde, orbitReferenceJD, ) } diff --git a/basic/local_ephemeris.go b/basic/local_ephemeris.go index 2980ded..cf10b58 100644 --- a/basic/local_ephemeris.go +++ b/basic/local_ephemeris.go @@ -71,7 +71,9 @@ func occultationPathVectorRaDec(vector occultationPathVector) (float64, float64, if !finite(distance) || distance <= 0 { return 0, 0, 0, false } - ra := math.Atan2(vector.y, vector.x) / rad + // atan2 给 (−180,180],统一到 [0,360):与精确分支(LoBoToRaDec、starMeanToApparentRaDec) + // 和 occultationRiseSetBodyFromVector 的 normalizeRA 一致;下游只按周期量使用,数值不变。 + ra := normalizeRA(math.Atan2(vector.y, vector.x) / rad) dec := math.Asin(math.Max(-1, math.Min(1, vector.z/distance))) / rad return ra, dec, distance, finite(ra) && finite(dec) } @@ -112,6 +114,9 @@ type starOccultationLocalEphemeris struct { star StarCoordinate nodes []localEphemerisVectorNode dense bool + // distanceKM 是中心时刻的当日距离;hasDistance 为假表示恒星距离未知,几何按无穷远处理。 + distanceKM float64 + hasDistance bool } func newStarOccultationLocalEphemeris(center float64, star StarCoordinate) *starOccultationLocalEphemeris { @@ -133,7 +138,16 @@ func newStarOccultationLocalEphemeris(center float64, star StarCoordinate) *star next: [3]float64{target.x, target.y, target.z}, } } - return &starOccultationLocalEphemeris{star: star, nodes: nodes} + return newStarOccultationLocalEphemerisFromNodes(center, star, nodes, false) +} + +// newStarOccultationLocalEphemerisFromNodes 装配局部星历:距离必须与节点同源,密集分支同样要带上。 +func newStarOccultationLocalEphemerisFromNodes(center float64, star StarCoordinate, nodes []localEphemerisVectorNode, dense bool) *starOccultationLocalEphemeris { + _, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star) + return &starOccultationLocalEphemeris{ + star: star, nodes: nodes, dense: dense, + distanceKM: distanceAU * occultationPathAstronomicalUnitKM, hasDistance: distanceAU > 0, + } } func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOccultationEphemerisState, bool) { @@ -144,15 +158,20 @@ func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOcculta moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{ x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2], }) - targetRA, targetDec, _, targetOK := occultationPathVectorRaDec(occultationPathVector{ + targetRA, targetDec, targetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{ x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2], }) if !moonOK || !targetOK { return starOccultationEphemerisState{}, false } + // 距离取插值矢量自身的模长,才与同一次插值给出的方向同源;距离未知时按 0 上报。 + starDistanceKM := 0.0 + if ephemeris.hasDistance { + starDistanceKM = targetDistance + } return starOccultationEphemerisState{ moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance, - starRA: targetRA, starDec: targetDec, starDistanceKM: ephemeris.starDistanceKM(), + starRA: targetRA, starDec: targetDec, starDistanceKM: starDistanceKM, valid: true, }, true } @@ -168,10 +187,7 @@ func (ephemeris *starOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float6 } func (ephemeris *starOccultationLocalEphemeris) starDistanceKM() float64 { - if ephemeris.star.ParallaxMas <= 0 { - return 0 - } - return 206264806.247 / ephemeris.star.ParallaxMas * occultationPathAstronomicalUnitKM + return ephemeris.distanceKM } type planetOccultationLocalEphemeris struct { diff --git a/basic/local_ephemeris_test.go b/basic/local_ephemeris_test.go index a588d55..20de13a 100644 --- a/basic/local_ephemeris_test.go +++ b/basic/local_ephemeris_test.go @@ -11,11 +11,11 @@ func TestSolarEclipseLocalEphemerisBoundedError(t *testing.T) { name string seed float64 }{ - {"2010-01-15", JDECalc(2010, 1, 15)}, - {"2014-04-29", JDECalc(2014, 4, 29)}, - {"2023-04-20", JDECalc(2023, 4, 20)}, - {"2031-05-21", JDECalc(2031, 5, 21)}, - {"2309-06-09", JDECalc(2309, 6, 9)}, + {"2010-01-15", JDCalc(2010, 1, 15)}, + {"2014-04-29", JDCalc(2014, 4, 29)}, + {"2023-04-20", JDCalc(2023, 4, 20)}, + {"2031-05-21", JDCalc(2031, 5, 21)}, + {"2309-06-09", JDCalc(2309, 6, 9)}, } for _, event := range events { t.Run(event.name, func(t *testing.T) { @@ -133,3 +133,25 @@ func TestOccultationLocalEphemerisFullWindowBoundedError(t *testing.T) { func vectorDifferenceKM(a, b [3]float64) float64 { return math.Sqrt((a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]) + (a[2]-b[2])*(a[2]-b[2])) } + +// 矢量回读的赤经必须落在 [0,360):atan2 给 (−180,180],而精确分支 +// (LoBoToRaDec、starMeanToApparentRaDec)与 rise/set 的矢量回读都在 [0,360), +// 不归一会让同一时刻的密集/精确状态相差 360 度,误导比较与日志。 +func TestOccultationPathVectorRaDecKeepsNormalizedRA(t *testing.T) { + for _, want := range []float64{0.5, 90, 189.1975, 270, 359.9} { + vector := occultationPathRaDecVector(want, -5.8, 2.5) + got, dec, distance, ok := occultationPathVectorRaDec(vector) + if !ok { + t.Fatalf("RA %.4f 的回读失败", want) + } + if math.Abs(got-want) > 1e-9 { + t.Fatalf("RA %.4f 回读为 %.4f,want [0,360) 内的同一读数", want, got) + } + if math.Abs(dec+5.8) > 1e-9 || math.Abs(distance-2.5) > 1e-12 { + t.Fatalf("RA %.4f 的赤纬/距离 = %.6f / %.6f,want -5.8 / 2.5", want, dec, distance) + } + } + if _, _, _, ok := occultationPathVectorRaDec(occultationPathVector{}); ok { + t.Fatal("零矢量应判为无效") + } +} diff --git a/basic/lunar_eclipse.go b/basic/lunar_eclipse.go index 8992db8..7976a8a 100644 --- a/basic/lunar_eclipse.go +++ b/basic/lunar_eclipse.go @@ -91,10 +91,9 @@ const ( // 沿用月食常量: // - 0.2725076 用于月亮视半径和半影几何 - // - 959.63 / 8.794 分别为太阳视半径与太阳视差的常用角秒常量 + // - 太阳视半径与日食共用标准档常量,太阳视差用常用角秒常量 8.794 lunarMoonRadiusRatio = 0.2725076 lunarMoonRadiusScale = lunarMoonRadiusRatio * lunarEarthEquatorialRadiusKM * 1.0000036 - lunarSolarRadiusArcsec = 959.63 lunarSolarParallaxArcsec = 8.794 lunarLongitudeAberration = -3.4e-6 lunarFiniteDifferenceStep = 60.0 / 86400.0 @@ -336,7 +335,7 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l moonRadiusArcsec := lunarMoonRadiusScale * lunarArcsecPerRadian / moonDistanceKM earthParallaxArcsec := lunarEarthEquatorialRadiusKM / moonDistanceKM * lunarArcsecPerRadian - solarRadiusArcsec := lunarSolarRadiusArcsec / sunDistanceAU + solarRadiusArcsec := eclipseSunRadiusStandardArcsec / sunDistanceAU solarParallaxArcsec := lunarSolarParallaxArcsec / sunDistanceAU umbraRadiusArcsec, penumbraRadiusArcsec := lunarEclipseShadowRadiiArcsec( earthParallaxArcsec, diff --git a/basic/lunar_eclipse_contact_test.go b/basic/lunar_eclipse_contact_test.go index 518a215..b3bf441 100644 --- a/basic/lunar_eclipse_contact_test.go +++ b/basic/lunar_eclipse_contact_test.go @@ -38,7 +38,7 @@ func TestLunarEclipseAbsentPhasesAreNaN(t *testing.T) { func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) { // 2025-01-13 的望月没有月食(半影食分 < 0)。 - eclipse := LunarEclipse(JDECalc(2025, 1, 13)) + eclipse := LunarEclipse(JDCalc(2025, 1, 13)) if eclipse.Type != LunarEclipseNone { t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipseNone) } @@ -63,7 +63,7 @@ func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) { } func TestLunarEclipseTotalKeepsAllContactsFinite(t *testing.T) { - eclipse := LunarEclipse(JDECalc(2025, 3, 14)) + eclipse := LunarEclipse(JDCalc(2025, 3, 14)) if eclipse.Type != LunarEclipseTotal || !eclipse.HasPenumbral || !eclipse.HasPartial || !eclipse.HasTotal { t.Fatalf("unexpected result: %+v", eclipse) } @@ -92,7 +92,7 @@ func TestLunarEclipseDiagramSkipsAbsentPhases(t *testing.T) { t.Fatalf("non-finite diagram point: %+v", point) } } - if empty := LunarEclipseDiagram(JDECalc(2025, 1, 13), LunarEclipseDiagramOptions{}); len(empty.Points) != 0 { + if empty := LunarEclipseDiagram(JDCalc(2025, 1, 13), LunarEclipseDiagramOptions{}); len(empty.Points) != 0 { t.Fatalf("eclipse-free diagram should have no points, got %d", len(empty.Points)) } } diff --git a/basic/lunar_eclipse_geometry.go b/basic/lunar_eclipse_geometry.go index 27d4cd1..757ab6b 100644 --- a/basic/lunar_eclipse_geometry.go +++ b/basic/lunar_eclipse_geometry.go @@ -10,11 +10,15 @@ import "math" // NASA lunar-eclipse charts; multiply a radius by the Earth's parallax at the Moon to get Earth radii. type LunarEclipseShadowGeometry struct { // Gamma 是月心到地影轴的最小距离,单位地球赤道半径。 + // Gamma is the least distance from the Moon's centre to the shadow axis, in Earth equatorial radii. Gamma float64 // PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是半影、本影在地影轴垂直面上的角半径,单位度。 + // PenumbralRadiusDegrees and UmbralRadiusDegrees are the penumbral and umbral angular radii on the + // plane perpendicular to the shadow axis, in degrees. PenumbralRadiusDegrees float64 UmbralRadiusDegrees float64 - // MoonDistanceEarthRadii 是食甚时的地心月距。 + // MoonDistanceEarthRadii 是食甚时的地心月距,单位地球赤道半径。 + // MoonDistanceEarthRadii is the geocentric lunar distance at greatest eclipse, in Earth equatorial radii. MoonDistanceEarthRadii float64 // AxisDegrees 是食甚时月心到地影轴的角距,即 NASA 月食图上那列 Axis。 // 它与 Gamma 是同一个量的两种刻度:Gamma 除以月球处的地球视差就是它。 diff --git a/basic/lunar_eclipse_geometry_test.go b/basic/lunar_eclipse_geometry_test.go index 6f2d539..3b7b1f2 100644 --- a/basic/lunar_eclipse_geometry_test.go +++ b/basic/lunar_eclipse_geometry_test.go @@ -13,7 +13,7 @@ func TestLunarEclipseShadowGeometryAxisMatchesGamma(t *testing.T) { time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC), time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC), } { - result := LunarEclipse(TD2UT(Date2JDE(date), true)) + result := LunarEclipse(UTC2TT(Date2JD(date))) geometry := LunarEclipseShadowGeometryAt(result.Maximum) // 影几何内部用的是小角近似的地球视差(R⊕/月距),这里必须用同一形式,否则二级差会露出来。 earthParallaxDegrees := (1 / geometry.MoonDistanceEarthRadii) * 180 / math.Pi @@ -32,7 +32,7 @@ func TestLunarEclipseMagnitudeInvertsToAxis(t *testing.T) { time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC), time.Date(2020, time.November, 30, 0, 0, 0, 0, time.UTC), } { - result := LunarEclipse(TD2UT(Date2JDE(date), true)) + result := LunarEclipse(UTC2TT(Date2JD(date))) geometry := LunarEclipseShadowGeometryAt(result.Maximum) moonSemidiameter := MoonSemidiameter(result.Maximum) / 3600 fromUmbral := geometry.UmbralRadiusDegrees + moonSemidiameter - diff --git a/basic/lunar_eclipse_test.go b/basic/lunar_eclipse_test.go index 8aa183d..b27b203 100644 --- a/basic/lunar_eclipse_test.go +++ b/basic/lunar_eclipse_test.go @@ -27,7 +27,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { testCases := []lunarEclipseBaseline{ { name: "2022-11-08 total", - jde: JDECalc(2022, 11, 8), + jde: JDCalc(2022, 11, 8), expectedType: LunarEclipseTotal, expectedMax: 2459891.9585873615, expectedMag: 1.3635170051692678, @@ -40,14 +40,14 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { }, { name: "2023-05-05 penumbral", - jde: JDECalc(2023, 5, 5), + jde: JDCalc(2023, 5, 5), expectedType: LunarEclipsePenumbral, expectedPenumbralStart: 2460070.1342392800, expectedPenumbralEnd: 2460070.3159191823, }, { name: "2023-10-28 partial", - jde: JDECalc(2023, 10, 28), + jde: JDCalc(2023, 10, 28), expectedType: LunarEclipsePartial, expectedMax: 2460246.3439460830, expectedMag: 0.12723850274626405, @@ -58,14 +58,14 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { }, { name: "2024-03-25 penumbral", - jde: JDECalc(2024, 3, 25), + jde: JDCalc(2024, 3, 25), expectedType: LunarEclipsePenumbral, expectedPenumbralStart: 2460394.7028870000, expectedPenumbralEnd: 2460394.8999071894, }, { name: "2024-09-18 partial", - jde: JDECalc(2024, 9, 18), + jde: JDCalc(2024, 9, 18), expectedType: LunarEclipsePartial, expectedMax: 2460571.6148748010, expectedMag: 0.09042791952817894, @@ -76,7 +76,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { }, { name: "2025-03-14 total", - jde: JDECalc(2025, 3, 14), + jde: JDCalc(2025, 3, 14), expectedType: LunarEclipseTotal, expectedMax: 2460748.7916214615, expectedMag: 1.1828107517800281, @@ -89,7 +89,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { }, { name: "2025-09-07 total", - jde: JDECalc(2025, 9, 7), + jde: JDCalc(2025, 9, 7), expectedType: LunarEclipseTotal, expectedMax: 2460926.2590034613, expectedMag: 1.3672329695760280, @@ -102,7 +102,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { }, { name: "2026-03-03 total", - jde: JDECalc(2026, 3, 3), + jde: JDCalc(2026, 3, 3), expectedType: LunarEclipseTotal, expectedMax: 2461102.9825476190, expectedMag: 1.1556387222746651, @@ -154,7 +154,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { } func TestLunarEclipseDefaultUsesDanjon(t *testing.T) { - jde := JDECalc(2025, 3, 14) + jde := JDCalc(2025, 3, 14) defaultResult := LunarEclipse(jde) danjonResult := LunarEclipseDanjon(jde) chauvenetResult := LunarEclipseChauvenet(jde) @@ -176,9 +176,9 @@ func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) { jde float64 calc func(float64) LunarEclipseResult }{ - {name: "default 2024-03-25", jde: JDECalc(2024, 3, 25), calc: LunarEclipse}, - {name: "danjon 2024-03-25", jde: JDECalc(2024, 3, 25), calc: LunarEclipseDanjon}, - {name: "chauvenet 2023-05-05", jde: JDECalc(2023, 5, 5), calc: LunarEclipseChauvenet}, + {name: "default 2024-03-25", jde: JDCalc(2024, 3, 25), calc: LunarEclipse}, + {name: "danjon 2024-03-25", jde: JDCalc(2024, 3, 25), calc: LunarEclipseDanjon}, + {name: "chauvenet 2023-05-05", jde: JDCalc(2023, 5, 5), calc: LunarEclipseChauvenet}, } for _, tc := range testCases { @@ -210,28 +210,28 @@ func TestLunarEclipseDanjonMagnitudesCloserToNASA(t *testing.T) { }{ { name: "2023-10-28 partial", - jde: JDECalc(2023, 10, 28), + jde: JDCalc(2023, 10, 28), expectedType: LunarEclipsePartial, nasaPenumbralMagnitude: 1.1181, nasaUmbralMagnitude: 0.1220, }, { name: "2025-03-14 total", - jde: JDECalc(2025, 3, 14), + jde: JDCalc(2025, 3, 14), expectedType: LunarEclipseTotal, nasaPenumbralMagnitude: 2.2595, nasaUmbralMagnitude: 1.1784, }, { name: "2026-03-03 total", - jde: JDECalc(2026, 3, 3), + jde: JDCalc(2026, 3, 3), expectedType: LunarEclipseTotal, nasaPenumbralMagnitude: 2.1838, nasaUmbralMagnitude: 1.1507, }, { name: "2026-08-28 partial", - jde: JDECalc(2026, 8, 28), + jde: JDCalc(2026, 8, 28), expectedType: LunarEclipsePartial, nasaPenumbralMagnitude: 1.9645, nasaUmbralMagnitude: 0.9299, @@ -277,7 +277,7 @@ func TestLunarEclipseNoEvent(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := tc.calc(JDECalc(2023, 6, 4)) + result := tc.calc(JDCalc(2023, 6, 4)) if result.Type != LunarEclipseNone { t.Fatalf("Type mismatch: got %s want %s", result.Type, LunarEclipseNone) } diff --git a/basic/lunisolar.go b/basic/lunisolar.go index 9d87d4f..7d29ce7 100644 --- a/basic/lunisolar.go +++ b/basic/lunisolar.go @@ -3,16 +3,14 @@ package basic import "math" func GetLunar(year, month, day int, tz float64) (lyear, lmonth, lday int, leap bool, result string) { - julianDayEpoch := JDECalc(year, month, float64(day)) + julianDayEpoch := JDCalc(year, month, float64(day)) // 确定农历年份 lyear = year adjustedYear := year if month == 11 || month == 12 { winterSolsticeDay := GetJQTime(year, 270) + tz - //firstNewMoonDay := TD2UT(CalcMoonS(float64(year)+11.0/12.0+5.0/30.0/12.0, 0), true) + tz - //nextNewMoonDay := TD2UT(CalcMoonS(float64(year)+1.0, 0), true) + tz - firstNewMoonDay := TD2UT(CalcMoonSHByJDE(winterSolsticeDay-16, 0), false) + tz - nextNewMoonDay := TD2UT(CalcMoonSHByJDE(firstNewMoonDay+28, 0), false) + tz + firstNewMoonDay := TT2UTC(CalcMoonSHByJDE(winterSolsticeDay-16, 0)) + tz + nextNewMoonDay := TT2UTC(CalcMoonSHByJDE(firstNewMoonDay+28, 0)) + tz firstNewMoonDay = normalizeTimePoint(firstNewMoonDay) nextNewMoonDay = normalizeTimePoint(nextNewMoonDay) diff --git a/basic/mars.go b/basic/mars.go index 98baf58..bd6cf4f 100644 --- a/basic/mars.go +++ b/basic/mars.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func MarsL(jd float64) float64 { - return planet.WherePlanet(3, 0, jd) +func MarsL(jde float64) float64 { + return planet.WherePlanet(3, 0, jde) } -func MarsB(jd float64) float64 { - return planet.WherePlanet(3, 1, jd) +func MarsB(jde float64) float64 { + return planet.WherePlanet(3, 1, jde) } -func MarsR(jd float64) float64 { - return planet.WherePlanet(3, 2, jd) +func MarsR(jde float64) float64 { + return planet.WherePlanet(3, 2, jde) } -func AMarsX(jd float64) float64 { - l := MarsL(jd) - b := MarsB(jd) - r := MarsR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMarsX(jde float64) float64 { + l := MarsL(jde) + b := MarsB(jde) + r := MarsR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func AMarsY(jd float64) float64 { +func AMarsY(jde float64) float64 { - l := MarsL(jd) - b := MarsB(jd) - r := MarsR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := MarsL(jde) + b := MarsB(jde) + r := MarsR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func AMarsZ(jd float64) float64 { - //l := MarsL(jd) - b := MarsB(jd) - r := MarsR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMarsZ(jde float64) float64 { + //l := MarsL(jde) + b := MarsB(jde) + r := MarsR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func AMarsXYZ(jd float64) (float64, float64, float64) { - l := MarsL(jd) - b := MarsB(jd) - r := MarsR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMarsXYZ(jde float64) (float64, float64, float64) { + l := MarsL(jde) + b := MarsB(jde) + r := MarsR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func MarsApparentRa(jd float64) float64 { - lo, bo := MarsApparentLoBo(jd) - eps := TrueObliquity(jd) +func MarsApparentRa(jde float64) float64 { + lo, bo := MarsApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func MarsApparentDec(jd float64) float64 { - lo, bo := MarsApparentLoBo(jd) - eps := TrueObliquity(jd) +func MarsApparentDec(jde float64) float64 { + lo, bo := MarsApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func MarsApparentRaDec(jd float64) (float64, float64) { - lo, bo := MarsApparentLoBo(jd) - eps := TrueObliquity(jd) +func MarsApparentRaDec(jde float64) (float64, float64) { + lo, bo := MarsApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -115,22 +115,22 @@ func MarsTrueLo(jd float64) float64 { return geo.lo } -func MarsMag(jd float64) float64 { - sunDistance := MarsR(jd) - earthDistance := EarthMarsAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func MarsMag(jde float64) float64 { + sunDistance := MarsR(jde) + earthDistance := EarthMarsAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -1.52 + 5*math.Log10(sunDistance*earthDistance) + 0.016*i return FloatRound(mag, 2) } -func MarsHeight(jde, lon, lat, timezone float64) float64 { +func MarsHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := MarsApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := MarsApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -139,12 +139,12 @@ func MarsHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func MarsAzimuth(jde, lon, lat, timezone float64) float64 { +func MarsAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := MarsApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := MarsApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -163,21 +163,21 @@ func MarsAzimuth(jde, lon, lat, timezone float64) float64 { } func MarsHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - MarsApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - MarsApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func MarsCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func MarsCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-MarsHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := MarsHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-MarsHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := MarsHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/mars_event_helpers_test.go b/basic/mars_event_helpers_test.go index 71cd3f2..fbcbb09 100644 --- a/basic/mars_event_helpers_test.go +++ b/basic/mars_event_helpers_test.go @@ -28,7 +28,7 @@ func marsEventSamples() []time.Time { } func marsEventSampleTTJD(date time.Time) float64 { - return TD2UT(Date2JDE(date.UTC()), true) + return UTC2TT(Date2JD(date.UTC())) } func marsEventCases() []marsEventCase { diff --git a/basic/mars_events.go b/basic/mars_events.go index 5f54fa8..775e298 100644 --- a/basic/mars_events.go +++ b/basic/mars_events.go @@ -77,15 +77,15 @@ func marsConjunctionFull(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := marsSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -93,7 +93,7 @@ func marsConjunctionFull(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func marsConjunction(jde, degree float64, next uint8) float64 { @@ -108,15 +108,15 @@ func marsConjunction(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := marsSunLongitudeDeltaN(prevJD, degree, true, marsEventSearchN) - longitudeSlope := (marsSunLongitudeDeltaN(prevJD+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJD-0.000005, degree, true, marsEventSearchN)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= marsPhaseCoarseTolerance { + prevJDE := estimateJDE + longitudeDelta := marsSunLongitudeDeltaN(prevJDE, degree, true, marsEventSearchN) + longitudeSlope := (marsSunLongitudeDeltaN(prevJDE+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJDE-0.000005, degree, true, marsEventSearchN)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= marsPhaseCoarseTolerance { converged = true break } @@ -126,12 +126,12 @@ func marsConjunction(jde, degree float64, next uint8) float64 { } converged = false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := marsSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -139,7 +139,7 @@ func marsConjunction(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func LastMarsConjunction(jde float64) float64 { @@ -178,22 +178,22 @@ func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition if !isFiniteFloat(oppositionJD) { return math.NaN() } - oppositionTT := TD2UT(oppositionJD, true) + oppositionTT := UTC2TT(oppositionJD) startTT := oppositionTT endTT := oppositionTT if searchBeforeOpposition { easternQuadratureUT := marsConjunction(oppositionTT, 90, 0) - startTT = TD2UT(easternQuadratureUT, true) + startTT = UTC2TT(easternQuadratureUT) } else { westernQuadratureUT := marsConjunction(oppositionTT, 270, 1) - endTT = TD2UT(westernQuadratureUT, true) + endTT = UTC2TT(westernQuadratureUT) } - bestJD := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 { return marsRADerivativeN(jd, marsStationDerivativeStepDay, marsEventSearchN) }, func(jd float64) float64 { return marsRADerivative(jd, marsStationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func NextMarsRetrogradeToPrograde(jde float64) float64 { diff --git a/basic/memo_bench_test.go b/basic/memo_bench_test.go index 3642730..e853438 100644 --- a/basic/memo_bench_test.go +++ b/basic/memo_bench_test.go @@ -27,7 +27,7 @@ func BenchmarkTrueObliquity(b *testing.B) { var benchMoonRise, benchMoonSet float64 func BenchmarkMoonRiseSetChain(b *testing.B) { - jd := JDECalc(2023, 6, 21) + jd := JDCalc(2023, 6, 21) b.ReportAllocs() for i := 0; i < b.N; i++ { benchMoonRise, _ = GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0) diff --git a/basic/mercury.go b/basic/mercury.go index 2fc2ea3..6434d7e 100644 --- a/basic/mercury.go +++ b/basic/mercury.go @@ -7,76 +7,76 @@ import ( . "b612.me/astro/tools" ) -func MercuryL(jd float64) float64 { - return planet.WherePlanet(1, 0, jd) +func MercuryL(jde float64) float64 { + return planet.WherePlanet(1, 0, jde) } -func MercuryB(jd float64) float64 { - return planet.WherePlanet(1, 1, jd) +func MercuryB(jde float64) float64 { + return planet.WherePlanet(1, 1, jde) } -func MercuryR(jd float64) float64 { - return planet.WherePlanet(1, 2, jd) +func MercuryR(jde float64) float64 { + return planet.WherePlanet(1, 2, jde) } -func AMercuryX(jd float64) float64 { - l := MercuryL(jd) - b := MercuryB(jd) - r := MercuryR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMercuryX(jde float64) float64 { + l := MercuryL(jde) + b := MercuryB(jde) + r := MercuryR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func AMercuryY(jd float64) float64 { +func AMercuryY(jde float64) float64 { - l := MercuryL(jd) - b := MercuryB(jd) - r := MercuryR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := MercuryL(jde) + b := MercuryB(jde) + r := MercuryR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func AMercuryZ(jd float64) float64 { - //l := MercuryL(jd) - b := MercuryB(jd) - r := MercuryR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMercuryZ(jde float64) float64 { + //l := MercuryL(jde) + b := MercuryB(jde) + r := MercuryR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func AMercuryXYZ(jd float64) (float64, float64, float64) { - l := MercuryL(jd) - b := MercuryB(jd) - r := MercuryR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AMercuryXYZ(jde float64) (float64, float64, float64) { + l := MercuryL(jde) + b := MercuryB(jde) + r := MercuryR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func MercuryApparentRa(jd float64) float64 { - lo, bo := MercuryApparentLoBo(jd) - return LoToRa(jd, lo, bo) +func MercuryApparentRa(jde float64) float64 { + lo, bo := MercuryApparentLoBo(jde) + return LoToRa(jde, lo, bo) } -func MercuryApparentDec(jd float64) float64 { - lo, bo := MercuryApparentLoBo(jd) - eps := TrueObliquity(jd) +func MercuryApparentDec(jde float64) float64 { + lo, bo := MercuryApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func MercuryApparentRaDec(jd float64) (float64, float64) { - lo, bo := MercuryApparentLoBo(jd) - return LoBoToRaDec(jd, lo, bo) +func MercuryApparentRaDec(jde float64) (float64, float64) { + lo, bo := MercuryApparentLoBo(jde) + return LoBoToRaDec(jde, lo, bo) } func EarthMercuryAway(jd float64) float64 { @@ -98,22 +98,22 @@ func MercuryApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func MercuryMag(jd float64) float64 { - sunDistance := MercuryR(jd) - earthDistance := EarthMercuryAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func MercuryMag(jde float64) float64 { + sunDistance := MercuryR(jde) + earthDistance := EarthMercuryAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -0.42 + 5*math.Log10(sunDistance*earthDistance) + 0.0380*i - 0.000273*i*i + 0.000002*i*i*i return FloatRound(mag, 2) } -func MercuryHeight(jde, lon, lat, timezone float64) float64 { +func MercuryHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := MercuryApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -122,12 +122,12 @@ func MercuryHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func MercuryAzimuth(jde, lon, lat, timezone float64) float64 { +func MercuryAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := MercuryApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -146,21 +146,21 @@ func MercuryAzimuth(jde, lon, lat, timezone float64) float64 { } func MercuryHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - MercuryApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - MercuryApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func MercuryCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func MercuryCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-MercuryHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := MercuryHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-MercuryHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := MercuryHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/mercury_event_helpers_test.go b/basic/mercury_event_helpers_test.go index 8d4d9ea..324c14d 100644 --- a/basic/mercury_event_helpers_test.go +++ b/basic/mercury_event_helpers_test.go @@ -31,7 +31,7 @@ func mercuryEventSamples() []time.Time { } func mercuryEventSampleTTJD(date time.Time) float64 { - return TD2UT(Date2JDE(date.UTC()), true) + return UTC2TT(Date2JD(date.UTC())) } func mercuryEventCases() []mercuryEventCase { diff --git a/basic/mercury_events.go b/basic/mercury_events.go index 15c9e2f..1af1a0d 100644 --- a/basic/mercury_events.go +++ b/basic/mercury_events.go @@ -51,11 +51,11 @@ type mercuryConjunctionResult struct { geoLightDays float64 } -func mercuryHelioN(planetIndex int, jd float64, n int) mercuryConjunctionLBR { +func mercuryHelioN(planetIndex int, jde float64, n int) mercuryConjunctionLBR { return mercuryConjunctionLBR{ - lo: planet.WherePlanetN(planetIndex, 0, jd, n), - bo: planet.WherePlanetN(planetIndex, 1, jd, n), - r: planet.WherePlanetN(planetIndex, 2, jd, n), + lo: planet.WherePlanetN(planetIndex, 0, jde, n), + bo: planet.WherePlanetN(planetIndex, 1, jde, n), + r: planet.WherePlanetN(planetIndex, 2, jde, n), } } @@ -82,9 +82,9 @@ func mercuryConjunctionAngleDelta(diff float64) float64 { return diff } -func mercuryConjunctionHeliocentricDelta(jd, targetDeg float64, n int) float64 { - planetLo := planet.WherePlanetN(1, 0, jd, n) - earthLo := planet.WherePlanetN(-1, 0, jd, n) +func mercuryConjunctionHeliocentricDelta(jde, targetDeg float64, n int) float64 { + planetLo := planet.WherePlanetN(1, 0, jde, n) + earthLo := planet.WherePlanetN(-1, 0, jde, n) return mercuryConjunctionAngleDelta(planetLo - earthLo - targetDeg) } @@ -101,8 +101,8 @@ func mercuryConjunctionDifference(jd float64, n int, targetDeg, sunLightDays, ge } } -func mercuryConjunctionExactDelta(jd float64) float64 { - return mercuryConjunctionAngleDelta(MercuryApparentLo(jd) - HSunApparentLo(jd)) +func mercuryConjunctionExactDelta(jde float64) float64 { + return mercuryConjunctionAngleDelta(MercuryApparentLo(jde) - HSunApparentLo(jde)) } func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 { @@ -110,32 +110,32 @@ func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 { if inferior { heliocentricTarget = 0 } - jd := seed + jde := seed for i := 0; i < 6; i++ { - jd -= mercuryConjunctionHeliocentricDelta(jd, heliocentricTarget, 8) / (360.0 / MERCURY_S_PERIOD) + jde -= mercuryConjunctionHeliocentricDelta(jde, heliocentricTarget, 8) / (360.0 / MERCURY_S_PERIOD) } - startSample := mercuryConjunctionDifference(jd, 8, 0, 0, 0) - nextSample := mercuryConjunctionDifference(jd+mercuryConjunctionDerivativeStepDay, 8, 0, 0, 0) + startSample := mercuryConjunctionDifference(jde, 8, 0, 0, 0) + nextSample := mercuryConjunctionDifference(jde+mercuryConjunctionDerivativeStepDay, 8, 0, 0, 0) diffSlope := mercuryConjunctionAngleDelta(nextSample.diff-startSample.diff) / mercuryConjunctionDerivativeStepDay - refined := mercuryConjunctionDifference(jd, 40, 0, startSample.sunLightDays, startSample.geoLightDays) - jd -= refined.diff / diffSlope - final := mercuryConjunctionDifference(jd, -1, 0, refined.sunLightDays, refined.geoLightDays) - jd -= final.diff / diffSlope - return jd + refined := mercuryConjunctionDifference(jde, 40, 0, startSample.sunLightDays, startSample.geoLightDays) + jde -= refined.diff / diffSlope + final := mercuryConjunctionDifference(jde, -1, 0, refined.sunLightDays, refined.geoLightDays) + jde -= final.diff / diffSlope + return jde } func mercuryConjunctionExactTT(seed float64, inferior bool) float64 { estimateJD := mercuryConjunctionApproxTT(seed, inferior) converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := mercuryConjunctionExactDelta(prevJD) - longitudeSlope := (mercuryConjunctionExactDelta(prevJD+0.000005) - mercuryConjunctionExactDelta(prevJD-0.000005)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope + prevJDE := estimateJD + longitudeDelta := mercuryConjunctionExactDelta(prevJDE) + longitudeSlope := (mercuryConjunctionExactDelta(prevJDE+0.000005) - mercuryConjunctionExactDelta(prevJDE-0.000005)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -161,7 +161,7 @@ func mercuryConjunction(jde float64, next uint8) float64 { if math.Abs(mercuryConjunctionExactDelta(jde)) <= mercuryConjunctionSameInstantDegrees { best := math.NaN() consider := func(inferior bool) { - eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false) + eventUT := TT2UTC(mercuryConjunctionExactTT(jde, inferior)) if !isFiniteFloat(eventUT) { return } @@ -186,80 +186,80 @@ func mercuryConjunction(jde float64, next uint8) float64 { if next == 0 { direction = -1 } - leftJD := jde - leftValue := mercuryConjunctionDeltaN(leftJD, mercuryEventSearchN) + leftJDE := jde + leftValue := mercuryConjunctionDeltaN(leftJDE, mercuryEventSearchN) if !isFiniteFloat(leftValue) { return math.NaN() } for i := 0; i < mercuryConjunctionScanMaxSteps; i++ { - rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) - rightValue := mercuryConjunctionDeltaN(rightJD, mercuryEventSearchN) + rightJDE := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) + rightValue := mercuryConjunctionDeltaN(rightJDE, mercuryEventSearchN) if !isFiniteFloat(rightValue) { return math.NaN() } if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { - return mercuryConjunctionPolish(jde, leftJD, rightJD, direction) + return mercuryConjunctionPolish(jde, leftJDE, rightJDE, direction) } - leftJD, leftValue = rightJD, rightValue + leftJDE, leftValue = rightJDE, rightValue } return math.NaN() } // mercuryConjunctionDeltaN 截断级数下的水星-太阳视黄经差(度,[-180,180]),用于方向性括号扫描。 -func mercuryConjunctionDeltaN(jd float64, n int) float64 { - return mercuryConjunctionAngleDelta(MercuryApparentLoN(jd, n) - HSunApparentLoN(jd, n)) +func mercuryConjunctionDeltaN(jde float64, n int) float64 { + return mercuryConjunctionAngleDelta(MercuryApparentLoN(jde, n) - HSunApparentLoN(jde, n)) } // mercuryConjunctionPolish 用全项级数在截断级数给出的括号内抛光。 // 截断误差可能让括号两端在全项函数上同号(罕见),此时沿扫描方向再扩一两个扫描步; // 若仍未被确认(典型情形:查询几乎正好落在合上,截断级数在根两侧的符号与全项不一致), // 退回全项级数的方向扫描,保证有界且不返回 NaN。 -func mercuryConjunctionPolish(jde, leftJD, rightJD, direction float64) float64 { +func mercuryConjunctionPolish(jde, leftJDE, rightJDE, direction float64) float64 { for attempt := 0; attempt < 3; attempt++ { - leftValue := mercuryConjunctionExactDelta(leftJD) - rightValue := mercuryConjunctionExactDelta(rightJD) + leftValue := mercuryConjunctionExactDelta(leftJDE) + rightValue := mercuryConjunctionExactDelta(rightJDE) if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) { return math.NaN() } if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { - root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, + root, ok := eventBracketSecantRoot(leftJDE, rightJDE, leftValue, rightValue, mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta) if !ok { return math.NaN() } - return TD2UT(root, false) + return TT2UTC(root) } if direction > 0 { - rightJD += mercuryConjunctionScanStepDay + rightJDE += mercuryConjunctionScanStepDay continue } - leftJD -= mercuryConjunctionScanStepDay + leftJDE -= mercuryConjunctionScanStepDay } return mercuryConjunctionFullDirectionalScan(jde, direction) } // mercuryConjunctionFullDirectionalScan 全项级数的方向扫描(截断括号未被确认时的兜底)。 func mercuryConjunctionFullDirectionalScan(jde, direction float64) float64 { - leftJD := jde - leftValue := mercuryConjunctionExactDelta(leftJD) + leftJDE := jde + leftValue := mercuryConjunctionExactDelta(leftJDE) if !isFiniteFloat(leftValue) { return math.NaN() } for i := 0; i < mercuryConjunctionScanMaxSteps; i++ { - rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) - rightValue := mercuryConjunctionExactDelta(rightJD) + rightJDE := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) + rightValue := mercuryConjunctionExactDelta(rightJDE) if !isFiniteFloat(rightValue) { return math.NaN() } if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { - root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, + root, ok := eventBracketSecantRoot(leftJDE, rightJDE, leftValue, rightValue, mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta) if !ok { return math.NaN() } - return TD2UT(root, false) + return TT2UTC(root) } - leftJD, leftValue = rightJD, rightValue + leftJDE, leftValue = rightJDE, rightValue } return math.NaN() } @@ -335,12 +335,12 @@ func mercuryRADerivativeN(jde, delta float64, n int) float64 { } func mercuryStationInWindow(startTT, endTT float64) float64 { - bestJD := zeroEventInWindow(startTT, endTT, mercuryStationCoarseStepDay, mercuryStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, mercuryStationCoarseStepDay, mercuryStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 { return mercuryRADerivativeN(jd, mercuryStationDerivativeStepDay, mercuryEventSearchN) }, func(jd float64) float64 { return mercuryRADerivative(jd, mercuryStationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func mercuryStationBetween(startTT, endTT float64) bool { @@ -377,12 +377,12 @@ func mercuryStationBetween(startTT, endTT float64) bool { } func mercuryProgradeToRetrogradeAroundInferior(inferiorUT float64) float64 { - inferiorTT := TD2UT(inferiorUT, true) + inferiorTT := UTC2TT(inferiorUT) return mercuryStationInWindow(inferiorTT-mercuryStationWindowDays, inferiorTT) } func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 { - inferiorTT := TD2UT(inferiorUT, true) + inferiorTT := UTC2TT(inferiorUT) return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays) } @@ -488,7 +488,7 @@ func NextMercuryRetrograde(jde float64) float64 { motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay) if motion > mercuryStationMotionTolerance { p2r := NextMercuryProgradeToRetrograde(jde) - if isFiniteFloat(p2r) && !mercuryStationBetween(jde, TD2UT(p2r, true)) { + if isFiniteFloat(p2r) && !mercuryStationBetween(jde, UTC2TT(p2r)) { return p2r } best := earliestFiniteEventUT(p2r, NextMercuryRetrogradeToPrograde(jde)) @@ -499,7 +499,7 @@ func NextMercuryRetrograde(jde float64) float64 { } if motion < -mercuryStationMotionTolerance { r2p := NextMercuryRetrogradeToPrograde(jde) - if isFiniteFloat(r2p) && !mercuryStationBetween(jde, TD2UT(r2p, true)) { + if isFiniteFloat(r2p) && !mercuryStationBetween(jde, UTC2TT(r2p)) { return r2p } best := earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), r2p) @@ -522,7 +522,7 @@ func LastMercuryRetrograde(jde float64) float64 { motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay) if motion > mercuryStationMotionTolerance { r2p := LastMercuryRetrogradeToPrograde(jde) - if isFiniteFloat(r2p) && !mercuryStationBetween(TD2UT(r2p, true), jde) { + if isFiniteFloat(r2p) && !mercuryStationBetween(UTC2TT(r2p), jde) { return r2p } best := latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), r2p) @@ -533,7 +533,7 @@ func LastMercuryRetrograde(jde float64) float64 { } if motion < -mercuryStationMotionTolerance { p2r := LastMercuryProgradeToRetrograde(jde) - if isFiniteFloat(p2r) && !mercuryStationBetween(TD2UT(p2r, true), jde) { + if isFiniteFloat(p2r) && !mercuryStationBetween(UTC2TT(p2r), jde) { return p2r } best := latestFiniteEventUT(p2r, LastMercuryRetrogradeToPrograde(jde)) @@ -572,9 +572,9 @@ func mercurySunElongationN(jde float64, n int) float64 { // 窗口两端是世界时,目标函数收力学时,因此逐次换算。 func mercuryGreatestElongationInWindow(start, end float64) float64 { return maximizeInWindow(start, end, 2.0, func(utJD float64) float64 { - return mercurySunElongationN(TD2UT(utJD, true), mercuryEventSearchN) + return mercurySunElongationN(UTC2TT(utJD), mercuryEventSearchN) }, func(utJD float64) float64 { - return MercurySunElongation(TD2UT(utJD, true)) + return MercurySunElongation(UTC2TT(utJD)) }) } diff --git a/basic/mercury_retrograde_optimization_test.go b/basic/mercury_retrograde_optimization_test.go index 78da959..4a66bb5 100644 --- a/basic/mercury_retrograde_optimization_test.go +++ b/basic/mercury_retrograde_optimization_test.go @@ -13,7 +13,7 @@ func TestMercuryRetrogradeFastPathKeepsTypedCandidateOrder(t *testing.T) { 1644733.927538287, 1645082.416782375, } { - queryTT := TD2UT(queryUT, true) + queryTT := UTC2TT(queryUT) nextP2R := NextMercuryProgradeToRetrograde(queryTT) nextR2P := NextMercuryRetrogradeToPrograde(queryTT) wantNext := math.Min(nextP2R, nextR2P) diff --git a/basic/mercury_station_regression_test.go b/basic/mercury_station_regression_test.go index 94c9067..8197d8d 100644 --- a/basic/mercury_station_regression_test.go +++ b/basic/mercury_station_regression_test.go @@ -8,7 +8,7 @@ import ( func mercuryTTJDJST(year int, month time.Month, day, hour, minute, second int) float64 { loc := time.FixedZone("JST", 9*3600) - return TD2UT(Date2JDE(time.Date(year, month, day, hour, minute, second, 0, loc).UTC()), true) + return UTC2TT(Date2JD(time.Date(year, month, day, hour, minute, second, 0, loc).UTC())) } func TestMercuryTypedStationRegression1929(t *testing.T) { @@ -22,19 +22,19 @@ func TestMercuryTypedStationRegression1929(t *testing.T) { nextP2R := NextMercuryProgradeToRetrograde(query) nextR2P := NextMercuryRetrogradeToPrograde(query) if math.Abs(nextP2R-wantP2R) > tolerance { - t.Fatalf("next P2R mismatch: got %s want %s", JDE2DateByZone(nextP2R, loc, false), JDE2DateByZone(wantP2R, loc, false)) + t.Fatalf("next P2R mismatch: got %s want %s", JD2DateByZone(nextP2R, loc, false), JD2DateByZone(wantP2R, loc, false)) } if math.Abs(nextR2P-wantR2P) > tolerance { - t.Fatalf("next R2P mismatch: got %s want %s", JDE2DateByZone(nextR2P, loc, false), JDE2DateByZone(wantR2P, loc, false)) + t.Fatalf("next R2P mismatch: got %s want %s", JD2DateByZone(nextR2P, loc, false), JD2DateByZone(wantR2P, loc, false)) } query = mercuryTTJDJST(1929, time.October, 20, 0, 0, 0) lastP2R := LastMercuryProgradeToRetrograde(query) lastR2P := LastMercuryRetrogradeToPrograde(query) if math.Abs(lastP2R-wantP2R) > tolerance { - t.Fatalf("last P2R mismatch: got %s want %s", JDE2DateByZone(lastP2R, loc, false), JDE2DateByZone(wantP2R, loc, false)) + t.Fatalf("last P2R mismatch: got %s want %s", JD2DateByZone(lastP2R, loc, false), JD2DateByZone(wantP2R, loc, false)) } if math.Abs(lastR2P-wantR2P) > tolerance { - t.Fatalf("last R2P mismatch: got %s want %s", JDE2DateByZone(lastR2P, loc, false), JDE2DateByZone(wantR2P, loc, false)) + t.Fatalf("last R2P mismatch: got %s want %s", JD2DateByZone(lastR2P, loc, false), JD2DateByZone(wantR2P, loc, false)) } } diff --git a/basic/moon.go b/basic/moon.go index 2fe393c..23fe1c1 100644 --- a/basic/moon.go +++ b/basic/moon.go @@ -5,58 +5,58 @@ import ( . "b612.me/astro/tools" ) -func MoonLo(jd float64) float64 { //'月球平黄经 - return planet.MoonLo(jd) +func MoonLo(jde float64) float64 { //'月球平黄经 + return planet.MoonLo(jde) } -func SunMoonAngle(jd float64) float64 { // '月日距角 - return planet.SunMoonAngle(jd) +func SunMoonAngle(jde float64) float64 { // '月日距角 + return planet.SunMoonAngle(jde) } -func MoonM(jd float64) float64 { // '月平近点角 - return planet.MoonM(jd) +func MoonM(jde float64) float64 { // '月平近点角 + return planet.MoonM(jde) } -func MoonLonX(jd float64) float64 { // As Double '月球经度参数(到升交点的平角距离) - return planet.MoonLonX(jd) +func MoonLonX(jde float64) float64 { // As Double '月球经度参数(到升交点的平角距离) + return planet.MoonLonX(jde) } -func MoonI(jd float64) float64 { - return planet.MoonI(jd) +func MoonI(jde float64) float64 { + return planet.MoonI(jde) } -func MoonR(jd float64) float64 { - return planet.MoonR(jd) +func MoonR(jde float64) float64 { + return planet.MoonR(jde) } -func MoonB(jd float64) float64 { - return planet.MoonB(jd) +func MoonB(jde float64) float64 { + return planet.MoonB(jde) } -func MoonTrueLo(jd float64) float64 { - return planet.MoonTrueLo(jd) +func MoonTrueLo(jde float64) float64 { + return planet.MoonTrueLo(jde) } -func MoonTrueBo(jd float64) float64 { - return planet.MoonTrueBo(jd) +func MoonTrueBo(jde float64) float64 { + return planet.MoonTrueBo(jde) } -func MoonAway(jd float64) float64 { //'月地距离 - return planet.MoonAway(jd) +func MoonAway(jde float64) float64 { //'月地距离 + return planet.MoonAway(jde) } /* * @name 月球视黄经 */ -func MoonApparentLo(jd float64) float64 { - return MoonTrueLo(jd) + Nutation2000Bi(jd) +func MoonApparentLo(jde float64) float64 { + return MoonTrueLo(jde) + Nutation2000Bi(jde) } /* * 月球真赤纬 */ -func MoonTrueDec(jd float64) float64 { - moonLo := MoonApparentLo(jd) - moonBo := MoonTrueBo(jd) - tmp := Sin(moonBo)*Cos(TrueObliquity(jd)) + Cos(moonBo)*Sin(TrueObliquity(jd))*Sin(moonLo) +func MoonTrueDec(jde float64) float64 { + moonLo := MoonApparentLo(jde) + moonBo := MoonTrueBo(jde) + tmp := Sin(moonBo)*Cos(TrueObliquity(jde)) + Cos(moonBo)*Sin(TrueObliquity(jde))*Sin(moonLo) res := ArcSin(tmp) return res } @@ -64,31 +64,31 @@ func MoonTrueDec(jd float64) float64 { /* * 月球真赤经 */ -func MoonTrueRa(jd float64) float64 { - return LoToRa(jd, MoonApparentLo(jd), MoonTrueBo(jd)) +func MoonTrueRa(jde float64) float64 { + return LoToRa(jde, MoonApparentLo(jde), MoonTrueBo(jde)) } -func MoonTrueRaDec(jd float64) (float64, float64) { - return LoBoToRaDec(jd, MoonApparentLo(jd), MoonTrueBo(jd)) +func MoonTrueRaDec(jde float64) (float64, float64) { + return LoBoToRaDec(jde, MoonApparentLo(jde), MoonTrueBo(jde)) } // MoonApparentRa 站心视赤经;jd 为当地时儒略日,tz 为时区小时数 / topocentric apparent right ascension; jd is local civil time and tz is the zone offset in hours. func MoonApparentRa(jd, lon, lat float64, tz int) float64 { - jde := TD2UT(jd, true) - utcJD := jde - float64(tz)/24.000 - ra := MoonTrueRa(utcJD) - dec := MoonTrueDec(utcJD) - away := MoonAway(utcJD) / 149597870.7 + // 本地时刻加 ΔT 再减时区偏移即该地时刻的 TT(加法可交换),故 jde 就是 TT。 + jde := UTC2TT(jd) - float64(tz)/24.000 + ra := MoonTrueRa(jde) + dec := MoonTrueDec(jde) + away := MoonAway(jde) / 149597870.7 topoRA := TopocentricRa(ra, dec, lat, lon, jd-float64(tz)/24.000, away, 0) return topoRA } func MoonApparentDec(jd, lon, lat, tz float64) float64 { - jde := TD2UT(jd, true) - utcJD := jde - tz/24 - ra := MoonTrueRa(utcJD) - dec := MoonTrueDec(utcJD) - away := MoonAway(utcJD) / 149597870.7 + // 同上:jde 是当地时刻的 TT。 + jde := UTC2TT(jd) - tz/24 + ra := MoonTrueRa(jde) + dec := MoonTrueDec(jde) + away := MoonAway(jde) / 149597870.7 topoDec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0) return topoDec } diff --git a/basic/moon_bright_limb.go b/basic/moon_bright_limb.go index 4fa1f0e..aad1a53 100644 --- a/basic/moon_bright_limb.go +++ b/basic/moon_bright_limb.go @@ -3,39 +3,39 @@ package basic import . "b612.me/astro/tools" // MoonBrightLimbPositionAngle 月亮明亮边缘位置角 / position angle of the Moon's bright limb. -func MoonBrightLimbPositionAngle(jd float64) float64 { - return MoonBrightLimbPositionAngleN(jd, -1) +func MoonBrightLimbPositionAngle(jde float64) float64 { + return MoonBrightLimbPositionAngleN(jde, -1) } // MoonBrightLimbPositionAngleN 月亮明亮边缘位置角(截断版) / truncated position angle of the Moon's bright limb. -func MoonBrightLimbPositionAngleN(jd float64, n int) float64 { - sunRA, sunDec := HSunApparentRaDecN(jd, n) - moonRA, moonDec := HMoonTrueRaDecN(jd, n) +func MoonBrightLimbPositionAngleN(jde float64, n int) float64 { + sunRA, sunDec := HSunApparentRaDecN(jde, n) + moonRA, moonDec := HMoonTrueRaDecN(jde, n) return brightLimbPositionAngleFromRaDec(sunRA, sunDec, moonRA, moonDec) } // MoonTopocentricBrightLimbPositionAngle 月亮站心明亮边缘位置角 / topocentric position angle of the Moon's bright limb. -func MoonTopocentricBrightLimbPositionAngle(jd, observerLon, observerLat, height float64) float64 { - return MoonTopocentricBrightLimbPositionAngleN(jd, observerLon, observerLat, height, -1) +func MoonTopocentricBrightLimbPositionAngle(jde, observerLon, observerLat, height float64) float64 { + return MoonTopocentricBrightLimbPositionAngleN(jde, observerLon, observerLat, height, -1) } // MoonTopocentricBrightLimbPositionAngleN 月亮站心明亮边缘位置角(截断版) / truncated topocentric position angle of the Moon's bright limb. -func MoonTopocentricBrightLimbPositionAngleN(jd, observerLon, observerLat, height float64, n int) float64 { - sunRA, sunDec := sunTopocentricApparentRaDecN(jd, observerLon, observerLat, height, n) - moonRA, moonDec := moonTopocentricApparentRaDecN(jd, observerLon, observerLat, height, n) +func MoonTopocentricBrightLimbPositionAngleN(jde, observerLon, observerLat, height float64, n int) float64 { + sunRA, sunDec := sunTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n) + moonRA, moonDec := moonTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n) return brightLimbPositionAngleFromRaDec(sunRA, sunDec, moonRA, moonDec) } -func moonTopocentricApparentRaDecN(jd, observerLon, observerLat, height float64, n int) (float64, float64) { - geocentricRA := HMoonTrueRaN(jd, n) - geocentricDec := HMoonTrueDecN(jd, n) - distanceAU := HMoonAwayN(jd, n) / moonPhysicalAstronomicalUnitKM - return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TD2UT(jd, false), distanceAU, height) +func moonTopocentricApparentRaDecN(jde, observerLon, observerLat, height float64, n int) (float64, float64) { + geocentricRA := HMoonTrueRaN(jde, n) + geocentricDec := HMoonTrueDecN(jde, n) + distanceAU := HMoonAwayN(jde, n) / moonPhysicalAstronomicalUnitKM + return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TT2UTC(jde), distanceAU, height) } -func sunTopocentricApparentRaDecN(jd, observerLon, observerLat, height float64, n int) (float64, float64) { - geocentricRA, geocentricDec := HSunApparentRaDecN(jd, n) - return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TD2UT(jd, false), EarthAwayN(jd, n), height) +func sunTopocentricApparentRaDecN(jde, observerLon, observerLat, height float64, n int) (float64, float64) { + geocentricRA, geocentricDec := HSunApparentRaDecN(jde, n) + return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TT2UTC(jde), EarthAwayN(jde, n), height) } func brightLimbPositionAngleFromRaDec(sunRA, sunDec, bodyRA, bodyDec float64) float64 { diff --git a/basic/moon_bright_limb_test.go b/basic/moon_bright_limb_test.go index f0cdd5f..adc7d07 100644 --- a/basic/moon_bright_limb_test.go +++ b/basic/moon_bright_limb_test.go @@ -11,7 +11,7 @@ func TestMoonBrightLimbPositionAngleMeeusExample(t *testing.T) { } func TestMoonBrightLimbPositionAngleNFullMatchesDefault(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) got := MoonBrightLimbPositionAngle(jd) gotN := MoonBrightLimbPositionAngleN(jd, -1) @@ -21,7 +21,7 @@ func TestMoonBrightLimbPositionAngleNFullMatchesDefault(t *testing.T) { } func TestMoonTopocentricBrightLimbPositionAngleSampleFiniteAndInRange(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) got := MoonTopocentricBrightLimbPositionAngle(jd, 121.4737, 31.2304, 4) assertFiniteRange(t, "MoonTopocentricBrightLimbPositionAngle", got, 0, 360, true) diff --git a/basic/moon_geocentric_apparent_external_test.go b/basic/moon_geocentric_apparent_external_test.go index 49b55b1..fe5fb81 100644 --- a/basic/moon_geocentric_apparent_external_test.go +++ b/basic/moon_geocentric_apparent_external_test.go @@ -34,7 +34,7 @@ func TestMoonGeocentricApparentCoordinatesMatchHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) prefix := "moon." + sample.InputUTC assertPlanetApparentAngleClose(t, prefix+".RightAscension", HMoonGeocentricApparentRa(jd), sample.RightAscension, 0.001) @@ -54,7 +54,7 @@ func TestMoonGeocentricTrueCoordinatesFollowDefinition(t *testing.T) { } for _, sample := range samples { - jd := TD2UT(Date2JDE(sample.UTC()), true) + jd := UTC2TT(Date2JD(sample.UTC())) wantRA, wantDec := LoBoToRaDec(jd, HMoonTrueLo(jd), HMoonTrueBo(jd)) gotRA, gotDec := HMoonGeocentricTrueRaDec(jd) diff --git a/basic/moon_geocentric_apparent_test.go b/basic/moon_geocentric_apparent_test.go index 4765f88..c2a6a05 100644 --- a/basic/moon_geocentric_apparent_test.go +++ b/basic/moon_geocentric_apparent_test.go @@ -6,7 +6,7 @@ import ( ) func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) { - jd := TD2UT(JDECalc(2026, 1, 1.25), true) + jd := UTC2TT(JDCalc(2026, 1, 1.25)) ra, dec := HMoonGeocentricApparentRaDec(jd) if diff := math.Abs(ra - HMoonGeocentricApparentRa(jd)); diff > 1e-12 { @@ -18,7 +18,7 @@ func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) { } func TestHMoonGeocentricTrueRaDecComponentsMatch(t *testing.T) { - jd := TD2UT(JDECalc(2026, 1, 1.25), true) + jd := UTC2TT(JDCalc(2026, 1, 1.25)) ra, dec := HMoonGeocentricTrueRaDec(jd) if diff := math.Abs(ra - HMoonGeocentricTrueRa(jd)); diff > 1e-12 { diff --git a/basic/moon_horizon.go b/basic/moon_horizon.go index 6192509..adaa66d 100644 --- a/basic/moon_horizon.go +++ b/basic/moon_horizon.go @@ -1,58 +1,15 @@ package basic -import "math" - -// MoonHorizon 返回 UT 儒略日下海平面几何月球中心地平圈(月球恰好在地平线上的观测者轨迹, +// MoonHorizon 返回 UTC 儒略日下海平面几何月球中心地平圈(月球恰好在地平线上的观测者轨迹, // 即月下点周围的地平圈)的 [经度, 纬度] 顶点,单位为度,不重复首点。视差与椭球口径同 // HMoonHeight,不含折射;与 HMoonHeight(经, 纬, ..., 0) 配合时该圈上的点高度角为 0。 // samples<=0 取 360,其余夹到 [12, 1440]。 -// MoonHorizon returns sea-level geometric Moon-centre horizon vertices in degrees for a UT Julian +// MoonHorizon returns sea-level geometric Moon-centre horizon vertices in degrees for a UTC Julian // day: the locus of observers that see the Moon exactly on the horizon. Parallax and the observer // ellipsoid match HMoonHeight; refraction is excluded. -func MoonHorizon(jdUT float64, samples int) [][2]float64 { - if !finite(jdUT) { +func MoonHorizon(jdUTC float64, samples int) [][2]float64 { + if !finite(jdUTC) { return nil } - if samples <= 0 { - samples = 360 - } - if samples < 12 { - samples = 12 - } else if samples > 1440 { - samples = 1440 - } - tt := TD2UT(jdUT, true) - ra, dec := HMoonTrueRaDec(tt) - distanceAU := HMoonAway(tt) / angularDiameterAstronomicalUnitKM - parallax := math.Sin(0.0024427777777*rad) / distanceAU - longitude := (ra - ApparentSiderealTime(jdUT)*15) * rad - latitude := dec * rad - if !finite(parallax) || parallax <= 0 || parallax >= 1 || !finite(longitude) || !finite(latitude) { - return nil - } - center := [3]float64{math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude)} - north := [3]float64{-math.Sin(latitude) * math.Cos(longitude), -math.Sin(latitude) * math.Sin(longitude), math.Cos(latitude)} - east := [3]float64{-math.Sin(longitude), math.Cos(longitude), 0} - points := make([][2]float64, samples) - for index := range points { - bearing := 2 * math.Pi * float64(index) / float64(samples) - radius := math.Acos(parallax) - var point [3]float64 - for iteration := 0; iteration < 8; iteration++ { - for axis := range point { - point[axis] = center[axis]*math.Cos(radius) + - (north[axis]*math.Cos(bearing)+east[axis]*math.Sin(bearing))*math.Sin(radius) - } - lat := math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad - // The topocentric direction is horizontal when its dot product - // with the geodetic zenith vanishes: cos(radius)=observer/range. - next := math.Acos(parallax * (pcosi(lat, 0)*math.Cos(lat*rad) + psini(lat, 0)*math.Sin(lat*rad))) - if math.Abs(next-radius) < 1e-14 { - break - } - radius = next - } - points[index] = [2]float64{math.Atan2(point[1], point[0]) / rad, math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad} - } - return points + return MoonStateAt(jdUTC).MoonHorizon(samples) } diff --git a/basic/moon_horizon_test.go b/basic/moon_horizon_test.go index 2879db5..2bdb118 100644 --- a/basic/moon_horizon_test.go +++ b/basic/moon_horizon_test.go @@ -3,10 +3,11 @@ package basic import ( "math" "testing" + "time" ) func TestMoonHorizonMatchesTopocentricAltitude(t *testing.T) { - for _, jd := range []float64{JDECalc(2026, 3, 3), JDECalc(2025, 9, 7), JDECalc(2024, 12, 15)} { + for _, jd := range []float64{JDCalc(2026, 3, 3), JDCalc(2025, 9, 7), JDCalc(2024, 12, 15)} { points := MoonHorizon(jd, 360) if len(points) != 360 { t.Fatalf("horizon points=%d", len(points)) @@ -21,3 +22,93 @@ func TestMoonHorizonMatchesTopocentricAltitude(t *testing.T) { t.Fatal("invalid JD accepted") } } + +// TestMoonHorizonUsesUTCInput 固定地平圈的时标口径:入参是 UTC 儒略日,站心恒星时按 UTC→UT1 换算。 +// 取 DUT1 明显的两个时刻,确认圈上点只在 UTC 口径下高度角为零,且两种口径在本地可区分。 +func TestMoonHorizonUsesUTCInput(t *testing.T) { + for _, at := range []time.Time{ + time.Date(1980, 3, 15, 18, 0, 0, 0, time.UTC), + time.Date(2035, 3, 15, 18, 0, 0, 0, time.UTC), + } { + jdUTC := Date2JD(at) + dut1Seconds := (UTC2UT1(jdUTC) - jdUTC) * 86400 + if math.Abs(dut1Seconds) < 0.3 { + t.Fatalf("%s DUT1=%.3f s 太小,区分不出两种口径", at.Format("2006-01-02"), dut1Seconds) + } + jdAsUT1 := jdUTC + dut1Seconds/86400 + distinguishable := false + for _, point := range MoonHorizon(jdUTC, 360) { + if altitude := HMoonHeight(jdUTC, point[0], point[1], 0); math.Abs(altitude) > 1e-9 { + t.Fatalf("%s UTC 口径下圈上点高度角=%g,应为零", at.Format("2006-01-02"), altitude) + } + if altitude := HMoonHeight(jdAsUT1, point[0], point[1], 0); math.Abs(altitude) > 1e-4 { + distinguishable = true + } + } + if !distinguishable { + t.Fatalf("%s 两种口径不可区分,用例失去意义", at.Format("2006-01-02")) + } + if MoonHorizon(jdAsUT1, 360)[0] == MoonHorizon(jdUTC, 360)[0] { + t.Fatalf("%s UTC 与 UT1 两种读法给出了同一条圈", at.Format("2006-01-02")) + } + } +} + +// TestMoonStateHorizonMatchesPackageHorizon 固定 MoonState 派生量与包级函数逐位一致,以及采样数与非法入参的兜底。 +func TestMoonStateHorizonMatchesPackageHorizon(t *testing.T) { + jd := JDCalc(2026, 3, 3) + state := MoonStateAt(jd) + for _, samples := range []int{0, 1, 12, 360, 5000} { + got := state.MoonHorizon(samples) + want := MoonHorizon(jd, samples) + if len(got) != len(want) { + t.Fatalf("samples=%d 点数 %d,包级 %d", samples, len(got), len(want)) + } + for index := range got { + if got[index] != want[index] { + t.Fatalf("samples=%d 第 %d 点 %v,包级 %v", samples, index, got[index], want[index]) + } + } + } + for _, invalid := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + if points := MoonStateAt(invalid).MoonHorizon(360); points != nil { + t.Fatalf("非有限 UTC 儒略日 %v 仍给出地平圈", invalid) + } + if points := MoonHorizon(invalid, 360); points != nil { + t.Fatalf("非有限 UTC 儒略日 %v 仍给出地平圈", invalid) + } + } + if altitude := MoonStateAt(math.NaN()).HMoonHeight(0, 0); !math.IsNaN(altitude) { + t.Fatalf("非有限状态的高度角=%v,期望 NaN", altitude) + } +} + +// TestHMoonHeightUsesLocalCivilFrame 固定 HMoonHeight 的时标框架:jd 是当地民用时(墙上时刻), +// tz 是时区偏移小时数。同一物理时刻写成「当地民用时 + tz」与「UTC 数值 + tz=0」必须一致; +// 把 UTC 数值再配非零 tz 会多减一次时区,必须能区分。 +func TestHMoonHeightUsesLocalCivilFrame(t *testing.T) { + utc := time.Date(2029, 1, 1, 16, 0, 0, 0, time.UTC) + zone := time.FixedZone("CST", 8*3600) + utcJD := Date2JD(utc) + localJD := Date2JD(utc.In(zone)) + // JD 约 2.5e6 天,双精度下 8 小时之差本身带 ~1e-8 h 的表示误差。 + if offset := (localJD - utcJD) * 24; math.Abs(offset-8) > 1e-6 { + t.Fatalf("当地民用时框架与 UTC 相差 %.9f h,期望 8 h", offset) + } + for _, site := range []struct{ lon, lat float64 }{{108.729, -59.937}, {0, 0}, {-70, 45}} { + local := HMoonHeight(localJD, site.lon, site.lat, 8) + reference := HMoonHeight(utcJD, site.lon, site.lat, 0) + if math.Abs(local-reference) > 1e-5 { + t.Fatalf("(%.3f,%.3f) 当地民用时 %.9f,UTC 数值配 tz=0 %.9f", site.lon, site.lat, local, reference) + } + } + state := MoonStateAt(utcJD) + for _, site := range []struct{ lon, lat float64 }{{108.729, -59.937}, {0, 0}, {-70, 45}} { + if state.HMoonHeight(site.lon, site.lat) != HMoonHeight(utcJD, site.lon, site.lat, 0) { + t.Fatalf("(%.3f,%.3f) MoonState 与 HMoonHeight 不一致", site.lon, site.lat) + } + } + if mixed := HMoonHeight(utcJD, 0, 0, 8) - HMoonHeight(utcJD, 0, 0, 0); math.Abs(mixed) < 0.1 { + t.Fatalf("UTC 数值配非零 tz 的差异只有 %.6f°,用例失去区分度", mixed) + } +} diff --git a/basic/moon_max_declination.go b/basic/moon_max_declination.go index 44fdccd..32c0d73 100644 --- a/basic/moon_max_declination.go +++ b/basic/moon_max_declination.go @@ -16,8 +16,8 @@ const ( // DeclinationEvent 赤纬极值事件 / declination extremum event. type DeclinationEvent struct { - // JDE 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day. - JDE float64 + // JD 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day. + JD float64 // Declination 是该时刻月心地心赤纬,单位度 / geocentric lunar declination at the event, in degrees. Declination float64 } @@ -124,8 +124,8 @@ func ClosestMoonMaximumSouthDeclination(jd float64) DeclinationEvent { func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDeclinationCoefficients) []DeclinationEvent { startUTC := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) endUTC := startUTC.AddDate(0, 1, 0) - startTT := TD2UT(Date2JDE(startUTC), true) - endTT := TD2UT(Date2JDE(endUTC), true) + startTT := UTC2TT(Date2JD(startUTC)) + endTT := UTC2TT(Date2JD(endUTC)) kStart := int(math.Floor((startTT-coeffs.JDE0)/moonMaxDeclinationMeanMonthDays)) - 1 kEnd := int(math.Ceil((endTT-coeffs.JDE0)/moonMaxDeclinationMeanMonthDays)) + 1 @@ -142,7 +142,7 @@ func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDe events := make([]DeclinationEvent, 0, 2) for k := kStart; k <= kEnd; k++ { event := moonMaximumDeclinationEvent(k, coeffs, cfg) - eventTimeUTC := JDE2DateByZone(event.JDE, time.UTC, false) + eventTimeUTC := JD2DateByZone(event.JD, time.UTC, false) if eventTimeUTC.Before(startUTC) || !eventTimeUTC.Before(endUTC) { continue } @@ -150,7 +150,7 @@ func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDe } sort.Slice(events, func(i, j int) bool { - return events[i].JDE < events[j].JDE + return events[i].JD < events[j].JD }) return events } @@ -161,7 +161,7 @@ func moonMaximumDeclinationEvent(k int, coeffs moonMaxDeclinationCoefficients, c return HMoonTrueDecN(sampleTT, -1) }) return DeclinationEvent{ - JDE: TD2UT(eventTT, false), + JD: TT2UTC(eventTT), Declination: declination, } } @@ -180,10 +180,10 @@ func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficie event := moonMaximumDeclinationEvent(centerK, coeffs, cfg) // 事件时刻随周期序号单调递增:该方向上最近的候选就是 centerK 沿该方向第一个满足方向不变量 // 的周期;命中后只需再回退检查更早的周期是否同样满足,可达范围仍旧是 ±3 个周期。 - if !moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + if !moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) { for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { event = moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg) - if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + if moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) { return event } } @@ -191,7 +191,7 @@ func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficie } for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { previous := moonMaximumDeclinationEvent(centerK-step*offset, coeffs, cfg) - if !moonMaximumDeclinationMatchesDirection(previous.JDE-jd, direction, includeCurrent) { + if !moonMaximumDeclinationMatchesDirection(previous.JD-jd, direction, includeCurrent) { break } event = previous @@ -207,15 +207,15 @@ func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoeffici centerK := moonMaximumDeclinationOffset(jd, coeffs) center := moonMaximumDeclinationEvent(centerK, coeffs, cfg) var last, next DeclinationEvent - if center.JDE <= jd { + if center.JD <= jd { last = center next = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, 1, false) } else { next = center last = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, -1, true) } - lastDistance := math.Abs(jd - last.JDE) - nextDistance := math.Abs(next.JDE - jd) + lastDistance := math.Abs(jd - last.JD) + nextDistance := math.Abs(next.JD - jd) if lastDistance <= nextDistance { return last } @@ -231,7 +231,7 @@ func moonMaximumDeclinationAround(jd float64, coeffs moonMaxDeclinationCoefficie } for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { event := moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg) - if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + if moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) { return event } } @@ -251,7 +251,7 @@ func moonMaximumDeclinationSearchConfig(coeffs moonMaxDeclinationCoefficients) a // moonMaximumDeclinationOffset 查询时刻落在哪个平均周期(种子多项式的中心序号)。 func moonMaximumDeclinationOffset(jd float64, coeffs moonMaxDeclinationCoefficients) int { - return int(math.Round((TD2UT(jd, true) - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays)) + return int(math.Round((UTC2TT(jd) - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays)) } func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includeCurrent bool) bool { @@ -272,7 +272,7 @@ func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includ } func moonMaximumDeclinationEarlier(a, b DeclinationEvent) bool { - return a.JDE < b.JDE + return a.JD < b.JD } func moonMaximumDeclinationSeedTT(k int, coeffs moonMaxDeclinationCoefficients) float64 { diff --git a/basic/moon_max_declination_perf_test.go b/basic/moon_max_declination_perf_test.go index 1b9da57..2eb2214 100644 --- a/basic/moon_max_declination_perf_test.go +++ b/basic/moon_max_declination_perf_test.go @@ -18,7 +18,7 @@ func moonMaximumDeclinationReferenceSearch(jd float64, coeffs moonMaxDeclination var best DeclinationEvent for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ { event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg) - delta := event.JDE - jd + delta := event.JD - jd if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) { continue } @@ -38,8 +38,8 @@ func moonMaximumDeclinationReferenceClosest(jd float64, coeffs moonMaxDeclinatio } last := moonMaximumDeclinationReferenceSearch(jd, coeffs, -1, true) next := moonMaximumDeclinationReferenceSearch(jd, coeffs, 1, false) - lastDistance := math.Abs(jd - last.JDE) - nextDistance := math.Abs(next.JDE - jd) + lastDistance := math.Abs(jd - last.JD) + nextDistance := math.Abs(next.JD - jd) if lastDistance <= nextDistance { return last } @@ -48,7 +48,7 @@ func moonMaximumDeclinationReferenceClosest(jd float64, coeffs moonMaxDeclinatio func TestMoonMaximumDeclinationPruningMatchesFullSearch(t *testing.T) { for _, coeffs := range []moonMaxDeclinationCoefficients{moonMaxDeclinationNorthCoefficients, moonMaxDeclinationSouthCoefficients} { - for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2025, 6, 1); jd += 2.5 { + for jd := JDCalc(2024, 1, 1); jd <= JDCalc(2025, 6, 1); jd += 2.5 { for _, direction := range []struct { name string dir int @@ -77,7 +77,7 @@ func TestMoonMaximumDeclinationPruningMatchesFullSearch(t *testing.T) { } func BenchmarkMoonMaximumDeclinationFullSearchReference(b *testing.B) { - jd := JDECalc(2025, 3, 1) + jd := JDCalc(2025, 3, 1) b.Run("Next", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = moonMaximumDeclinationReferenceSearch(jd, moonMaxDeclinationNorthCoefficients, 1, false) diff --git a/basic/moon_max_declination_test.go b/basic/moon_max_declination_test.go index 36e3d14..41177eb 100644 --- a/basic/moon_max_declination_test.go +++ b/basic/moon_max_declination_test.go @@ -79,7 +79,7 @@ func TestMoonMaximumDeclinationsMatchHorizonsBaseline(t *testing.T) { t.Fatalf("unknown declination kind %q", sample.Kind) } - gotTime := JDE2DateByZone(got.JDE, time.UTC, false) + gotTime := JD2DateByZone(got.JD, time.UTC, false) timeDiff := gotTime.Sub(wantTime) if timeDiff < 0 { timeDiff = -timeDiff @@ -124,23 +124,23 @@ func TestMoonMaximumDeclinationSignsAndOrder(t *testing.T) { if event.Declination <= 0 { t.Fatalf("north event #%d should be positive, got %.8f", i+1, event.Declination) } - if i > 0 && !(north[i-1].JDE < event.JDE) { - t.Fatalf("north events not strictly increasing: %.12f then %.12f", north[i-1].JDE, event.JDE) + if i > 0 && !(north[i-1].JD < event.JD) { + t.Fatalf("north events not strictly increasing: %.12f then %.12f", north[i-1].JD, event.JD) } } for i, event := range south { if event.Declination >= 0 { t.Fatalf("south event #%d should be negative, got %.8f", i+1, event.Declination) } - if i > 0 && !(south[i-1].JDE < event.JDE) { - t.Fatalf("south events not strictly increasing: %.12f then %.12f", south[i-1].JDE, event.JDE) + if i > 0 && !(south[i-1].JD < event.JD) { + t.Fatalf("south events not strictly increasing: %.12f then %.12f", south[i-1].JD, event.JD) } } } func TestMoonMaximumDeclinationSearchMatchesMonthlyEvents(t *testing.T) { query := time.Date(2026, time.January, 10, 0, 0, 0, 0, time.UTC) - queryJDE := Date2JDE(query) + queryJD := Date2JD(query) northEvents := append([]DeclinationEvent{}, MoonMaximumNorthDeclinations(2025, time.December)...) northEvents = append(northEvents, MoonMaximumNorthDeclinations(2026, time.January)...) @@ -150,13 +150,13 @@ func TestMoonMaximumDeclinationSearchMatchesMonthlyEvents(t *testing.T) { southEvents = append(southEvents, MoonMaximumSouthDeclinations(2026, time.January)...) southEvents = append(southEvents, MoonMaximumSouthDeclinations(2026, time.February)...) - assertSameDeclinationEvent(t, "last north", LastMoonMaximumNorthDeclination(queryJDE), expectedDirectionalDeclinationEvent(northEvents, queryJDE, -1, true)) - assertSameDeclinationEvent(t, "next north", NextMoonMaximumNorthDeclination(queryJDE), expectedDirectionalDeclinationEvent(northEvents, queryJDE, 1, false)) - assertSameDeclinationEvent(t, "closest north", ClosestMoonMaximumNorthDeclination(queryJDE), expectedClosestDeclinationEvent(northEvents, queryJDE)) + assertSameDeclinationEvent(t, "last north", LastMoonMaximumNorthDeclination(queryJD), expectedDirectionalDeclinationEvent(northEvents, queryJD, -1, true)) + assertSameDeclinationEvent(t, "next north", NextMoonMaximumNorthDeclination(queryJD), expectedDirectionalDeclinationEvent(northEvents, queryJD, 1, false)) + assertSameDeclinationEvent(t, "closest north", ClosestMoonMaximumNorthDeclination(queryJD), expectedClosestDeclinationEvent(northEvents, queryJD)) - assertSameDeclinationEvent(t, "last south", LastMoonMaximumSouthDeclination(queryJDE), expectedDirectionalDeclinationEvent(southEvents, queryJDE, -1, true)) - assertSameDeclinationEvent(t, "next south", NextMoonMaximumSouthDeclination(queryJDE), expectedDirectionalDeclinationEvent(southEvents, queryJDE, 1, false)) - assertSameDeclinationEvent(t, "closest south", ClosestMoonMaximumSouthDeclination(queryJDE), expectedClosestDeclinationEvent(southEvents, queryJDE)) + assertSameDeclinationEvent(t, "last south", LastMoonMaximumSouthDeclination(queryJD), expectedDirectionalDeclinationEvent(southEvents, queryJD, -1, true)) + assertSameDeclinationEvent(t, "next south", NextMoonMaximumSouthDeclination(queryJD), expectedDirectionalDeclinationEvent(southEvents, queryJD, 1, false)) + assertSameDeclinationEvent(t, "closest south", ClosestMoonMaximumSouthDeclination(queryJD), expectedClosestDeclinationEvent(southEvents, queryJD)) } func TestMoonMaximumDeclinationSearchAtExactEventTime(t *testing.T) { @@ -165,29 +165,29 @@ func TestMoonMaximumDeclinationSearchAtExactEventTime(t *testing.T) { t.Fatalf("expected at least two north events spanning Jan 2026 search window, got %d", len(north)) } - exactJDE := north[0].JDE - assertSameDeclinationEvent(t, "exact last north", LastMoonMaximumNorthDeclination(exactJDE), north[0]) - assertSameDeclinationEvent(t, "exact closest north", ClosestMoonMaximumNorthDeclination(exactJDE), north[0]) - assertSameDeclinationEvent(t, "exact next north", NextMoonMaximumNorthDeclination(exactJDE), north[1]) + exactJD := north[0].JD + assertSameDeclinationEvent(t, "exact last north", LastMoonMaximumNorthDeclination(exactJD), north[0]) + assertSameDeclinationEvent(t, "exact closest north", ClosestMoonMaximumNorthDeclination(exactJD), north[0]) + assertSameDeclinationEvent(t, "exact next north", NextMoonMaximumNorthDeclination(exactJD), north[1]) } func assertSameDeclinationEvent(t *testing.T, name string, got, want DeclinationEvent) { t.Helper() - if math.Abs(got.JDE-want.JDE) > 1e-12 { - t.Fatalf("%s JDE mismatch: got %.12f want %.12f", name, got.JDE, want.JDE) + if math.Abs(got.JD-want.JD) > 1e-12 { + t.Fatalf("%s JD mismatch: got %.12f want %.12f", name, got.JD, want.JD) } if math.Float64bits(got.Declination) != math.Float64bits(want.Declination) { t.Fatalf("%s declination mismatch: got %.12f want %.12f", name, got.Declination, want.Declination) } } -func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJDE float64, direction int, includeCurrent bool) DeclinationEvent { +func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJD float64, direction int, includeCurrent bool) DeclinationEvent { var ( found bool best DeclinationEvent ) for _, event := range events { - delta := event.JDE - queryJDE + delta := event.JD - queryJD if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) { continue } @@ -196,17 +196,17 @@ func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJDE flo found = true continue } - if math.Abs(delta) < math.Abs(best.JDE-queryJDE) || (math.Abs(delta) == math.Abs(best.JDE-queryJDE) && event.JDE < best.JDE) { + if math.Abs(delta) < math.Abs(best.JD-queryJD) || (math.Abs(delta) == math.Abs(best.JD-queryJD) && event.JD < best.JD) { best = event } } return best } -func expectedClosestDeclinationEvent(events []DeclinationEvent, queryJDE float64) DeclinationEvent { - last := expectedDirectionalDeclinationEvent(events, queryJDE, -1, true) - next := expectedDirectionalDeclinationEvent(events, queryJDE, 1, false) - if math.Abs(queryJDE-last.JDE) <= math.Abs(next.JDE-queryJDE) { +func expectedClosestDeclinationEvent(events []DeclinationEvent, queryJD float64) DeclinationEvent { + last := expectedDirectionalDeclinationEvent(events, queryJD, -1, true) + next := expectedDirectionalDeclinationEvent(events, queryJD, 1, false) + if math.Abs(queryJD-last.JD) <= math.Abs(next.JD-queryJD) { return last } return next diff --git a/basic/moon_observation.go b/basic/moon_observation.go index 894a3d7..a9d108c 100644 --- a/basic/moon_observation.go +++ b/basic/moon_observation.go @@ -12,14 +12,14 @@ import ( func MoonAzimuth(jd, lon, lat, tz float64) float64 { //tmp := (tz*15 - lon) * 4 / 60 - calcjd := TD2UT(jd-tz/24, true) - ra := MoonTrueRa(calcjd) - dec := MoonTrueDec(calcjd) - away := MoonAway(calcjd) / 149597870.7 + jde := UTC2TT(jd - tz/24) + ra := MoonTrueRa(jde) + dec := MoonTrueDec(jde) + away := MoonAway(jde) / 149597870.7 ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0) nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0) - calcjd = jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) + jdUT := jd - tz/24 + st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon) hourAngle := Limit360(st - nra) tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(ndec)*Cos(lat)) azimuth := ArcTan(tmp2) @@ -42,14 +42,14 @@ func MoonAzimuth(jd, lon, lat, tz float64) float64 { func MoonHeight(jd, lon, lat, tz float64) float64 { // tmp := (tz*15 - lon) * 4 / 60 //truejd=jd-tmp/24; - calcjd := TD2UT(jd-tz/24, true) - ra := MoonTrueRa(calcjd) - dec := MoonTrueDec(calcjd) - away := MoonAway(calcjd) / 149597870.7 + jde := UTC2TT(jd - tz/24) + ra := MoonTrueRa(jde) + dec := MoonTrueDec(jde) + away := MoonAway(jde) / 149597870.7 ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0) nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0) - calcjd = jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) + jdUT := jd - tz/24 + st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon) hourAngle := Limit360(st - nra) tmp2 := Sin(lat)*Sin(ndec) + Cos(ndec)*Cos(lat)*Cos(hourAngle) return ArcSin(tmp2) @@ -60,14 +60,14 @@ func HMoonAzimuth(jd, lon, lat, tz float64) float64 { } func HMoonAzimuthN(jd, lon, lat, tz float64, n int) float64 { - calcjd := TD2UT(jd-tz/24, true) - ra := HMoonTrueRaN(calcjd, n) - dec := HMoonTrueDecN(calcjd, n) - away := HMoonAwayN(calcjd, n) / 149597870.7 + jde := UTC2TT(jd - tz/24) + ra := HMoonTrueRaN(jde, n) + dec := HMoonTrueDecN(jde, n) + away := HMoonAwayN(jde, n) / 149597870.7 ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0) nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0) - calcjd = jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) + jdUT := jd - tz/24 + st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon) hourAngle := Limit360(st - nra) tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(ndec)*Cos(lat)) azimuth := ArcTan(tmp2) @@ -85,6 +85,14 @@ func HMoonAzimuthN(jd, lon, lat, tz float64, n int) float64 { } } } + +// HMoonHeight 当地民用时儒略日下的月心几何高度角(度,不含折射)/ geometric Moon-centre altitude in degrees for a local civil Julian day. +// +// jd 是该时区的当地民用时(墙上时刻)儒略日,tz 是时区偏移小时数,库内按 jd−tz/24 换成 UTC。 +// 只有 tz 给 0 时 jd 才是 UTC 儒略日;不要拿 UTC 数值再配非零 tz,那会多减一次时区。 +// jd is that zone's local civil (wall-clock) Julian day and tz is the zone offset in hours, +// converted internally as jd-tz/24. Only tz 0 makes jd a UTC Julian day: pairing a UTC value with a +// non-zero tz subtracts the offset twice. func HMoonHeight(jd, lon, lat, tz float64) float64 { return HMoonHeightN(jd, lon, lat, tz, -1) } @@ -95,12 +103,12 @@ type moonObservationState struct { } func hMoonObservationStateN(jd, lon, lat, tz, height float64, n int) moonObservationState { - calculationJD := TD2UT(jd-tz/24, true) - ra, dec := HMoonTrueRaDecN(calculationJD, n) - distanceKM := HMoonAwayN(calculationJD, n) + calculationJDE := UTC2TT(jd - tz/24) + ra, dec := HMoonTrueRaDecN(calculationJDE, n) + distanceKM := HMoonAwayN(calculationJDE, n) distanceAU := distanceKM / angularDiameterAstronomicalUnitKM topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd-tz/24, distanceAU, height) - siderealTime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon) + siderealTime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon) hourAngle := Limit360(siderealTime - topocentricRA) altitudeSine := Sin(lat)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(lat)*Cos(hourAngle) return moonObservationState{ @@ -113,6 +121,89 @@ func HMoonHeightN(jd, lon, lat, tz float64, n int) float64 { return hMoonObservationStateN(jd, lon, lat, tz, 0, n).altitude } +// MoonState 同一瞬间可对任意观测点复用的月球位置与恒星时 / one instant's lunar position and sidereal time, reusable across observers. +type MoonState struct { + rightAscension float64 + declination float64 + distanceAU float64 + siderealTime float64 +} + +// MoonStateAt 由 UTC 儒略日构造该瞬间的可复用月球状态 / builds the reusable state for one UTC Julian day. +func MoonStateAt(utcJD float64) MoonState { + jde := UTC2TT(utcJD) + rightAscension, declination := HMoonTrueRaDec(jde) + return MoonState{ + rightAscension: rightAscension, + declination: declination, + distanceAU: HMoonAway(jde) / angularDiameterAstronomicalUnitKM, + siderealTime: ApparentSiderealTime(UTC2UT1(utcJD)) * 15, + } +} + +func (state MoonState) finite() bool { + return finite(state.rightAscension) && finite(state.declination) && + finite(state.distanceAU) && finite(state.siderealTime) +} + +// HMoonHeight 给定观测者经度、纬度(度,椭球高 0)的月心几何高度角,等于 HMoonHeight(构造本状态时的 UTC 儒略日, 经, 纬, 0)。 +// HMoonHeight returns the geometric Moon-centre altitude for one observer, equal to HMoonHeight(the UTC Julian day given to MoonStateAt, lon, lat, 0). +func (state MoonState) HMoonHeight(longitude, latitude float64) float64 { + // 本状态固定是 UTC 瞬间、椭球高 0,因此只对应包级 tz=0、height=0 的用法。 + // 恒星时已在状态里算好,这里不再走会重算恒星时与时标换算的 TopocentricRaDec。 + topocentricRA, topocentricDec := topocentricRaDecWithSidereal( + state.rightAscension, state.declination, latitude, longitude, state.siderealTime, state.distanceAU, 0, + ) + hourAngle := Limit360(Limit360(state.siderealTime+longitude) - topocentricRA) + return ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle)) +} + +// MoonHorizon 用本状态生成海平面几何月心地平圈,口径同包级 MoonHorizon / sea-level geometric Moon-centre horizon ring from this state. +func (state MoonState) MoonHorizon(samples int) [][2]float64 { + if !state.finite() { + return nil + } + if samples <= 0 { + samples = 360 + } + if samples < 12 { + samples = 12 + } else if samples > 1440 { + samples = 1440 + } + parallax := math.Sin(0.0024427777777*rad) / state.distanceAU + longitude := (state.rightAscension - state.siderealTime) * rad + latitude := state.declination * rad + if !finite(parallax) || parallax <= 0 || parallax >= 1 || !finite(longitude) || !finite(latitude) { + return nil + } + center := [3]float64{math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude)} + north := [3]float64{-math.Sin(latitude) * math.Cos(longitude), -math.Sin(latitude) * math.Sin(longitude), math.Cos(latitude)} + east := [3]float64{-math.Sin(longitude), math.Cos(longitude), 0} + points := make([][2]float64, samples) + for index := range points { + bearing := 2 * math.Pi * float64(index) / float64(samples) + radius := math.Acos(parallax) + var point [3]float64 + for iteration := 0; iteration < 8; iteration++ { + for axis := range point { + point[axis] = center[axis]*math.Cos(radius) + + (north[axis]*math.Cos(bearing)+east[axis]*math.Sin(bearing))*math.Sin(radius) + } + lat := math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad + // The topocentric direction is horizontal when its dot product + // with the geodetic zenith vanishes: cos(radius)=observer/range. + next := math.Acos(parallax * (pcosi(lat, 0)*math.Cos(lat*rad) + psini(lat, 0)*math.Sin(lat*rad))) + if math.Abs(next-radius) < 1e-14 { + break + } + radius = next + } + points[index] = [2]float64{math.Atan2(point[1], point[0]) / rad, math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad} + } + return points +} + func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 { state := hMoonObservationStateN(jd, longitude, latitude, timeZone, height, n) // 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon. @@ -157,13 +248,12 @@ func GetMoonTZTime(jd, lon, lat, tz float64) float64 { //实际中天时间{ return estimateJD } -func MoonCulminationTime(jde, lon, lat, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 - //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-MoonTimeAngle(jde, lon, lat, timezone))/15.0/24.0/0.9 - limitHA := func(jde, lon, timezone float64) float64 { - ha := MoonTimeAngle(jde, lon, lat, timezone) +func MoonCulminationTime(localJD, lon, lat, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时;ra/dec 为瞬时天球坐标,非 J2000 等固定历元。 + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-MoonTimeAngle(localJD, lon, lat, timezone))/15.0/24.0/0.9 + limitHA := func(localJD, lon, timezone float64) float64 { + ha := MoonTimeAngle(localJD, lon, lat, timezone) if ha < 180 { ha += 360 } @@ -182,7 +272,7 @@ func MoonCulminationTime(jde, lon, lat, timezone float64) float64 { } func MoonTimeAngle(jd, lon, lat, tz float64) float64 { - startime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon) + startime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon) timeangle := startime - HMoonApparentRa(jd, lon, lat, tz) if timeangle < 0 { timeangle += 360 @@ -198,8 +288,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig timeZone = longitude / 15 var timeToMeridian float64 civilDayStart := math.Floor(julianDay) + 0.5 - //julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE - //fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错 + // 时间分界线以传入的时区为准,不用当地时区,否则 0 时的判断会出错。 julianDay = math.Floor(julianDay) + 0.5 estimatedTime := julianDay moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1) @@ -282,8 +371,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh timeZone = longitude / 15 var timeToMeridian float64 civilDayStart := math.Floor(julianDay) + 0.5 - //julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE - //fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错 + // 时间分界线以传入的时区为准,不用当地时区,否则 0 时的判断会出错。 julianDay = math.Floor(julianDay) + 0.5 estimatedTime := julianDay moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1) diff --git a/basic/moon_phase.go b/basic/moon_phase.go index abb1548..f4a9358 100644 --- a/basic/moon_phase.go +++ b/basic/moon_phase.go @@ -6,13 +6,13 @@ import ( . "b612.me/astro/tools" ) -func MoonPhase(jd float64) float64 { - moonBo := HMoonTrueBo(jd) - sunLo := HSunApparentLo(jd) - moonLo := HMoonApparentLo(jd) +func MoonPhase(jde float64) float64 { + moonBo := HMoonTrueBo(jde) + sunLo := HSunApparentLo(jde) + moonLo := HMoonApparentLo(jde) tmp := Cos(moonBo) * Cos(sunLo-moonLo) - earthSunDistance := Distance(jd) * 149597870.691 - i := earthSunDistance * Sin(ArcCos(tmp)) / (HMoonAway(jd) - earthSunDistance*tmp) + earthSunDistance := Distance(jde) * 149597870.691 + i := earthSunDistance * Sin(ArcCos(tmp)) / (HMoonAway(jde) - earthSunDistance*tmp) i = ArcTan(i) if i < 0 { i += 180 @@ -197,8 +197,6 @@ func CalcMoonX(year float64, quarterType int) float64 { A14 := 331.55 + 3.592518*k planetaryCorrection := 325*Sin(A1) + 165*Sin(A2) + 164*Sin(A3) + 126*Sin(A4) + 110*Sin(A5) + 62*Sin(A6) + 60*Sin(A7) + 56*Sin(A8) + 47*Sin(A9) + 42*Sin(A10) + 40*Sin(A11) + 37*Sin(A12) + 35*Sin(A13) + 23*Sin(A14) planetaryCorrection /= 1000000 - //die(tmp2); - //die(JDE." ".tmp." ".tmp2." ".W); jde = jde + planetaryCorrection + correction if quarterType == 0 { jde += W diff --git a/basic/moon_physical.go b/basic/moon_physical.go index 4033683..53ab674 100644 --- a/basic/moon_physical.go +++ b/basic/moon_physical.go @@ -28,35 +28,35 @@ type MoonPhysicalInfo struct { } // MoonPhysical 月球物理观测参数 / physical observing parameters of the Moon. -func MoonPhysical(jd float64) MoonPhysicalInfo { - return MoonPhysicalN(jd, -1) +func MoonPhysical(jde float64) MoonPhysicalInfo { + return MoonPhysicalN(jde, -1) } // MoonPhysicalN 月球物理观测参数(截断版) / truncated physical observing parameters of the Moon. -func MoonPhysicalN(jd float64, n int) MoonPhysicalInfo { - return moonPhysicalNFromCoordinates(jd, n, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n), HMoonTrueRaN(jd, n)) +func MoonPhysicalN(jde float64, n int) MoonPhysicalInfo { + return moonPhysicalNFromCoordinates(jde, n, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n), HMoonTrueRaN(jde, n)) } // MoonTopocentricPhysical 月球站心物理观测参数 / topocentric physical observing parameters of the Moon. -func MoonTopocentricPhysical(jd, observerLon, observerLat, height float64) MoonPhysicalInfo { - return MoonTopocentricPhysicalN(jd, observerLon, observerLat, height, -1) +func MoonTopocentricPhysical(jde, observerLon, observerLat, height float64) MoonPhysicalInfo { + return MoonTopocentricPhysicalN(jde, observerLon, observerLat, height, -1) } // MoonTopocentricPhysicalN 月球站心物理观测参数(截断版) / truncated topocentric physical observing parameters of the Moon. -func MoonTopocentricPhysicalN(jd, observerLon, observerLat, height float64, n int) MoonPhysicalInfo { - lambda, beta, alpha := moonTopocentricPhysicalCoordinatesN(jd, observerLon, observerLat, height, n) - return moonPhysicalNFromCoordinates(jd, n, lambda, beta, alpha) +func MoonTopocentricPhysicalN(jde, observerLon, observerLat, height float64, n int) MoonPhysicalInfo { + lambda, beta, alpha := moonTopocentricPhysicalCoordinatesN(jde, observerLon, observerLat, height, n) + return moonPhysicalNFromCoordinates(jde, n, lambda, beta, alpha) } -func moonPhysicalNFromCoordinates(jd float64, n int, lambda, beta, alpha float64) MoonPhysicalInfo { - t := (jd - 2451545.0) / 36525.0 - epsilon := TrueObliquity(jd) - deltaPsi := Nutation2000Bi(jd) +func moonPhysicalNFromCoordinates(jde float64, n int, lambda, beta, alpha float64) MoonPhysicalInfo { + t := (jde - 2451545.0) / 36525.0 + epsilon := TrueObliquity(jde) + deltaPsi := Nutation2000Bi(jde) - D := Limit360(SunMoonAngle(jd)) - sunMeanAnomaly := Limit360(SunM(jd)) - moonMeanAnomaly := Limit360(MoonM(jd)) - F := Limit360(MoonLonX(jd)) + D := Limit360(SunMoonAngle(jde)) + sunMeanAnomaly := Limit360(SunM(jde)) + moonMeanAnomaly := Limit360(MoonM(jde)) + F := Limit360(MoonLonX(jde)) omega := moonPhysicalMeanAscendingNode(t) E := 1 - 0.002516*t - 0.0000074*t*t K1 := 119.75 + 131.849*t @@ -91,15 +91,15 @@ func moonPhysicalNFromCoordinates(jd float64, n int, lambda, beta, alpha float64 } } -func moonTopocentricPhysicalCoordinatesN(jd, observerLon, observerLat, height float64, n int) (lambda, beta, alpha float64) { - geocentricRA := HMoonTrueRaN(jd, n) - geocentricDec := HMoonTrueDecN(jd, n) - distanceAU := HMoonAwayN(jd, n) / moonPhysicalAstronomicalUnitKM - utJD := TD2UT(jd, false) +func moonTopocentricPhysicalCoordinatesN(jde, observerLon, observerLat, height float64, n int) (lambda, beta, alpha float64) { + geocentricRA := HMoonTrueRaN(jde, n) + geocentricDec := HMoonTrueDecN(jde, n) + distanceAU := HMoonAwayN(jde, n) / moonPhysicalAstronomicalUnitKM + utcJD := TT2UTC(jde) var topocentricDec float64 - alpha, topocentricDec = TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, utJD, distanceAU, height) - lambda, beta = RaDecToLoBo(jd, alpha, topocentricDec) + alpha, topocentricDec = TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, utcJD, distanceAU, height) + lambda, beta = RaDecToLoBo(jde, alpha, topocentricDec) return } diff --git a/basic/moon_physical_test.go b/basic/moon_physical_test.go index 24e74ba..4c3fa98 100644 --- a/basic/moon_physical_test.go +++ b/basic/moon_physical_test.go @@ -43,7 +43,7 @@ func TestMoonPhysicalSampleSweepFiniteAndInRange(t *testing.T) { } for _, date := range dates { - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) info := MoonPhysical(jd) prefix := date.Format(time.RFC3339) diff --git a/basic/moon_planet_conjunction.go b/basic/moon_planet_conjunction.go index c70384e..8d85a66 100644 --- a/basic/moon_planet_conjunction.go +++ b/basic/moon_planet_conjunction.go @@ -184,7 +184,7 @@ func moonPlanetConjunctionEventUT(leftTT, rightTT float64, planet MoonPlanetConj if math.Abs(moonPlanetConjunctionDeltaAt(eventTT, planet, -1)) > moonPlanetConjunctionEventTolerance { return math.NaN() } - return TD2UT(eventTT, false) + return TT2UTC(eventTT) } func moonPlanetConjunctionCollectLocalEvent(result *moonPlanetConjunctionLocalResult, queryTT, eventUT float64) { diff --git a/basic/moon_planet_conjunction_external_test.go b/basic/moon_planet_conjunction_external_test.go index 689a788..00dad7f 100644 --- a/basic/moon_planet_conjunction_external_test.go +++ b/basic/moon_planet_conjunction_external_test.go @@ -92,9 +92,9 @@ func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err) } - queryTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true) + queryTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC())) gotUT := tc.next(queryTT, tc.planet) - gotTime := JDE2DateByZone(gotUT, time.UTC, false) + gotTime := JD2DateByZone(gotUT, time.UTC, false) diff := gotTime.Sub(wantTime) if diff < 0 { diff = -diff @@ -106,7 +106,7 @@ func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) { t.Fatalf("%s %04d-%02d time mismatch: got %s want %s tolerance %v", sample.Planet, sample.Year, sample.Month, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, tolerance) } - delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), tc.planet, -1)) + delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), tc.planet, -1)) if delta > 0.01 { t.Fatalf("%s %04d-%02d event not near conjunction: delta=%.8f deg", sample.Planet, sample.Year, sample.Month, delta) } @@ -144,11 +144,11 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T if err != nil { t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err) } - seedTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true) + seedTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC())) eventUT := NextMoonPlanetConjunction(seedTT, planet) - eventTime := JDE2DateByZone(eventUT, time.UTC, false) - queryAtTT := TD2UT(Date2JDE(eventTime.UTC()), true) - queryAfterTT := TD2UT(Date2JDE(eventTime.Add(time.Hour).UTC()), true) + eventTime := JD2DateByZone(eventUT, time.UTC, false) + queryAtTT := UTC2TT(Date2JD(eventTime.UTC())) + queryAfterTT := UTC2TT(Date2JD(eventTime.Add(time.Hour).UTC())) exactNext := NextMoonPlanetConjunction(queryAtTT, planet) exactClosest := ClosestMoonPlanetConjunction(queryAtTT, planet) @@ -159,7 +159,7 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T "exactClosest": exactClosest, "lastAfterEvent": exactLastAfter, } { - gotTime := JDE2DateByZone(gotUT, time.UTC, false) + gotTime := JD2DateByZone(gotUT, time.UTC, false) if diff := math.Abs(gotUT - eventUT); diff > 1e-9 { t.Fatalf("%s %s mismatch: got %s want %s diff=%v", sample.Planet, name, gotTime.Format(time.RFC3339Nano), eventTime.Format(time.RFC3339Nano), diff*86400) } @@ -169,25 +169,25 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T func TestMoonPlanetConjunctionRejectsOppositionBranchJump(t *testing.T) { query := time.Date(1900, 11, 10, 12, 0, 0, 0, time.UTC) - queryTT := TD2UT(Date2JDE(query), true) + queryTT := UTC2TT(Date2JD(query)) lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn) nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn) - if math.Abs(lastUT-Date2JDE(query)) <= 5.0/86400.0 { - t.Fatalf("last returned query time on branch jump: got %s", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano)) + if math.Abs(lastUT-Date2JD(query)) <= 5.0/86400.0 { + t.Fatalf("last returned query time on branch jump: got %s", JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano)) } - if math.Abs(nextUT-Date2JDE(query)) <= 5.0/86400.0 { - t.Fatalf("next returned query time on branch jump: got %s", JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano)) + if math.Abs(nextUT-Date2JD(query)) <= 5.0/86400.0 { + t.Fatalf("next returned query time on branch jump: got %s", JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano)) } for name, gotUT := range map[string]float64{ "last": lastUT, "next": nextUT, } { - delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), MoonPlanetConjunctionSaturn, -1)) + delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), MoonPlanetConjunctionSaturn, -1)) if delta > moonPlanetConjunctionEventTolerance { - t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano)) + t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JD2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano)) } } } @@ -208,7 +208,7 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) { } for _, sample := range samples { - queryTT := TD2UT(Date2JDE(sample.query.UTC()), true) + queryTT := UTC2TT(Date2JD(sample.query.UTC())) lastUT := LastMoonPlanetConjunction(queryTT, sample.planet) nextUT := NextMoonPlanetConjunction(queryTT, sample.planet) closestUT := ClosestMoonPlanetConjunction(queryTT, sample.planet) @@ -217,22 +217,22 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) { t.Fatalf("planet=%v query=%s returned NaN event(s): last=%v next=%v closest=%v", sample.planet, sample.query.Format(time.RFC3339), lastUT, nextUT, closestUT) } if !eventUTQueryBeforeOrEqual(lastUT, queryTT) { - t.Fatalf("planet=%v last after query: last=%s query=%s", sample.planet, JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano)) + t.Fatalf("planet=%v last after query: last=%s query=%s", sample.planet, JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano)) } if !eventUTQueryAfterOrEqual(nextUT, queryTT) { - t.Fatalf("planet=%v next before query: next=%s query=%s", sample.planet, JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano)) + t.Fatalf("planet=%v next before query: next=%s query=%s", sample.planet, JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano)) } if closestUT != closestEventUTToQueryTT(queryTT, lastUT, nextUT) { - t.Fatalf("planet=%v closest mismatch: got=%s want=%s", sample.planet, JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(closestEventUTToQueryTT(queryTT, lastUT, nextUT), time.UTC, false).Format(time.RFC3339Nano)) + t.Fatalf("planet=%v closest mismatch: got=%s want=%s", sample.planet, JD2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JD2DateByZone(closestEventUTToQueryTT(queryTT, lastUT, nextUT), time.UTC, false).Format(time.RFC3339Nano)) } for name, gotUT := range map[string]float64{ "last": lastUT, "next": nextUT, "closest": closestUT, } { - delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), sample.planet, -1)) + delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), sample.planet, -1)) if delta > moonPlanetConjunctionEventTolerance { - t.Fatalf("planet=%v %s returned non-event candidate: delta=%.8f event=%s", sample.planet, name, delta, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano)) + t.Fatalf("planet=%v %s returned non-event candidate: delta=%.8f event=%s", sample.planet, name, delta, JD2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano)) } } } @@ -240,7 +240,7 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) { func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) { query := time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC) - queryTT := TD2UT(Date2JDE(query.UTC()), true) + queryTT := UTC2TT(Date2JD(query.UTC())) lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn) nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn) @@ -250,35 +250,35 @@ func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) { wantNext := time.Date(1700, 5, 13, 0, 35, 5, 981616675, time.UTC) const tolerance = 5.0 / 86400.0 - if diff := math.Abs(lastUT - Date2JDE(wantLast)); diff > tolerance { - t.Fatalf("last mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), wantLast.Format(time.RFC3339Nano), diff*86400) + if diff := math.Abs(lastUT - Date2JD(wantLast)); diff > tolerance { + t.Fatalf("last mismatch: got=%s want=%s diff=%.3fs", JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), wantLast.Format(time.RFC3339Nano), diff*86400) } - if diff := math.Abs(nextUT - Date2JDE(wantNext)); diff > tolerance { - t.Fatalf("next mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), wantNext.Format(time.RFC3339Nano), diff*86400) + if diff := math.Abs(nextUT - Date2JD(wantNext)); diff > tolerance { + t.Fatalf("next mismatch: got=%s want=%s diff=%.3fs", JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), wantNext.Format(time.RFC3339Nano), diff*86400) } if !sameEventJD(closestUT, lastUT) { - t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano)) + t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JD2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano)) } } func TestMoonPlanetConjunctionNextAdvancesPastReturnedEvent(t *testing.T) { - seed := TD2UT(Date2JDE(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)), true) + seed := UTC2TT(Date2JD(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC))) eventUT := NextMoonPlanetConjunction(seed, MoonPlanetConjunctionMercury) - query := JDE2DateByZone(eventUT, time.UTC, false).Add(time.Second) - queryTT := TD2UT(Date2JDE(query.UTC()), true) + query := JD2DateByZone(eventUT, time.UTC, false).Add(time.Second) + queryTT := UTC2TT(Date2JD(query.UTC())) nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionMercury) if eventUTQueryTTDelta(nextUT, queryTT) <= 0 { t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%.6fs", query.Format(time.RFC3339Nano), - JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), + JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), eventUTQueryTTDelta(nextUT, queryTT)*86400, ) } if sameEventJD(nextUT, eventUT) { t.Fatalf("next conjunction should advance to a later event: event=%s next=%s", - JDE2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano), - JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), + JD2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano), + JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), ) } } diff --git a/basic/moon_precision.go b/basic/moon_precision.go index c7af26b..f19f3e3 100644 --- a/basic/moon_precision.go +++ b/basic/moon_precision.go @@ -134,8 +134,8 @@ func moonReducedLinearPhase(offset, rate, t, tLow float64) float64 { return math.FMA(-turns, 2*math.Pi, product) + (roundoff - turns*twoPiLow) + offset } -func HMoonTrueLo(jd float64) float64 { //计算月亮 - return HMoonTrueLoN(jd, -1) +func HMoonTrueLo(jde float64) float64 { //计算月亮 + return HMoonTrueLoN(jde, -1) } func HMoonTrueLoN(jd float64, n int) float64 { //计算月亮 @@ -143,8 +143,8 @@ func HMoonTrueLoN(jd float64, n int) float64 { //计算月亮 return Limit360(v) } -func HMoonTrueBo(jd float64) float64 { - return HMoonTrueBoN(jd, -1) +func HMoonTrueBo(jde float64) float64 { + return HMoonTrueBoN(jde, -1) } func HMoonTrueBoN(jd float64, n int) float64 { @@ -152,8 +152,8 @@ func HMoonTrueBoN(jd float64, n int) float64 { return v } -func HMoonAway(jd float64) float64 { //'月地距离 - return HMoonAwayN(jd, -1) +func HMoonAway(jde float64) float64 { //'月地距离 + return HMoonAwayN(jde, -1) } func HMoonAwayN(jd float64, n int) float64 { //'月地距离 @@ -163,95 +163,95 @@ func HMoonAwayN(jd float64, n int) float64 { //'月地距离 /* * @name 月球视黄经 */ -func HMoonApparentLo(jd float64) float64 { - return HMoonApparentLoN(jd, -1) +func HMoonApparentLo(jde float64) float64 { + return HMoonApparentLoN(jde, -1) } -func HMoonApparentLoN(jd float64, n int) float64 { - return HMoonTrueLoN(jd, n) + Nutation2000Bi(jd) +func HMoonApparentLoN(jde float64, n int) float64 { + return HMoonTrueLoN(jde, n) + Nutation2000Bi(jde) } // HMoonGeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension of the Moon. -func HMoonGeocentricApparentRa(jd float64) float64 { - return HMoonGeocentricApparentRaN(jd, -1) +func HMoonGeocentricApparentRa(jde float64) float64 { + return HMoonGeocentricApparentRaN(jde, -1) } // HMoonGeocentricApparentRaN 月亮地心视赤经(截断版) / truncated apparent geocentric right ascension of the Moon. -func HMoonGeocentricApparentRaN(jd float64, n int) float64 { - return LoToRa(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonGeocentricApparentRaN(jde float64, n int) float64 { + return LoToRa(jde, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n)) } // HMoonGeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination of the Moon. -func HMoonGeocentricApparentDec(jd float64) float64 { - return HMoonGeocentricApparentDecN(jd, -1) +func HMoonGeocentricApparentDec(jde float64) float64 { + return HMoonGeocentricApparentDecN(jde, -1) } // HMoonGeocentricApparentDecN 月亮地心视赤纬(截断版) / truncated apparent geocentric declination of the Moon. -func HMoonGeocentricApparentDecN(jd float64, n int) float64 { - return ArcSin(Sin(HMoonTrueBoN(jd, n))*Cos(TrueObliquity(jd)) + - Cos(HMoonTrueBoN(jd, n))*Sin(TrueObliquity(jd))*Sin(HMoonApparentLoN(jd, n))) +func HMoonGeocentricApparentDecN(jde float64, n int) float64 { + return ArcSin(Sin(HMoonTrueBoN(jde, n))*Cos(TrueObliquity(jde)) + + Cos(HMoonTrueBoN(jde, n))*Sin(TrueObliquity(jde))*Sin(HMoonApparentLoN(jde, n))) } // HMoonGeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination of the Moon. -func HMoonGeocentricApparentRaDec(jd float64) (float64, float64) { - return HMoonGeocentricApparentRaDecN(jd, -1) +func HMoonGeocentricApparentRaDec(jde float64) (float64, float64) { + return HMoonGeocentricApparentRaDecN(jde, -1) } // HMoonGeocentricApparentRaDecN 月亮地心视赤经、视赤纬(截断版) / truncated apparent geocentric right ascension and declination of the Moon. -func HMoonGeocentricApparentRaDecN(jd float64, n int) (float64, float64) { - return LoBoToRaDec(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonGeocentricApparentRaDecN(jde float64, n int) (float64, float64) { + return LoBoToRaDec(jde, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n)) } // HMoonGeocentricTrueRa 月亮地心真赤经 / true geocentric right ascension of the Moon. -func HMoonGeocentricTrueRa(jd float64) float64 { - return HMoonGeocentricTrueRaN(jd, -1) +func HMoonGeocentricTrueRa(jde float64) float64 { + return HMoonGeocentricTrueRaN(jde, -1) } // HMoonGeocentricTrueRaN 月亮地心真赤经(截断版) / truncated true geocentric right ascension of the Moon. -func HMoonGeocentricTrueRaN(jd float64, n int) float64 { - return LoToRa(jd, HMoonTrueLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonGeocentricTrueRaN(jde float64, n int) float64 { + return LoToRa(jde, HMoonTrueLoN(jde, n), HMoonTrueBoN(jde, n)) } // HMoonGeocentricTrueDec 月亮地心真赤纬 / true geocentric declination of the Moon. -func HMoonGeocentricTrueDec(jd float64) float64 { - return HMoonGeocentricTrueDecN(jd, -1) +func HMoonGeocentricTrueDec(jde float64) float64 { + return HMoonGeocentricTrueDecN(jde, -1) } // HMoonGeocentricTrueDecN 月亮地心真赤纬(截断版) / truncated true geocentric declination of the Moon. -func HMoonGeocentricTrueDecN(jd float64, n int) float64 { - return ArcSin(Sin(HMoonTrueBoN(jd, n))*Cos(TrueObliquity(jd)) + - Cos(HMoonTrueBoN(jd, n))*Sin(TrueObliquity(jd))*Sin(HMoonTrueLoN(jd, n))) +func HMoonGeocentricTrueDecN(jde float64, n int) float64 { + return ArcSin(Sin(HMoonTrueBoN(jde, n))*Cos(TrueObliquity(jde)) + + Cos(HMoonTrueBoN(jde, n))*Sin(TrueObliquity(jde))*Sin(HMoonTrueLoN(jde, n))) } // HMoonGeocentricTrueRaDec 月亮地心真赤经、真赤纬 / true geocentric right ascension and declination of the Moon. -func HMoonGeocentricTrueRaDec(jd float64) (float64, float64) { - return HMoonGeocentricTrueRaDecN(jd, -1) +func HMoonGeocentricTrueRaDec(jde float64) (float64, float64) { + return HMoonGeocentricTrueRaDecN(jde, -1) } // HMoonGeocentricTrueRaDecN 月亮地心真赤经、真赤纬(截断版) / truncated true geocentric right ascension and declination of the Moon. -func HMoonGeocentricTrueRaDecN(jd float64, n int) (float64, float64) { - return LoBoToRaDec(jd, HMoonTrueLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonGeocentricTrueRaDecN(jde float64, n int) (float64, float64) { + return LoBoToRaDec(jde, HMoonTrueLoN(jde, n), HMoonTrueBoN(jde, n)) } -func HMoonTrueRaDec(jd float64) (float64, float64) { - return HMoonTrueRaDecN(jd, -1) +func HMoonTrueRaDec(jde float64) (float64, float64) { + return HMoonTrueRaDecN(jde, -1) } -func HMoonTrueRaDecN(jd float64, n int) (float64, float64) { - return LoBoToRaDec(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonTrueRaDecN(jde float64, n int) (float64, float64) { + return LoBoToRaDec(jde, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n)) } /* * 月球真赤纬 */ -func HMoonTrueDec(jd float64) float64 { - return HMoonTrueDecN(jd, -1) +func HMoonTrueDec(jde float64) float64 { + return HMoonTrueDecN(jde, -1) } -func HMoonTrueDecN(jd float64, n int) float64 { - moonLo := HMoonApparentLoN(jd, n) - moonBo := HMoonTrueBoN(jd, n) - tmp := Sin(moonBo)*Cos(TrueObliquity(jd)) + Cos(moonBo)*Sin(TrueObliquity(jd))*Sin(moonLo) +func HMoonTrueDecN(jde float64, n int) float64 { + moonLo := HMoonApparentLoN(jde, n) + moonBo := HMoonTrueBoN(jde, n) + tmp := Sin(moonBo)*Cos(TrueObliquity(jde)) + Cos(moonBo)*Sin(TrueObliquity(jde))*Sin(moonLo) res := ArcSin(tmp) return res } @@ -259,12 +259,12 @@ func HMoonTrueDecN(jd float64, n int) float64 { /* * 月球真赤经 */ -func HMoonTrueRa(jd float64) float64 { - return HMoonTrueRaN(jd, -1) +func HMoonTrueRa(jde float64) float64 { + return HMoonTrueRaN(jde, -1) } -func HMoonTrueRaN(jd float64, n int) float64 { - return LoToRa(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n)) +func HMoonTrueRaN(jde float64, n int) float64 { + return LoToRa(jde, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n)) } /* @@ -275,7 +275,7 @@ func HMoonApparentRaDec(jd, lon, lat, tz float64) (float64, float64) { } func HMoonApparentRaDecN(jd, lon, lat, tz float64, n int) (float64, float64) { - jde := TD2UT(jd, true) + jde := UTC2TT(jd) ra := HMoonTrueRaN(jde-tz/24, n) dec := HMoonTrueDecN(jde-tz/24, n) away := HMoonAwayN(jde-tz/24, n) / 149597870.7 @@ -288,10 +288,10 @@ func HMoonApparentRa(jd, lon, lat, tz float64) float64 { } func HMoonApparentRaN(jd, lon, lat, tz float64, n int) float64 { - jde := TD2UT(jd, true) - ra := HMoonTrueRaN(jde-tz/24, n) - dec := HMoonTrueDecN(jde-tz/24, n) - away := HMoonAwayN(jde-tz/24, n) / 149597870.7 + jde := UTC2TT(jd) - tz/24 + ra := HMoonTrueRaN(jde, n) + dec := HMoonTrueDecN(jde, n) + away := HMoonAwayN(jde, n) / 149597870.7 topoRA := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0) return topoRA } @@ -300,10 +300,10 @@ func HMoonApparentDec(jd, lon, lat, tz float64) float64 { } func HMoonApparentDecN(jd, lon, lat, tz float64, n int) float64 { - jde := TD2UT(jd, true) - ra := HMoonTrueRaN(jde-tz/24, n) - dec := HMoonTrueDecN(jde-tz/24, n) - away := HMoonAwayN(jde-tz/24, n) / 149597870.7 + jde := UTC2TT(jd) - tz/24 + ra := HMoonTrueRaN(jde, n) + dec := HMoonTrueDecN(jde, n) + away := HMoonAwayN(jde, n) / 149597870.7 topoDec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0) return topoDec } diff --git a/basic/moon_rise_set_convention_test.go b/basic/moon_rise_set_convention_test.go index 1bae94b..b9a9861 100644 --- a/basic/moon_rise_set_convention_test.go +++ b/basic/moon_rise_set_convention_test.go @@ -39,10 +39,10 @@ func TestMoonRiseSetMissingEventConvention(t *testing.T) { lon, lat, tz float64 riseErr, setErr error }{ - {"极昼:全天在地平线上", JDECalc(2023, 6, 21), 0, 85, 0, ErrNeverSet, ErrNeverSet}, - {"极夜:全天在地平线下", JDECalc(2023, 12, 22), 0, -85, 0, ErrNeverRise, ErrNeverRise}, - {"当日无升起但别日有", JDECalc(2024, 2, 29), 0, 60, 0, ErrNotOnThisDate, nil}, - {"正常日两侧都有", JDECalc(2025, 6, 5), 116.4074, 39.9042, 8, nil, nil}, + {"极昼:全天在地平线上", JDCalc(2023, 6, 21), 0, 85, 0, ErrNeverSet, ErrNeverSet}, + {"极夜:全天在地平线下", JDCalc(2023, 12, 22), 0, -85, 0, ErrNeverRise, ErrNeverRise}, + {"当日无升起但别日有", JDCalc(2024, 2, 29), 0, 60, 0, ErrNotOnThisDate, nil}, + {"正常日两侧都有", JDCalc(2025, 6, 5), 116.4074, 39.9042, 8, nil, nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -58,8 +58,8 @@ func TestMoonRiseSetMissingEventConvention(t *testing.T) { func TestMoonRiseSetMissingEventMatchesDailyGeometry(t *testing.T) { dates := []float64{ - JDECalc(2023, 6, 21), JDECalc(2023, 12, 22), JDECalc(2024, 2, 29), - JDECalc(2025, 6, 21), JDECalc(2025, 12, 22), JDECalc(2026, 3, 3), + JDCalc(2023, 6, 21), JDCalc(2023, 12, 22), JDCalc(2024, 2, 29), + JDCalc(2025, 6, 21), JDCalc(2025, 12, 22), JDCalc(2026, 3, 3), } latitudes := []float64{-89, -85, -75, -66, -60, 60, 66, 75, 85, 89} for _, jd := range dates { diff --git a/basic/moon_rise_set_external_test.go b/basic/moon_rise_set_external_test.go index 10c08ea..8750170 100644 --- a/basic/moon_rise_set_external_test.go +++ b/basic/moon_rise_set_external_test.go @@ -113,7 +113,7 @@ func TestMoonRiseSetMatchesExternalBaselines(t *testing.T) { if err != nil { t.Fatalf("parse %s date %q: %v", sample.Site, sample.DateUTC, err) } - jd := Date2JDE(day) + jd := Date2JD(day) currentRiseJD, err := GetMoonRiseTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight) if err != nil { t.Fatalf("%s current moonrise: %v", sample.Site, err) @@ -160,7 +160,7 @@ func TestMoonRiseSetLegacyComparatorMatchesPreFixSnapshot(t *testing.T) { SetDeltaTFn(DefaultDeltaTv2) defer SetDeltaTFn(previousDeltaT) - jd := JDECalc(2023, 1, 15) + jd := JDCalc(2023, 1, 15) currentRise, err := GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0) if err != nil { t.Fatalf("current moonrise: %v", err) @@ -206,8 +206,8 @@ func compareMoonRiseSetEvent(t *testing.T, name string, currentJD, legacyJD floa horizonsUTC, metUTC, imcceUTC string, tolerances moonRiseSetExternalTolerances, stats *moonRiseSetComparisonStats) { t.Helper() - current := JDE2DateByZone(currentJD, time.UTC, false) - legacy := JDE2DateByZone(legacyJD, time.UTC, false) + current := JD2DateByZone(currentJD, time.UTC, false) + legacy := JD2DateByZone(legacyJD, time.UTC, false) horizons := parseMoonRiseSetExternalTime(t, name+".jpl", horizonsUTC) met := parseMoonRiseSetExternalTime(t, name+".met", metUTC) imcce := parseMoonRiseSetExternalTime(t, name+".imcce", imcceUTC) @@ -301,7 +301,7 @@ func legacyMoonRiseSetFromCurrent(currentJD, longitude, latitude, timeZone, zeni } func legacyHMoonHeight(jd, longitude, latitude, timeZone float64) float64 { - calculationJD := TD2UT(jd-timeZone/24, true) + calculationJD := UTC2TT(jd - timeZone/24) ra, dec := HMoonTrueRaDecN(calculationJD, -1) distanceAU := HMoonAwayN(calculationJD, -1) / 149597870.7 topocentricRA, topocentricDec := legacyTopocentricRaDec(ra, dec, latitude, longitude, calculationJD, distanceAU, 0) @@ -316,7 +316,7 @@ func legacyTopocentricRaDec(ra, dec, latitude, longitude, jd, distanceAU, height horizontalParallaxSine := tools.Sin(0.0024427777777) / distanceAU observerCosine := pcosi(latitude, height) observerSine := psini(latitude, height) - hourAngle := tools.Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + longitude - ra) + hourAngle := tools.Limit360(TT2UTC(ApparentSiderealTime(jd))*15 + longitude - ra) raCorrection := math.Atan2(-observerCosine*horizontalParallaxSine*tools.Sin(hourAngle), tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi correctedDec := math.Atan2((tools.Sin(dec)-observerSine*horizontalParallaxSine)*tools.Cos(raCorrection), diff --git a/basic/moon_state_test.go b/basic/moon_state_test.go new file mode 100644 index 0000000..e45b39d --- /dev/null +++ b/basic/moon_state_test.go @@ -0,0 +1,17 @@ +package basic + +import "testing" + +// TestMoonStateMatchesHMoonHeight 固定 MoonState 与 HMoonHeight 逐位同口径。 +func TestMoonStateMatchesHMoonHeight(t *testing.T) { + for _, jd := range []float64{2462502.5, 2416745.5, 2469807.75, 2378496.25} { + state := MoonStateAt(jd) + for lon := -180.0; lon < 180; lon += 23.5 { + for lat := -89.5; lat <= 89.5; lat += 7.5 { + if got, want := state.HMoonHeight(lon, lat), HMoonHeight(jd, lon, lat, 0); got != want { + t.Fatalf("jd=%v lon=%v lat=%v got %v want %v", jd, lon, lat, got, want) + } + } + } + } +} diff --git a/basic/moon_test.go b/basic/moon_test.go index bfac47f..7a3b548 100644 --- a/basic/moon_test.go +++ b/basic/moon_test.go @@ -7,7 +7,7 @@ import ( ) func Benchmark_MoonRiseBench(b *testing.B) { - jde := GetNowJDE() + jde := GetNowJD() for i := 0; i < b.N; i++ { GetMoonRiseTime(jde, 105, 40, 8, 0, 10) } @@ -632,7 +632,7 @@ func TestMoonRiseSetRegression(t *testing.T) { ) for i, testCase := range moonRiseSetTestData { - julianDay := JDECalc(testCase.Year, testCase.Month, testCase.Day) + julianDay := JDCalc(testCase.Year, testCase.Month, testCase.Day) // 测试月出时间 @@ -646,7 +646,7 @@ func TestMoonRiseSetRegression(t *testing.T) { } if !riseMatches { t.Errorf("测试用例 %d 月出时间不匹配:\n"+ - " 日期: %d-%d-%.1f, 经纬度: (%.4f, %.4f), 时区: %.1f, 天顶修正: %.0f, 海拔: %.0f\n"+ + " 日期: %d-%d-%.1f, 经纬度: (%.4f, %.4f), 时区: %.1f, 天顶修正: %.0f, 椭球高: %.0f\n"+ " 期望月出: %s, 实际月出: %.6f, 实际错误: %v, 差值: %.9f", i, testCase.Year, testCase.Month, testCase.Day, testCase.Longitude, testCase.Latitude, testCase.TimeZone, @@ -665,7 +665,7 @@ func TestMoonRiseSetRegression(t *testing.T) { } if !setMatches { t.Errorf("测试用例 %d 月落时间不匹配:\n"+ - " 日期: %d-%d-%.1f, 经纬度: (%.4f, %.4f), 时区: %.1f, 天顶修正: %.0f, 海拔: %.0f\n"+ + " 日期: %d-%d-%.1f, 经纬度: (%.4f, %.4f), 时区: %.1f, 天顶修正: %.0f, 椭球高: %.0f\n"+ " 期望月落: %s, 实际月落: %.6f, 实际错误: %v, 差值: %.9f", i, testCase.Year, testCase.Month, testCase.Day, testCase.Longitude, testCase.Latitude, testCase.TimeZone, @@ -715,7 +715,7 @@ func TestMoonRiseSetSpecialCases(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - julianDay := JDECalc(tc.year, tc.month, tc.day) + julianDay := JDCalc(tc.year, tc.month, tc.day) actualRise, riseErr := GetMoonRiseTime(julianDay, tc.longitude, tc.latitude, tc.timeZone, tc.zenithShift, tc.height) @@ -751,7 +751,7 @@ func moonRiseSetExpectation(code float64) string { if err := moonRiseSetExpectedError(code); err != nil { return err.Error() } - return JDE2Date(code).String() + return JD2Date(code).String() } func moonRiseSetMatches(actual float64, err error, expected float64, tolerance float64) bool { @@ -791,7 +791,7 @@ func moonRiseSetDynamicResidualMatches(actual float64, testCase MoonRiseSetTestC } func moonRiseSetNearCivilBoundary(jd float64, testCase MoonRiseSetTestCase, tolerance float64) bool { - dayStart := math.Floor(JDECalc(testCase.Year, testCase.Month, testCase.Day)) + 0.5 + dayStart := math.Floor(JDCalc(testCase.Year, testCase.Month, testCase.Day)) + 0.5 return math.Abs(jd-dayStart) <= tolerance || math.Abs(jd-(dayStart+1)) <= tolerance } @@ -802,7 +802,7 @@ func floatEquals(a, b, tolerance float64) bool { // BenchmarkMoonRiseTime 月出时间计算性能测试 func BenchmarkMoonRiseTime(b *testing.B) { - julianDay := JDECalc(2023, 6, 21) + julianDay := JDCalc(2023, 6, 21) longitude, latitude := 116.4074, 39.9042 timeZone, zenithShift, height := 8.0, 1.0, 0.0 @@ -814,7 +814,7 @@ func BenchmarkMoonRiseTime(b *testing.B) { // BenchmarkMoonSetTime 月落时间计算性能测试 func BenchmarkMoonSetTime(b *testing.B) { - julianDay := JDECalc(2023, 6, 21) + julianDay := JDCalc(2023, 6, 21) longitude, latitude := 116.4074, 39.9042 timeZone, zenithShift, height := 8.0, 1.0, 0.0 diff --git a/basic/moon_topocentric_physical_test.go b/basic/moon_topocentric_physical_test.go index 5520fd2..247d502 100644 --- a/basic/moon_topocentric_physical_test.go +++ b/basic/moon_topocentric_physical_test.go @@ -8,7 +8,7 @@ import ( ) func TestMoonTopocentricPhysicalMatchesCorrectionMethod(t *testing.T) { - jd := TD2UT(Date2JDE(testTime(2026, 4, 28, 9, 30, 45)), true) + jd := UTC2TT(Date2JD(testTime(2026, 4, 28, 9, 30, 45))) observerLon := 121.4737 observerLat := 31.2304 @@ -28,8 +28,8 @@ func TestMoonTopocentricPhysicalSampleSweepFiniteAndInRange(t *testing.T) { observerLat float64 height float64 }{ - {"shanghai", TD2UT(Date2JDE(testTime(2026, 4, 28, 9, 30, 45)), true), 121.4737, 31.2304, 4}, - {"chicago", TD2UT(Date2JDE(testTime(2024, 3, 25, 7, 0, 0)), true), -87.65, 41.85, 180}, + {"shanghai", UTC2TT(Date2JD(testTime(2026, 4, 28, 9, 30, 45))), 121.4737, 31.2304, 4}, + {"chicago", UTC2TT(Date2JD(testTime(2024, 3, 25, 7, 0, 0))), -87.65, 41.85, 180}, } for _, sample := range samples { @@ -50,7 +50,7 @@ func moonTopocentricPhysicalByCorrection(jd, observerLon, observerLat float64) M geocentric := MoonPhysical(jd) moonRA := HMoonTrueRa(jd) moonDec := HMoonTrueDec(jd) - hourAngle := StarHourAngle(TD2UT(jd, false), moonRA, observerLon, 0) + hourAngle := StarHourAngle(TT2UTC(jd), moonRA, observerLon, 0) horizontalParallax := ArcSin(6378.1366 / HMoonAway(jd)) Q := ArcTan2( diff --git a/basic/neptune.go b/basic/neptune.go index 0ce9b4d..48c82ce 100644 --- a/basic/neptune.go +++ b/basic/neptune.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func NeptuneL(jd float64) float64 { - return planet.WherePlanet(7, 0, jd) +func NeptuneL(jde float64) float64 { + return planet.WherePlanet(7, 0, jde) } -func NeptuneB(jd float64) float64 { - return planet.WherePlanet(7, 1, jd) +func NeptuneB(jde float64) float64 { + return planet.WherePlanet(7, 1, jde) } -func NeptuneR(jd float64) float64 { - return planet.WherePlanet(7, 2, jd) +func NeptuneR(jde float64) float64 { + return planet.WherePlanet(7, 2, jde) } -func ANeptuneX(jd float64) float64 { - l := NeptuneL(jd) - b := NeptuneB(jd) - r := NeptuneR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ANeptuneX(jde float64) float64 { + l := NeptuneL(jde) + b := NeptuneB(jde) + r := NeptuneR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func ANeptuneY(jd float64) float64 { +func ANeptuneY(jde float64) float64 { - l := NeptuneL(jd) - b := NeptuneB(jd) - r := NeptuneR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := NeptuneL(jde) + b := NeptuneB(jde) + r := NeptuneR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func ANeptuneZ(jd float64) float64 { - //l := NeptuneL(jd) - b := NeptuneB(jd) - r := NeptuneR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ANeptuneZ(jde float64) float64 { + //l := NeptuneL(jde) + b := NeptuneB(jde) + r := NeptuneR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func ANeptuneXYZ(jd float64) (float64, float64, float64) { - l := NeptuneL(jd) - b := NeptuneB(jd) - r := NeptuneR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ANeptuneXYZ(jde float64) (float64, float64, float64) { + l := NeptuneL(jde) + b := NeptuneB(jde) + r := NeptuneR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func NeptuneApparentRa(jd float64) float64 { - lo, bo := NeptuneApparentLoBo(jd) - eps := TrueObliquity(jd) +func NeptuneApparentRa(jde float64) float64 { + lo, bo := NeptuneApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func NeptuneApparentDec(jd float64) float64 { - lo, bo := NeptuneApparentLoBo(jd) - eps := TrueObliquity(jd) +func NeptuneApparentDec(jde float64) float64 { + lo, bo := NeptuneApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func NeptuneApparentRaDec(jd float64) (float64, float64) { - lo, bo := NeptuneApparentLoBo(jd) - eps := TrueObliquity(jd) +func NeptuneApparentRaDec(jde float64) (float64, float64) { + lo, bo := NeptuneApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -105,22 +105,22 @@ func NeptuneApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func NeptuneMag(jd float64) float64 { - sunDistance := NeptuneR(jd) - earthDistance := EarthNeptuneAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func NeptuneMag(jde float64) float64 { + sunDistance := NeptuneR(jde) + earthDistance := EarthNeptuneAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -6.87 + 5*math.Log10(sunDistance*earthDistance) return FloatRound(mag, 2) } -func NeptuneHeight(jde, lon, lat, timezone float64) float64 { +func NeptuneHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := NeptuneApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := NeptuneApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -129,12 +129,12 @@ func NeptuneHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func NeptuneAzimuth(jde, lon, lat, timezone float64) float64 { +func NeptuneAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := NeptuneApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := NeptuneApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -153,21 +153,21 @@ func NeptuneAzimuth(jde, lon, lat, timezone float64) float64 { } func NeptuneHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - NeptuneApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - NeptuneApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func NeptuneCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func NeptuneCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-NeptuneHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := NeptuneHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-NeptuneHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := NeptuneHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/neptune_events.go b/basic/neptune_events.go index 7df681f..4f264c3 100644 --- a/basic/neptune_events.go +++ b/basic/neptune_events.go @@ -74,15 +74,15 @@ func neptuneConjunctionFull(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := neptuneSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (neptuneSunLongitudeDelta(prevJDE+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -90,7 +90,7 @@ func neptuneConjunctionFull(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func neptuneConjunction(jde, degree float64, next uint8) float64 { @@ -105,15 +105,15 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := neptuneSunLongitudeDeltaN(prevJD, degree, true, neptuneEventSearchN) - longitudeSlope := (neptuneSunLongitudeDeltaN(prevJD+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJD-0.000005, degree, true, neptuneEventSearchN)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= neptunePhaseCoarseTolerance { + prevJDE := estimateJDE + longitudeDelta := neptuneSunLongitudeDeltaN(prevJDE, degree, true, neptuneEventSearchN) + longitudeSlope := (neptuneSunLongitudeDeltaN(prevJDE+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJDE-0.000005, degree, true, neptuneEventSearchN)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= neptunePhaseCoarseTolerance { converged = true break } @@ -123,12 +123,12 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 { } converged = false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := neptuneSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (neptuneSunLongitudeDelta(prevJDE+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -136,7 +136,7 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func LastNeptuneConjunction(jde float64) float64 { @@ -175,22 +175,22 @@ func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit if !isFiniteFloat(oppositionJD) { return math.NaN() } - oppositionTT := TD2UT(oppositionJD, true) + oppositionTT := UTC2TT(oppositionJD) startTT := oppositionTT endTT := oppositionTT if searchBeforeOpposition { easternQuadratureUT := neptuneConjunction(oppositionTT, 90, 0) - startTT = TD2UT(easternQuadratureUT, true) + startTT = UTC2TT(easternQuadratureUT) } else { westernQuadratureUT := neptuneConjunction(oppositionTT, 270, 1) - endTT = TD2UT(westernQuadratureUT, true) + endTT = UTC2TT(westernQuadratureUT) } - bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { return neptuneRADerivativeN(jd, stationDerivativeStepDay, neptuneEventSearchN) }, func(jd float64) float64 { return neptuneRADerivative(jd, stationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func NextNeptuneRetrogradeToPrograde(jde float64) float64 { diff --git a/basic/nutation.go b/basic/nutation.go index a396ece..7056077 100644 --- a/basic/nutation.go +++ b/basic/nutation.go @@ -15,8 +15,8 @@ func EclipticObliquity(jde float64, nutation bool) float64 { return eps } -func TrueObliquity(JD float64) float64 { - return EclipticObliquity(JD, true) +func TrueObliquity(JDE float64) float64 { + return EclipticObliquity(JDE, true) } // 黄经章动 1980 diff --git a/basic/occultation.go b/basic/occultation.go index ba14776..8a80c6f 100644 --- a/basic/occultation.go +++ b/basic/occultation.go @@ -6,6 +6,8 @@ import ( "math" "strings" "time" + + "b612.me/astro/tools" ) // ErrInvalidOccultationInput 表示月掩输入契约无效。 @@ -22,6 +24,8 @@ const ( occultationPathMinimumTargetSpacingKM = 1.0 occultationEventSelectionTolerance = 10 * time.Millisecond occultationEventSelectionToleranceDays = float64(occultationEventSelectionTolerance) / float64(24*time.Hour) + // 银河系内恒星的径向速度上限,仅用于挡掉明显填错的输入。 + starRadialVelocityLimitKmPerSecond = 1000.0 ) // CoordinateFrame 标识恒星输入坐标使用的赤道坐标系。 @@ -155,7 +159,7 @@ func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 if !finite(moonRA) || !finite(moonDec) || !finite(moonDistanceKM) || moonDistanceKM <= 0 { return math.NaN() } - distanceKM := topocentricDistanceKM(moonRA, moonDec, moonDistanceKM, observer, TD2UT(tt, false)) + distanceKM := topocentricDistanceKM(moonRA, moonDec, moonDistanceKM, observer, TT2UTC(tt)) if !finite(distanceKM) || distanceKM <= 0 { return math.NaN() } @@ -167,7 +171,7 @@ func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 // topocentricDistanceKM uses the same WGS-84-style site factors as TopocentricRaDec. // The target uses apparent equatorial coordinates, and the sidereal angle is based on UTC/UT. func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 { - return topocentricDistanceKMWithSidereal(ra, dec, distanceKM, observer, ApparentSiderealTime(ut)*15) + return topocentricDistanceKMWithSidereal(ra, dec, distanceKM, observer, ApparentSiderealTime(UTC2UT1(ut))*15) } func topocentricDistanceKMWithSidereal( @@ -231,7 +235,18 @@ type StarCoordinate struct { ProperMotionRACosDecMasPerYear float64 ProperMotionDecMasPerYear float64 - ParallaxMas float64 + // ParallaxMas 为周年视差,单位毫角秒;0 表示未提供距离,此时退回二维自行传播。 + // ParallaxMas is the annual parallax in milliarcseconds; 0 means no distance is given and the + // two-dimensional proper-motion path is used. + ParallaxMas float64 + // DistanceLightYear 是 ParallaxMas 的替代输入,单位光年;仅当 ParallaxMas 为 0 时生效,0 表示未提供。 + // DistanceLightYear is an alternative to ParallaxMas in light-years; it applies only when + // ParallaxMas is 0, and 0 means it was not given. + DistanceLightYear float64 + // RadialVelocityKmPerSecond 单位千米/秒,0 合法,只在距离已知时参与三维空间运动。 + // RadialVelocityKmPerSecond is in kilometres per second; 0 is valid and it enters the + // three-dimensional motion only when a distance is known. + RadialVelocityKmPerSecond float64 } // Validate 在构造目标前检查恒星坐标契约。 @@ -255,9 +270,26 @@ func (s StarCoordinate) Validate() error { if !finite(s.ParallaxMas) || s.ParallaxMas < 0 { return fmt.Errorf("%w: star parallax must be finite and non-negative", ErrInvalidOccultationInput) } + if !finite(s.DistanceLightYear) || s.DistanceLightYear < 0 { + return fmt.Errorf("%w: star distance in light-years must be finite and non-negative", ErrInvalidOccultationInput) + } + if !finite(s.RadialVelocityKmPerSecond) || math.Abs(s.RadialVelocityKmPerSecond) > starRadialVelocityLimitKmPerSecond { + return fmt.Errorf("%w: star radial velocity must be finite and within +/-%.0f km/s", ErrInvalidOccultationInput, starRadialVelocityLimitKmPerSecond) + } return nil } +// parallaxMas 返回生效的视差:显式视差优先,否则由光年距离折算。 +func (s StarCoordinate) parallaxMas() float64 { + if s.ParallaxMas > 0 { + return s.ParallaxMas + } + if s.DistanceLightYear <= 0 { + return 0 + } + return 1000 / tools.DistanceToParsecs(s.DistanceLightYear, tools.DistanceLightYear) +} + // StarCoordinateFromStarData 将一条内嵌星表记录转换为月掩搜索使用的 J2000 坐标契约。 // 星表自行从角秒/年转换为毫角秒/年;正的秒差距距离转换为毫角秒年视差。本函数只转换传入值,不会加载星表。 // StarCoordinateFromStarData converts one embedded-catalog entry into the J2000 coordinate contract used by lunar-occultation searches. @@ -284,6 +316,7 @@ func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) { ProperMotionRACosDecMasPerYear: star.PmRA * 1000, ProperMotionDecMasPerYear: star.PmDec * 1000, ParallaxMas: parallaxMas, + RadialVelocityKmPerSecond: star.RadVel, } if err := coordinate.Validate(); err != nil { return StarCoordinate{}, fmt.Errorf("convert star catalog coordinate: %w", err) @@ -491,11 +524,25 @@ type PlanetOccultationInfo struct { // LimitSeparationKM is non-zero only on northern/southern limit samples (total limits included) and is the same-instant ground distance to the opposite limit; it is constructed differently from WidthKM and must not be converted into it. // Base samples are solved directly; adaptive samples use width interpolation and five-meter error checks. type OccultationPathPoint struct { - Time time.Time - Longitude float64 - Latitude float64 - MoonAltitude float64 - WidthKM float64 + // Time 是该点的时刻。 + // Time is the instant the point describes. + Time time.Time + // Longitude 与 Latitude 是地面坐标,东经、北纬为正。 + // Longitude and Latitude are ground coordinates, east and north positive. + Longitude float64 + Latitude float64 + // MoonAltitude 是月球几何高度角,单位度,不做蒙气差修正。 + // MoonAltitude is the geometric lunar altitude in degrees, without refraction. + MoonAltitude float64 + // WidthKM 是可见掩带在该点的地面宽度。掩星可见带可以宽达数千公里,与日食中心带宽度不是同一口径,不要互相比较。 + // 擦边事件的路径可以没有中心线,此时它仍表示可见带宽度,与 LimitSeparationKM 口径不同。 + // WidthKM is the ground width of the visible occultation band at the point. An occultation band + // can span thousands of kilometres and is not comparable to a solar central-path width. A grazing + // path can have no center line while this field still describes the visible band, which is a + // different construction from LimitSeparationKM. + WidthKM float64 + // LimitSeparationKM 是该点南北限的地面间距,无论中心线是否存在都有定义。 + // LimitSeparationKM is the ground separation of the two limits at the point, defined whether or not a center line exists. LimitSeparationKM float64 } @@ -529,6 +576,8 @@ type StarOccultationPath struct { // Complete is true when Start and End are the global outer-limb contacts rather than query-window clipping points. Complete bool + // CenterLine 是掩星中心线;掠掩事件可以整条没有中心线(只有南北限),此时该切片为空而 Complete 仍可为真。 + // CenterLine is the occultation center line; a grazing event can have no center line at all (limits only), leaving this slice empty while Complete can still be true. CenterLine []OccultationPathPoint NorthernLimit []OccultationPathPoint SouthernLimit []OccultationPathPoint @@ -622,7 +671,9 @@ type PlanetOccultationPath struct { Complete bool // CenterLine 是影轴与椭球交点的轨迹;非中心事件退化为最接近影轴的椭球点。 + // 掠掩事件的路径可以整条没有中心线(只有南北限),此时该切片为空而 Complete 仍可为真。 // CenterLine is the track of the shadow-axis/ellipsoid intersection, degenerating to the ellipsoid point nearest the axis for non-central events. + // A grazing event can have no center line at all (limits only); the slice is then empty while Complete can still be true. CenterLine []OccultationPathPoint // NorthernLimit 和 SouthernLimit 是外接触锥的切点轨迹:掩星在该线上恰好退化为擦边,不是中心线的等距横向平移。 // NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant-width offset of the center line. diff --git a/basic/occultation_contact_rate_test.go b/basic/occultation_contact_rate_test.go new file mode 100644 index 0000000..fc6bacb --- /dev/null +++ b/basic/occultation_contact_rate_test.go @@ -0,0 +1,80 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +// 解析速率与中心差分必须给出同一个接触度量导数:点源目标曾把零位置当站心位置, +// 减掉观测者速度后得到日尺度的假速率,掩带边界因此无法加密。 +// The analytic contact rate must agree with the central difference for every target +// kind: the point-source branch once treated a zero position as a topocentric vector +// and produced a spurious diurnal rate that stopped the band boundary from refining. +func TestOccultationContactRateMatchesCentralDifference(t *testing.T) { + const tolerance = 0.01 // 度/日 + star := hr4799OccultationCoordinateForTest() + infinite := star + infinite.ParallaxMas = 0 + parallaxStar := star + parallaxStar.ParallaxMas = 10 + planetConfig, _ := planetOccultationConfigFor(OccultationMercury) + planetCache := newPlanetOccultationEventCache(planetConfig) + + starEvaluation := func(coordinate StarCoordinate) func(float64) occultationRiseSetEvaluation { + cache := newStarOccultationEventCache(coordinate) + return newOccultationRiseSetEvaluationCache(cache.riseSetContextAt).evaluation + } + cases := []struct { + name string + sampleTime time.Time + evaluation func(float64) occultationRiseSetEvaluation + }{ + {"point-source star", time.Date(2025, 6, 5, 10, 0, 0, 0, time.UTC), starEvaluation(infinite)}, + {"finite star", time.Date(2025, 6, 5, 10, 0, 0, 0, time.UTC), starEvaluation(parallaxStar)}, + {"planet", time.Date(2024, 8, 21, 10, 0, 0, 0, time.UTC), newOccultationRiseSetEvaluationCache(planetCache.riseSetContextAt).evaluation}, + } + sites := [][2]float64{{0, 0}, {115, 33}, {-74, 40}, {179, 80}, {-123, -64}} + for _, testCase := range cases { + tt := occultationTimeToTT(testCase.sampleTime) + evaluation := testCase.evaluation(tt) + for _, site := range sites { + analytic := evaluation.contactRateAt(site[0], site[1]) + reference := evaluation.contactDerivative(site[0], site[1]) + if !finite(analytic) || !finite(reference) { + t.Fatalf("%s lon=%g lat=%g: analytic=%g reference=%g", testCase.name, site[0], site[1], analytic, reference) + } + if math.Abs(analytic-reference) > tolerance { + t.Errorf("%s lon=%g lat=%g: analytic=%.6f reference=%.6f degrees/day", + testCase.name, site[0], site[1], analytic, reference) + } + } + } +} + +// 无视差恒星的掩带边界必须加密到请求的地面间距:修复前相邻点可达 150 km。 +func TestStarOccultationBandContourSpacing(t *testing.T) { + star := hr4799OccultationCoordinateForTest() + start := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC) + paths, err := FindStarOccultationPaths(start, start.Add(24*time.Hour), star, + OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 30, DisableFootprints: true, DisableRiseSet: true}) + if err != nil { + t.Fatal(err) + } + const limitKM = 45.0 + sampled := 0 + for _, path := range paths { + for branch, contour := range path.BandContours { + for index := 1; index < len(contour); index++ { + spacing := occultationPathDistanceKM(contour[index-1], contour[index]) + sampled++ + if spacing > limitKM { + t.Fatalf("branch %d sample %d: spacing %.3f km exceeds %.0f km", branch, index, spacing, limitKM) + } + } + } + } + if sampled == 0 { + t.Fatal("no band contour samples") + } +} diff --git a/basic/occultation_instant.go b/basic/occultation_instant.go index 96bac19..6f0e36e 100644 --- a/basic/occultation_instant.go +++ b/basic/occultation_instant.go @@ -9,7 +9,11 @@ import ( // StarOccultationInstant 包含指定时刻的点源恒星月掩可见足迹;若接触锥在该时刻未到达可见地球,Footprint 为 nil。 // StarOccultationInstant contains the visible point-source footprint at one requested instant; Footprint is nil when no part of the contact cone reaches the visible Earth. type StarOccultationInstant struct { - Time time.Time + // Time 是本次查询的时刻。 + // Time is the instant this result describes. + Time time.Time + // TargetID 是查询恒星的标识,与 StarCoordinate.ID 同值。 + // TargetID identifies the queried star and equals StarCoordinate.ID. TargetID string // DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used. DeltaTSeconds float64 @@ -22,8 +26,14 @@ type StarOccultationInstant struct { // PlanetOccultationInstant 包含指定时刻的行星外接触和内接触可见足迹;相应接触锥未到达可见地球时,Partial 或 Total 为 nil。 // PlanetOccultationInstant contains the visible outer- and inner-contact footprints at one requested instant; Partial or Total is nil when that contact cone does not reach the visible Earth. type PlanetOccultationInstant struct { - Time time.Time - Planet OccultationPlanet + // Time 是本次查询的时刻。 + // Time is the instant this result describes. + Time time.Time + // Planet 是本次查询的行星。 + // Planet is the queried planet. + Planet OccultationPlanet + // TargetID 是行星标识,与 Planet.String() 同值。 + // TargetID identifies the planet and equals Planet.String(). TargetID string // DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used. DeltaTSeconds float64 @@ -91,7 +101,7 @@ func occultationSublunarPoint(tt float64, frameAt occultationPathFrameFunc) (flo } rightAscension := math.Atan2(frame.moon.y, frame.moon.x) * 180 / math.Pi declination := math.Asin(math.Max(-1, math.Min(1, frame.moon.z/distance))) * 180 / math.Pi - longitude := rightAscension - ApparentSiderealTime(TD2UT(tt, false))*15 + longitude := rightAscension - ApparentSiderealTime(TT2UT1(tt))*15 for longitude > 180 { longitude -= 360 } diff --git a/basic/occultation_isochrone.go b/basic/occultation_isochrone.go index b1e1338..ecbaed7 100644 --- a/basic/occultation_isochrone.go +++ b/basic/occultation_isochrone.go @@ -15,6 +15,11 @@ const ( occultationGreatestTimeContourCorrectionIterations = 12 occultationGreatestTimeContourGradientStepDegrees = 1e-4 occultationGreatestTimeContourLatitudeLimitDegrees = 88.0 + // 残差量纲随判据口径变化:点源用角距平方(梯度约 1.4e-4/度),行星用外接触度量(约 1/度), + // 同一个绝对阈值在两种口径下相差四个量级——按角距平方给阈值会放过 78 km 的位置偏差。 + // 收敛改按位置偏移判定:|残差| / |梯度| 即离零集的地面距离,1e-5 度约 1.1 m, + // 两种口径的残差噪声折算成位置抖动都不超过 2e-7 度。 + occultationGreatestTimeContourPositionToleranceDegrees = 1e-5 ) // occultationGreatestTimeArc 固定一个掩甚时刻后的等时线求根器。 @@ -128,6 +133,9 @@ func (arc occultationGreatestTimeArc) correct(longitude, latitude float64) (floa if denominator < 1e-18 || cosine < 1e-6 { return 0, 0, state, false } + if math.Abs(value) <= occultationGreatestTimeContourPositionToleranceDegrees*math.Sqrt(denominator) { + return longitude, latitude, state, true + } // 完整牛顿步可能一步跨出可见域(掩带很窄,限界附近的种子尤其容易);逐步二分回退, // 只要还有一步落在域内就继续投影。 scale, advanced := 1.0, false @@ -150,7 +158,14 @@ func (arc occultationGreatestTimeArc) correct(longitude, latitude float64) (floa } } value, current, ok := arc.sample(longitude, latitude) - if !ok || math.Abs(value) > 1e-6 { + if !ok { + return 0, 0, state, false + } + if math.Abs(value) <= greatestTimeContourResidualTolerance { + return longitude, latitude, current, true + } + east, north, gradientOK := arc.metricGradient(longitude, latitude) + if !gradientOK || math.Abs(value) > occultationGreatestTimeContourPositionToleranceDegrees*math.Hypot(east, north) { return 0, 0, state, false } return longitude, latitude, current, true diff --git a/basic/occultation_isochrone_test.go b/basic/occultation_isochrone_test.go index 4754f2d..b1f8e91 100644 --- a/basic/occultation_isochrone_test.go +++ b/basic/occultation_isochrone_test.go @@ -145,6 +145,91 @@ func TestPlanetOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) { } } +// 只要存在落在可见域内的序列种子,就必须至少有一个被投影接受:全部被拒会让整条等时线退回 5° 粗扫, +// 产生相位错开的重复支路。投影判据只能按位置偏移给(残差量纲随口径变化),不能用绝对残差阈值。 +func TestOccultationGreatestTimeContourSeedsConvergeInVisibleDomain(t *testing.T) { + star := occultationIsochroneTestStar() + starPaths, err := FindStarOccultationPaths( + time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star, + OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}, + ) + if err != nil || len(starPaths) == 0 { + t.Fatalf("star: %v", err) + } + planetPaths, err := FindPlanetOccultationPaths( + time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn, + OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}, + ) + if err != nil || len(planetPaths) == 0 { + t.Fatalf("planet: %v", err) + } + config, _ := planetOccultationConfigFor(OccultationSaturn) + starCache := newStarOccultationEventCache(star) + planetCache := newPlanetOccultationEventCache(config) + cases := []struct { + name string + contours []OccultationGreatestTimeContour + series [][]OccultationPathPoint + arcAt func(level float64) occultationGreatestTimeArc + }{ + { + name: "star", + contours: starPaths[0].GreatestTimeContours, + series: [][]OccultationPathPoint{ + starPaths[0].CenterLine, starPaths[0].NorthernLimit, starPaths[0].SouthernLimit, + }, + arcAt: func(level float64) occultationGreatestTimeArc { + return occultationGreatestTimeArc{ + evaluation: starCache.riseSetCache.evaluation(level), location: time.UTC, + } + }, + }, + { + name: "planet", + contours: planetPaths[0].GreatestTimeContours, + series: [][]OccultationPathPoint{ + planetPaths[0].CenterLine, planetPaths[0].NorthernLimit, planetPaths[0].SouthernLimit, + }, + arcAt: func(level float64) occultationGreatestTimeArc { + return occultationGreatestTimeArc{ + evaluation: planetCache.riseSetCache.evaluation(level), + location: time.UTC, + useContactMetric: true, + } + }, + }, + } + sampled := 0 + for _, tc := range cases { + if len(tc.contours) == 0 { + t.Fatalf("%s: no contours", tc.name) + } + for _, contour := range tc.contours { + arc := tc.arcAt(contour.JDE) + inDomain, accepted := 0, 0 + for _, seed := range occultationGreatestTimeContourSeeds(tc.series, contour.JDE) { + if _, _, ok := arc.sample(seed[0], seed[1]); !ok { + continue + } + inDomain++ + if _, _, _, corrected := arc.correct(seed[0], seed[1]); corrected { + accepted++ + } + } + sampled += inDomain + if inDomain > 0 && accepted == 0 { + t.Fatalf("%s contour %s: %d in-domain seeds, none accepted", + tc.name, contour.Time.Format("15:04"), inDomain) + } + } + } + if sampled == 0 { + t.Fatalf("no in-domain seeds sampled") + } +} + // 同一时刻取值的等时线必须是单条连通曲线:曾因"覆盖判据用点到顶点距离"而被重复延拓 // (行星掩星每个取值 3 条重叠支路)。 func TestOccultationGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) { diff --git a/basic/occultation_limit_separation_test.go b/basic/occultation_limit_separation_test.go index 6eae2a9..9c0137d 100644 --- a/basic/occultation_limit_separation_test.go +++ b/basic/occultation_limit_separation_test.go @@ -16,6 +16,8 @@ const ( occultationLimitExactTolerance = 1e-9 occultationLimitAnchorToleranceKM = 0.5 occultationLimitRatioTolerance = 0.002 + // 掠射端点锚点的容差:只钉住端点补采带来的百公里级缺口,不追求末位。 + occultationLimitGrazingAnchorToleranceKM = 0.01 ) type occultationLimitFixture struct { @@ -141,6 +143,11 @@ func occultationLimitIndependentGroundWidth(tt float64, frameAt occultationPathF consider(vector) } } + // 掠射时横向极值恰好落在可见 θ 区间的相切端点上,端点判别式为 0:等差写法的末样本 + // 与 interval.right 相差 1 ULP,足以把判别式推成负值而丢掉端点,端点必须按原值补采。 + if vector, _, pointOK := occultationPathBoundaryVector(frame, interval.right); pointOK { + consider(vector) + } } for index := 0; index < occultationPathBoundaryScanPoints; index++ { if vector, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(index)/float64(occultationPathBoundaryScanPoints)); pointOK { @@ -327,3 +334,70 @@ func TestStarOccultationLimitSeparationMatchesExportedLimits(t *testing.T) { t.Fatalf("greatest limit separation/width=%.6f, want 1.023508 within %.4f", ratio, occultationLimitRatioTolerance) } } + +// TestPlanetOccultationGrazingEndpointWidthAnchors 钉住两个掠射相切端点样本的带宽。 +// +// 这两个时刻的横向极值恰好落在可见 θ 区间的相切端点上,只能靠端点原值取到:等差写法 +// left+(right-left)*n/n 与端点相差 1 ULP,该 ULP 会把端点判别式推成负值而被判"无地面交点", +// 于是 Saturn 2024-08-21 的 CenterLine[160] 少 47.9 km(3810.7491 对 3858.7641)、 +// Venus 1227-05-19 的 CenterLine[15] 少 158.6 km(6047.4858 对 6206.0535)。 +func TestPlanetOccultationGrazingEndpointWidthAnchors(t *testing.T) { + testCases := []struct { + name string + planet OccultationPlanet + start time.Time + options OccultationPathOptions + tt float64 + widthKM float64 + }{ + { + name: "Saturn 2024-08-21 center-line[160]", planet: OccultationSaturn, + start: time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC), + tt: 2460543.661859489, + widthKM: 3858.7641, + }, + { + name: "Venus 1227-05-19 center-line[15]", planet: OccultationVenus, + start: time.Date(1227, time.May, 18, 12, 0, 0, 0, time.UTC), + options: OccultationPathOptions{ + Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, + TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute, + }, + tt: 2169357.811307699, + widthKM: 6206.0535, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + paths, err := FindPlanetOccultationPaths(tc.start, tc.start.Add(24*time.Hour), tc.planet, tc.options) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + frameAt := occultationLimitExactFrame(t, tc.planet, centerTimeTT(path.Greatest.Time)) + matched := 0 + for _, point := range append([]OccultationPathPoint{path.Greatest}, path.CenterLine...) { + tt := centerTimeTT(point.Time) + if math.Abs(tt-tc.tt) > 1e-6 { + continue + } + matched++ + if difference := math.Abs(point.WidthKM - tc.widthKM); difference > occultationLimitGrazingAnchorToleranceKM { + t.Fatalf("width at %v = %.6f km, want %.4f within %.4f km", + point.Time, point.WidthKM, tc.widthKM, occultationLimitGrazingAnchorToleranceKM) + } + independent, ok := occultationLimitIndependentGroundWidth(tt, frameAt) + if !ok || independent <= 0 { + t.Fatalf("independent ground width at %v is unavailable", point.Time) + } + if difference := math.Abs(point.WidthKM - independent); difference > occultationLimitIndependentTolerance*independent { + t.Fatalf("width at %v field=%.9f independent=%.9f, want within %.3e relative", + point.Time, point.WidthKM, independent, occultationLimitIndependentTolerance) + } + } + if matched != 1 { + t.Fatalf("matched %d samples at tt=%.9f, want exactly one", matched, tc.tt) + } + }) + } +} diff --git a/basic/occultation_path.go b/basic/occultation_path.go index 68d1992..7e6f31f 100644 --- a/basic/occultation_path.go +++ b/basic/occultation_path.go @@ -858,11 +858,9 @@ func starOccultationPathFrameAt(tt float64, star StarCoordinate) (occultationPat func starOccultationEphemerisStateAt(tt float64, star StarCoordinate) starOccultationEphemerisState { moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) moonDistanceKM := HMoonAwayN(tt, -1) - starRA, starDec := starApparentRaDecGeocentric(tt, star) - starDistanceKM := 0.0 - if star.ParallaxMas > 0 { - starDistanceKM = 206264806.247 / star.ParallaxMas * occultationPathAstronomicalUnitKM - } + // 方向与距离必须来自同一次三维推进,否则视差修正会退回历元距离。 + starRA, starDec, starDistanceAU := starApparentRaDecDistanceGeocentric(tt, star) + starDistanceKM := starDistanceAU * occultationPathAstronomicalUnitKM return starOccultationEphemerisState{ moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistanceKM, starRA: starRA, starDec: starDec, starDistanceKM: starDistanceKM, @@ -994,6 +992,11 @@ func occultationPathScannedLimitsAtFrame(tt float64, frame occultationPathFrame) consider(vector) } } + // 同 occultationPathFiniteCrossTrackExtrema:等差末样本与 rightTheta 差 1 ULP, + // 掠射时端点会被判"无地面交点"而丢掉极值,端点原值必须补采一次。 + if vector, _, ok := occultationPathBoundaryVector(frame, rightTheta); ok { + consider(vector) + } } } for i := 0; i < occultationPathBoundaryScanPoints; i++ { @@ -1258,7 +1261,7 @@ func occultationPathPointFromVectorWithMoon( location *time.Location, ) OccultationPathPoint { return occultationPathPointFromVectorWithMoonSidereal( - tt, vector, width, moon, ApparentSiderealTime(TD2UT(tt, false))*15, location, + tt, vector, width, moon, ApparentSiderealTime(TT2UT1(tt))*15, location, ) } @@ -1531,8 +1534,8 @@ func occultationEarthLineIntersectionWithTolerance(origin, direction occultation } func occultationPathGeodetic(tt float64, vector occultationPathVector) (float64, float64) { - ut := TD2UT(tt, false) - return occultationPathGeodeticWithSidereal(vector, ApparentSiderealTime(ut)*15) + ut1 := TT2UT1(tt) + return occultationPathGeodeticWithSidereal(vector, ApparentSiderealTime(ut1)*15) } func occultationPathGeodeticWithSidereal( @@ -1560,7 +1563,7 @@ type occultationPathEarthRotation struct { } func occultationPathEarthRotationAt(tt float64) occultationPathEarthRotation { - angle := ApparentSiderealTime(TD2UT(tt, false)) * 15 * math.Pi / 180 + angle := ApparentSiderealTime(TT2UT1(tt)) * 15 * math.Pi / 180 return occultationPathEarthRotation{cosine: math.Cos(angle), sine: math.Sin(angle)} } diff --git a/basic/occultation_path_ephemeris.go b/basic/occultation_path_ephemeris.go index 416f8f9..ac49115 100644 --- a/basic/occultation_path_ephemeris.go +++ b/basic/occultation_path_ephemeris.go @@ -33,7 +33,7 @@ func (cache *starOccultationEventCache) preparePathEphemeris(center float64, alg return [3]float64{moon.x, moon.y, moon.z}, [3]float64{target.x, target.y, target.z} }) if len(nodes) > 0 { - cache.local = &starOccultationLocalEphemeris{star: cache.star, nodes: nodes, dense: true} + cache.local = newStarOccultationLocalEphemerisFromNodes(center, cache.star, nodes, true) } } cache.prepareLocalEphemeris(center) diff --git a/basic/occultation_planet.go b/basic/occultation_planet.go index 9e645a9..6556b1a 100644 --- a/basic/occultation_planet.go +++ b/basic/occultation_planet.go @@ -370,7 +370,7 @@ func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer * moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n) planetRA, planetDec := config.apparentRaDecN(tt, n) if observer != nil { - ut := TD2UT(tt, false) + ut := TT2UTC(tt) moonDistanceAU := HMoonAwayN(tt, n) / angularDiameterAstronomicalUnitKM planetDistanceAU := config.earthDistanceN(tt, n) moonRA, moonDec = TopocentricRaDec(moonRA, moonDec, observer.Latitude, observer.Longitude, ut, moonDistanceAU, observer.Height) @@ -425,7 +425,7 @@ func planetTopocentricSemidiameterN(tt float64, config planetOccultationConfig, if !finite(ra) || !finite(dec) || !finite(distanceKM) || distanceKM <= 0 { return math.NaN() } - distanceKM = topocentricDistanceKM(ra, dec, distanceKM, observer, TD2UT(tt, false)) + distanceKM = topocentricDistanceKM(ra, dec, distanceKM, observer, TT2UTC(tt)) if !finite(distanceKM) || distanceKM <= config.equatorialRadiusKM { return math.NaN() } diff --git a/basic/occultation_planet_footprint.go b/basic/occultation_planet_footprint.go index acaccc0..f29826d 100644 --- a/basic/occultation_planet_footprint.go +++ b/basic/occultation_planet_footprint.go @@ -440,7 +440,7 @@ func planetOccultationFootprintAtWithResolution( if !ok { return PlanetOccultationFootprint{}, false } - siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 + siderealDegrees := ApparentSiderealTime(TT2UT1(tt)) * 15 samples := make([]planetOccultationFootprintSample, boundaryPoints) for index := range samples { theta := 2 * math.Pi * float64(index) / float64(len(samples)) @@ -954,7 +954,7 @@ func planetOccultationHorizonCircle( secondAxis := occultationPathCross(centerDirection, firstAxis) centerDistance := 1 / distance circleRadius := math.Sqrt(math.Max(0, 1-centerDistance*centerDistance)) - siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 + siderealDegrees := ApparentSiderealTime(TT2UT1(tt)) * 15 points := make([]OccultationPathPoint, count) for index := range points { angle := 2 * math.Pi * float64(index) / float64(count) diff --git a/basic/occultation_planet_path.go b/basic/occultation_planet_path.go index f69b120..c68a08e 100644 --- a/basic/occultation_planet_path.go +++ b/basic/occultation_planet_path.go @@ -548,7 +548,7 @@ func occultationPathBoundarySamplesAtTimesForFrame( first := make([]OccultationPathPoint, len(samples)) second := make([]OccultationPathPoint, len(samples)) for index, sample := range samples { - siderealDegrees := ApparentSiderealTime(TD2UT(sample.tt, false)) * 15 + siderealDegrees := ApparentSiderealTime(TT2UT1(sample.tt)) * 15 first[index] = occultationPathPointFromVectorWithMoonSidereal( sample.tt, sample.first, 0, sample.moon, siderealDegrees, location, ) @@ -1532,6 +1532,11 @@ func occultationPathLimitsAndWidthForFrame( consider(point) } } + // 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值, + // 端点处的横向极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。 + if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK { + consider(point) + } } } for i := 0; i < occultationPathBoundaryScanPoints; i++ { @@ -1641,6 +1646,11 @@ func occultationPathFiniteCrossTrackExtrema( consider(point) } } + // 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值, + // 端点处的极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。 + if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK { + consider(point) + } } } for _, interval := range occultationPathBoundaryThetaIntervals(frame) { @@ -1650,6 +1660,11 @@ func occultationPathFiniteCrossTrackExtrema( consider(point) } } + // 与 occultationPathLimitsAndWidthForFrame 同因:等差末样本与 interval.right 差 1 ULP, + // 掠射时会把端点判别式推成负值而丢掉该处极值,端点原值必须补采一次。 + if point, _, pointOK := occultationPathBoundaryVector(frame, interval.right); pointOK { + consider(point) + } } if !finite(minimumOffset) || !finite(maximumOffset) { return occultationPathVector{}, occultationPathVector{}, 0, false diff --git a/basic/occultation_planet_test.go b/basic/occultation_planet_test.go index 3986e91..2926a38 100644 --- a/basic/occultation_planet_test.go +++ b/basic/occultation_planet_test.go @@ -7,7 +7,7 @@ import ( ) func TestPlanetOccultationSupportsAllPlanetTargets(t *testing.T) { - tt := TD2UT(Date2JDE(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)), true) + tt := UTC2TT(Date2JD(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))) tests := []struct { planet OccultationPlanet name string @@ -103,7 +103,7 @@ func TestPlanetOccultationUsesStationMoonDistanceForRadius(t *testing.T) { t.Fatal("Saturn occultation config is unavailable") } moonRA, _ := HMoonGeocentricApparentRaDecN(tt, -1) - subMoonLongitude := normalizeLongitude180(moonRA - ApparentSiderealTime(TD2UT(tt, false))*15) + subMoonLongitude := normalizeLongitude180(moonRA - ApparentSiderealTime(TT2UTC(tt))*15) near := Observer{Longitude: subMoonLongitude, Latitude: 0} far := Observer{Longitude: normalizeLongitude180(subMoonLongitude + 180), Latitude: 0} nearState := planetOccultationStateAt(tt, config, &near, -1) @@ -128,7 +128,7 @@ func TestPlanetOccultationUsesStationPlanetDistanceForRadius(t *testing.T) { t.Fatal("Mercury occultation config is unavailable") } planetRA, _ := config.apparentRaDecN(tt, -1) - subPlanetLongitude := normalizeLongitude180(planetRA - ApparentSiderealTime(TD2UT(tt, false))*15) + subPlanetLongitude := normalizeLongitude180(planetRA - ApparentSiderealTime(TT2UTC(tt))*15) near := Observer{Longitude: subPlanetLongitude, Latitude: 0} far := Observer{Longitude: normalizeLongitude180(subPlanetLongitude + 180), Latitude: 0} nearState := planetOccultationStateAt(tt, config, &near, -1) diff --git a/basic/occultation_rise_set.go b/basic/occultation_rise_set.go index fcece48..22960bf 100644 --- a/basic/occultation_rise_set.go +++ b/basic/occultation_rise_set.go @@ -217,7 +217,7 @@ func newOccultationRiseSetContext( target := newOccultationRiseSetBody(targetRA, targetDec, targetDistanceKM) return occultationRiseSetContext{ tt: tt, - siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15, + siderealDegrees: ApparentSiderealTime(TT2UT1(tt)) * 15, moonRA: moonRA, moonDec: moonDec, moon: moon, @@ -245,7 +245,7 @@ func newOccultationRiseSetContextFromVectors( target, _, _, targetOK := occultationRiseSetBodyFromVector(targetXYZ, targetAtFiniteDistance) return occultationRiseSetContext{ tt: tt, - siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15, + siderealDegrees: ApparentSiderealTime(TT2UT1(tt)) * 15, moonRA: moonRA, moonDec: moonDec, moon: moon, @@ -635,7 +635,7 @@ func (evaluation occultationRiseSetEvaluation) pointsAt( if !evaluation.center.valid || !evaluation.before.valid || !evaluation.after.valid { return result } - gst := ApparentSiderealTime(TD2UT(evaluation.tt, false)) * 15 + gst := ApparentSiderealTime(TT2UT1(evaluation.tt)) * 15 centerLongitude := normalizeLongitude(evaluation.center.moonRA - gst) centerLatitude := evaluation.center.moonDec appendRoots := func(greatest bool) { @@ -776,6 +776,130 @@ func (evaluation occultationRiseSetEvaluation) classify( }, occultationRiseSetCurveKey{phase: phase, direction: direction}, true } +// occultationSiderealRatePerDay 是视恒星时的角速率(弧度/日),用于观测者与天顶矢量的时间导数。 +const occultationSiderealRatePerDay = 2 * math.Pi * 1.00273790935 + +// occultationRiseSetBodyVelocity 由前后时刻的体位置给出速度(千米/日);点源(距离为零)返回零。 +func occultationRiseSetBodyVelocity(before, after occultationRiseSetBody, stepDays float64) occultationPathVector { + if stepDays <= 0 || before.distanceKM <= 0 || after.distanceKM <= 0 { + return occultationPathVector{} + } + return occultationPathScale(occultationPathSub(after.positionKM, before.positionKM), 1/(2*stepDays)) +} + +// occultationRiseSetDirectionRate 给出单位方向的时间导数 du/dt = (v − u(u·v))/|p|。 +func occultationRiseSetDirectionRate(position, velocity, direction occultationPathVector) occultationPathVector { + norm := occultationPathNorm(position) + if norm <= 0 { + return occultationPathVector{} + } + radial := occultationPathScale(direction, occultationPathDot(direction, velocity)) + return occultationPathScale(occultationPathSub(velocity, radial), 1/norm) +} + +// occultationRiseSetRadiusRate 给出视半径 asin(R/d) 的解析时间导数(度/日),d 为站心距离。 +func occultationRiseSetRadiusRate( + body occultationRiseSetBody, + observer, direction, velocity occultationPathVector, + radiusKM float64, +) (float64, float64, bool) { + if body.distanceKM <= 0 || radiusKM <= 0 { + return 0, 0, true + } + position := occultationPathSub(body.positionKM, observer) + distance := occultationPathNorm(position) + if distance <= radiusKM { + return 0, 0, false + } + ratio := radiusKM / distance + sinRadius := math.Max(-1, math.Min(1, ratio)) + cosRadius := math.Sqrt(math.Max(0, 1-sinRadius*sinRadius)) + if cosRadius <= 1e-12 { + return 0, 0, false + } + return math.Asin(sinRadius) / rad, + -(ratio / distance) * occultationPathDot(velocity, direction) / cosRadius / rad, true +} + +// contactRateAt 用体位置的前后差分给出接触度量的解析时间导数(度/日):与 contactDerivative 的中心差分同口径, +// 但没有差分噪声,且不需要为前后时刻各求一次站心几何。 +func (evaluation occultationRiseSetEvaluation) contactRateAt(longitude, latitude float64) float64 { + center := evaluation.center + observer, observerDistance, _ := occultationRiseSetObserverVectors(center.siderealDegrees, longitude, latitude) + observerVelocity := occultationPathCross(occultationPathVector{z: occultationSiderealRatePerDay}, observer) + moonPosition := occultationPathSub(center.moon.positionKM, observer) + targetPosition := occultationPathSub(center.target.positionKM, observer) + moonDirection := occultationRiseSetTopocentricDirection(center.moon, observer) + targetDirection := occultationRiseSetTopocentricDirection(center.target, observer) + moonVelocity := occultationPathSub( + occultationRiseSetBodyVelocity(evaluation.before.moon, evaluation.after.moon, occultationRiseSetDerivativeStepDays), + observerVelocity, + ) + targetVelocity := occultationPathSub( + occultationRiseSetBodyVelocity(evaluation.before.target, evaluation.after.target, occultationRiseSetDerivativeStepDays), + observerVelocity, + ) + if center.target.distanceKM <= 0 { + // 点源目标没有站心视差:视线方向就是地心视方向,速率取单位矢量的前后差分。 + // 零位置减观测者会得到日尺度的虚假速率,掩带边界因此无法加密。 + targetPosition = targetDirection + targetVelocity = occultationPathScale( + occultationPathSub(evaluation.after.target.direction, evaluation.before.target.direction), + 1/(2*occultationRiseSetDerivativeStepDays), + ) + } + moonDirectionRate := occultationRiseSetDirectionRate(moonPosition, moonVelocity, moonDirection) + targetDirectionRate := occultationRiseSetDirectionRate(targetPosition, targetVelocity, targetDirection) + cosSeparation := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, targetDirection))) + sinSeparation := math.Sqrt(math.Max(0, 1-cosSeparation*cosSeparation)) + if sinSeparation <= 1e-12 { + return math.NaN() + } + separationRate := -(occultationPathDot(moonDirectionRate, targetDirection) + + occultationPathDot(moonDirection, targetDirectionRate)) / sinSeparation / rad + if occultationRiseSetTopocentricDistance(center.moon, observerDistance) <= 0 { + return math.NaN() + } + moonRadius, moonRadiusRate, moonOK := occultationRiseSetRadiusRate( + center.moon, observer, moonDirection, moonVelocity, moonEquatorialRadiusKM, + ) + targetRadius, targetRadiusRate, targetOK := occultationRiseSetRadiusRate( + center.target, observer, targetDirection, targetVelocity, center.targetRadiusKM, + ) + if !moonOK || !targetOK { + return math.NaN() + } + if center.internalContact { + if moonRadius >= targetRadius { + return separationRate - (moonRadiusRate - targetRadiusRate) + } + return separationRate + (moonRadiusRate - targetRadiusRate) + } + return separationRate - moonRadiusRate - targetRadiusRate +} + +// moonAltitudeRateAt 给出月球几何高度角的解析时间导数(度/日)。 +func (evaluation occultationRiseSetEvaluation) moonAltitudeRateAt(longitude, latitude float64) float64 { + center := evaluation.center + observer, _, zenith := occultationRiseSetObserverVectors(center.siderealDegrees, longitude, latitude) + spin := occultationPathVector{z: occultationSiderealRatePerDay} + observerVelocity := occultationPathCross(spin, observer) + moonPosition := occultationPathSub(center.moon.positionKM, observer) + moonDirection := occultationRiseSetTopocentricDirection(center.moon, observer) + moonVelocity := occultationPathSub( + occultationRiseSetBodyVelocity(evaluation.before.moon, evaluation.after.moon, occultationRiseSetDerivativeStepDays), + observerVelocity, + ) + moonDirectionRate := occultationRiseSetDirectionRate(moonPosition, moonVelocity, moonDirection) + sinAltitude := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, zenith))) + cosAltitude := math.Sqrt(math.Max(0, 1-sinAltitude*sinAltitude)) + if cosAltitude <= 1e-12 { + return math.NaN() + } + return (occultationPathDot(moonDirectionRate, zenith) + + occultationPathDot(moonDirection, occultationPathCross(spin, zenith))) / cosAltitude / rad +} + func (evaluation occultationRiseSetEvaluation) contactDerivative(longitude, latitude float64) float64 { before := evaluation.before.stateAt(longitude, latitude) after := evaluation.after.stateAt(longitude, latitude) diff --git a/basic/occultation_rise_set_vector_test.go b/basic/occultation_rise_set_vector_test.go index 4c19eb5..3c79a6c 100644 --- a/basic/occultation_rise_set_vector_test.go +++ b/basic/occultation_rise_set_vector_test.go @@ -189,7 +189,7 @@ func legacyOccultationRiseSetStateAt( targetRA, targetDec, targetDistanceKM, targetRadiusKM, longitude, latitude float64, ) occultationRiseSetState { - siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 + siderealDegrees := ApparentSiderealTime(TT2UT1(tt)) * 15 observer := Observer{Longitude: longitude, Latitude: latitude} moonTopocentricRA, moonTopocentricDec := topocentricRaDecWithSidereal( moonRA, moonDec, latitude, longitude, siderealDegrees, diff --git a/basic/occultation_star.go b/basic/occultation_star.go index 1f9e909..4abe9be 100644 --- a/basic/occultation_star.go +++ b/basic/occultation_star.go @@ -273,7 +273,7 @@ func starOccultationMinimizeValue(left, right float64, value func(float64) float func starOccultationGeocentricSeparationArcsec(tt float64, star StarCoordinate) float64 { moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) - starRA, starDec := starApparentRaDecGeocentric(tt, star) + starRA, starDec, _ := starApparentRaDecDistanceGeocentric(tt, star) return angularSeparationDegrees(moonRA, moonDec, starRA, starDec) * 3600 } @@ -389,45 +389,107 @@ func starMoonPositionAt(tt float64, star StarCoordinate, observer Observer) star func moonTopocentricApparentRaDec(tt float64, observer Observer, n int) (float64, float64) { ra, dec := HMoonGeocentricApparentRaDecN(tt, n) - ut := TD2UT(tt, false) + ut := TT2UTC(tt) distanceAU := HMoonAwayN(tt, n) / 149597870.7 ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, ut, distanceAU, observer.Height) return normalizeRA(ra), dec } func starApparentRaDec(tt float64, star StarCoordinate, observer Observer) (float64, float64) { - ra, dec := starApparentRaDecGeocentric(tt, star) - if star.ParallaxMas > 0 { - // 1 秒差距处 1 角秒对应 206264.806 AU。 - // One arcsecond at 1 pc corresponds to 206264.806 AU. - distanceAU := 206264806.247 / star.ParallaxMas - ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, TD2UT(tt, false), distanceAU, observer.Height) + ra, dec, distanceAU := starApparentRaDecDistanceGeocentric(tt, star) + if distanceAU > 0 { + ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, TT2UTC(tt), distanceAU, observer.Height) ra = normalizeRA(ra) } return ra, dec } func starApparentRaDecGeocentric(tt float64, star StarCoordinate) (float64, float64) { + ra, dec, _ := starApparentRaDecDistanceGeocentric(tt, star) + return ra, dec +} + +// starApparentRaDecDistanceGeocentric 同时给出视位置与推进后的距离(天文单位,0 表示距离未知)。 +func starApparentRaDecDistanceGeocentric(tt float64, star StarCoordinate) (float64, float64, float64) { epochJD := occultationTimeToTT(star.Epoch) - years := (tt - epochJD) / 365.25 + parallaxMas := star.parallaxMas() ra := star.RA dec := star.Dec precessionEpoch := 2451545.0 if star.Frame == CoordinateFrameICRS { ra, dec = starICRSToMeanJ2000RaDec(ra, dec) } else if star.Frame == CoordinateFrameApparentOfDate { - ra, dec = starApparentToMeanRaDec(epochJD, ra, dec, star.ParallaxMas) + ra, dec = starApparentToMeanRaDec(epochJD, ra, dec, parallaxMas) precessionEpoch = epochJD } + epochDistanceAU := starEpochDistanceAU(parallaxMas) + ra, dec, distanceAU := starProperMotionRaDec(tt, star, ra, dec, epochDistanceAU) + ra, dec = Precess(ra, dec, precessionEpoch, tt) + ra, dec = starMeanToApparentRaDec(tt, ra, dec, parallaxMasAtDistance(parallaxMas, distanceAU)) + return ra, dec, distanceAU +} + +// starEpochDistanceAU 由历元视差给出距离,非正表示距离未知。 +func starEpochDistanceAU(parallaxMas float64) float64 { + if parallaxMas <= 0 { + return 0 + } + return 206264806.247 / parallaxMas +} + +// starPropagatedPositionAU 把历元位置矢量按三维匀速直线运动推进一个历元差。 +// 切向速度取自行乘历元距离,视向分量取径向速度;返回推进后的矢量,其模长即当日距离。 +func starPropagatedPositionAU(star StarCoordinate, ra, dec, epochDistanceAU, years float64) ([3]float64, float64) { + if epochDistanceAU <= 0 { + return [3]float64{}, 0 + } + raRad := ra * math.Pi / 180 + decRad := dec * math.Pi / 180 + cosDec, sinDec := math.Cos(decRad), math.Sin(decRad) + cosRA, sinRA := math.Cos(raRad), math.Sin(raRad) + pmRA := star.ProperMotionRACosDecMasPerYear / 1000 * math.Pi / (180 * 3600) * epochDistanceAU + pmDec := star.ProperMotionDecMasPerYear / 1000 * math.Pi / (180 * 3600) * epochDistanceAU + radial := star.RadialVelocityKmPerSecond * 365.25 * 86400 / 149597870.7 + position := [3]float64{ + epochDistanceAU*cosDec*cosRA + years*(pmDec*(-sinDec*cosRA)-pmRA*sinRA+radial*cosDec*cosRA), + epochDistanceAU*cosDec*sinRA + years*(pmDec*(-sinDec*sinRA)+pmRA*cosRA+radial*cosDec*sinRA), + epochDistanceAU*sinDec + years*(pmDec*cosDec+radial*sinDec), + } + norm := math.Sqrt(position[0]*position[0] + position[1]*position[1] + position[2]*position[2]) + return position, norm +} + +// parallaxMasAtDistance 把推进后的距离折回周年视差,视差修正必须跟着距离一起变。 +func parallaxMasAtDistance(parallaxMas, distanceAU float64) float64 { + if parallaxMas <= 0 || distanceAU <= 0 { + return 0 + } + return 206264806.247 / distanceAU +} + +// starProperMotionRaDec 把历元输入坐标推进到 tt,并给出推进后的距离。 +// 距离已知走三维、否则只推进两个角分量(此时距离返回 0)。 +func starProperMotionRaDec(tt float64, star StarCoordinate, ra, dec, epochDistanceAU float64) (float64, float64, float64) { + years := (tt - occultationTimeToTT(star.Epoch)) / 365.25 + if epochDistanceAU > 0 { + return starProperMotionRaDec3D(ra, dec, years, epochDistanceAU, star) + } cosDec := math.Cos(dec * math.Pi / 180) if math.Abs(cosDec) > 1e-12 { ra += years * star.ProperMotionRACosDecMasPerYear / (3600000.0 * cosDec) } dec += years * star.ProperMotionDecMasPerYear / 3600000.0 - dec = math.Max(-90, math.Min(90, dec)) + return ra, math.Max(-90, math.Min(90, dec)), 0 +} - ra, dec = Precess(ra, dec, precessionEpoch, tt) - return starMeanToApparentRaDec(tt, ra, dec, star.ParallaxMas) +// starProperMotionRaDec3D 按三维匀速直线运动推进:赤经赤纬只是位置矢量的方向。 +func starProperMotionRaDec3D(ra, dec, years, epochDistanceAU float64, star StarCoordinate) (float64, float64, float64) { + position, norm := starPropagatedPositionAU(star, ra, dec, epochDistanceAU, years) + outRA := math.Atan2(position[1], position[0]) * 180 / math.Pi + if outRA < 0 { + outRA += 360 + } + return outRA, math.Asin(position[2]/norm) * 180 / math.Pi, norm } func starICRSToMeanJ2000RaDec(ra, dec float64) (float64, float64) { @@ -628,7 +690,7 @@ func occultationPositionAngle(moonRA, moonDec, starRA, starDec float64) float64 func occultationAltitude(tt float64, observer Observer, ra, dec float64) float64 { return occultationAltitudeWithSidereal( - ApparentSiderealTime(TD2UT(tt, false))*15, observer, ra, dec, + ApparentSiderealTime(TT2UT1(tt))*15, observer, ra, dec, ) } @@ -641,7 +703,7 @@ func occultationAltitudeWithSidereal(siderealDegrees float64, observer Observer, } func occultationAzimuth(tt float64, observer Observer, ra, dec float64) float64 { - hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180 + hourAngle := signedAngleDifference(ApparentSiderealTime(TT2UT1(tt))*15+observer.Longitude, ra) * math.Pi / 180 lat := observer.Latitude * math.Pi / 180 declination := dec * math.Pi / 180 y := math.Sin(hourAngle) @@ -666,12 +728,12 @@ func normalizeRA(ra float64) float64 { } func occultationTimeToTT(value time.Time) float64 { - return TD2UT(Date2JDE(value.UTC()), true) + return UTC2TT(Date2JD(value.UTC())) } func occultationTTToLocation(tt float64, location *time.Location) time.Time { if location == nil { location = time.UTC } - return JDE2DateByZone(TD2UT(tt, false), location, false) + return JD2DateByZone(TT2UTC(tt), location, false) } diff --git a/basic/occultation_star_3d_test.go b/basic/occultation_star_3d_test.go new file mode 100644 index 0000000..7b1cfdc --- /dev/null +++ b/basic/occultation_star_3d_test.go @@ -0,0 +1,339 @@ +package basic + +import ( + "fmt" + "math" + "testing" + "time" + + "b612.me/astro/tools" +) + +// 本文件锁定掩星恒星坐标的距离契约:无距离走二维、给光年或视差走三维。 + +func occStar3DTestCoordinate(ra, dec float64) StarCoordinate { + return StarCoordinate{ + ID: "3D contract", + RA: ra, + Dec: dec, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: CoordinateFrameJ2000, + } +} + +func TestStarCoordinateDistanceGateKeepsTwoDimensions(t *testing.T) { + star := occStar3DTestCoordinate(189.1975, -5.831944444444) + star.ProperMotionRACosDecMasPerYear = -28 + star.ProperMotionDecMasPerYear = -18 + star.RadialVelocityKmPerSecond = -45 + if got := star.parallaxMas(); got != 0 { + t.Fatalf("parallaxMas() = %v, want 0 without any distance", got) + } + tt := occultationTimeToTT(time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC)) + gotRA, gotDec, gotDistance := starProperMotionRaDec(tt, star, star.RA, star.Dec, starEpochDistanceAU(star.parallaxMas())) + if gotDistance != 0 { + t.Fatalf("no-distance propagation reported distance %v, want 0", gotDistance) + } + cosDec := math.Cos(star.Dec * math.Pi / 180) + years := (tt - occultationTimeToTT(star.Epoch)) / 365.25 + wantRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*cosDec) + wantDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000 + if gotRA != wantRA || gotDec != wantDec { + t.Fatalf("no-distance propagation = %.12f %.12f, want 2D %.12f %.12f", gotRA, gotDec, wantRA, wantDec) + } +} + +func TestStarCoordinateLightYearDistanceMatchesParallax(t *testing.T) { + const lightYears = 10 + byLightYear := func() StarCoordinate { + star := occStar3DTestCoordinate(224.366667, -21.415556) + star.ProperMotionRACosDecMasPerYear = 1045 + star.ProperMotionDecMasPerYear = -1729 + star.RadialVelocityKmPerSecond = 20 + star.DistanceLightYear = lightYears + return star + }() + byParallax := byLightYear + byParallax.DistanceLightYear = 0 + byParallax.ParallaxMas = byLightYear.parallaxMas() + if byParallax.ParallaxMas <= 0 || byParallax.ParallaxMas > 400 { + t.Fatalf("derived parallax = %v mas, want a positive sub-arcsecond value", byParallax.ParallaxMas) + } + tt := occultationTimeToTT(time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC)) + lightRA, lightDec := starApparentRaDecGeocentric(tt, byLightYear) + parallaxRA, parallaxDec := starApparentRaDecGeocentric(tt, byParallax) + if lightRA != parallaxRA || lightDec != parallaxDec { + t.Fatalf("light-year path = %.12f %.12f, want bit-identical to parallax path %.12f %.12f", + lightRA, lightDec, parallaxRA, parallaxDec) + } + if separation := starSepArcsec(lightRA, lightDec, byLightYear.RA, byLightYear.Dec); separation < 60 { + t.Fatalf("proper motion over 50 years moved the star only %.6f arcsec", separation) + } +} + +func TestStarCoordinateParallaxTakesPriorityOverLightYear(t *testing.T) { + star := occStar3DTestCoordinate(120, 15) + star.DistanceLightYear = 10 + if got, want := star.parallaxMas(), 1000/tools.DistanceToParsecs(10, tools.DistanceLightYear); math.Abs(got-want) > 1e-9 { + t.Fatalf("parallaxMas() = %.12f, want %.12f derived from light-years", got, want) + } + star.ParallaxMas = 25 + if got := star.parallaxMas(); got != 25 { + t.Fatalf("parallaxMas() = %v, want the explicit 25 mas to win", got) + } +} + +func TestStarCoordinateRadialVelocityContract(t *testing.T) { + base := occStar3DTestCoordinate(120, 15) + for _, velocity := range []float64{0, -500, 500, starRadialVelocityLimitKmPerSecond} { + star := base + star.RadialVelocityKmPerSecond = velocity + if err := star.Validate(); err != nil { + t.Fatalf("radial velocity %v rejected: %v", velocity, err) + } + } + for _, velocity := range []float64{-5000, 5000, math.NaN(), math.Inf(1), math.Inf(-1)} { + star := base + star.RadialVelocityKmPerSecond = velocity + if err := star.Validate(); err == nil { + t.Fatalf("radial velocity %v accepted, want a contract error", velocity) + } + } + for _, distance := range []float64{-1, math.NaN(), math.Inf(1)} { + star := base + star.DistanceLightYear = distance + if err := star.Validate(); err == nil { + t.Fatalf("light-year distance %v accepted, want a contract error", distance) + } + } +} + +func TestStarCoordinateThreeDimensionsShiftsOccultationTimingWithinBudget(t *testing.T) { + // HR 5568 于 2026-01-13 的一次真实全掩;同一站、同一窗口下比较三种口径的真实接触时刻。 + star := occStar3DTestCoordinate(224.366667, -21.415556) + star.ProperMotionRACosDecMasPerYear = 1045 + star.ProperMotionDecMasPerYear = -1729 + star.ParallaxMas = 173 + star.RadialVelocityKmPerSecond = 20 + noRadial := star + noRadial.RadialVelocityKmPerSecond = 0 + noDistance := star + noDistance.ParallaxMas = 0 + + const longitude, latitude = 97.471, -45.720 + start := time.Date(2026, 1, 12, 18, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 13, 6, 0, 0, 0, time.UTC) + + event := func(c StarCoordinate) StarOccultationInfo { + t.Helper() + events, err := FindStarOccultations(start, end, c, longitude, latitude, 0, OccultationSearchOptions{}) + if err != nil { + t.Fatalf("FindStarOccultations: %v", err) + } + if len(events) != 1 { + t.Fatalf("event count = %d, want 1", len(events)) + } + return events[0] + } + // 无距离确实走的是二维分支,否则下面的对照没有意义。 + if noDistance.parallaxMas() != 0 || star.parallaxMas() != 173 { + t.Fatalf("parallax gate = %v / %v, want 0 for the 2D branch and 173 for the 3D branch", + noDistance.parallaxMas(), star.parallaxMas()) + } + unlimited := event(star) + limited := event(noDistance) + if unlimited.Type != OccultationTotal || limited.Type != OccultationTotal { + t.Fatalf("event types = %v / %v, want both total", unlimited.Type, limited.Type) + } + + milliseconds := func(a, b time.Time) float64 { return math.Abs(a.Sub(b).Seconds()) * 1000 } + // 纯空间运动项:只把径向速度清零,几何仍为三维。这是本轮三维改动引入的那一项。 + // 实测约 14 ms(路径距离也随径向项变化后由 8.6 ms 升到 14 ms),阈值留约 1.8 倍余量, + // 目的是抓口径回退,不是卡精度指标。 + if got := milliseconds(unlimited.Immersion, event(noRadial).Immersion); got > 25 { + t.Fatalf("space-motion term shifts immersion by %.1f ms, want the 25 ms budget respected", got) + } + // 距离有无会额外启用站心视差修正,属于既有几何而非本轮改动,量级单独记录。 + fmt.Printf("空间运动项 %.1f ms;距离项 %.1f ms\n", + milliseconds(unlimited.Immersion, event(noRadial).Immersion), + milliseconds(unlimited.Immersion, limited.Immersion)) +} + +func TestStarCoordinateTwoAndThreeDimensionsAreDistinctGeometries(t *testing.T) { + // 径向速度反号必须让三维结果分居两侧,且二维分支确实给出不同的角距, + // 否则"三维对二维"的对照就是空保证。 + star := occStar3DTestCoordinate(224.366667, -21.415556) + star.ProperMotionRACosDecMasPerYear = 1045 + star.ProperMotionDecMasPerYear = -1729 + star.ParallaxMas = 173 + star.RadialVelocityKmPerSecond = -100 + away := star + away.RadialVelocityKmPerSecond = 100 + noDistance := star + noDistance.ParallaxMas = 0 + + tt := occultationTimeToTT(time.Date(2026, 1, 13, 0, 14, 7, 0, time.UTC)) + observer := Observer{Longitude: 97.471, Latitude: -45.720} + approaching := starMoonSeparationArcsec(tt, star, observer) + receding := starMoonSeparationArcsec(tt, away, observer) + twoDimension := starMoonSeparationArcsec(tt, noDistance, observer) + if math.Abs(approaching-receding) < 0.005 { + t.Fatalf("opposite radial velocities separate by only %.6f arcsec, want a measurable space-motion term", + math.Abs(approaching-receding)) + } + if math.Abs(twoDimension-approaching) < 0.05 { + t.Fatalf("2D separation %.6f arcsec is indistinguishable from the 3D one %.6f", twoDimension, approaching) + } + fmt.Printf("同一时刻角距:三维 rv=-100 为 %.4f\",二维为 %.4f\",三维 rv=+100 为 %.4f\"\n", + approaching, twoDimension, receding) +} + +func TestStarCoordinatePathDistanceAcceptsLightYears(t *testing.T) { + // 光年与等价视差必须进入同一套路径几何,不能一个当无穷远。 + byParallax := occStar3DTestCoordinate(224.366667, -21.415556) + byParallax.ParallaxMas = 173 + byLightYear := byParallax + byLightYear.ParallaxMas = 0 + byLightYear.DistanceLightYear = (1000.0 / 173) * tools.AstronomicalUnitKilometers * (648000 / math.Pi) / tools.LightYearKilometers + + tt := occultationTimeToTT(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)) + fromParallax := starOccultationEphemerisStateAt(tt, byParallax) + fromLightYear := starOccultationEphemerisStateAt(tt, byLightYear) + if fromParallax.starDistanceKM <= 0 { + t.Fatal("parallax input should yield a finite path distance") + } + // 两种等价输入只允许差一个浮点往返的量级。 + if relative := math.Abs(fromLightYear.starDistanceKM-fromParallax.starDistanceKM) / fromParallax.starDistanceKM; relative > 1e-12 { + t.Fatalf("path distance = %g km from light-years, want %g km from the equivalent parallax (relative %.3g)", + fromLightYear.starDistanceKM, fromParallax.starDistanceKM, relative) + } + kept := newStarOccultationLocalEphemeris(tt, byLightYear) + if relative := math.Abs(kept.starDistanceKM()-fromParallax.starDistanceKM) / fromParallax.starDistanceKM; relative > 1e-12 { + t.Fatalf("local ephemeris distance = %g km, want %g km (relative %.3g)", kept.starDistanceKM(), fromParallax.starDistanceKM, relative) + } +} + +func TestStarCoordinatePathGeometryUsesPropagatedDistance(t *testing.T) { + // 路径与本地星历必须用当日的距离,而不是历元距离。1 pc、径向 -100 km/s,1000 年后 + // 距离缩短约 10%,两种口径给出的路径距离必须能区分开。 + star := occStar3DTestCoordinate(189.1975, -5.831944444444) + star.ParallaxMas = 1000 + star.RadialVelocityKmPerSecond = -100 + epochDistanceKM := 206264806.247 / star.ParallaxMas * occultationPathAstronomicalUnitKM + + for _, years := range []float64{26, 1000, 3000} { + tt := occultationTimeToTT(star.Epoch) + years*365.25 + state := starOccultationEphemerisStateAt(tt, star) + if !state.valid { + t.Fatalf("ephemeris state invalid at %.0f years", years) + } + _, _, distanceAU := starApparentRaDecDistanceGeocentric(tt, star) + want := distanceAU * occultationPathAstronomicalUnitKM + if math.Abs(state.starDistanceKM-want) > want*1e-12 { + t.Fatalf("path distance at %.0f years = %g km, want the propagated %g km", years, state.starDistanceKM, want) + } + if years >= 1000 && math.Abs(state.starDistanceKM-epochDistanceKM) < want*0.01 { + t.Fatalf("path distance at %.0f years matches the epoch distance, so the radial term is still dropped", years) + } + if got := newStarOccultationLocalEphemeris(tt, star).starDistanceKM(); math.Abs(got-want) > want*1e-12 { + t.Fatalf("local ephemeris distance at %.0f years = %g km, want %g km", years, got, want) + } + } + + // 径向速度为零时两种口径必须重合,否则说明引入了与运动无关的偏移。 + star.RadialVelocityKmPerSecond = 0 + still := starOccultationEphemerisStateAt(occultationTimeToTT(star.Epoch)+1000*365.25, star) + if math.Abs(still.starDistanceKM-epochDistanceKM) > epochDistanceKM*1e-9 { + t.Fatalf("path distance without radial motion = %g km, want the epoch %g km", still.starDistanceKM, epochDistanceKM) + } +} + +// TestStarCoordinateOptimizedEphemerisKeepsFiniteDistance 固定优化分支(密集星历)也带当日距离: +// 节点按每个采样时刻的推进距离装配,状态回读若丢掉它,有限距离会悄悄退化成无穷远框架, +// 而且 exact 与 optimized 两条分支会在同一颗带视差恒星上给出不同的帧。 +func TestStarCoordinateOptimizedEphemerisKeepsFiniteDistance(t *testing.T) { + star := occStar3DTestCoordinate(189.1975, -5.831944444444) + star.ProperMotionRACosDecMasPerYear = -28 + star.ProperMotionDecMasPerYear = -18 + star.ParallaxMas = 768.5 + star.RadialVelocityKmPerSecond = -30 + + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) + center := occultationTimeToTT(start.Add(12 * time.Hour)) + _, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star) + want := distanceAU * occultationPathAstronomicalUnitKM + + cache := newStarOccultationEventCache(star) + cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized) + if cache.local == nil || !cache.local.dense { + t.Fatalf("dense ephemeris not selected, the optimized branch is not exercised") + } + if got := cache.local.starDistanceKM(); math.Abs(got-want) > want*1e-9 { + t.Fatalf("optimized ephemeris distance = %g km, want the propagated %g km", got, want) + } + state, ok := cache.local.stateAt(center) + if !ok { + t.Fatal("optimized state unavailable") + } + if math.Abs(state.starDistanceKM-want) > want*1e-6 { + t.Fatalf("optimized state distance = %g km, want the propagated %g km", state.starDistanceKM, want) + } + denseFrame, ok := starOccultationPathFrameFromState(state) + if !ok { + t.Fatal("optimized frame unavailable") + } + exactFrame, ok := starOccultationPathFrameFromState(starOccultationEphemerisStateAt(center, star)) + if !ok { + t.Fatal("exact frame unavailable") + } + if angle := occultationPathNorm(occultationPathCross(denseFrame.axis, exactFrame.axis)) * 180 / math.Pi * 3600 * 1000; angle > 1e-6 { + t.Fatalf("optimized and exact frame axes differ by %.3e mas, want only the dense table's own error", angle) + } + + // 距离未知时仍按 0 上报,几何保持无穷远,不要退化成"1 km 处"的假视差。 + unknown := star + unknown.ParallaxMas = 0 + unknownCache := newStarOccultationEventCache(unknown) + unknownCache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized) + if unknownCache.local == nil { + t.Fatal("local ephemeris missing for the distance-free star") + } + if got := unknownCache.local.starDistanceKM(); got != 0 { + t.Fatalf("distance-free ephemeris distance = %g, want 0", got) + } + if state, ok := unknownCache.local.stateAt(center); !ok || state.starDistanceKM != 0 { + t.Fatalf("distance-free state distance = %g (ok=%v), want 0", state.starDistanceKM, ok) + } + + // 路径层:两条分支必须给出同一条路径。 + options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200} + end := start.Add(24 * time.Hour) + exactOptions, optimizedOptions := options, options + exactOptions.Algorithm = OccultationPathAlgorithmExact + optimizedOptions.Algorithm = OccultationPathAlgorithmOptimized + exactPaths, err := FindStarOccultationPaths(start, end, star, exactOptions) + if err != nil || len(exactPaths) == 0 { + t.Fatalf("exact path: %v (paths=%d)", err, len(exactPaths)) + } + optimizedPaths, err := FindStarOccultationPaths(start, end, star, optimizedOptions) + if err != nil || len(optimizedPaths) == 0 { + t.Fatalf("optimized path: %v (paths=%d)", err, len(optimizedPaths)) + } + exact, optimized := exactPaths[0], optimizedPaths[0] + for _, moment := range []struct { + name string + exact, optimiz time.Time + }{ + {"start", exact.Start.Time, optimized.Start.Time}, + {"greatest", exact.Greatest.Time, optimized.Greatest.Time}, + {"end", exact.End.Time, optimized.End.Time}, + } { + if delta := math.Abs(moment.exact.Sub(moment.optimiz).Seconds()); delta > 1e-3 { + t.Fatalf("%s differs by %.6f s between the two ephemeris branches", moment.name, delta) + } + } + if delta := math.Abs(exact.Greatest.WidthKM - optimized.Greatest.WidthKM); delta > 1e-3 { + t.Fatalf("greatest width differs by %.6f km between the two ephemeris branches", delta) + } +} diff --git a/basic/occultation_star_internal_test.go b/basic/occultation_star_internal_test.go index aa29cb1..f0495d0 100644 --- a/basic/occultation_star_internal_test.go +++ b/basic/occultation_star_internal_test.go @@ -102,7 +102,7 @@ func TestStarOccultationApparentPlaceCorrections(t *testing.T) { t.Fatalf("apparent place = %.9f %.9f, want near 189.527817 -5.973401", gotRA, gotDec) } - years := (tt - Date2JDE(star.Epoch.UTC())) / 365.25 + years := (tt - Date2JD(star.Epoch.UTC())) / 365.25 meanRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*math.Cos(star.Dec*math.Pi/180)) meanDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000 meanRA, meanDec = Precess(meanRA, meanDec, 2451545, tt) @@ -226,7 +226,7 @@ func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) func TestOccultationPathCachedEarthRotationMatchesDirectGeometry(t *testing.T) { tt := occultationTimeToTT(time.Date(2025, time.June, 5, 12, 2, 6, 0, time.UTC)) vector := occultationPathVector{x: 4123.5, y: -2789.25, z: 3950.75} - angle := ApparentSiderealTime(TD2UT(tt, false)) * 15 * math.Pi / 180 + angle := ApparentSiderealTime(TT2UT1(tt)) * 15 * math.Pi / 180 want := occultationPathVector{ x: math.Cos(angle)*vector.x + math.Sin(angle)*vector.y, y: -math.Sin(angle)*vector.x + math.Cos(angle)*vector.y, @@ -257,7 +257,7 @@ func TestOccultationPathCachedMoonAndSiderealMatchDirectPoint(t *testing.T) { direct := occultationPathPointFromVector(tt, vector, 1234.5, time.UTC) cached := occultationPathPointFromVectorWithMoonSidereal( - tt, vector, 1234.5, frame.moon, ApparentSiderealTime(TD2UT(tt, false))*15, time.UTC, + tt, vector, 1234.5, frame.moon, ApparentSiderealTime(TT2UT1(tt))*15, time.UTC, ) if !cached.Time.Equal(direct.Time) { t.Fatalf("cached point time = %v, want %v", cached.Time, direct.Time) diff --git a/basic/occultation_station_correction.go b/basic/occultation_station_correction.go index 821e084..8ee6447 100644 --- a/basic/occultation_station_correction.go +++ b/basic/occultation_station_correction.go @@ -991,7 +991,12 @@ func occultationStationEnvelopeJacobian( tt := referenceTT + coordinates[2]/occultationStationEnvelopeTimeScale longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] evaluation := cache.evaluation(tt) - residual, ok := model.residual(evaluation, longitude, latitude) + // 点源目标(恒星)没有盘面半径项,接触度量可用解析速率;有限盘面行星保持中心差分。 + residualAt := model.residual + if evaluation.center.targetRadiusKM <= 0 { + residualAt = model.residualWithRate + } + residual, ok := residualAt(evaluation, longitude, latitude) if !ok { return [2]float64{}, [2][3]float64{}, false } @@ -1002,7 +1007,7 @@ func occultationStationEnvelopeJacobian( shifted[column] += steps[column] shiftedTT := referenceTT + shifted[2]/occultationStationEnvelopeTimeScale shiftedEvaluation := cache.evaluation(shiftedTT) - shiftedResidual, shiftedOK := model.residual( + shiftedResidual, shiftedOK := residualAt( shiftedEvaluation, normalizeLongitude(shifted[0]), shifted[1], ) if !shiftedOK { @@ -1028,6 +1033,30 @@ func (model occultationStationEnvelopeModel) residual( return [2]float64{state.contactMetric, derivative}, state.valid && finite(derivative) } +// residualWithRate 与 residual 取值完全相同,但把时间导数换成解析速率:每列只需一次站心几何求值, +// 不再为前后时刻各求一次状态。接受判据仍走 residual 的精确中心差分。 +func (model occultationStationEnvelopeModel) residualWithRate( + evaluation occultationRiseSetEvaluation, + longitude, latitude float64, +) ([2]float64, bool) { + state := evaluation.center.stateAt(longitude, latitude) + if !state.valid { + return [2]float64{}, false + } + if model.kind == occultationStationVisibilityEnvelope { + rate := evaluation.moonAltitudeRateAt(longitude, latitude) + if !finite(rate) { + return [2]float64{}, false + } + return [2]float64{state.moonAltitude, rate}, true + } + rate := evaluation.contactRateAt(longitude, latitude) + if !finite(rate) { + return [2]float64{}, false + } + return [2]float64{state.contactMetric, rate}, true +} + func (model occultationStationEnvelopeModel) valueTolerance() float64 { if model.kind == occultationStationVisibilityEnvelope { return occultationStationHorizonResidualToleranceDeg @@ -1530,7 +1559,7 @@ func occultationStationOracleSampleAt( ) occultationStationBoundarySample { rotation := occultationPathEarthRotationAt(tt) inertial := occultationStationInverseEarthRotation(fixed, rotation) - longitude, latitude := occultationPathGeodeticWithSidereal(inertial, ApparentSiderealTime(TD2UT(tt, false))*15) + longitude, latitude := occultationPathGeodeticWithSidereal(inertial, ApparentSiderealTime(TT2UT1(tt))*15) return occultationStationBoundarySample{ point: OccultationPathPoint{ Time: occultationTTToLocation(tt, location), diff --git a/basic/orbit_coordinates.go b/basic/orbit_coordinates.go index 107430a..dd0244f 100644 --- a/basic/orbit_coordinates.go +++ b/basic/orbit_coordinates.go @@ -10,8 +10,8 @@ import ( var orbitJ2000Obliquity = EclipticObliquity(orbitReferenceJD, false) // OrbitHeliocentricXYZJ2000 返回日心 J2000 平黄道直角坐标,单位 AU。 -func OrbitHeliocentricXYZJ2000(jd float64, elements OrbitElements) Vector3 { - trueAnomaly, radius, resolved, ok := orbitTrueAnomalyAndRadius(jd, elements) +func OrbitHeliocentricXYZJ2000(jde float64, elements OrbitElements) Vector3 { + trueAnomaly, radius, resolved, ok := orbitTrueAnomalyAndRadius(jde, elements) if !ok { nan := math.NaN() return Vector3{nan, nan, nan} @@ -33,24 +33,24 @@ func OrbitHeliocentricXYZJ2000(jd float64, elements OrbitElements) Vector3 { } // OrbitHeliocentricEclipticJ2000 返回日心 J2000 平黄道球坐标,单位度/AU。 -func OrbitHeliocentricEclipticJ2000(jd float64, elements OrbitElements) (lon, lat, distance float64) { - return orbitVectorToEcliptic(OrbitHeliocentricXYZJ2000(jd, elements)) +func OrbitHeliocentricEclipticJ2000(jde float64, elements OrbitElements) (lon, lat, distance float64) { + return orbitVectorToEcliptic(OrbitHeliocentricXYZJ2000(jde, elements)) } // OrbitHeliocentricXYZ 返回日心历元黄道直角坐标,单位 AU。 -func OrbitHeliocentricXYZ(jd float64, elements OrbitElements) Vector3 { - return eclipticVectorAtReferenceEpoch(OrbitHeliocentricXYZJ2000(jd, elements), orbitReferenceJD, jd) +func OrbitHeliocentricXYZ(jde float64, elements OrbitElements) Vector3 { + return eclipticVectorAtReferenceEpoch(OrbitHeliocentricXYZJ2000(jde, elements), orbitReferenceJD, jde) } // OrbitHeliocentricEcliptic 返回日心历元黄道球坐标,单位度/AU。 -func OrbitHeliocentricEcliptic(jd float64, elements OrbitElements) (lon, lat, distance float64) { - return orbitVectorToEcliptic(OrbitHeliocentricXYZ(jd, elements)) +func OrbitHeliocentricEcliptic(jde float64, elements OrbitElements) (lon, lat, distance float64) { + return orbitVectorToEcliptic(OrbitHeliocentricXYZ(jde, elements)) } // OrbitGeocentricXYZJ2000 返回地心 J2000 平黄道直角坐标,单位 AU。 -func OrbitGeocentricXYZJ2000(jd float64, elements OrbitElements) Vector3 { - objectVector := OrbitHeliocentricXYZJ2000(jd, elements) - earthVector := earthHeliocentricVectorJ2000(jd) +func OrbitGeocentricXYZJ2000(jde float64, elements OrbitElements) Vector3 { + objectVector := OrbitHeliocentricXYZJ2000(jde, elements) + earthVector := earthHeliocentricVectorJ2000(jde) return Vector3{ objectVector[0] - earthVector[0], objectVector[1] - earthVector[1], @@ -59,14 +59,14 @@ func OrbitGeocentricXYZJ2000(jd float64, elements OrbitElements) Vector3 { } // OrbitGeocentricEclipticJ2000 返回地心 J2000 平黄道球坐标,单位度/AU。 -func OrbitGeocentricEclipticJ2000(jd float64, elements OrbitElements) (lon, lat, distance float64) { - return orbitVectorToEcliptic(OrbitGeocentricXYZJ2000(jd, elements)) +func OrbitGeocentricEclipticJ2000(jde float64, elements OrbitElements) (lon, lat, distance float64) { + return orbitVectorToEcliptic(OrbitGeocentricXYZJ2000(jde, elements)) } // OrbitGeocentricXYZ 返回地心历元黄道直角坐标,单位 AU。 -func OrbitGeocentricXYZ(jd float64, elements OrbitElements) Vector3 { - objectVector := OrbitHeliocentricXYZ(jd, elements) - earthVector := earthHeliocentricVectorOfDate(jd) +func OrbitGeocentricXYZ(jde float64, elements OrbitElements) Vector3 { + objectVector := OrbitHeliocentricXYZ(jde, elements) + earthVector := earthHeliocentricVectorOfDate(jde) return Vector3{ objectVector[0] - earthVector[0], objectVector[1] - earthVector[1], @@ -75,33 +75,33 @@ func OrbitGeocentricXYZ(jd float64, elements OrbitElements) Vector3 { } // OrbitGeocentricEcliptic 返回地心历元黄道球坐标,单位度/AU。 -func OrbitGeocentricEcliptic(jd float64, elements OrbitElements) (lon, lat, distance float64) { - return orbitVectorToEcliptic(OrbitGeocentricXYZ(jd, elements)) +func OrbitGeocentricEcliptic(jde float64, elements OrbitElements) (lon, lat, distance float64) { + return orbitVectorToEcliptic(OrbitGeocentricXYZ(jde, elements)) } // OrbitGeocentricEquatorialJ2000 返回地心 J2000 平赤道球坐标,单位度/AU。 -func OrbitGeocentricEquatorialJ2000(jd float64, elements OrbitElements) (ra, dec, distance float64) { - vector := rotateEclipticToEquatorial(OrbitGeocentricXYZJ2000(jd, elements), orbitJ2000Obliquity) +func OrbitGeocentricEquatorialJ2000(jde float64, elements OrbitElements) (ra, dec, distance float64) { + vector := rotateEclipticToEquatorial(OrbitGeocentricXYZJ2000(jde, elements), orbitJ2000Obliquity) return orbitVectorToEquatorial(vector) } // OrbitGeocentricEquatorial 返回地心历元平赤道球坐标,单位度/AU。 -func OrbitGeocentricEquatorial(jd float64, elements OrbitElements) (ra, dec, distance float64) { - vector := rotateEclipticToEquatorial(OrbitGeocentricXYZ(jd, elements), EclipticObliquity(jd, false)) +func OrbitGeocentricEquatorial(jde float64, elements OrbitElements) (ra, dec, distance float64) { + vector := rotateEclipticToEquatorial(OrbitGeocentricXYZ(jde, elements), EclipticObliquity(jde, false)) return orbitVectorToEquatorial(vector) } // OrbitAstrometricGeocentricXYZJ2000 返回光行时修正后的地心 J2000 平黄道直角坐标,单位 AU。 -func OrbitAstrometricGeocentricXYZJ2000(jd float64, elements OrbitElements) Vector3 { - if !isFinite(jd) { +func OrbitAstrometricGeocentricXYZJ2000(jde float64, elements OrbitElements) Vector3 { + if !isFinite(jde) { nan := math.NaN() return Vector3{nan, nan, nan} } - earthVector := earthHeliocentricVectorJ2000(jd) + earthVector := earthHeliocentricVectorJ2000(jde) lightTime := 0.0 result := Vector3{} for i := 0; i < 8; i++ { - objectVector := OrbitHeliocentricXYZJ2000(jd-lightTime, elements) + objectVector := OrbitHeliocentricXYZJ2000(jde-lightTime, elements) result = Vector3{ objectVector[0] - earthVector[0], objectVector[1] - earthVector[1], @@ -117,40 +117,40 @@ func OrbitAstrometricGeocentricXYZJ2000(jd float64, elements OrbitElements) Vect } // OrbitAstrometricGeocentricEquatorialJ2000 返回光行时修正后的地心 J2000 赤道坐标,单位度/AU。 -func OrbitAstrometricGeocentricEquatorialJ2000(jd float64, elements OrbitElements) (ra, dec, distance float64) { - vector := rotateEclipticToEquatorial(OrbitAstrometricGeocentricXYZJ2000(jd, elements), orbitJ2000Obliquity) +func OrbitAstrometricGeocentricEquatorialJ2000(jde float64, elements OrbitElements) (ra, dec, distance float64) { + vector := rotateEclipticToEquatorial(OrbitAstrometricGeocentricXYZJ2000(jde, elements), orbitJ2000Obliquity) return orbitVectorToEquatorial(vector) } // OrbitApparentGeocentricEcliptic 返回光行时与章动修正后的地心视黄道坐标,单位度/AU。 -func OrbitApparentGeocentricEcliptic(jd float64, elements OrbitElements) (lon, lat, distance float64) { - vectorDate := eclipticVectorAtReferenceEpoch(OrbitAstrometricGeocentricXYZJ2000(jd, elements), orbitReferenceJD, jd) +func OrbitApparentGeocentricEcliptic(jde float64, elements OrbitElements) (lon, lat, distance float64) { + vectorDate := eclipticVectorAtReferenceEpoch(OrbitAstrometricGeocentricXYZJ2000(jde, elements), orbitReferenceJD, jde) lon, lat, distance = orbitVectorToEcliptic(vectorDate) if math.IsNaN(lon) { return math.NaN(), math.NaN(), math.NaN() } - lon = Limit360(lon + Nutation2000Bi(jd)) + lon = Limit360(lon + Nutation2000Bi(jde)) return lon, lat, distance } // OrbitApparentGeocentricEquatorial 返回光行时与章动修正后的地心视赤道坐标,单位度/AU。 -func OrbitApparentGeocentricEquatorial(jd float64, elements OrbitElements) (ra, dec, distance float64) { - lon, lat, distance := OrbitApparentGeocentricEcliptic(jd, elements) +func OrbitApparentGeocentricEquatorial(jde float64, elements OrbitElements) (ra, dec, distance float64) { + lon, lat, distance := OrbitApparentGeocentricEcliptic(jde, elements) if math.IsNaN(lon) { return math.NaN(), math.NaN(), math.NaN() } - ra, dec = LoBoToRaDec(jd, lon, lat) + ra, dec = LoBoToRaDec(jde, lon, lat) return ra, dec, distance } // OrbitApparentTopocentricEquatorial 返回光行时、章动与站心修正后的视赤道坐标,单位度/AU。 -func OrbitApparentTopocentricEquatorial(jd, observerLon, observerLat, observerHeight float64, elements OrbitElements) (ra, dec, distance float64) { - geocentricRA, geocentricDec, geocentricDistance := OrbitApparentGeocentricEquatorial(jd, elements) +func OrbitApparentTopocentricEquatorial(jde, observerLon, observerLat, observerHeight float64, elements OrbitElements) (ra, dec, distance float64) { + geocentricRA, geocentricDec, geocentricDistance := OrbitApparentGeocentricEquatorial(jde, elements) if math.IsNaN(geocentricRA) { return math.NaN(), math.NaN(), math.NaN() } geocentricVector := orbitEquatorialVector(geocentricRA, geocentricDec, geocentricDistance) - observerVector := orbitObserverEquatorialVectorOfDate(TD2UT(jd, false), observerLon, observerLat, observerHeight) + observerVector := orbitObserverEquatorialVectorOfDate(TT2UTC(jde), observerLon, observerLat, observerHeight) topocentricVector := Vector3{ geocentricVector[0] - observerVector[0], geocentricVector[1] - observerVector[1], @@ -159,16 +159,16 @@ func OrbitApparentTopocentricEquatorial(jd, observerLon, observerLat, observerHe return orbitVectorToEquatorial(topocentricVector) } -func earthHeliocentricVectorOfDate(jd float64) Vector3 { +func earthHeliocentricVectorOfDate(jde float64) Vector3 { return eclipticCartesian( - planet.WherePlanet(-1, 0, jd), - planet.WherePlanet(-1, 1, jd), - planet.WherePlanet(-1, 2, jd), + planet.WherePlanet(-1, 0, jde), + planet.WherePlanet(-1, 1, jde), + planet.WherePlanet(-1, 2, jde), ) } -func earthHeliocentricVectorJ2000(jd float64) Vector3 { - return eclipticVectorAtReferenceEpoch(earthHeliocentricVectorOfDate(jd), jd, orbitReferenceJD) +func earthHeliocentricVectorJ2000(jde float64) Vector3 { + return eclipticVectorAtReferenceEpoch(earthHeliocentricVectorOfDate(jde), jde, orbitReferenceJD) } func orbitVectorToEcliptic(vector Vector3) (lon, lat, distance float64) { @@ -207,7 +207,7 @@ func orbitEquatorialVector(ra, dec, distance float64) Vector3 { } func orbitObserverEquatorialVectorOfDate(jdUT, observerLon, observerLat, observerHeight float64) Vector3 { - localApparentSiderealLongitude := Limit360(ApparentSiderealTime(jdUT)*15 + observerLon) + localApparentSiderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + observerLon) observerScaleAU := Sin(0.0024427777777) rhoCosPhiPrime := pcosi(observerLat, observerHeight) rhoSinPhiPrime := psini(observerLat, observerHeight) diff --git a/basic/orbit_kepler.go b/basic/orbit_kepler.go index d154c5d..70988fd 100644 --- a/basic/orbit_kepler.go +++ b/basic/orbit_kepler.go @@ -57,8 +57,8 @@ func OrbitEccentricAnomaly(jd float64, elements OrbitElements) float64 { } // OrbitTrueAnomaly 返回给定 TT/TDB 儒略日的真近点角,单位度。 -func OrbitTrueAnomaly(jd float64, elements OrbitElements) float64 { - trueAnomaly, _, _, ok := orbitTrueAnomalyAndRadius(jd, elements) +func OrbitTrueAnomaly(jde float64, elements OrbitElements) float64 { + trueAnomaly, _, _, ok := orbitTrueAnomalyAndRadius(jde, elements) if !ok { return math.NaN() } @@ -149,25 +149,25 @@ func orbitHyperbolicAnomaly(meanAnomaly, eccentricity float64) (float64, bool) { return hyperbolicAnomaly, true } -func orbitTrueAnomalyAndRadius(jd float64, elements OrbitElements) (trueAnomaly, radius float64, resolved OrbitElements, ok bool) { - resolved = orbitElementsAt(jd, elements) +func orbitTrueAnomalyAndRadius(jde float64, elements OrbitElements) (trueAnomaly, radius float64, resolved OrbitElements, ok bool) { + resolved = orbitElementsAt(jde, elements) if resolved.usesPerihelionForm() { if !resolved.validPerihelionForm() { return math.NaN(), math.NaN(), resolved, false } switch { case math.Abs(resolved.E-1) <= orbitParabolicTolerance: - return orbitParabolicTrueAnomalyAndRadius(jd, resolved) + return orbitParabolicTrueAnomalyAndRadius(jde, resolved) case resolved.E < 1: - return orbitEllipticTrueAnomalyAndRadiusFromPerihelion(jd, resolved) + return orbitEllipticTrueAnomalyAndRadiusFromPerihelion(jde, resolved) default: - return orbitHyperbolicTrueAnomalyAndRadius(jd, resolved) + return orbitHyperbolicTrueAnomalyAndRadius(jde, resolved) } } if !resolved.validEllipticClassical() { return math.NaN(), math.NaN(), resolved, false } - meanAnomalyDeg, ok := orbitMeanAnomalyDegAt(jd, elements) + meanAnomalyDeg, ok := orbitMeanAnomalyDegAt(jde, elements) if !ok { return math.NaN(), math.NaN(), resolved, false } diff --git a/basic/orbit_magnitude.go b/basic/orbit_magnitude.go index 6940539..23a3c97 100644 --- a/basic/orbit_magnitude.go +++ b/basic/orbit_magnitude.go @@ -3,14 +3,14 @@ package basic import "math" // OrbitAsteroidMagnitudeHG 返回小行星 H-G 模型的视星等。 -func OrbitAsteroidMagnitudeHG(jd float64, elements OrbitElements, absoluteMagnitude, slopeParameter float64) float64 { - if !isFinite(jd) || !isFinite(absoluteMagnitude) || !isFinite(slopeParameter) { +func OrbitAsteroidMagnitudeHG(jde float64, elements OrbitElements, absoluteMagnitude, slopeParameter float64) float64 { + if !isFinite(jde) || !isFinite(absoluteMagnitude) || !isFinite(slopeParameter) { return math.NaN() } - sunDistance := OrbitSunDistance(jd, elements) - earthDistance := OrbitEarthDistance(jd, elements) - phaseAngle := OrbitPhaseAngle(jd, elements) + sunDistance := OrbitSunDistance(jde, elements) + earthDistance := OrbitEarthDistance(jde, elements) + phaseAngle := OrbitPhaseAngle(jde, elements) if !isFinitePositive(sunDistance) || !isFinitePositive(earthDistance) || !isFinite(phaseAngle) { return math.NaN() } diff --git a/basic/orbit_observation.go b/basic/orbit_observation.go index 894eb7c..68ea962 100644 --- a/basic/orbit_observation.go +++ b/basic/orbit_observation.go @@ -6,24 +6,24 @@ import ( . "b612.me/astro/tools" ) -func orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone float64, elements OrbitElements) (ra, dec, distance float64) { - utcJde := jde - timezone/24.0 - return OrbitApparentTopocentricEquatorial(TD2UT(utcJde, true), observerLon, observerLat, observerHeight, elements) +func orbitTopocentricObservation(localJD, observerLon, observerLat, observerHeight, timezone float64, elements OrbitElements) (ra, dec, distance float64) { + utcJD := localJD - timezone/24.0 + return OrbitApparentTopocentricEquatorial(UTC2TT(utcJD), observerLon, observerLat, observerHeight, elements) } // OrbitHeight 返回轨道目标在观测者所在地的视高度角,单位度。 -func OrbitHeight(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { - ra, dec, _ := orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements) - st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon) +func OrbitHeight(localJD, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { + ra, dec, _ := orbitTopocentricObservation(localJD, observerLon, observerLat, observerHeight, timezone, elements) + st := Limit360(ApparentSiderealTime(UTC2UT1(localJD-timezone/24.0))*15 + observerLon) hourAngle := Limit360(st - ra) sinHeight := Sin(observerLat)*Sin(dec) + Cos(dec)*Cos(observerLat)*Cos(hourAngle) return ArcSin(sinHeight) } // OrbitAzimuth 返回轨道目标在观测者所在地的视方位角,按正北为 0°、向东增加。 -func OrbitAzimuth(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { - ra, dec, _ := orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements) - st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon) +func OrbitAzimuth(localJD, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { + ra, dec, _ := orbitTopocentricObservation(localJD, observerLon, observerLat, observerHeight, timezone, elements) + st := Limit360(ApparentSiderealTime(UTC2UT1(localJD-timezone/24.0))*15 + observerLon) hourAngle := Limit360(st - ra) tanAzimuth := Sin(hourAngle) / (Cos(hourAngle)*Sin(observerLat) - Tan(dec)*Cos(observerLat)) azimuth := ArcTan(tanAzimuth) @@ -40,9 +40,9 @@ func OrbitAzimuth(jde, observerLon, observerLat, timezone, observerHeight float6 } // OrbitHourAngle 返回轨道目标的站心视时角,单位度。 -func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { - ra, _, _ := orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements) - st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon) +func OrbitHourAngle(localJD, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { + ra, _, _ := orbitTopocentricObservation(localJD, observerLon, observerLat, observerHeight, timezone, elements) + st := Limit360(ApparentSiderealTime(UTC2UT1(localJD-timezone/24.0))*15 + observerLon) hourAngle := st - ra if hourAngle < 0 { hourAngle += 360 @@ -52,11 +52,11 @@ func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight floa // OrbitHourAngleWithTopocentric 返回站心视时角及同一状态的站心视赤经、视赤纬 / hour angle with its topocentric state. // -// jde 沿用 OrbitHourAngle 的当地时口径:函数内部先减 timezone/24 得世界时,再按 UT→TT 换算。 -// jde follows the local-time convention of OrbitHourAngle: timezone/24 is subtracted before the UT→TT conversion. -func OrbitHourAngleWithTopocentric(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) (ra, dec, hourAngle float64) { - ra, dec, _ = orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements) - st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon) +// localJD 沿用 OrbitHourAngle 的当地时口径:函数内部先减 timezone/24 得世界时,再按 UT→TT 换算。 +// localJD follows the local-time convention of OrbitHourAngle: timezone/24 is subtracted before the UT→TT conversion. +func OrbitHourAngleWithTopocentric(localJD, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) (ra, dec, hourAngle float64) { + ra, dec, _ = orbitTopocentricObservation(localJD, observerLon, observerLat, observerHeight, timezone, elements) + st := Limit360(ApparentSiderealTime(UTC2UT1(localJD-timezone/24.0))*15 + observerLon) hourAngle = st - ra if hourAngle < 0 { hourAngle += 360 @@ -65,14 +65,14 @@ func OrbitHourAngleWithTopocentric(jde, observerLon, observerLat, timezone, obse } // OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。 -func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { - if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) { +func OrbitCulminationTime(localJD, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { + if !isFiniteFloat(localJD) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) { return math.NaN() } - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight, elements))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde float64) float64 { - currentHourAngle := OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight, elements) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-OrbitHourAngle(localJD, observerLon, observerLat, timezone, observerHeight, elements))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD float64) float64 { + currentHourAngle := OrbitHourAngle(localJD, observerLon, observerLat, timezone, observerHeight, elements) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/orbit_phase.go b/basic/orbit_phase.go index 57e5f0e..0bfed6b 100644 --- a/basic/orbit_phase.go +++ b/basic/orbit_phase.go @@ -3,37 +3,37 @@ package basic import . "b612.me/astro/tools" // OrbitSunDistance 返回轨道目标在给定 TT/TDB 儒略日的日心距离,单位 AU。 -func OrbitSunDistance(jd float64, elements OrbitElements) float64 { - _, _, distance := OrbitHeliocentricEclipticJ2000(jd, elements) +func OrbitSunDistance(jde float64, elements OrbitElements) float64 { + _, _, distance := OrbitHeliocentricEclipticJ2000(jde, elements) return distance } // OrbitEarthDistance 返回轨道目标在给定 TT/TDB 儒略日的地心距离,单位 AU。 -func OrbitEarthDistance(jd float64, elements OrbitElements) float64 { - _, _, distance := OrbitGeocentricEclipticJ2000(jd, elements) +func OrbitEarthDistance(jde float64, elements OrbitElements) float64 { + _, _, distance := OrbitGeocentricEclipticJ2000(jde, elements) return distance } // OrbitPhaseAngle 返回轨道目标的相位角,单位度。 -func OrbitPhaseAngle(jd float64, elements OrbitElements) float64 { - return ArcCos(orbitPhaseCosine(jd, elements)) +func OrbitPhaseAngle(jde float64, elements OrbitElements) float64 { + return ArcCos(orbitPhaseCosine(jde, elements)) } // OrbitIlluminatedFraction 返回轨道目标的被照亮比例。 -func OrbitIlluminatedFraction(jd float64, elements OrbitElements) float64 { - return (1 + orbitPhaseCosine(jd, elements)) / 2 +func OrbitIlluminatedFraction(jde float64, elements OrbitElements) float64 { + return (1 + orbitPhaseCosine(jde, elements)) / 2 } // OrbitElongation 返回轨道目标相对于太阳的地心视角距,单位度。 -func OrbitElongation(jd float64, elements OrbitElements) float64 { - lon, lat, _ := OrbitApparentGeocentricEcliptic(jd, elements) - return StarAngularSeparation(lon, lat, HSunApparentLo(jd), HSunTrueBo(jd)) +func OrbitElongation(jde float64, elements OrbitElements) float64 { + lon, lat, _ := OrbitApparentGeocentricEcliptic(jde, elements) + return StarAngularSeparation(lon, lat, HSunApparentLo(jde), HSunTrueBo(jde)) } -func orbitPhaseCosine(jd float64, elements OrbitElements) float64 { - sunDistance := OrbitSunDistance(jd, elements) - earthDistance := OrbitEarthDistance(jd, elements) - earthSunDistance := EarthAway(jd) +func orbitPhaseCosine(jde float64, elements OrbitElements) float64 { + sunDistance := OrbitSunDistance(jde, elements) + earthDistance := OrbitEarthDistance(jde, elements) + earthSunDistance := EarthAway(jde) cosine := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) return clampUnit(cosine) } diff --git a/basic/orbital_nodes.go b/basic/orbital_nodes.go index 50d0a78..75395a1 100644 --- a/basic/orbital_nodes.go +++ b/basic/orbital_nodes.go @@ -202,14 +202,14 @@ func orbitalAscendingNodeLongitude(jd float64, n int, step float64, position fun return Limit360(math.Atan2(nodeVector[1], nodeVector[0]) * deg) } -func eclipticVectorAtReferenceEpoch(vector Vector3, sampleJD, referenceJD float64) Vector3 { - if sampleJD == referenceJD { +func eclipticVectorAtReferenceEpoch(vector Vector3, sampleJDE, referenceJDE float64) Vector3 { + if sampleJDE == referenceJDE { return vector } - sampleEquatorial := rotateEclipticToEquatorial(vector, EclipticObliquity(sampleJD, false)) - precessedEquatorial := applyMatrix3(precessionMatrix(sampleJD, referenceJD), sampleEquatorial) - return rotateEquatorialToEcliptic(precessedEquatorial, EclipticObliquity(referenceJD, false)) + sampleEquatorial := rotateEclipticToEquatorial(vector, EclipticObliquity(sampleJDE, false)) + precessedEquatorial := applyMatrix3(precessionMatrix(sampleJDE, referenceJDE), sampleEquatorial) + return rotateEquatorialToEcliptic(precessedEquatorial, EclipticObliquity(referenceJDE, false)) } func rotateEclipticToEquatorial(vector Vector3, obliquity float64) Vector3 { @@ -265,17 +265,17 @@ func applyMatrix3(matrix Matrix3, vector Vector3) Vector3 { } } -func planetHeliocentricNodePositionN(planetIndex int, jd float64, n int) Vector3 { - longitude := planet.WherePlanetN(planetIndex, 0, jd, n) - latitude := planet.WherePlanetN(planetIndex, 1, jd, n) - radius := planet.WherePlanetN(planetIndex, 2, jd, n) +func planetHeliocentricNodePositionN(planetIndex int, jde float64, n int) Vector3 { + longitude := planet.WherePlanetN(planetIndex, 0, jde, n) + latitude := planet.WherePlanetN(planetIndex, 1, jde, n) + radius := planet.WherePlanetN(planetIndex, 2, jde, n) return eclipticCartesian(longitude, latitude, radius) } -func moonGeocentricNodePositionN(jd float64, n int) Vector3 { - longitude := HMoonTrueLoN(jd, n) - latitude := HMoonTrueBoN(jd, n) - radius := HMoonAwayN(jd, n) +func moonGeocentricNodePositionN(jde float64, n int) Vector3 { + longitude := HMoonTrueLoN(jde, n) + latitude := HMoonTrueBoN(jde, n) + radius := HMoonAwayN(jde, n) return eclipticCartesian(longitude, latitude, radius) } diff --git a/basic/outer_planet_event_boundary_test.go b/basic/outer_planet_event_boundary_test.go index 5bcedb6..534addc 100644 --- a/basic/outer_planet_event_boundary_test.go +++ b/basic/outer_planet_event_boundary_test.go @@ -37,7 +37,7 @@ func TestOuterPlanetExactEventBoundaryIncludesCurrent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - queryTT := TD2UT(tc.seed, true) + queryTT := UTC2TT(tc.seed) last := tc.lastFn(queryTT) next := tc.nextFn(queryTT) if !sameEventJD(last, tc.seed) { @@ -67,7 +67,7 @@ func TestOuterPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { first := tc.next(tc.seed) - query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true) + query := UTC2TT(Date2JD(JD2DateByZone(first, time.UTC, false).Add(time.Second))) next := tc.next(query) if !eventUTQueryAfterOrEqual(next, query) { t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next) @@ -80,5 +80,5 @@ func TestOuterPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) { } func ttjdUTC(year, month, day, hour, min, sec int) float64 { - return TD2UT(Date2JDE(time.Date(year, time.Month(month), day, hour, min, sec, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(year, time.Month(month), day, hour, min, sec, 0, time.UTC))) } diff --git a/basic/outer_planet_event_helpers_test.go b/basic/outer_planet_event_helpers_test.go index 5966458..974f00a 100644 --- a/basic/outer_planet_event_helpers_test.go +++ b/basic/outer_planet_event_helpers_test.go @@ -28,7 +28,7 @@ func (plan outerPlanetEventPlan) allCases() []outerPlanetEventCase { } func outerPlanetEventSampleTTJD(date time.Time) float64 { - return TD2UT(Date2JDE(date.UTC()), true) + return UTC2TT(Date2JD(date.UTC())) } func jupiterEventSamples() []time.Time { diff --git a/basic/outer_planet_truth_test.go b/basic/outer_planet_truth_test.go index 92cab51..6a90648 100644 --- a/basic/outer_planet_truth_test.go +++ b/basic/outer_planet_truth_test.go @@ -135,8 +135,8 @@ func assertOuterTruthBaselineEvent(t *testing.T, event outerTruthBaselineEvent, when := parseInnerBaselineTime(t, event.VerifiedJST) before := when.Add(-7 * 24 * time.Hour) after := when.Add(7 * 24 * time.Hour) - next := JDE2DateByZone(nextFn(toUTJD(before)), when.Location(), false) - last := JDE2DateByZone(lastFn(toUTJD(after)), when.Location(), false) + next := JD2DateByZone(nextFn(toUTJD(before)), when.Location(), false) + last := JD2DateByZone(lastFn(toUTJD(after)), when.Location(), false) tolerance := outerTruthTolerance(event) if diff := next.Sub(when); diff < -tolerance || diff > tolerance { diff --git a/basic/parallactic_test.go b/basic/parallactic_test.go index d5e0838..fe3181a 100644 --- a/basic/parallactic_test.go +++ b/basic/parallactic_test.go @@ -29,7 +29,7 @@ func TestParallacticAngleByHourAngleKnownCases(t *testing.T) { func TestStarParallacticAngleMatchesHourAngleForm(t *testing.T) { date := time.Date(2026, 4, 29, 21, 15, 0, 0, time.FixedZone("CST", 8*3600)) - jde := Date2JDE(date) + jde := Date2JD(date) _, offsetSeconds := date.Zone() timezone := float64(offsetSeconds) / 3600.0 ra := 101.28715533 diff --git a/basic/path_regression_p0_test.go b/basic/path_regression_p0_test.go index cf9c5a3..d6f6bb4 100644 --- a/basic/path_regression_p0_test.go +++ b/basic/path_regression_p0_test.go @@ -7,7 +7,7 @@ import ( ) func TestSolarEclipseSarosFamilyRemainsFiniteAcrossFiveCenturies(t *testing.T) { - base := JDECalc(2024, 4, 8) + base := JDCalc(2024, 4, 8) const sarosDays = 6585.321314 for familyIndex := -28; familyIndex <= 28; familyIndex++ { seed := base + float64(familyIndex)*sarosDays @@ -39,11 +39,11 @@ func TestSolarEclipseRepresentativePathSeriesAreOrderedAndFinite(t *testing.T) { name string seed float64 }{ - {name: "2009-07-22", seed: JDECalc(2009, 7, 22)}, - {name: "2010-01-15", seed: JDECalc(2010, 1, 15)}, - {name: "2014-04-29-non-central", seed: JDECalc(2014, 4, 29)}, - {name: "2023-04-20", seed: JDECalc(2023, 4, 20)}, - {name: "2043-10-03", seed: JDECalc(2043, 10, 3)}, + {name: "2009-07-22", seed: JDCalc(2009, 7, 22)}, + {name: "2010-01-15", seed: JDCalc(2010, 1, 15)}, + {name: "2014-04-29-non-central", seed: JDCalc(2014, 4, 29)}, + {name: "2023-04-20", seed: JDCalc(2023, 4, 20)}, + {name: "2043-10-03", seed: JDCalc(2043, 10, 3)}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { @@ -187,7 +187,7 @@ func assertOccultationPointSeriesFinite(t *testing.T, points []OccultationPathPo } func TestSolarEclipseRepresentativePathPointsDoNotContainNaN(t *testing.T) { - path := SolarEclipseCentralPath(JDECalc(2010, 1, 15), SolarEclipsePathOptions{StepDays: 20.0 / 1440.0}) + path := SolarEclipseCentralPath(JDCalc(2010, 1, 15), SolarEclipsePathOptions{StepDays: 20.0 / 1440.0}) for index, point := range append(append(append([]SolarEclipsePathPoint{}, path.CenterLine...), path.NorthernLimit...), path.SouthernLimit...) { if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) || math.IsNaN(point.JDE) { t.Fatalf("path point %d contains NaN: %+v", index, point) diff --git a/basic/path_regression_p2_test.go b/basic/path_regression_p2_test.go index c2a9419..ca87960 100644 --- a/basic/path_regression_p2_test.go +++ b/basic/path_regression_p2_test.go @@ -9,7 +9,7 @@ import "testing" // used by GeoJSON and SVG consumers. func TestSolarEclipseSarosPathSeriesRemainFiniteWithinBudget(t *testing.T) { const sarosDays = 6585.321314 - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} { familyIndex := familyIndex t.Run("saros-"+formatSignedRegressionIndex(familyIndex), func(t *testing.T) { @@ -54,7 +54,7 @@ func TestSolarEclipseHighResolutionSamplingHonorsPointBudget(t *testing.T) { {name: "partial-and-central-shadow", shadowStep: 1.0 / 86400.0}, } { t.Run(test.name, func(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ StepDays: 1.0 / 86400.0, BoundaryPoints: solarEclipsePartialFootprintMaxBoundaryPoints, CentralShadowStepDays: test.shadowStep, diff --git a/basic/planet_apparent.go b/basic/planet_apparent.go index c9858d9..4a4842e 100644 --- a/basic/planet_apparent.go +++ b/basic/planet_apparent.go @@ -15,17 +15,17 @@ type planetGeocentricPosition struct { bo float64 } -func planetHeliocentricXYZN(planetIndex int, jd float64, n int) (float64, float64, float64) { - l := planet.WherePlanetN(planetIndex, 0, jd, n) - b := planet.WherePlanetN(planetIndex, 1, jd, n) - r := planet.WherePlanetN(planetIndex, 2, jd, n) +func planetHeliocentricXYZN(planetIndex int, jde float64, n int) (float64, float64, float64) { + l := planet.WherePlanetN(planetIndex, 0, jde, n) + b := planet.WherePlanetN(planetIndex, 1, jde, n) + r := planet.WherePlanetN(planetIndex, 2, jde, n) return sphericalToRectangular(l, b, r) } -func earthHeliocentricXYZN(jd float64, n int) (float64, float64, float64) { - l := planet.WherePlanetN(-1, 0, jd, n) - b := planet.WherePlanetN(-1, 1, jd, n) - r := planet.WherePlanetN(-1, 2, jd, n) +func earthHeliocentricXYZN(jde float64, n int) (float64, float64, float64) { + l := planet.WherePlanetN(-1, 0, jde, n) + b := planet.WherePlanetN(-1, 1, jde, n) + r := planet.WherePlanetN(-1, 2, jde, n) return sphericalToRectangular(l, b, r) } @@ -48,9 +48,9 @@ func geocentricPositionFromRectangular(x, y, z float64) planetGeocentricPosition } } -func planetGeocentricPositionN(planetIndex int, planetJD, earthJD float64, n int) planetGeocentricPosition { +func planetGeocentricPositionN(planetIndex int, planetJD, earthJDE float64, n int) planetGeocentricPosition { px, py, pz := planetHeliocentricXYZN(planetIndex, planetJD, n) - ex, ey, ez := earthHeliocentricXYZN(earthJD, n) + ex, ey, ez := earthHeliocentricXYZN(earthJDE, n) return geocentricPositionFromRectangular(px-ex, py-ey, pz-ez) } @@ -66,26 +66,26 @@ func planetApparentGeocentricPositionN(planetIndex int, jd float64, n int) (plan func planetApparentGeocentricPositionAndDistanceN( planetIndex int, - jd float64, + jde float64, n int, ) (planetGeocentricPosition, float64, float64) { - ex, ey, ez := earthHeliocentricXYZN(jd, n) - geoNow := planetGeocentricPositionWithEarthN(planetIndex, jd, ex, ey, ez, n) + ex, ey, ez := earthHeliocentricXYZN(jde, n) + geoNow := planetGeocentricPositionWithEarthN(planetIndex, jde, ex, ey, ez, n) distance := math.Sqrt(geoNow.x*geoNow.x + geoNow.y*geoNow.y + geoNow.z*geoNow.z) tau := 0.0057755183 * distance - geo := planetGeocentricPositionWithEarthN(planetIndex, jd-tau, ex, ey, ez, n) + geo := planetGeocentricPositionWithEarthN(planetIndex, jde-tau, ex, ey, ez, n) baseLo := geo.lo baseBo := geo.bo - geo.lo = Limit360(baseLo + GXCLo(baseLo, baseBo, jd)/3600.0 + Nutation2000Bi(jd)) - geo.bo = baseBo + GXCBo(baseLo, baseBo, jd)/3600.0 + geo.lo = Limit360(baseLo + GXCLo(baseLo, baseBo, jde)/3600.0 + Nutation2000Bi(jde)) + geo.bo = baseBo + GXCBo(baseLo, baseBo, jde)/3600.0 return geo, tau, distance } -func planetTrueGeocentricPositionN(planetIndex int, jd float64, n int) (planetGeocentricPosition, float64) { - ex, ey, ez := earthHeliocentricXYZN(jd, n) - geoNow := planetGeocentricPositionWithEarthN(planetIndex, jd, ex, ey, ez, n) +func planetTrueGeocentricPositionN(planetIndex int, jde float64, n int) (planetGeocentricPosition, float64) { + ex, ey, ez := earthHeliocentricXYZN(jde, n) + geoNow := planetGeocentricPositionWithEarthN(planetIndex, jde, ex, ey, ez, n) tau := 0.0057755183 * math.Sqrt(geoNow.x*geoNow.x+geoNow.y*geoNow.y+geoNow.z*geoNow.z) - return planetGeocentricPositionWithEarthN(planetIndex, jd-tau, ex, ey, ez, n), tau + return planetGeocentricPositionWithEarthN(planetIndex, jde-tau, ex, ey, ez, n), tau } func planetEarthAwayExplicitN(planetIndex int, jd float64, n int) float64 { diff --git a/basic/planet_apparent_external_test.go b/basic/planet_apparent_external_test.go index 06af383..7d4868f 100644 --- a/basic/planet_apparent_external_test.go +++ b/basic/planet_apparent_external_test.go @@ -59,7 +59,7 @@ func TestPlanetApparentCoordinatesMatchHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) prefix := sample.Body + "." + sample.InputUTC assertPlanetApparentAngleClose(t, prefix+".RightAscension", tc.ra(jd), sample.RightAscension, 0.001) diff --git a/basic/planet_elongation_objective_test.go b/basic/planet_elongation_objective_test.go index 4897790..1c30a8b 100644 --- a/basic/planet_elongation_objective_test.go +++ b/basic/planet_elongation_objective_test.go @@ -24,7 +24,7 @@ func elongationObjectiveSeeds() []float64 { seeds := make([]float64, 0, 24) for year := 2024; year <= 2027; year++ { for month := 1; month <= 12; month += 2 { - seeds = append(seeds, JDECalc(year, month, 1)) + seeds = append(seeds, JDCalc(year, month, 1)) } } return seeds @@ -34,7 +34,7 @@ func elongationObjectiveTruth(elongate func(float64) float64, eventUT float64) f left, right := eventUT-30.0/1440.0, eventUT+30.0/1440.0 for i := 0; i < 200; i++ { third := (right - left) / 3 - if elongate(TD2UT(left+third, true)) <= elongate(TD2UT(right-third, true)) { + if elongate(UTC2TT(left+third)) <= elongate(UTC2TT(right-third)) { left += third continue } @@ -81,7 +81,7 @@ func TestGreatestElongationLateralToleranceIsEventTolerance(t *testing.T) { {"VenusEast", NextVenusGreatestElongationEast, LastVenusGreatestElongationEast}, {"VenusWest", NextVenusGreatestElongationWest, LastVenusGreatestElongationWest}, } - seeds := []float64{JDECalc(2024, 3, 1), JDECalc(2025, 7, 1), JDECalc(2026, 11, 1)} + seeds := []float64{JDCalc(2024, 3, 1), JDCalc(2025, 7, 1), JDCalc(2026, 11, 1)} tolerance := exactQueryTTToleranceUT for _, tc := range cases { for _, seed := range seeds { @@ -89,7 +89,7 @@ func TestGreatestElongationLateralToleranceIsEventTolerance(t *testing.T) { if math.IsNaN(event) { t.Fatalf("%s at %.1f returned NaN", tc.name, seed) } - eventTT := TD2UT(event, true) + eventTT := UTC2TT(event) afterUT := event + offsetSeconds/86400.0 if got := tc.next(eventTT + offsetSeconds/86400.0); got < afterUT-tolerance { t.Fatalf("%s: Next at event+%.1f s = %.9f returned an event %.3f s before the query", @@ -121,7 +121,7 @@ func TestGreatestElongationAnySideKeepsBothSideExtremum(t *testing.T) { LastVenusGreatestElongationEast, LastVenusGreatestElongationWest}, } for _, tc := range cases { - for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2027, 1, 1); jd += 14 { + for jd := JDCalc(2024, 1, 1); jd <= JDCalc(2027, 1, 1); jd += 14 { east, west := tc.eastNext(jd), tc.westNext(jd) want := east if !sameEventJD(east, west) { diff --git a/basic/planet_event_perf_test.go b/basic/planet_event_perf_test.go index 15f786d..f4f2617 100644 --- a/basic/planet_event_perf_test.go +++ b/basic/planet_event_perf_test.go @@ -8,7 +8,7 @@ import ( // 本切片(行星/月球事件层)的性能守护基准;改动热路径时必须给出改前→改后。 func BenchmarkPlanetEventGreatestElongationAnySide(b *testing.B) { - jd := JDECalc(2025, 3, 1) + jd := JDCalc(2025, 3, 1) b.Run("Mercury", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = NextMercuryGreatestElongation(jd) @@ -41,7 +41,7 @@ func BenchmarkPlanetEventOuterStationNonFiniteQuery(b *testing.B) { } func BenchmarkPlanetEventMoonMaximumDeclination(b *testing.B) { - jd := JDECalc(2025, 3, 1) + jd := JDCalc(2025, 3, 1) b.Run("Next", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = NextMoonMaximumNorthDeclination(jd) @@ -77,7 +77,7 @@ func BenchmarkPlanetEventJupiterGalileanCallisto(b *testing.B) { } func BenchmarkPlanetEventJupiterSixEventsSameInstant(b *testing.B) { - jd := JDECalc(2025, 3, 1) + jd := JDCalc(2025, 3, 1) for i := 0; i < b.N; i++ { _ = NextJupiterConjunction(jd) _ = NextJupiterOpposition(jd) @@ -98,7 +98,7 @@ func BenchmarkPlanetEventLunarEclipseDiagram(b *testing.B) { // 差分对照基准:改动前“两侧都算再取极值”的聚合方式。 func BenchmarkPlanetEventGreatestElongationBothSidesReference(b *testing.B) { - mercuryJD, venusJD := JDECalc(2025, 3, 1), JDECalc(2025, 3, 1) + mercuryJD, venusJD := JDCalc(2025, 3, 1), JDCalc(2025, 3, 1) b.Run("Mercury", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = earliestFiniteEventUT(NextMercuryGreatestElongationEast(mercuryJD), NextMercuryGreatestElongationWest(mercuryJD)) diff --git a/basic/planet_input_guard_test.go b/basic/planet_input_guard_test.go index e1edee1..e19ffa7 100644 --- a/basic/planet_input_guard_test.go +++ b/basic/planet_input_guard_test.go @@ -117,7 +117,7 @@ func TestMoonMaximumDeclinationEventsRejectNonFiniteQuery(t *testing.T) { // A non-finite query used to seed an out-of-range cycle index and spin in the // sampling sweep forever; it must return the zero event instead. got := event.fn(query.jd) - if got.JDE != 0 || got.Declination != 0 { + if got.JD != 0 || got.Declination != 0 { t.Fatalf("%s(%s) = %+v, want zero event", event.name, query.name, got) } } @@ -126,11 +126,11 @@ func TestMoonMaximumDeclinationEventsRejectNonFiniteQuery(t *testing.T) { // The finite path must keep working. const jd = 2460310.5 north := NextMoonMaximumNorthDeclination(jd) - if !(north.JDE > jd) || north.Declination == 0 { + if !(north.JD > jd) || north.Declination == 0 { t.Fatalf("NextMoonMaximumNorthDeclination(%v) = %+v, want a later event", jd, north) } south := LastMoonMaximumSouthDeclination(jd) - if !(south.JDE <= jd) || south.Declination == 0 { + if !(south.JD <= jd) || south.Declination == 0 { t.Fatalf("LastMoonMaximumSouthDeclination(%v) = %+v, want an earlier event", jd, south) } } @@ -214,14 +214,14 @@ func TestOuterPlanetStationSecondCandidateKeepsSideInvariant(t *testing.T) { {"Neptune", NextNeptuneProgradeToRetrograde}, } for _, tc := range cases { - event := tc.fn(JDECalc(2025, 1, 1)) + event := tc.fn(JDCalc(2025, 1, 1)) if math.IsNaN(event) { t.Fatalf("%s: no station event found", tc.name) } query := event + offsetSeconds/86400.0 - got := tc.fn(TD2UT(query, true)) + got := tc.fn(UTC2TT(query)) if math.IsNaN(got) { - t.Fatalf("%s: second candidate rejected as NaN at %s", tc.name, JDE2Date(query)) + t.Fatalf("%s: second candidate rejected as NaN at %s", tc.name, JD2Date(query)) } if got < query-stationQueryToleranceUT { t.Fatalf("%s: returned %.9f before the query %.9f", tc.name, got, query) diff --git a/basic/planet_observation_n_test.go b/basic/planet_observation_n_test.go index 2cc7ca5..44479a4 100644 --- a/basic/planet_observation_n_test.go +++ b/basic/planet_observation_n_test.go @@ -10,8 +10,8 @@ import ( func TestBasicPlanetObservationNFullMatchesDefault(t *testing.T) { date := time.Date(2026, 4, 26, 9, 30, 45, 123456789, time.FixedZone("CST", 8*3600)) - ttJD := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - jde := basic.Date2JDE(date) + ttJD := basic.UTC2TT(basic.Date2JD(date.UTC())) + jde := basic.Date2JD(date) lon := 116.391 lat := 39.907 tz := 8.0 diff --git a/basic/planet_phase.go b/basic/planet_phase.go index ff180db..6ce9a88 100644 --- a/basic/planet_phase.go +++ b/basic/planet_phase.go @@ -8,8 +8,8 @@ import ( type planetRaDecNFunc func(jd float64, n int) (float64, float64) // MercuryPhaseAngle 水星相位角 / phase angle of Mercury. -func MercuryPhaseAngle(jd float64) float64 { - return MercuryPhaseAngleN(jd, -1) +func MercuryPhaseAngle(jde float64) float64 { + return MercuryPhaseAngleN(jde, -1) } // MercuryPhaseAngleN 水星相位角(截断版) / truncated phase angle of Mercury. @@ -28,18 +28,18 @@ func MercuryIlluminatedFractionN(jd float64, n int) float64 { } // MercuryBrightLimbPositionAngle 水星亮面中心位置角 / position angle of Mercury bright limb. -func MercuryBrightLimbPositionAngle(jd float64) float64 { - return MercuryBrightLimbPositionAngleN(jd, -1) +func MercuryBrightLimbPositionAngle(jde float64) float64 { + return MercuryBrightLimbPositionAngleN(jde, -1) } // MercuryBrightLimbPositionAngleN 水星亮面中心位置角(截断版) / truncated position angle of Mercury bright limb. -func MercuryBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, MercuryApparentRaDecN) +func MercuryBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, MercuryApparentRaDecN) } // VenusPhaseAngle 金星相位角 / phase angle of Venus. -func VenusPhaseAngle(jd float64) float64 { - return VenusPhaseAngleN(jd, -1) +func VenusPhaseAngle(jde float64) float64 { + return VenusPhaseAngleN(jde, -1) } // VenusPhaseAngleN 金星相位角(截断版) / truncated phase angle of Venus. @@ -58,18 +58,18 @@ func VenusIlluminatedFractionN(jd float64, n int) float64 { } // VenusBrightLimbPositionAngle 金星亮面中心位置角 / position angle of Venus bright limb. -func VenusBrightLimbPositionAngle(jd float64) float64 { - return VenusBrightLimbPositionAngleN(jd, -1) +func VenusBrightLimbPositionAngle(jde float64) float64 { + return VenusBrightLimbPositionAngleN(jde, -1) } // VenusBrightLimbPositionAngleN 金星亮面中心位置角(截断版) / truncated position angle of Venus bright limb. -func VenusBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, VenusApparentRaDecN) +func VenusBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, VenusApparentRaDecN) } // MarsPhaseAngle 火星相位角 / phase angle of Mars. -func MarsPhaseAngle(jd float64) float64 { - return MarsPhaseAngleN(jd, -1) +func MarsPhaseAngle(jde float64) float64 { + return MarsPhaseAngleN(jde, -1) } // MarsPhaseAngleN 火星相位角(截断版) / truncated phase angle of Mars. @@ -88,18 +88,18 @@ func MarsIlluminatedFractionN(jd float64, n int) float64 { } // MarsBrightLimbPositionAngle 火星亮面中心位置角 / position angle of Mars bright limb. -func MarsBrightLimbPositionAngle(jd float64) float64 { - return MarsBrightLimbPositionAngleN(jd, -1) +func MarsBrightLimbPositionAngle(jde float64) float64 { + return MarsBrightLimbPositionAngleN(jde, -1) } // MarsBrightLimbPositionAngleN 火星亮面中心位置角(截断版) / truncated position angle of Mars bright limb. -func MarsBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, MarsApparentRaDecN) +func MarsBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, MarsApparentRaDecN) } // JupiterPhaseAngle 木星相位角 / phase angle of Jupiter. -func JupiterPhaseAngle(jd float64) float64 { - return JupiterPhaseAngleN(jd, -1) +func JupiterPhaseAngle(jde float64) float64 { + return JupiterPhaseAngleN(jde, -1) } // JupiterPhaseAngleN 木星相位角(截断版) / truncated phase angle of Jupiter. @@ -118,18 +118,18 @@ func JupiterIlluminatedFractionN(jd float64, n int) float64 { } // JupiterBrightLimbPositionAngle 木星亮面中心位置角 / position angle of Jupiter bright limb. -func JupiterBrightLimbPositionAngle(jd float64) float64 { - return JupiterBrightLimbPositionAngleN(jd, -1) +func JupiterBrightLimbPositionAngle(jde float64) float64 { + return JupiterBrightLimbPositionAngleN(jde, -1) } // JupiterBrightLimbPositionAngleN 木星亮面中心位置角(截断版) / truncated position angle of Jupiter bright limb. -func JupiterBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, JupiterApparentRaDecN) +func JupiterBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, JupiterApparentRaDecN) } // SaturnPhaseAngle 土星相位角 / phase angle of Saturn. -func SaturnPhaseAngle(jd float64) float64 { - return SaturnPhaseAngleN(jd, -1) +func SaturnPhaseAngle(jde float64) float64 { + return SaturnPhaseAngleN(jde, -1) } // SaturnPhaseAngleN 土星相位角(截断版) / truncated phase angle of Saturn. @@ -148,18 +148,18 @@ func SaturnIlluminatedFractionN(jd float64, n int) float64 { } // SaturnBrightLimbPositionAngle 土星亮面中心位置角 / position angle of Saturn bright limb. -func SaturnBrightLimbPositionAngle(jd float64) float64 { - return SaturnBrightLimbPositionAngleN(jd, -1) +func SaturnBrightLimbPositionAngle(jde float64) float64 { + return SaturnBrightLimbPositionAngleN(jde, -1) } // SaturnBrightLimbPositionAngleN 土星亮面中心位置角(截断版) / truncated position angle of Saturn bright limb. -func SaturnBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, SaturnApparentRaDecN) +func SaturnBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, SaturnApparentRaDecN) } // UranusPhaseAngle 天王星相位角 / phase angle of Uranus. -func UranusPhaseAngle(jd float64) float64 { - return UranusPhaseAngleN(jd, -1) +func UranusPhaseAngle(jde float64) float64 { + return UranusPhaseAngleN(jde, -1) } // UranusPhaseAngleN 天王星相位角(截断版) / truncated phase angle of Uranus. @@ -178,18 +178,18 @@ func UranusIlluminatedFractionN(jd float64, n int) float64 { } // UranusBrightLimbPositionAngle 天王星亮面中心位置角 / position angle of Uranus bright limb. -func UranusBrightLimbPositionAngle(jd float64) float64 { - return UranusBrightLimbPositionAngleN(jd, -1) +func UranusBrightLimbPositionAngle(jde float64) float64 { + return UranusBrightLimbPositionAngleN(jde, -1) } // UranusBrightLimbPositionAngleN 天王星亮面中心位置角(截断版) / truncated position angle of Uranus bright limb. -func UranusBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, UranusApparentRaDecN) +func UranusBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, UranusApparentRaDecN) } // NeptunePhaseAngle 海王星相位角 / phase angle of Neptune. -func NeptunePhaseAngle(jd float64) float64 { - return NeptunePhaseAngleN(jd, -1) +func NeptunePhaseAngle(jde float64) float64 { + return NeptunePhaseAngleN(jde, -1) } // NeptunePhaseAngleN 海王星相位角(截断版) / truncated phase angle of Neptune. @@ -208,13 +208,13 @@ func NeptuneIlluminatedFractionN(jd float64, n int) float64 { } // NeptuneBrightLimbPositionAngle 海王星亮面中心位置角 / position angle of Neptune bright limb. -func NeptuneBrightLimbPositionAngle(jd float64) float64 { - return NeptuneBrightLimbPositionAngleN(jd, -1) +func NeptuneBrightLimbPositionAngle(jde float64) float64 { + return NeptuneBrightLimbPositionAngleN(jde, -1) } // NeptuneBrightLimbPositionAngleN 海王星亮面中心位置角(截断版) / truncated position angle of Neptune bright limb. -func NeptuneBrightLimbPositionAngleN(jd float64, n int) float64 { - return planetBrightLimbPositionAngleN(jd, n, NeptuneApparentRaDecN) +func NeptuneBrightLimbPositionAngleN(jde float64, n int) float64 { + return planetBrightLimbPositionAngleN(jde, n, NeptuneApparentRaDecN) } func planetPhaseAngleN(planetIndex int, jd float64, n int) float64 { @@ -225,17 +225,17 @@ func planetIlluminatedFractionN(planetIndex int, jd float64, n int) float64 { return (1 + planetPhaseCosineN(planetIndex, jd, n)) / 2 } -func planetPhaseCosineN(planetIndex int, jd float64, n int) float64 { - planetSunDistance := planet.WherePlanetN(planetIndex, 2, jd, n) - planetEarthDistance := planetEarthAwayN(planetIndex, jd, n) - earthSunDistance := EarthAwayN(jd, n) +func planetPhaseCosineN(planetIndex int, jde float64, n int) float64 { + planetSunDistance := planet.WherePlanetN(planetIndex, 2, jde, n) + planetEarthDistance := planetEarthAwayN(planetIndex, jde, n) + earthSunDistance := EarthAwayN(jde, n) cosine := (planetSunDistance*planetSunDistance + planetEarthDistance*planetEarthDistance - earthSunDistance*earthSunDistance) / (2 * planetSunDistance * planetEarthDistance) return clampUnit(cosine) } -func planetBrightLimbPositionAngleN(jd float64, n int, apparentRaDec planetRaDecNFunc) float64 { - sunRa, sunDec := HSunApparentRaDecN(jd, n) - planetRa, planetDec := apparentRaDec(jd, n) +func planetBrightLimbPositionAngleN(jde float64, n int, apparentRaDec planetRaDecNFunc) float64 { + sunRa, sunDec := HSunApparentRaDecN(jde, n) + planetRa, planetDec := apparentRaDec(jde, n) y := Cos(sunDec) * Sin(sunRa-planetRa) x := Sin(sunDec)*Cos(planetDec) - Cos(sunDec)*Sin(planetDec)*Cos(sunRa-planetRa) return ArcTan2(y, x) diff --git a/basic/planet_phase_invariant_test.go b/basic/planet_phase_invariant_test.go index 1673057..a30882f 100644 --- a/basic/planet_phase_invariant_test.go +++ b/basic/planet_phase_invariant_test.go @@ -88,7 +88,7 @@ func phaseStationCases() []phaseStationCase { } func phaseEpochTT(year int, month time.Month, day int) float64 { - return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC))) } // TestPlanetStationOrderInvariant 在固定的历史失败时点附近逐点检查顺序不变量。 @@ -141,19 +141,19 @@ func TestPlanetStationOrderInvariant(t *testing.T) { gotUT := f.fn(q) if math.IsNaN(gotUT) { t.Fatalf("%s %s at %s returned NaN", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05")) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05")) } - got := TD2UT(gotUT, true) + got := UTC2TT(gotUT) // 1) 顺序不变量 if f.next && got < q-phaseInvariantToleranceDay { t.Fatalf("%s %s at %s returned past event %s", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), - JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), + JD2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) } if !f.next && got > q+phaseInvariantToleranceDay { t.Fatalf("%s %s at %s returned future event %s", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), - JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), + JD2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) } // 2) 必须是真值事件 nearest, nearestDev := math.NaN(), math.Inf(1) @@ -167,7 +167,7 @@ func TestPlanetStationOrderInvariant(t *testing.T) { } if nearestDev > 60.0/86400.0 { t.Fatalf("%s %s at %s returned non-event %.6f (nearest truth %.3f d away)", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), got, nearestDev) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), got, nearestDev) } _ = nearest // 3) 不得跳过更近的同类型事件 @@ -177,13 +177,13 @@ func TestPlanetStationOrderInvariant(t *testing.T) { } if f.next && st.jd > q+phaseInvariantToleranceDay && st.jd < got-60.0/86400.0 { t.Fatalf("%s %s at %s skipped %s", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), - JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05")) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), + JD2DateByZone(TT2UTC(st.jd), time.UTC, false).Format("2006-01-02 15:04:05")) } if !f.next && st.jd < q-phaseInvariantToleranceDay && st.jd > got+60.0/86400.0 { t.Fatalf("%s %s at %s skipped %s", tc.name, f.label, - JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), - JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05")) + JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), + JD2DateByZone(TT2UTC(st.jd), time.UTC, false).Format("2006-01-02 15:04:05")) } } } @@ -243,7 +243,7 @@ func TestMercuryConjunctionNeverSkips(t *testing.T) { } for _, q := range []float64{center, center + 0.5, center + 12, center - 12, center + 60, center - 60} { for _, next := range []uint8{0, 1} { - got := TD2UT(mercuryConjunction(q, next), true) + got := UTC2TT(mercuryConjunction(q, next)) if math.IsNaN(got) { t.Fatalf("mercuryConjunction(%.6f, %d) = NaN", q, next) } @@ -320,7 +320,7 @@ func TestGreatestElongationNoSkip(t *testing.T) { const matchTolerance = 30.0 / 1440.0 for i, maximum := range maxima { // 查询落在极大前一天:Next 必须命中该极大(不得跳过) - next := TD2UT(tc.next(maximum-1), true) + next := UTC2TT(tc.next(maximum - 1)) if math.IsNaN(next) { t.Fatalf("%s: Next at %s returned NaN", tc.name, tmpPhaseDate(maximum-1)) } @@ -332,7 +332,7 @@ func TestGreatestElongationNoSkip(t *testing.T) { t.Fatalf("%s: Next returned an event before the query", tc.name) } // 查询落在极大后一天:Last 必须命中该极大,Next 必须命中下一个极大 - last := TD2UT(tc.last(maximum+1), true) + last := UTC2TT(tc.last(maximum + 1)) if dev := math.Abs(last - maximum); dev > matchTolerance { t.Fatalf("%s: Last at %s = %s, expected the previous maximum %s (%.2f min off)", tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(last), tmpPhaseDate(maximum), dev*1440) @@ -341,7 +341,7 @@ func TestGreatestElongationNoSkip(t *testing.T) { t.Fatalf("%s: Last returned an event after the query", tc.name) } if i+1 < len(maxima) { - following := TD2UT(tc.next(maximum+1), true) + following := UTC2TT(tc.next(maximum + 1)) if dev := math.Abs(following - maxima[i+1]); dev > matchTolerance { t.Fatalf("%s: Next at %s = %s, expected the following maximum %s (%.2f min off)", tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(following), tmpPhaseDate(maxima[i+1]), dev*1440) @@ -352,5 +352,5 @@ func TestGreatestElongationNoSkip(t *testing.T) { } func tmpPhaseDate(jd float64) string { - return JDE2DateByZone(TD2UT(jd, false), time.UTC, false).Format("2006-01-02 15:04") + return JD2DateByZone(TT2UTC(jd), time.UTC, false).Format("2006-01-02 15:04") } diff --git a/basic/planet_phase_test.go b/basic/planet_phase_test.go index da8c56d..4921493 100644 --- a/basic/planet_phase_test.go +++ b/basic/planet_phase_test.go @@ -7,14 +7,14 @@ import ( ) func TestVenusIlluminatedFractionMeeusExample(t *testing.T) { - jd := Date2JDE(time.Date(1992, 12, 20, 0, 0, 0, 0, time.UTC)) + jd := Date2JD(time.Date(1992, 12, 20, 0, 0, 0, 0, time.UTC)) assertPlanetPhaseClose(t, "VenusPhaseAngle", VenusPhaseAngle(jd), 72.96, 0.01) assertPlanetPhaseClose(t, "VenusIlluminatedFraction", VenusIlluminatedFraction(jd), 0.647, 0.001) } func TestPlanetIlluminatedFractionRanges(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 26, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 26, 9, 30, 45, 0, time.UTC))) cases := []struct { name string phaseAngle func(float64) float64 diff --git a/basic/planet_physical.go b/basic/planet_physical.go index 4b312ea..0a151d9 100644 --- a/basic/planet_physical.go +++ b/basic/planet_physical.go @@ -163,21 +163,21 @@ func planetPhysicalN(jd float64, n int, model planetPhysicalModel) PlanetPhysica initialX, initialY, initialZ := planetXYZN(model.planetIndex, jd, n) targetVector := Vector3{initialX, initialY, initialZ} lightTimeDays := astronomicalUnitLightTimeDays * vectorMagnitude(targetVector) - targetJD := jd - lightTimeDays + targetJDE := jd - lightTimeDays - geoX, geoY, geoZ := planetXYZN(model.planetIndex, targetJD, n) + geoX, geoY, geoZ := planetXYZN(model.planetIndex, targetJDE, n) geocentricVector := Vector3{geoX, geoY, geoZ} observerDirection := normalizeVector(Vector3{-geocentricVector[0], -geocentricVector[1], -geocentricVector[2]}) - heliocentricLongitude := planet.WherePlanetN(model.planetIndex, 0, targetJD, n) - heliocentricLatitude := planet.WherePlanetN(model.planetIndex, 1, targetJD, n) + heliocentricLongitude := planet.WherePlanetN(model.planetIndex, 0, targetJDE, n) + heliocentricLatitude := planet.WherePlanetN(model.planetIndex, 1, targetJDE, n) solarDirection := normalizeVector(eclipticCartesian(heliocentricLongitude+180, -heliocentricLatitude, 1)) - obliquity := EclipticObliquity(targetJD, false) + obliquity := EclipticObliquity(targetJDE, false) observerEquatorial := normalizeVector(rotateEclipticToEquatorial(observerDirection, obliquity)) solarEquatorial := normalizeVector(rotateEclipticToEquatorial(solarDirection, obliquity)) - poleRA, poleDec, rotationEast := model.poleRotation(targetJD) + poleRA, poleDec, rotationEast := model.poleRotation(targetJDE) poleJ2000 := raDecToVector(poleRA, poleDec) nodeJ2000 := Vector3{-math.Sin(poleRA * rad), math.Cos(poleRA * rad), 0} eastJ2000 := normalizeVector(pxp(poleJ2000, nodeJ2000)) @@ -187,7 +187,7 @@ func planetPhysicalN(jd float64, n int, model planetPhysicalModel) PlanetPhysica nodeJ2000[2]*Cos(rotationEast) + eastJ2000[2]*Sin(rotationEast), }) - j2000ToDate := precessionMatrix(2451545.0, targetJD) + j2000ToDate := precessionMatrix(2451545.0, targetJDE) poleDate := normalizeVector(applyMatrix3(j2000ToDate, poleJ2000)) primeMeridianDate := normalizeVector(applyMatrix3(j2000ToDate, primeMeridianJ2000)) eastDate := normalizeVector(pxp(poleDate, primeMeridianDate)) diff --git a/basic/planet_physical_test.go b/basic/planet_physical_test.go index c2057ad..719df0b 100644 --- a/basic/planet_physical_test.go +++ b/basic/planet_physical_test.go @@ -49,7 +49,7 @@ func TestPlanetPhysicalMatchesHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) got := physical(jd) assertPlanetPhaseClose(t, sample.Body+"."+sample.InputUTC+".SubEarthLongitude", got.SubEarthLongitude, sample.SubEarthLongitude, 0.02) @@ -61,7 +61,7 @@ func TestPlanetPhysicalMatchesHorizonsBaseline(t *testing.T) { } func TestPlanetPhysicalNFullMatchesDefault(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) cases := []struct { name string @@ -111,7 +111,7 @@ func TestPlanetPhysicalSampleSweepFiniteAndInRange(t *testing.T) { } for _, date := range dates { - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) for _, tc := range cases { info := tc.physical(jd) prefix := tc.name + "." + date.Format(time.RFC3339) diff --git a/basic/planet_rise_set_external_test.go b/basic/planet_rise_set_external_test.go index 068cb27..3f66125 100644 --- a/basic/planet_rise_set_external_test.go +++ b/basic/planet_rise_set_external_test.go @@ -60,7 +60,7 @@ func TestPlanetRiseSetMatchesHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse input time %q: %v", sample.InputUTC, err) } - jd := Date2JDE(inputTime.UTC()) + jd := Date2JD(inputTime.UTC()) riseJD, err := tc.rise(jd, sample.Longitude, sample.Latitude, 0, 1, 0) if err != nil { @@ -98,7 +98,7 @@ func assertEventTimeClose(t *testing.T, name string, gotJD float64, wantUTC stri t.Fatalf("parse %s baseline time %q: %v", name, wantUTC, err) } - gotTime := JDE2DateByZone(gotJD, time.UTC, false) + gotTime := JD2DateByZone(gotJD, time.UTC, false) diff := gotTime.Sub(wantTime) if diff < 0 { diff = -diff diff --git a/basic/planet_transit.go b/basic/planet_transit.go index c417781..79ca182 100644 --- a/basic/planet_transit.go +++ b/basic/planet_transit.go @@ -79,7 +79,7 @@ func mercuryTransitConfig() planetTransitConfig { return planetTransitConfig{ planetIndex: 1, synodicPeriodDays: MERCURY_S_PERIOD, - anchorInferiorTT: TD2UT(JDECalc(2019, 11, 11+(15+21.0/60+40.0/3600)/24), true), + anchorInferiorTT: UTC2TT(JDCalc(2019, 11, 11+(15+21.0/60+40.0/3600)/24)), seasonWindowDays: 12, latitudePrefilter: 1.0, conjunctionStepDay: 0.00001, @@ -96,7 +96,7 @@ func venusTransitConfig() planetTransitConfig { return planetTransitConfig{ planetIndex: 2, synodicPeriodDays: VENUS_S_PERIOD, - anchorInferiorTT: TD2UT(JDECalc(2012, 6, 6+(1+29.0/60)/24), true), + anchorInferiorTT: UTC2TT(JDCalc(2012, 6, 6+(1+29.0/60)/24)), seasonWindowDays: 8, latitudePrefilter: 0.8, conjunctionStepDay: 0.00001, @@ -160,12 +160,12 @@ func closestPlanetTransit(jde float64, cfg planetTransitConfig) PlanetTransitRes return next } -func searchPlanetTransit(jde float64, cfg planetTransitConfig, direction int, includeCurrent bool) (PlanetTransitResult, bool) { - if !isFiniteFloat(jde) || direction == 0 { +func searchPlanetTransit(jd float64, cfg planetTransitConfig, direction int, includeCurrent bool) (PlanetTransitResult, bool) { + if !isFiniteFloat(jd) || direction == 0 { return PlanetTransitResult{}, false } - targetTT := TD2UT(jde, true) + targetTT := UTC2TT(jd) probeTT := targetTT for i := 0; i < planetTransitSearchLimit; i++ { seasonTT, ok := nextPlanetTransitSeasonTT(probeTT, cfg, direction) @@ -495,14 +495,14 @@ func bisectPlanetTransitContactTT(leftJD, leftValue, rightJD, rightValue float64 } func planetTransitResultTTToUT(result PlanetTransitResult) PlanetTransitResult { - result.Greatest = TD2UT(result.Greatest, false) - result.ExternalIngress = TD2UT(result.ExternalIngress, false) - result.ExternalEgress = TD2UT(result.ExternalEgress, false) + result.Greatest = TT2UTC(result.Greatest) + result.ExternalIngress = TT2UTC(result.ExternalIngress) + result.ExternalEgress = TT2UTC(result.ExternalEgress) if result.InternalIngress != 0 { - result.InternalIngress = TD2UT(result.InternalIngress, false) + result.InternalIngress = TT2UTC(result.InternalIngress) } if result.InternalEgress != 0 { - result.InternalEgress = TD2UT(result.InternalEgress, false) + result.InternalEgress = TT2UTC(result.InternalEgress) } return result } diff --git a/basic/planet_transit_test.go b/basic/planet_transit_test.go index dac89f1..07ee65b 100644 --- a/basic/planet_transit_test.go +++ b/basic/planet_transit_test.go @@ -26,15 +26,15 @@ func TestKnownMercuryTransits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := NextMercuryTransit(Date2JDE(tc.query)) + result := NextMercuryTransit(Date2JD(tc.query)) if !result.Valid { t.Fatal("expected valid transit") } - got := JDE2DateByZone(result.Greatest, time.UTC, false) + got := JD2DateByZone(result.Greatest, time.UTC, false) t.Logf("start=%s greatest=%s end=%s min=%.3f sun=%.3f planet=%.3f", - JDE2DateByZone(result.ExternalIngress, time.UTC, false), + JD2DateByZone(result.ExternalIngress, time.UTC, false), got, - JDE2DateByZone(result.ExternalEgress, time.UTC, false), + JD2DateByZone(result.ExternalEgress, time.UTC, false), result.MinimumSeparationArcsec, result.SunSemidiameterArcsec, result.PlanetSemidiameterArcsec, @@ -75,15 +75,15 @@ func TestKnownVenusTransits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := NextVenusTransit(Date2JDE(tc.query)) + result := NextVenusTransit(Date2JD(tc.query)) if !result.Valid { t.Fatal("expected valid transit") } - got := JDE2DateByZone(result.Greatest, time.UTC, false) + got := JD2DateByZone(result.Greatest, time.UTC, false) t.Logf("start=%s greatest=%s end=%s min=%.3f sun=%.3f planet=%.3f", - JDE2DateByZone(result.ExternalIngress, time.UTC, false), + JD2DateByZone(result.ExternalIngress, time.UTC, false), got, - JDE2DateByZone(result.ExternalEgress, time.UTC, false), + JD2DateByZone(result.ExternalEgress, time.UTC, false), result.MinimumSeparationArcsec, result.SunSemidiameterArcsec, result.PlanetSemidiameterArcsec, @@ -105,27 +105,27 @@ func TestKnownVenusTransits(t *testing.T) { } func TestTransitSearchSkipsSparseEvents(t *testing.T) { - mercuryResult := NextMercuryTransit(Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) + mercuryResult := NextMercuryTransit(Date2JD(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) if !mercuryResult.Valid { t.Fatal("expected Mercury transit") } - mercuryGreatest := JDE2DateByZone(mercuryResult.Greatest, time.UTC, false) + mercuryGreatest := JD2DateByZone(mercuryResult.Greatest, time.UTC, false) if mercuryGreatest.Year() != 2032 || mercuryGreatest.Month() != time.November { t.Fatalf("unexpected next Mercury transit: %s", mercuryGreatest) } - venusResult := NextVenusTransit(Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) + venusResult := NextVenusTransit(Date2JD(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) if !venusResult.Valid { t.Fatal("expected Venus transit") } - venusGreatest := JDE2DateByZone(venusResult.Greatest, time.UTC, false) + venusGreatest := JD2DateByZone(venusResult.Greatest, time.UTC, false) if venusGreatest.Year() != 2117 || venusGreatest.Month() != time.December { t.Fatalf("unexpected next Venus transit: %s", venusGreatest) } } func BenchmarkNextMercuryTransitFrom2026(b *testing.B) { - jd := Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + jd := Date2JD(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) for i := 0; i < b.N; i++ { result := NextMercuryTransit(jd) if !result.Valid { @@ -135,7 +135,7 @@ func BenchmarkNextMercuryTransitFrom2026(b *testing.B) { } func BenchmarkNextVenusTransitFrom2026(b *testing.B) { - jd := Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + jd := Date2JD(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) for i := 0; i < b.N; i++ { result := NextVenusTransit(jd) if !result.Valid { diff --git a/basic/planet_truncated.go b/basic/planet_truncated.go index d615377..ce6919d 100644 --- a/basic/planet_truncated.go +++ b/basic/planet_truncated.go @@ -10,13 +10,13 @@ import ( // Exported N variants below keep the same jd semantics as the non-N APIs; n < 0 means full series. type planetDeclinationFuncN func(float64, int) float64 -func planetXYZN(planetIndex int, jd float64, n int) (float64, float64, float64) { - l := planet.WherePlanetN(planetIndex, 0, jd, n) - b := planet.WherePlanetN(planetIndex, 1, jd, n) - r := planet.WherePlanetN(planetIndex, 2, jd, n) - el := planet.WherePlanetN(-1, 0, jd, n) - eb := planet.WherePlanetN(-1, 1, jd, n) - er := planet.WherePlanetN(-1, 2, jd, n) +func planetXYZN(planetIndex int, jde float64, n int) (float64, float64, float64) { + l := planet.WherePlanetN(planetIndex, 0, jde, n) + b := planet.WherePlanetN(planetIndex, 1, jde, n) + r := planet.WherePlanetN(planetIndex, 2, jde, n) + el := planet.WherePlanetN(-1, 0, jde, n) + eb := planet.WherePlanetN(-1, 1, jde, n) + er := planet.WherePlanetN(-1, 2, jde, n) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) @@ -28,26 +28,26 @@ func planetApparentLoBoN(planetIndex int, jd float64, n int) (float64, float64) return geo.lo, geo.bo } -func planetApparentRaManualN(planetIndex int, jd float64, n int) float64 { - lo, bo := planetApparentLoBoN(planetIndex, jd, n) - eps := TrueObliquity(jd) +func planetApparentRaManualN(planetIndex int, jde float64, n int) float64 { + lo, bo := planetApparentLoBoN(planetIndex, jde, n) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) return Limit360(ra * 180 / math.Pi) } -func planetApparentDecManualN(planetIndex int, jd float64, n int) float64 { - lo, bo := planetApparentLoBoN(planetIndex, jd, n) - eps := TrueObliquity(jd) +func planetApparentDecManualN(planetIndex int, jde float64, n int) float64 { + lo, bo := planetApparentLoBoN(planetIndex, jde, n) + eps := TrueObliquity(jde) return ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) } -func planetApparentRaDecManualN(planetIndex int, jd float64, n int) (float64, float64) { - lo, bo := planetApparentLoBoN(planetIndex, jd, n) - return planetApparentRaDecFromLoBo(jd, lo, bo) +func planetApparentRaDecManualN(planetIndex int, jde float64, n int) (float64, float64) { + lo, bo := planetApparentLoBoN(planetIndex, jde, n) + return planetApparentRaDecFromLoBo(jde, lo, bo) } -func planetApparentRaDecFromLoBo(jd, lo, bo float64) (float64, float64) { - eps := TrueObliquity(jd) +func planetApparentRaDecFromLoBo(jde, lo, bo float64) (float64, float64) { + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -58,19 +58,19 @@ func planetEarthAwayN(planetIndex int, jd float64, n int) float64 { return planetEarthAwayExplicitN(planetIndex, jd, n) } -func planetHeightN(jde, lon, lat, timezone float64, n int, apparentRaDec func(float64, int) (float64, float64)) float64 { - utcJde := jde - timezone/24.0 - ra, dec := apparentRaDec(TD2UT(utcJde, true), n) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) +func planetHeightN(localJD, lon, lat, timezone float64, n int, apparentRaDec func(float64, int) (float64, float64)) float64 { + utcJD := localJD - timezone/24.0 + ra, dec := apparentRaDec(UTC2TT(utcJD), n) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) H := Limit360(st - ra) sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(H) return ArcSin(sinHeight) } -func planetAzimuthN(jde, lon, lat, timezone float64, n int, apparentRaDec func(float64, int) (float64, float64)) float64 { - utcJde := jde - timezone/24.0 - ra, dec := apparentRaDec(TD2UT(utcJde, true), n) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) +func planetAzimuthN(localJD, lon, lat, timezone float64, n int, apparentRaDec func(float64, int) (float64, float64)) float64 { + utcJD := localJD - timezone/24.0 + ra, dec := apparentRaDec(UTC2TT(utcJD), n) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) H := Limit360(st - ra) tanAzimuth := Sin(H) / (Cos(H)*Sin(lat) - Tan(dec)*Cos(lat)) azimuth := ArcTan(tanAzimuth) @@ -87,22 +87,22 @@ func planetAzimuthN(jde, lon, lat, timezone float64, n int, apparentRaDec func(f } func planetHourAngleN(jd, lon, timezone float64, n int, apparentRa func(float64, int) float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - apparentRa(TD2UT(jd-timezone/24.0, true), n) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - apparentRa(UTC2TT(jd-timezone/24.0), n) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(float64, float64, float64, int) float64) float64 { - if !isFiniteFloat(jde) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) { +func planetCulminationTimeN(localJD, lon, timezone float64, n int, hourAngle func(float64, float64, float64, int) float64) float64 { + if !isFiniteFloat(localJD) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) { return math.NaN() } - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-hourAngle(jde, lon, timezone, n))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := hourAngle(jde, lon, timezone, n) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-hourAngle(localJD, lon, timezone, n))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := hourAngle(localJD, lon, timezone, n) if currentHourAngle < 180 { currentHourAngle += 360 } @@ -142,7 +142,7 @@ func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight floa if previousHeight > targetAltitude { return 0, ErrNeverSet } - dec := declination(TD2UT(culminationJD-localTimezone/24, true), n) + dec := declination(UTC2TT(culminationJD-localTimezone/24), n) cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat)) if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) { return 0, ErrInvalidObservationInput @@ -199,22 +199,22 @@ func MercuryApparentLoBoN(jd float64, n int) (float64, float64) { } // MercuryApparentRaN 水星视赤经(截断版) / truncated apparent right ascension of Mercury. -func MercuryApparentRaN(jd float64, n int) float64 { - lo, bo := MercuryApparentLoBoN(jd, n) - return LoToRa(jd, lo, bo) +func MercuryApparentRaN(jde float64, n int) float64 { + lo, bo := MercuryApparentLoBoN(jde, n) + return LoToRa(jde, lo, bo) } // MercuryApparentDecN 水星视赤纬(截断版) / truncated apparent declination of Mercury. -func MercuryApparentDecN(jd float64, n int) float64 { - lo, bo := MercuryApparentLoBoN(jd, n) - eps := TrueObliquity(jd) +func MercuryApparentDecN(jde float64, n int) float64 { + lo, bo := MercuryApparentLoBoN(jde, n) + eps := TrueObliquity(jde) return ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) } // MercuryApparentRaDecN 水星视赤经赤纬(截断版) / truncated apparent right ascension and declination of Mercury. -func MercuryApparentRaDecN(jd float64, n int) (float64, float64) { - lo, bo := MercuryApparentLoBoN(jd, n) - return LoBoToRaDec(jd, lo, bo) +func MercuryApparentRaDecN(jde float64, n int) (float64, float64) { + lo, bo := MercuryApparentLoBoN(jde, n) + return LoBoToRaDec(jde, lo, bo) } // EarthMercuryAwayN 地水距离(截断版) / truncated Earth-Mercury distance. @@ -223,10 +223,10 @@ func EarthMercuryAwayN(jd float64, n int) float64 { } // MercuryMagN 水星视星等(截断版) / truncated apparent magnitude of Mercury. -func MercuryMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(1, 2, jd, n) - awayEarth := EarthMercuryAwayN(jd, n) - away := planet.WherePlanetN(-1, 2, jd, n) +func MercuryMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(1, 2, jde, n) + awayEarth := EarthMercuryAwayN(jde, n) + away := planet.WherePlanetN(-1, 2, jde, n) i := (awaySun*awaySun + awayEarth*awayEarth - away*away) / (2 * awaySun * awayEarth) i = ArcCos(i) mag := -0.42 + 5*math.Log10(awaySun*awayEarth) + 0.0380*i - 0.000273*i*i + 0.000002*i*i*i @@ -234,13 +234,13 @@ func MercuryMagN(jd float64, n int) float64 { } // MercuryHeightN 水星高度角(截断版) / truncated altitude of Mercury. -func MercuryHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, MercuryApparentRaDecN) +func MercuryHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, MercuryApparentRaDecN) } // MercuryAzimuthN 水星方位角(截断版) / truncated azimuth of Mercury. -func MercuryAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, MercuryApparentRaDecN) +func MercuryAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, MercuryApparentRaDecN) } // MercuryHourAngleN 水星时角(截断版) / truncated hour angle of Mercury. @@ -249,8 +249,8 @@ func MercuryHourAngleN(jd, lon, timezone float64, n int) float64 { } // MercuryCulminationTimeN 水星中天时间(截断版) / truncated culmination time of Mercury. -func MercuryCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, MercuryHourAngleN) +func MercuryCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, MercuryHourAngleN) } // MercuryRiseTimeN 水星升起时间(截断版) / truncated rise time of Mercury. @@ -301,10 +301,10 @@ func EarthVenusAwayN(jd float64, n int) float64 { } // VenusMagN 金星视星等(截断版) / truncated apparent magnitude of Venus. -func VenusMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(2, 2, jd, n) - awayEarth := EarthVenusAwayN(jd, n) - away := planet.WherePlanetN(-1, 2, jd, n) +func VenusMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(2, 2, jde, n) + awayEarth := EarthVenusAwayN(jde, n) + away := planet.WherePlanetN(-1, 2, jde, n) i := (awaySun*awaySun + awayEarth*awayEarth - away*away) / (2 * awaySun * awayEarth) i = ArcCos(i) mag := -4.40 + 5*math.Log10(awaySun*awayEarth) + 0.0009*i + 0.000239*i*i - 0.00000065*i*i*i @@ -312,13 +312,13 @@ func VenusMagN(jd float64, n int) float64 { } // VenusHeightN 金星高度角(截断版) / truncated altitude of Venus. -func VenusHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, VenusApparentRaDecN) +func VenusHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, VenusApparentRaDecN) } // VenusAzimuthN 金星方位角(截断版) / truncated azimuth of Venus. -func VenusAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, VenusApparentRaDecN) +func VenusAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, VenusApparentRaDecN) } // VenusHourAngleN 金星时角(截断版) / truncated hour angle of Venus. @@ -327,8 +327,8 @@ func VenusHourAngleN(jd, lon, timezone float64, n int) float64 { } // VenusCulminationTimeN 金星中天时间(截断版) / truncated culmination time of Venus. -func VenusCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, VenusHourAngleN) +func VenusCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, VenusHourAngleN) } // VenusRiseTimeN 金星升起时间(截断版) / truncated rise time of Venus. @@ -379,10 +379,10 @@ func EarthMarsAwayN(jd float64, n int) float64 { } // MarsMagN 火星视星等(截断版) / truncated apparent magnitude of Mars. -func MarsMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(3, 2, jd, n) - awayEarth := EarthMarsAwayN(jd, n) - away := planet.WherePlanetN(-1, 2, jd, n) +func MarsMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(3, 2, jde, n) + awayEarth := EarthMarsAwayN(jde, n) + away := planet.WherePlanetN(-1, 2, jde, n) i := (awaySun*awaySun + awayEarth*awayEarth - away*away) / (2 * awaySun * awayEarth) i = ArcCos(i) mag := -1.52 + 5*math.Log10(awaySun*awayEarth) + 0.016*i @@ -390,13 +390,13 @@ func MarsMagN(jd float64, n int) float64 { } // MarsHeightN 火星高度角(截断版) / truncated altitude of Mars. -func MarsHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, MarsApparentRaDecN) +func MarsHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, MarsApparentRaDecN) } // MarsAzimuthN 火星方位角(截断版) / truncated azimuth of Mars. -func MarsAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, MarsApparentRaDecN) +func MarsAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, MarsApparentRaDecN) } // MarsHourAngleN 火星时角(截断版) / truncated hour angle of Mars. @@ -405,8 +405,8 @@ func MarsHourAngleN(jd, lon, timezone float64, n int) float64 { } // MarsCulminationTimeN 火星中天时间(截断版) / truncated culmination time of Mars. -func MarsCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, MarsHourAngleN) +func MarsCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, MarsHourAngleN) } // MarsRiseTimeN 火星升起时间(截断版) / truncated rise time of Mars. @@ -457,10 +457,10 @@ func EarthJupiterAwayN(jd float64, n int) float64 { } // JupiterMagN 木星视星等(截断版) / truncated apparent magnitude of Jupiter. -func JupiterMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(4, 2, jd, n) - awayEarth := EarthJupiterAwayN(jd, n) - away := planet.WherePlanetN(-1, 2, jd, n) +func JupiterMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(4, 2, jde, n) + awayEarth := EarthJupiterAwayN(jde, n) + away := planet.WherePlanetN(-1, 2, jde, n) i := (awaySun*awaySun + awayEarth*awayEarth - away*away) / (2 * awaySun * awayEarth) i = ArcCos(i) mag := -9.40 + 5*math.Log10(awaySun*awayEarth) + 0.0005*i @@ -468,13 +468,13 @@ func JupiterMagN(jd float64, n int) float64 { } // JupiterHeightN 木星高度角(截断版) / truncated altitude of Jupiter. -func JupiterHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, JupiterApparentRaDecN) +func JupiterHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, JupiterApparentRaDecN) } // JupiterAzimuthN 木星方位角(截断版) / truncated azimuth of Jupiter. -func JupiterAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, JupiterApparentRaDecN) +func JupiterAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, JupiterApparentRaDecN) } // JupiterHourAngleN 木星时角(截断版) / truncated hour angle of Jupiter. @@ -483,8 +483,8 @@ func JupiterHourAngleN(jd, lon, timezone float64, n int) float64 { } // JupiterCulminationTimeN 木星中天时间(截断版) / truncated culmination time of Jupiter. -func JupiterCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, JupiterHourAngleN) +func JupiterCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, JupiterHourAngleN) } // JupiterRiseTimeN 木星升起时间(截断版) / truncated rise time of Jupiter. @@ -535,23 +535,23 @@ func EarthSaturnAwayN(jd float64, n int) float64 { } // SaturnMagN 土星视星等(截断版) / truncated apparent magnitude of Saturn. -func SaturnMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(5, 2, jd, n) - awayEarth := EarthSaturnAwayN(jd, n) - ringB, _, _, deltaU, _, _ := SaturnRingParametersN(jd, n) +func SaturnMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(5, 2, jde, n) + awayEarth := EarthSaturnAwayN(jde, n) + ringB, _, _, deltaU, _, _ := SaturnRingParametersN(jde, n) ringB = math.Abs(ringB) mag := -8.68 + 5*math.Log10(awaySun*awayEarth) + 0.044*deltaU - 2.6*Sin(ringB) + 1.25*Sin(ringB)*Sin(ringB) return FloatRound(mag, 2) } // SaturnHeightN 土星高度角(截断版) / truncated altitude of Saturn. -func SaturnHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, SaturnApparentRaDecN) +func SaturnHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, SaturnApparentRaDecN) } // SaturnAzimuthN 土星方位角(截断版) / truncated azimuth of Saturn. -func SaturnAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, SaturnApparentRaDecN) +func SaturnAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, SaturnApparentRaDecN) } // SaturnHourAngleN 土星时角(截断版) / truncated hour angle of Saturn. @@ -560,8 +560,8 @@ func SaturnHourAngleN(jd, lon, timezone float64, n int) float64 { } // SaturnCulminationTimeN 土星中天时间(截断版) / truncated culmination time of Saturn. -func SaturnCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, SaturnHourAngleN) +func SaturnCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, SaturnHourAngleN) } // SaturnRiseTimeN 土星升起时间(截断版) / truncated rise time of Saturn. @@ -612,10 +612,10 @@ func EarthUranusAwayN(jd float64, n int) float64 { } // UranusMagN 天王星视星等(截断版) / truncated apparent magnitude of Uranus. -func UranusMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(6, 2, jd, n) - awayEarth := EarthUranusAwayN(jd, n) - away := planet.WherePlanetN(-1, 2, jd, n) +func UranusMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(6, 2, jde, n) + awayEarth := EarthUranusAwayN(jde, n) + away := planet.WherePlanetN(-1, 2, jde, n) i := (awaySun*awaySun + awayEarth*awayEarth - away*away) / (2 * awaySun * awayEarth) i = ArcCos(i) mag := -7.19 + 5*math.Log10(awaySun*awayEarth) + 0.016*i @@ -623,13 +623,13 @@ func UranusMagN(jd float64, n int) float64 { } // UranusHeightN 天王星高度角(截断版) / truncated altitude of Uranus. -func UranusHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, UranusApparentRaDecN) +func UranusHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, UranusApparentRaDecN) } // UranusAzimuthN 天王星方位角(截断版) / truncated azimuth of Uranus. -func UranusAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, UranusApparentRaDecN) +func UranusAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, UranusApparentRaDecN) } // UranusHourAngleN 天王星时角(截断版) / truncated hour angle of Uranus. @@ -638,8 +638,8 @@ func UranusHourAngleN(jd, lon, timezone float64, n int) float64 { } // UranusCulminationTimeN 天王星中天时间(截断版) / truncated culmination time of Uranus. -func UranusCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, UranusHourAngleN) +func UranusCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, UranusHourAngleN) } // UranusRiseTimeN 天王星升起时间(截断版) / truncated rise time of Uranus. @@ -690,21 +690,21 @@ func EarthNeptuneAwayN(jd float64, n int) float64 { } // NeptuneMagN 海王星视星等(截断版) / truncated apparent magnitude of Neptune. -func NeptuneMagN(jd float64, n int) float64 { - awaySun := planet.WherePlanetN(7, 2, jd, n) - awayEarth := EarthNeptuneAwayN(jd, n) +func NeptuneMagN(jde float64, n int) float64 { + awaySun := planet.WherePlanetN(7, 2, jde, n) + awayEarth := EarthNeptuneAwayN(jde, n) mag := -6.87 + 5*math.Log10(awaySun*awayEarth) return FloatRound(mag, 2) } // NeptuneHeightN 海王星高度角(截断版) / truncated altitude of Neptune. -func NeptuneHeightN(jde, lon, lat, timezone float64, n int) float64 { - return planetHeightN(jde, lon, lat, timezone, n, NeptuneApparentRaDecN) +func NeptuneHeightN(localJD, lon, lat, timezone float64, n int) float64 { + return planetHeightN(localJD, lon, lat, timezone, n, NeptuneApparentRaDecN) } // NeptuneAzimuthN 海王星方位角(截断版) / truncated azimuth of Neptune. -func NeptuneAzimuthN(jde, lon, lat, timezone float64, n int) float64 { - return planetAzimuthN(jde, lon, lat, timezone, n, NeptuneApparentRaDecN) +func NeptuneAzimuthN(localJD, lon, lat, timezone float64, n int) float64 { + return planetAzimuthN(localJD, lon, lat, timezone, n, NeptuneApparentRaDecN) } // NeptuneHourAngleN 海王星时角(截断版) / truncated hour angle of Neptune. @@ -713,8 +713,8 @@ func NeptuneHourAngleN(jd, lon, timezone float64, n int) float64 { } // NeptuneCulminationTimeN 海王星中天时间(截断版) / truncated culmination time of Neptune. -func NeptuneCulminationTimeN(jde, lon, timezone float64, n int) float64 { - return planetCulminationTimeN(jde, lon, timezone, n, NeptuneHourAngleN) +func NeptuneCulminationTimeN(localJD, lon, timezone float64, n int) float64 { + return planetCulminationTimeN(localJD, lon, timezone, n, NeptuneHourAngleN) } // NeptuneRiseTimeN 海王星升起时间(截断版) / truncated rise time of Neptune. diff --git a/basic/rise_set.go b/basic/rise_set.go index f0b2ac1..cb3b3e4 100644 --- a/basic/rise_set.go +++ b/basic/rise_set.go @@ -77,7 +77,7 @@ func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float return 0, ErrNeverSet } - dec := declination(TD2UT(culminationJD-localTimezone/24, true)) + dec := declination(UTC2TT(culminationJD - localTimezone/24)) cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat)) if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) { return 0, ErrInvalidObservationInput diff --git a/basic/rise_set_curve_test.go b/basic/rise_set_curve_test.go index 3283d73..47f3934 100644 --- a/basic/rise_set_curve_test.go +++ b/basic/rise_set_curve_test.go @@ -9,13 +9,13 @@ import ( ) func TestSolarEclipseRiseSetCurvesSatisfyLocalPhaseEquations(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, }) if len(result.RiseSetCurves) != 6 { t.Fatalf("solar rise/set curve count = %d, want 6", len(result.RiseSetCurves)) } - solver := newSolarEclipseSolver(CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0), SolarEclipseModelNASABulletinSplitK) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(JDCalc(2024, 4, 8), 0), SolarEclipseModelNASABulletinSplitK) for _, curve := range result.RiseSetCurves { for _, segment := range curve.Segments { for _, point := range riseSetSolarTestSamples(segment) { @@ -40,7 +40,7 @@ func TestSolarEclipseRiseSetCurvesSatisfyLocalPhaseEquations(t *testing.T) { } func TestSolarEclipseRiseSetCurvesCloseFoldsAndPhaseJunctions(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2031, 5, 21), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2031, 5, 21), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, }) if len(result.RiseSetCurves) != 6 { @@ -84,7 +84,7 @@ func TestSolarEclipseRiseSetCurvesCloseFoldsAndPhaseJunctions(t *testing.T) { } func TestSolarEclipseRiseSetCurvesCloseFoldsWithAdditionalBranches(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, }) for _, phase := range []RiseSetPhase{RiseSetPhaseGreatest, RiseSetPhaseEnd} { @@ -113,7 +113,7 @@ func TestSolarEclipseRiseSetCurvesCloseFoldsWithAdditionalBranches(t *testing.T) } func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions20100115(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, }) curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) @@ -132,7 +132,7 @@ func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions20100115(t *testing.T } func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions23090609(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2309, 6, 9), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2309, 6, 9), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, }) curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) @@ -160,7 +160,7 @@ func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) { } for _, test := range tests { result := SolarEclipsePartialFootprints( - JDECalc(test.year, test.month, float64(test.day)), + JDCalc(test.year, test.month, float64(test.day)), SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0}, ) if len(result.RiseSetCurves) != 6 { @@ -168,7 +168,7 @@ func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) { test.year, test.month, test.day, len(result.RiseSetCurves)) } solver := newSolarEclipseSolver( - CalcMoonSHByJDE(JDECalc(test.year, test.month, float64(test.day)), 0), + CalcMoonSHByJDE(JDCalc(test.year, test.month, float64(test.day)), 0), SolarEclipseModelNASABulletinSplitK, ) curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) @@ -201,7 +201,7 @@ func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) { } func TestSolarEclipseNonCentralGreatestHorizonWithoutTimeFold(t *testing.T) { - seed := JDECalc(1957, 10, 23) + seed := JDCalc(1957, 10, 23) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) for _, stepSeconds := range []float64{120, 5} { t.Run(fmt.Sprintf("step_%gs", stepSeconds), func(t *testing.T) { @@ -933,7 +933,7 @@ func BenchmarkOccultationRiseSetRegression(b *testing.B) { } func TestSolarEclipseRiseSetStepAllowsCoarseSampling(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) // Five- and thirty-minute inputs can both hit the spatial chord limit. // Use a genuinely dense input so the test measures time decimation. fine := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ diff --git a/basic/rise_set_dynamic_test.go b/basic/rise_set_dynamic_test.go index 16d7b30..7f23fdf 100644 --- a/basic/rise_set_dynamic_test.go +++ b/basic/rise_set_dynamic_test.go @@ -16,7 +16,7 @@ func TestSunRiseSetDynamicResidual(t *testing.T) { timeZone = 8.0 height = 0.0 ) - jd := JDECalc(2025, 6, 5) + jd := JDCalc(2025, 6, 5) for _, event := range []struct { name string @@ -54,7 +54,7 @@ func TestSunApparentStateReusesDistanceWithoutChangingCoordinates(t *testing.T) func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) { date := time.Date(2025, 6, 10, 0, 0, 0, 0, time.UTC) - jd := Date2JDE(date) + jd := Date2JD(date) dayStart := math.Floor(jd) + 0.5 rise, err := GetSunRiseTime(jd, 0, 66, 0, 1, 0) @@ -76,7 +76,7 @@ func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) { func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) { date := time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC) - jd := Date2JDE(date) + jd := Date2JD(date) dayStart := math.Floor(jd) + 0.5 set, err := GetMoonSetTime(jd, 0, 80, 0, 1, 0) if err != nil { @@ -89,7 +89,7 @@ func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) { func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) { date := time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC) - jd := Date2JDE(date) + jd := Date2JD(date) _, err := GetMoonRiseTime(jd, -42.6043, 71.7069, -3, 1, 0) if !errors.Is(err, ErrNeverRise) { t.Fatalf("moonrise error = %v, want %v", err, ErrNeverRise) @@ -98,7 +98,7 @@ func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) func TestMoonRiseSetDynamicUsesObserverHeightForParallax(t *testing.T) { date := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC) - jd := Date2JDE(date) + jd := Date2JD(date) const ( longitude = 116.4074 latitude = 39.9042 @@ -133,12 +133,12 @@ func assertRiseSetEvent(t *testing.T, name string, eventJD, dayStart float64, is } func moonRiseSetResidualAtObserverHeight(jd, longitude, latitude, timeZone, zenithShift, height float64) float64 { - calculationJD := TD2UT(jd-timeZone/24, true) + calculationJD := UTC2TT(jd - timeZone/24) ra, dec := HMoonTrueRaDecN(calculationJD, -1) distanceKM := HMoonAwayN(calculationJD, -1) topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, latitude, longitude, jd-timeZone/24, distanceKM/angularDiameterAstronomicalUnitKM, height) - siderealTime := Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude) + siderealTime := Limit360(ApparentSiderealTime(UTC2UT1(jd-timeZone/24))*15 + longitude) hourAngle := Limit360(siderealTime - topocentricRA) altitude := ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle)) residual := altitude + HeightDegreeByLat(height, latitude) diff --git a/basic/saturn.go b/basic/saturn.go index 45c708d..59d0ea4 100644 --- a/basic/saturn.go +++ b/basic/saturn.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func SaturnL(jd float64) float64 { - return planet.WherePlanet(5, 0, jd) +func SaturnL(jde float64) float64 { + return planet.WherePlanet(5, 0, jde) } -func SaturnB(jd float64) float64 { - return planet.WherePlanet(5, 1, jd) +func SaturnB(jde float64) float64 { + return planet.WherePlanet(5, 1, jde) } -func SaturnR(jd float64) float64 { - return planet.WherePlanet(5, 2, jd) +func SaturnR(jde float64) float64 { + return planet.WherePlanet(5, 2, jde) } -func ASaturnX(jd float64) float64 { - l := SaturnL(jd) - b := SaturnB(jd) - r := SaturnR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ASaturnX(jde float64) float64 { + l := SaturnL(jde) + b := SaturnB(jde) + r := SaturnR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func ASaturnY(jd float64) float64 { +func ASaturnY(jde float64) float64 { - l := SaturnL(jd) - b := SaturnB(jd) - r := SaturnR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := SaturnL(jde) + b := SaturnB(jde) + r := SaturnR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func ASaturnZ(jd float64) float64 { - //l := SaturnL(jd) - b := SaturnB(jd) - r := SaturnR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ASaturnZ(jde float64) float64 { + //l := SaturnL(jde) + b := SaturnB(jde) + r := SaturnR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func ASaturnXYZ(jd float64) (float64, float64, float64) { - l := SaturnL(jd) - b := SaturnB(jd) - r := SaturnR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func ASaturnXYZ(jde float64) (float64, float64, float64) { + l := SaturnL(jde) + b := SaturnB(jde) + r := SaturnR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func SaturnApparentRa(jd float64) float64 { - lo, bo := SaturnApparentLoBo(jd) - eps := TrueObliquity(jd) +func SaturnApparentRa(jde float64) float64 { + lo, bo := SaturnApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func SaturnApparentDec(jd float64) float64 { - lo, bo := SaturnApparentLoBo(jd) - eps := TrueObliquity(jd) +func SaturnApparentDec(jde float64) float64 { + lo, bo := SaturnApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func SaturnApparentRaDec(jd float64) (float64, float64) { - lo, bo := SaturnApparentLoBo(jd) - eps := TrueObliquity(jd) +func SaturnApparentRaDec(jde float64) (float64, float64) { + lo, bo := SaturnApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -105,16 +105,16 @@ func SaturnApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func SaturnMag(jd float64) float64 { - return SaturnMagN(jd, -1) +func SaturnMag(jde float64) float64 { + return SaturnMagN(jde, -1) } -func SaturnHeight(jde, lon, lat, timezone float64) float64 { +func SaturnHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := SaturnApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := SaturnApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -123,12 +123,12 @@ func SaturnHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func SaturnAzimuth(jde, lon, lat, timezone float64) float64 { +func SaturnAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := SaturnApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := SaturnApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -147,21 +147,21 @@ func SaturnAzimuth(jde, lon, lat, timezone float64) float64 { } func SaturnHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - SaturnApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - SaturnApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func SaturnCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func SaturnCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-SaturnHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := SaturnHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-SaturnHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := SaturnHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/saturn_events.go b/basic/saturn_events.go index 263b1f5..c37daa7 100644 --- a/basic/saturn_events.go +++ b/basic/saturn_events.go @@ -74,15 +74,15 @@ func saturnConjunctionFull(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := saturnSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (saturnSunLongitudeDelta(prevJDE+0.000005, degree, true) - saturnSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -90,7 +90,7 @@ func saturnConjunctionFull(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func saturnConjunction(jde, degree float64, next uint8) float64 { @@ -105,15 +105,15 @@ func saturnConjunction(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := saturnSunLongitudeDeltaN(prevJD, degree, true, saturnEventSearchN) - longitudeSlope := (saturnSunLongitudeDeltaN(prevJD+0.000005, degree, true, saturnEventSearchN) - saturnSunLongitudeDeltaN(prevJD-0.000005, degree, true, saturnEventSearchN)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= saturnPhaseCoarseTolerance { + prevJDE := estimateJDE + longitudeDelta := saturnSunLongitudeDeltaN(prevJDE, degree, true, saturnEventSearchN) + longitudeSlope := (saturnSunLongitudeDeltaN(prevJDE+0.000005, degree, true, saturnEventSearchN) - saturnSunLongitudeDeltaN(prevJDE-0.000005, degree, true, saturnEventSearchN)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= saturnPhaseCoarseTolerance { converged = true break } @@ -123,12 +123,12 @@ func saturnConjunction(jde, degree float64, next uint8) float64 { } converged = false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := saturnSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (saturnSunLongitudeDelta(prevJDE+0.000005, degree, true) - saturnSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -136,7 +136,7 @@ func saturnConjunction(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func LastSaturnConjunction(jde float64) float64 { @@ -175,22 +175,22 @@ func saturnRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOppositi if !isFiniteFloat(oppositionJD) { return math.NaN() } - oppositionTT := TD2UT(oppositionJD, true) + oppositionTT := UTC2TT(oppositionJD) startTT := oppositionTT endTT := oppositionTT if searchBeforeOpposition { easternQuadratureUT := saturnConjunction(oppositionTT, 90, 0) - startTT = TD2UT(easternQuadratureUT, true) + startTT = UTC2TT(easternQuadratureUT) } else { westernQuadratureUT := saturnConjunction(oppositionTT, 270, 1) - endTT = TD2UT(westernQuadratureUT, true) + endTT = UTC2TT(westernQuadratureUT) } - bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { return saturnRADerivativeN(jd, stationDerivativeStepDay, saturnEventSearchN) }, func(jd float64) float64 { return saturnRADerivative(jd, stationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func NextSaturnRetrogradeToPrograde(jde float64) float64 { diff --git a/basic/saturn_ring.go b/basic/saturn_ring.go index 09b5e1c..24de300 100644 --- a/basic/saturn_ring.go +++ b/basic/saturn_ring.go @@ -8,81 +8,81 @@ import ( ) // SaturnRingParameters 土星环参数 / Saturn ring parameters. -func SaturnRingParameters(jd float64) (earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis float64) { - return SaturnRingParametersN(jd, -1) +func SaturnRingParameters(jde float64) (earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis float64) { + return SaturnRingParametersN(jde, -1) } // SaturnRingParametersN 土星环参数(截断版) / truncated Saturn ring parameters. -func SaturnRingParametersN(jd float64, n int) (earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis float64) { - inclination, node := saturnRingPlane(jd) - earthLon, earthLat := SaturnApparentLoBoN(jd, n) - sunLon, sunLat := saturnRingSunLoBo(jd, n, node) +func SaturnRingParametersN(jde float64, n int) (earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis float64) { + inclination, node := saturnRingPlane(jde) + earthLon, earthLat := SaturnApparentLoBoN(jde, n) + sunLon, sunLat := saturnRingSunLoBo(jde, n, node) earthLatitude = saturnRingLatitude(inclination, node, earthLon, earthLat) sunLatitude = saturnRingLatitude(inclination, node, sunLon, sunLat) - positionAngle = saturnRingPositionAngle(jd, inclination, node, earthLon, earthLat) + positionAngle = saturnRingPositionAngle(jde, inclination, node, earthLon, earthLat) earthU := saturnRingLongitude(inclination, node, earthLon, earthLat) sunU := saturnRingLongitude(inclination, node, sunLon, sunLat) deltaU = saturnRingLongitudeDelta(earthU, sunU) - earthDistance := EarthSaturnAwayN(jd, n) + earthDistance := EarthSaturnAwayN(jde, n) majorAxis = 375.35 / earthDistance minorAxis = majorAxis * math.Abs(Sin(earthLatitude)) return } // SaturnRingB 土星环张角 B / Saturn ring opening angle B. -func SaturnRingB(jd float64) float64 { - return SaturnRingBN(jd, -1) +func SaturnRingB(jde float64) float64 { + return SaturnRingBN(jde, -1) } // SaturnRingBN 土星环张角 B(截断版) / truncated Saturn ring opening angle B. -func SaturnRingBN(jd float64, n int) float64 { - earthLatitude, _, _, _, _, _ := SaturnRingParametersN(jd, n) +func SaturnRingBN(jde float64, n int) float64 { + earthLatitude, _, _, _, _, _ := SaturnRingParametersN(jde, n) return earthLatitude } // SaturnRingSunB 土星环太阳侧张角 B' / Sun-side Saturn ring opening angle B'. -func SaturnRingSunB(jd float64) float64 { - return SaturnRingSunBN(jd, -1) +func SaturnRingSunB(jde float64) float64 { + return SaturnRingSunBN(jde, -1) } // SaturnRingSunBN 土星环太阳侧张角 B'(截断版) / truncated Sun-side Saturn ring opening angle B'. -func SaturnRingSunBN(jd float64, n int) float64 { - _, sunLatitude, _, _, _, _ := SaturnRingParametersN(jd, n) +func SaturnRingSunBN(jde float64, n int) float64 { + _, sunLatitude, _, _, _, _ := SaturnRingParametersN(jde, n) return sunLatitude } // SaturnRingPositionAngle 土星环北半短轴位置角 / position angle of Saturn ring northern semiminor axis. -func SaturnRingPositionAngle(jd float64) float64 { - return SaturnRingPositionAngleN(jd, -1) +func SaturnRingPositionAngle(jde float64) float64 { + return SaturnRingPositionAngleN(jde, -1) } // SaturnRingPositionAngleN 土星环北半短轴位置角(截断版) / truncated position angle of Saturn ring northern semiminor axis. -func SaturnRingPositionAngleN(jd float64, n int) float64 { - _, _, positionAngle, _, _, _ := SaturnRingParametersN(jd, n) +func SaturnRingPositionAngleN(jde float64, n int) float64 { + _, _, positionAngle, _, _, _ := SaturnRingParametersN(jde, n) return positionAngle } // SaturnRingDeltaU 太阳和地球在环面内的土星心黄经差 / difference of Saturnicentric ring longitudes. -func SaturnRingDeltaU(jd float64) float64 { - return SaturnRingDeltaUN(jd, -1) +func SaturnRingDeltaU(jde float64) float64 { + return SaturnRingDeltaUN(jde, -1) } // SaturnRingDeltaUN 太阳和地球在环面内的土星心黄经差(截断版) / truncated Saturnicentric ring longitude difference. -func SaturnRingDeltaUN(jd float64, n int) float64 { - _, _, _, deltaU, _, _ := SaturnRingParametersN(jd, n) +func SaturnRingDeltaUN(jde float64, n int) float64 { + _, _, _, deltaU, _, _ := SaturnRingParametersN(jde, n) return deltaU } // SaturnRingAxis 土星环外缘长短轴,单位角秒 / outer ring axes in arcseconds. -func SaturnRingAxis(jd float64) (majorAxis, minorAxis float64) { - return SaturnRingAxisN(jd, -1) +func SaturnRingAxis(jde float64) (majorAxis, minorAxis float64) { + return SaturnRingAxisN(jde, -1) } // SaturnRingAxisN 土星环外缘长短轴(截断版),单位角秒 / truncated outer ring axes in arcseconds. -func SaturnRingAxisN(jd float64, n int) (majorAxis, minorAxis float64) { - _, _, _, _, majorAxis, minorAxis = SaturnRingParametersN(jd, n) +func SaturnRingAxisN(jde float64, n int) (majorAxis, minorAxis float64) { + _, _, _, _, majorAxis, minorAxis = SaturnRingParametersN(jde, n) return } @@ -93,10 +93,10 @@ func saturnRingPlane(jd float64) (inclination, node float64) { return } -func saturnRingSunLoBo(jd float64, n int, node float64) (lon, lat float64) { - lon = planet.WherePlanetN(5, 0, jd, n) - lat = planet.WherePlanetN(5, 1, jd, n) - distance := planet.WherePlanetN(5, 2, jd, n) +func saturnRingSunLoBo(jde float64, n int, node float64) (lon, lat float64) { + lon = planet.WherePlanetN(5, 0, jde, n) + lat = planet.WherePlanetN(5, 1, jde, n) + distance := planet.WherePlanetN(5, 2, jde, n) lat -= 0.000764 * Cos(lon-node) / distance lon -= 0.01759 / distance return lon, lat @@ -120,10 +120,10 @@ func saturnRingLongitudeDelta(a, b float64) float64 { return delta } -func saturnRingPositionAngle(jd, inclination, node, lon, lat float64) float64 { - poleLon := node - 90 + Nutation2000Bi(jd) +func saturnRingPositionAngle(jde, inclination, node, lon, lat float64) float64 { + poleLon := node - 90 + Nutation2000Bi(jde) poleLat := 90 - inclination - eps := TrueObliquity(jd) + eps := TrueObliquity(jde) poleRa, poleDec := saturnRingEclipticToEquatorial(poleLon, poleLat, eps) saturnRa, saturnDec := saturnRingEclipticToEquatorial(lon, lat, eps) diff --git a/basic/sidereal_memo.go b/basic/sidereal_memo.go index 897be17..ae8677f 100644 --- a/basic/sidereal_memo.go +++ b/basic/sidereal_memo.go @@ -6,24 +6,7 @@ import ( "sync/atomic" ) -// 视恒星时是 UT 的纯函数,但月掩全球路径会在同一条计算链里反复向它求值: -// 地球自转、升落上下文、测地投影各自按自己的调用点重算同一个瞬时。实测单场 Saturn -// 2025-01-05 的请求里,191,517 次求值只对应 51,308 个不同的儒略日(重复距离中位数只有 -// 2 次调用),而每次求值都要完整算一遍 77 项 IAU2000B 章动。 -// -// 这里用一张有界直接映射表把结果记下来:无分配、容量固定(4096 槽 × 24 字节), -// 用 RWMutex 保证 C 共享库被宿主多线程调用时安全。表项记录写入时的 ΔT 世代, -// 因此 astro.SetDeltaT 覆盖之后旧条目自然失效,不会返回陈旧恒星时。 -// -// Apparent sidereal time is a pure function of UT, yet one occultation path query evaluates -// it many times for the same instant from independent code paths (Earth rotation, rise/set -// contexts, geodetic projection). A single Saturn 2025-01-05 request performed 191,517 -// evaluations for only 51,308 distinct Julian days, and each evaluation ran the full -// 77-term IAU2000B nutation. This bounded direct-mapped memo removes that redundancy without -// allocating: a fixed 4096-slot table guarded by an RWMutex, with the ΔT generation stored in -// each entry so an astro.SetDeltaT override invalidates stale values instead of replaying them. -// 4096 槽对单场月掩的 5 万余个不同儒略日而言明显偏小(重复距离中位数只有 2 次调用), -// 这里扩到 16384 槽(16384×24 B = 384 KB,BSS 静态数组,不参与初始化)。 +// 固定容量缓存以 ΔT 世代区分模型,RWMutex 保护宿主多线程访问。 const siderealMemoBits = 14 const siderealMemoSize = 1 << siderealMemoBits @@ -43,14 +26,12 @@ var ( ) // siderealMemoIndex 用高低位混合避免相邻儒略日落在相邻槽位而互相驱逐。 -// siderealMemoIndex mixes high and low bits so adjacent Julian days do not evict each other. func siderealMemoIndex(jd float64) uint64 { bits := math.Float64bits(jd) return (bits ^ (bits >> 29)) & (siderealMemoSize - 1) } // siderealMemoLoad 返回缓存命中值;ΔT 世代不匹配时按未命中处理。 -// siderealMemoLoad returns a cached value; a generation mismatch counts as a miss. func siderealMemoLoad(jd float64) (float64, bool) { generation := deltaTGenerationValue() entry := &siderealMemoTable[siderealMemoIndex(jd)] @@ -65,11 +46,7 @@ func siderealMemoLoad(jd float64) (float64, bool) { return 0, false } -// siderealMemoStore 只在 ΔT 世代未变时写入:世代必须在**求值前**采样(见 siderealMemoGeneration), -// 否则求值期间发生的 SetDeltaTFn 会把旧 ΔT 的结果打上新世代并长期回放。 -// siderealMemoStore writes only while the ΔT generation is unchanged; the generation must be sampled -// before the evaluation, otherwise a SetDeltaTFn during the computation would stamp the old value -// with the new generation and replay it. +// 世代须在求值前采样,防止旧模型结果被缓存为新模型结果。 func siderealMemoStore(jd, value float64, generation uint64) { if deltaTGenerationValue() != generation { return diff --git a/basic/sidereal_memo_test.go b/basic/sidereal_memo_test.go index c23aa3e..a552974 100644 --- a/basic/sidereal_memo_test.go +++ b/basic/sidereal_memo_test.go @@ -21,7 +21,7 @@ func directApparentSiderealTime1982(jd float64) float64 { func siderealMemoTestTimes() []float64 { var times []float64 for _, year := range []int{-720, -100, 0, 1000, 1582, 1900, 2025, 2026, 3000, 5000} { - times = append(times, JDECalc(year, 3, 7.25), JDECalc(year, 9, 20.5), JDECalc(year, 12, 31.75)) + times = append(times, JDCalc(year, 3, 7.25), JDCalc(year, 9, 20.5), JDCalc(year, 12, 31.75)) } return times } @@ -51,14 +51,14 @@ func TestApparentSiderealTimeMemoMatchesDirectSeries(t *testing.T) { func TestApparentSiderealTimeMemoInvalidatedByDeltaTOverride(t *testing.T) { original := GetDeltaTFn() t.Cleanup(func() { SetDeltaTFn(original) }) - jd := JDECalc(2025, 1, 5.5) + jd := JDCalc(2025, 1, 5.5) baseline := ApparentSiderealTime2006(jd) if repeat := ApparentSiderealTime2006(jd); repeat != baseline { t.Fatalf("memoized sidereal=%v, want stable %v", repeat, baseline) } - SetDeltaTFn(func(date float64, isJDE bool) float64 { return 6000 }) + SetDeltaTFn(func(date float64, isJulianDay bool) float64 { return 6000 }) shifted := ApparentSiderealTime2006(jd) if shifted == baseline { t.Fatalf("ΔT override replayed the memoized value %v", shifted) @@ -67,14 +67,14 @@ func TestApparentSiderealTimeMemoInvalidatedByDeltaTOverride(t *testing.T) { t.Errorf("sidereal after ΔT override=%v, want %v", shifted, want) } - SetDeltaTFn(DefaultDeltaTv2) + SetDeltaTFn(nil) if restored := ApparentSiderealTime2006(jd); restored != baseline { t.Errorf("sidereal after restoring ΔT=%v, want %v", restored, baseline) } } func TestApparentSiderealTimeMemoIsRaceFree(t *testing.T) { - base := JDECalc(2025, 1, 5.5) + base := JDCalc(2025, 1, 5.5) var wait sync.WaitGroup for worker := 0; worker < 4; worker++ { wait.Add(1) diff --git a/basic/solar_eclipse.go b/basic/solar_eclipse.go index f2fc1d3..f3d688b 100644 --- a/basic/solar_eclipse.go +++ b/basic/solar_eclipse.go @@ -12,6 +12,24 @@ const ( SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k" ) +// SolarEclipseSunRadiusModel 日食几何的太阳半径口径 / solar radius convention for eclipse geometry. +type SolarEclipseSunRadiusModel string + +const ( + // SolarEclipseSunRadiusStandard 标准档,1 AU 处 959.639″,复现已发布星历表与目录 / standard. + SolarEclipseSunRadiusStandard SolarEclipseSunRadiusModel = "standard" + // SolarEclipseSunRadiusMeasured 边缘档,1 AU 处 959.95″:全食带每侧约窄 0.6 千米、中心食时长约短 1.5 秒 / measured. + SolarEclipseSunRadiusMeasured SolarEclipseSunRadiusModel = "measured" +) + +// SolarEclipseOptions 日食计算的半径口径 / radius conventions for a solar eclipse computation. +type SolarEclipseOptions struct { + // RadiusModel 月亮平均半径 k 的口径,零值为 NASA bulletin Split-K / lunar radius model. + RadiusModel SolarEclipseRadiusModel + // SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention. + SunRadiusModel SolarEclipseSunRadiusModel +} + // SolarEclipseType 整场日食的全局食型。 type SolarEclipseType string @@ -45,9 +63,12 @@ const ( // 所有时刻字段都使用力学时儒略日(JDE, TT)。 // 输入 seedJDE 只需要落在目标朔月附近,允许相差数天。 type SolarEclipseResult struct { - Model SolarEclipseRadiusModel - Type SolarEclipseType - Centrality SolarEclipseCentrality + // 下列字段是决定上述数值的口径,随结果一起保留。 + // The fields below are the conventions that fix the numbers above. + Model SolarEclipseRadiusModel + SunRadiusModel SolarEclipseSunRadiusModel + Type SolarEclipseType + Centrality SolarEclipseCentrality // GreatestEclipse 是全局“影轴最接近地心”的时刻。 GreatestEclipse float64 @@ -68,8 +89,17 @@ type SolarEclipseResult struct { // CentralDurationDays is the central-phase duration at the greatest eclipse, // in days, and 0 when the event has no central phase. CentralDurationDays float64 - // PathWidthKM 是食甚点处中心食带宽度。非中心食时为 0。 + // PathWidthKM 是食甚处中心食带宽度;非中心食为 0;单侧极限(中心带仅触及地球边缘)时该解析式 + // 失效并一并置 0,此时 PathWidthDefined 为 false,NASA 目录该栏印 '-'。 + // PathWidthKM is the central path width at greatest eclipse, 0 for a non-central + // event, and 0 when the analytic formula fails at a single-sided limit where the + // band only grazes the Earth's limb; PathWidthDefined is false there and + // catalogues print '-' for this column. PathWidthKM float64 + // PathWidthDefined 表示上面的带宽是否有定义:只有南北两限都存在(central_two_limits)时才为 true。 + // PathWidthDefined reports whether the width above is defined: it is true only + // when both band limits exist, that is for central_two_limits. + PathWidthDefined bool // GreatestLongitude / GreatestLatitude 是日食食甚点地理坐标,东经为正,西经为负。 GreatestLongitude float64 @@ -83,8 +113,9 @@ type SolarEclipseResult struct { } type solarEclipseModelParameters struct { - penumbralK float64 - umbralK float64 + penumbralK float64 + umbralK float64 + sunRadiusRatio float64 } type solarEclipseShadowRadii struct { @@ -101,9 +132,10 @@ type solarEclipseAxis struct { } type solarEclipseSolver struct { - newMoonJDE float64 - model SolarEclipseRadiusModel - params solarEclipseModelParameters + newMoonJDE float64 + model SolarEclipseRadiusModel + sunRadiusModel SolarEclipseSunRadiusModel + params solarEclipseModelParameters localStateContextCache map[uint64]localSolarEclipseStateContext localEphemeris *solarEclipseLocalEphemeris @@ -170,17 +202,23 @@ const ( solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio solarEclipseAstronomicalUnitKM = 1.49597870691e8 - // IAU Single-K 对所有接触统一使用 0.2725076; - // NASA bulletin Split-K 对半影仍使用 0.2725076,对本影/反本影使用 0.2722810。 - solarEclipseSolarRadiusRatio = 109.1222 - solarEclipsePenumbralK = 0.2725076 + // 标准档与边缘档在 1 AU 处的太阳视半径(角秒),日食与月食几何共用这一组常量。 + eclipseSunRadiusStandardArcsec = 959.639 + eclipseSunRadiusMeasuredArcsec = 959.95 + // 标准档太阳半径是地球赤道半径的 109.1222 倍,与上面的标准档视半径等价;边缘档按视半径比例放大。 + solarEclipseSunRadiusRatioStandard = 109.1222 + solarEclipseSunRadiusRatioMeasured = solarEclipseSunRadiusRatioStandard * eclipseSunRadiusMeasuredArcsec / eclipseSunRadiusStandardArcsec + // Split-K:半影(偏食)0.2724880、本影与反本影 0.2722810;IAU Single-K 全部使用 0.2725076。 + solarEclipsePenumbralK = 0.2724880 solarEclipseUmbralK = 0.2722810 - // SolarEclipsePenumbralK 与 SolarEclipseUmbralK 是月面半径与地球赤道半径之比, - // 即 NASA 星历表里的 k1(半影)与 k2(本影/反本影);IAU Single-K 两者都用 k1。 - // SolarEclipsePenumbralK and SolarEclipseUmbralK are the lunar-to-terrestrial radius ratios - // published as k1 (penumbra) and k2 (umbra/antumbra); IAU Single-K uses k1 for both. - SolarEclipsePenumbralK = solarEclipsePenumbralK - SolarEclipseUmbralK = solarEclipseUmbralK + solarEclipseIAUSingleRadiusK = 0.2725076 + // SolarEclipsePenumbralK / SolarEclipseUmbralK 是 Split-K 的半影与本影月地半径比 k1/k2, + // SolarEclipseIAUSingleRadiusK 是 IAU Single-K 的单一值。 + // SolarEclipsePenumbralK / SolarEclipseUmbralK are the split-k lunar-to-terrestrial radius ratios, + // SolarEclipseIAUSingleRadiusK the IAU single value. + SolarEclipsePenumbralK = solarEclipsePenumbralK + SolarEclipseUmbralK = solarEclipseUmbralK + SolarEclipseIAUSingleRadiusK = solarEclipseIAUSingleRadiusK solarEclipseNodeCount = 7 solarEclipseNodeStepDays = 0.04 @@ -196,11 +234,42 @@ const ( var solarEclipseArcsecPerRadian = 180.0 * 3600.0 / math.Pi -// SolarEclipse 计算给定近朔时刻附近的一次全局日食,默认使用 NASABulletin Split-K 模型。 +func normalizeSolarEclipseRadiusModel(model SolarEclipseRadiusModel) SolarEclipseRadiusModel { + if model == SolarEclipseModelIAUSingleK { + return SolarEclipseModelIAUSingleK + } + return SolarEclipseModelNASABulletinSplitK +} + +func normalizeSolarEclipseSunRadiusModel(model SolarEclipseSunRadiusModel) SolarEclipseSunRadiusModel { + if model == SolarEclipseSunRadiusMeasured { + return SolarEclipseSunRadiusMeasured + } + return SolarEclipseSunRadiusStandard +} + +func solarEclipseSunRadiusRatio(model SolarEclipseSunRadiusModel) float64 { + if normalizeSolarEclipseSunRadiusModel(model) == SolarEclipseSunRadiusMeasured { + return solarEclipseSunRadiusRatioMeasured + } + return solarEclipseSunRadiusRatioStandard +} + +// SolarEclipseSunSemidiameter 指定太阳半径口径下的视半径,单位角秒 / apparent solar semidiameter in arcseconds under a given eclipse sun radius convention. +func SolarEclipseSunSemidiameter(jde float64, model SolarEclipseSunRadiusModel) float64 { + return angularSemidiameterFromAU(solarEclipseSunRadiusRatio(model)*solarEclipseEarthEquatorialRadiusKM, EarthAwayN(jde, -1)) +} + +// SolarEclipse 计算给定近朔时刻附近的一次全局日食,默认使用 NASABulletin Split-K 模型与标准太阳半径。 func SolarEclipse(seedJDE float64) SolarEclipseResult { return SolarEclipseNASABulletinSplitK(seedJDE) } +// SolarEclipseWithOptions 计算给定近朔时刻附近的一次全局日食,半径口径由 options 指定 / computes one global solar eclipse with the given radius conventions. +func SolarEclipseWithOptions(seedJDE float64, options SolarEclipseOptions) SolarEclipseResult { + return solarEclipseWithDeltaT(seedJDE, options, 0) +} + // SolarEclipseIAUSingleK 计算给定近朔时刻附近的一次全局日食,使用 IAU Single-K 模型。 func SolarEclipseIAUSingleK(seedJDE float64) SolarEclipseResult { return solarEclipse(seedJDE, SolarEclipseModelIAUSingleK) @@ -212,16 +281,16 @@ func SolarEclipseNASABulletinSplitK(seedJDE float64) SolarEclipseResult { } func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseResult { - return solarEclipseWithDeltaT(seedJDE, model, 0) + return solarEclipseWithDeltaT(seedJDE, SolarEclipseOptions{RadiusModel: model}, 0) } func solarEclipseWithDeltaT( seedJDE float64, - model SolarEclipseRadiusModel, + options SolarEclipseOptions, deltaTSeconds float64, ) SolarEclipseResult { newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(deltaTSeconds) + solver := newSolarEclipseSolverWithOptions(newMoonJDE, options).withDeltaTSeconds(deltaTSeconds) return solver.eclipseResult() } @@ -231,6 +300,7 @@ func (solver solarEclipseSolver) eclipseResult() SolarEclipseResult { result := SolarEclipseResult{ Model: model, + SunRadiusModel: solver.sunRadiusModel, Type: SolarEclipseNone, Centrality: SolarEclipseNonCentral, GreatestEclipse: feature.greatestEclipseJDE, @@ -252,12 +322,12 @@ func (solver solarEclipseSolver) eclipseResult() SolarEclipseResult { result.Type = SolarEclipseHybrid } - switch feature.typeCode { - case "A1", "T1": - result.Centrality = SolarEclipseCentralOneLimit - case "A", "T", "H", "H2", "H3": + if solarEclipseTwoLimitsTypeCode(feature.typeCode) { result.Centrality = SolarEclipseCentralTwoLimits + } else if feature.typeCode == "A1" || feature.typeCode == "T1" { + result.Centrality = SolarEclipseCentralOneLimit } + result.PathWidthDefined = result.Centrality == SolarEclipseCentralTwoLimits if result.Type != SolarEclipseNone { result.HasPartial = true @@ -299,13 +369,13 @@ func (solver solarEclipseSolver) greatestCentralDuration(result SolarEclipseResu } func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) solarEclipseSolver { - params := solarEclipseModelParameters{ - penumbralK: solarEclipsePenumbralK, - umbralK: solarEclipsePenumbralK, - } - if model == SolarEclipseModelNASABulletinSplitK { - params.umbralK = solarEclipseUmbralK - } + return newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{RadiusModel: model}) +} + +func newSolarEclipseSolverWithOptions(newMoonJDE float64, options SolarEclipseOptions) solarEclipseSolver { + options.RadiusModel = normalizeSolarEclipseRadiusModel(options.RadiusModel) + options.SunRadiusModel = normalizeSolarEclipseSunRadiusModel(options.SunRadiusModel) + params := solarEclipseModelParams(options.RadiusModel, options.SunRadiusModel) firstNodeJDE := newMoonJDE + (0-float64(solarEclipseNodeCount)/2+0.5)*solarEclipseNodeStepDays lastNodeJDE := newMoonJDE + (float64(solarEclipseNodeCount-1)-float64(solarEclipseNodeCount)/2+0.5)*solarEclipseNodeStepDays @@ -316,15 +386,16 @@ func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) so return solarEclipseSolver{ newMoonJDE: newMoonJDE, - model: model, + model: options.RadiusModel, + sunRadiusModel: options.SunRadiusModel, params: params, deltaTSeconds: math.NaN(), localStateContextCache: make(map[uint64]localSolarEclipseStateContext), besselGeometryCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry), besselCandidateCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry), meanSunMoonDistance: meanSunMoonDistance, - penumbraConeTangent: (solarEclipseSolarRadiusRatio + params.penumbralK) / meanSunMoonDistance, - umbraConeTangent: (solarEclipseSolarRadiusRatio - params.umbralK) / meanSunMoonDistance, + penumbraConeTangent: (params.sunRadiusRatio + params.penumbralK) / meanSunMoonDistance, + umbraConeTangent: (params.sunRadiusRatio - params.umbralK) / meanSunMoonDistance, } } @@ -342,20 +413,22 @@ func (solver solarEclipseSolver) withDeltaTSeconds(deltaTSeconds float64) solarE } // effectiveDeltaTSeconds 返回本求解器在某 TT 时刻实际使用的 ΔT(秒)。 -func (solver solarEclipseSolver) effectiveDeltaTSeconds(jd float64) float64 { +// 未覆盖时用真 TT−UT1(观测表/外推),不能回退到混入 UTC 的进程级 DeltaT, +// 否则恒星时相位会少掉 DUT1,站心与影轴两条路径就不一致。 +func (solver solarEclipseSolver) effectiveDeltaTSeconds(jde float64) float64 { if math.IsNaN(solver.deltaTSeconds) { - return DeltaT(jd, true) + return ut1ToTTOffsetSeconds(ttToUT1JDE(jde)) } return solver.deltaTSeconds } // siderealTimeAt 返回某 TT 时刻的视恒星时(弧度),ΔT 覆盖时同样生效。 -func (solver solarEclipseSolver) siderealTimeAt(jd float64) float64 { - utJDE := TD2UT(jd, false) +func (solver solarEclipseSolver) siderealTimeAt(jde float64) float64 { + ut1JDE := TT2UT1(jde) if !math.IsNaN(solver.deltaTSeconds) { - utJDE = jd - solver.deltaTSeconds/86400 + ut1JDE = jde - solver.deltaTSeconds/86400 } - return ApparentSiderealTime(utJDE) * 15 * rad + return ApparentSiderealTime(ut1JDE) * 15 * rad } // withLocalEphemeris prepares the immutable event-local interpolator used by @@ -368,14 +441,23 @@ func (solver solarEclipseSolver) withLocalEphemeris() solarEclipseSolver { return solver } +// solarEclipseTwoLimitsTypeCode 报告该类型码的南北两限是否都存在,带宽解析式只在这一类中心食上有定义。 +func solarEclipseTwoLimitsTypeCode(typeCode string) bool { + switch typeCode { + case "A", "T", "H", "H2", "H3": + return true + } + return false +} + func (solver solarEclipseSolver) feature() solarEclipseFeature { const finiteDifferenceStep = 0.04 candidateSolver := solver.withLocalEphemeris() - jd := solver.newMoonJDE - before := candidateSolver.besselMoonCandidateAt(jd - finiteDifferenceStep) - center := candidateSolver.besselMoonCandidateAt(jd) - after := candidateSolver.besselMoonCandidateAt(jd + finiteDifferenceStep) + jde := solver.newMoonJDE + before := candidateSolver.besselMoonCandidateAt(jde - finiteDifferenceStep) + center := candidateSolver.besselMoonCandidateAt(jde) + after := candidateSolver.besselMoonCandidateAt(jde + finiteDifferenceStep) vx := (after[0] - before[0]) / (2 * finiteDifferenceStep) vy := (after[1] - before[1]) / (2 * finiteDifferenceStep) @@ -384,7 +466,7 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature { speedSquared := speed * speed t0 := -(center[0]*vx + center[1]*vy) / speedSquared - greatestEclipseJDE := jd + t0 + greatestEclipseJDE := jde + t0 // The three-node velocity fit locates greatest eclipse accurately, but its // linearly extrapolated coordinates can miss the true Bessel position by // tens of kilometres in a grazing non-central event. Re-evaluate the @@ -476,7 +558,9 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature { } } - if typeCode != "N" && typeCode != "P" { + // 单侧极限(A1/T1)只有一侧限界,非中心中心食(A0/T0)连限界都没有:解析式 2r/|sin h| + // 在 h→0 时发散,两类事件该栏都无定义,与中心线逐点宽度一起置 0。 + if solarEclipseTwoLimitsTypeCode(typeCode) { sunAltitude := solarEclipseSunAltitudeAtGreatest(greatestEclipseJDE, greatestLongitude, greatestLatitude, axis.gst) if math.Abs(math.Sin(sunAltitude)) > 1e-12 { pathWidthKM = math.Abs(2*greatestRadii.umbraRadius*solarEclipseEarthEquatorialRadiusKM) / math.Abs(math.Sin(sunAltitude)) @@ -495,13 +579,13 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature { } if typeCode != "N" { - _, _, feature.partialBeginJDE, _ = solver.quickContactAt(partialStartParam+jd, vx, vy, true) - _, _, feature.partialEndJDE, _ = solver.quickContactAt(partialEndParam+jd, vx, vy, true) + _, _, feature.partialBeginJDE, _ = solver.quickContactAt(partialStartParam+jde, vx, vy, true) + _, _, feature.partialEndJDE, _ = solver.quickContactAt(partialEndParam+jde, vx, vy, true) } if axisIntersection.valid && typeCode != "N" && typeCode != "P" { - _, _, feature.centralBeginJDE, _ = solver.quickContactAt(centralStartParam+jd, vx, vy, false) - _, _, feature.centralEndJDE, _ = solver.quickContactAt(centralEndParam+jd, vx, vy, false) + _, _, feature.centralBeginJDE, _ = solver.quickContactAt(centralStartParam+jde, vx, vy, false) + _, _, feature.centralEndJDE, _ = solver.quickContactAt(centralEndParam+jde, vx, vy, false) if refined, ok := solver.centralAxisContactJDE(feature.centralBeginJDE, greatestEclipseJDE, -1); ok { feature.centralBeginJDE = refined } @@ -569,8 +653,8 @@ func (solver solarEclipseSolver) centralAxisContactJDE( return (insideJDE + outsideJDE) / 2, true } -func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jd float64) float64 { - moon, axis, _ := solver.besselGeometryAt(jd) +func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jde float64) float64 { + moon, axis, _ := solver.besselGeometryAt(jde) return solarEclipseLineEllipsoidDiscriminant( moon[0], moon[1], 2, moon[0], moon[1], 0, @@ -578,8 +662,8 @@ func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jd float64) float6 ) } -func (solver solarEclipseSolver) centralAxisContactPointAt(jd float64) (SolarEclipsePathPoint, bool) { - moon, axis, _ := solver.besselGeometryAt(jd) +func (solver solarEclipseSolver) centralAxisContactPointAt(jde float64) (SolarEclipsePathPoint, bool) { + moon, axis, _ := solver.besselGeometryAt(jde) cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt) x1 := moon[0] y1 := cosTilt*moon[1] - 2*sinTilt @@ -606,15 +690,15 @@ func (solver solarEclipseSolver) centralAxisContactPointAt(jd float64) (SolarEcl return SolarEclipsePathPoint{}, false } return SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: longitude, Latitude: latitude, - SunAltitude: solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) / rad, + SunAltitude: solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) / rad, }, true } -func (solver solarEclipseSolver) quickContactAt(jd, dx, dy float64, penumbral bool) (float64, float64, float64, bool) { - moon := solver.besselMoonAt(jd) +func (solver solarEclipseSolver) quickContactAt(jde, dx, dy float64, penumbral bool) (float64, float64, float64, bool) { + moon := solver.besselMoonAt(jde) radii := solver.shadowRadiiAt(moon[2]) radius := 0.0 if penumbral { @@ -635,15 +719,15 @@ func (solver solarEclipseSolver) quickContactAt(jd, dx, dy float64, penumbral bo correction := (effectiveRadius*effectiveRadius - moon[0]*moon[0] - moon[1]*moon[1]) / (2 * velocityProjection) x := moon[0] + correction*dx y := moon[1] + correction*dy - jd += correction + jde += correction curvature := (1 - solarEclipseEarthPolarRatioSquared) * radius * x * y / math.Pow(effectiveRadius, 3) x += curvature * y y -= curvature * x - axis := solver.besselAxisAt(jd) + axis := solver.besselAxisAt(jde) longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x/effectiveRadius, y/effectiveRadius, axis, true) - return longitude, latitude, jd, ok + return longitude, latitude, jde, ok } func (solver solarEclipseSolver) shadowRadiiAt(moonBesselZ float64) solarEclipseShadowRadii { @@ -651,14 +735,14 @@ func (solver solarEclipseSolver) shadowRadiiAt(moonBesselZ float64) solarEclipse penumbraRadius: solver.params.penumbralK + solver.penumbraConeTangent*moonBesselZ, umbraRadius: solver.params.umbralK - solver.umbraConeTangent*moonBesselZ, absUmbraRadius: math.Abs(solver.params.umbralK - solver.umbraConeTangent*moonBesselZ), - magnitude: solver.params.umbralK / moonBesselZ / solarEclipseSolarRadiusRatio * (solver.meanSunMoonDistance + moonBesselZ), + magnitude: solver.params.umbralK / moonBesselZ / solver.params.sunRadiusRatio * (solver.meanSunMoonDistance + moonBesselZ), } } -func (solver solarEclipseSolver) besselAxisAt(jd float64) solarEclipseAxis { - sun, moon := solarEclipseSunMoonEquatorial(jd) +func (solver solarEclipseSolver) besselAxisAt(jde float64) solarEclipseAxis { + sun, moon := solarEclipseSunMoonEquatorial(jde) return solarEclipseBesselAxisFromEquatorialWithDeltaT( - jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + jde, sun, moon, solver.effectiveDeltaTSeconds(jde), ) } @@ -686,30 +770,30 @@ func solarEclipseBesselAxisFromEquatorialWithDeltaT( } } -func (solver solarEclipseSolver) besselMoonAt(jd float64) [3]float64 { - moon, _, _ := solver.besselGeometryAt(jd) +func (solver solarEclipseSolver) besselMoonAt(jde float64) [3]float64 { + moon, _, _ := solver.besselGeometryAt(jde) return moon } -func (solver solarEclipseSolver) besselMoonCandidateAt(jd float64) [3]float64 { - moon, _, _, ok := solver.besselGeometryCandidateAt(jd) +func (solver solarEclipseSolver) besselMoonCandidateAt(jde float64) [3]float64 { + moon, _, _, ok := solver.besselGeometryCandidateAt(jde) if !ok { - return solver.besselMoonAt(jd) + return solver.besselMoonAt(jde) } return moon } -func (solver solarEclipseSolver) besselGeometryAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64) { - key := math.Float64bits(jd) +func (solver solarEclipseSolver) besselGeometryAt(jde float64) ([3]float64, solarEclipseAxis, [3]float64) { + key := math.Float64bits(jde) // 命中要求 ΔT 世代一致:轴里的 gst 依赖 ΔT,SetDeltaTFn 之后旧条目必须视为未命中。 // A hit requires the same ΔT generation: the cached axis carries a ΔT-dependent gst, // so entries written before a SetDeltaTFn override must count as misses. if entry, ok := solver.besselGeometryCache[key]; ok && entry.generation == deltaTGenerationValue() { return entry.moon, entry.axis, entry.sun } - sun, moon := solarEclipseSunMoonEquatorial(jd) + sun, moon := solarEclipseSunMoonEquatorial(jde) axis := solarEclipseBesselAxisFromEquatorialWithDeltaT( - jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + jde, sun, moon, solver.effectiveDeltaTSeconds(jde), ) geometry := solarEclipseBesselGeometryCacheEntry{ moon: solarEclipseBesselMoonFromEquatorial(moon, axis), @@ -721,20 +805,20 @@ func (solver solarEclipseSolver) besselGeometryAt(jd float64) ([3]float64, solar return geometry.moon, geometry.axis, geometry.sun } -func (solver solarEclipseSolver) besselGeometryCandidateAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64, bool) { - key := math.Float64bits(jd) +func (solver solarEclipseSolver) besselGeometryCandidateAt(jde float64) ([3]float64, solarEclipseAxis, [3]float64, bool) { + key := math.Float64bits(jde) if entry, ok := solver.besselCandidateCache[key]; ok && entry.generation == deltaTGenerationValue() { return entry.moon, entry.axis, entry.sun, entry.valid } if solver.localEphemeris == nil { return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false } - sun, moon, ok := solver.localEphemeris.equatorialAt(jd) + sun, moon, ok := solver.localEphemeris.equatorialAt(jde) if !ok { return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false } axis := solarEclipseBesselAxisFromEquatorialWithDeltaT( - jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + jde, sun, moon, solver.effectiveDeltaTSeconds(jde), ) geometry := solarEclipseBesselGeometryCacheEntry{ moon: solarEclipseBesselMoonFromEquatorial(moon, axis), @@ -779,20 +863,20 @@ func solarEclipseBesselMoonFromEquatorial(moon [3]float64, axis solarEclipseAxis } } -func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) { - julianCentury := (jd - 2451545.0) / 36525.0 - nutationLongitude, nutationObliquity := Nutation2000B(jd) - obliquity := (Obliquity1980(jd) + nutationObliquity) * rad +func solarEclipseSunMoonEquatorial(jde float64) ([3]float64, [3]float64) { + julianCentury := (jde - 2451545.0) / 36525.0 + nutationLongitude, nutationObliquity := Nutation2000B(jde) + obliquity := (Obliquity1980(jde) + nutationObliquity) * rad // Share the full-series distance and nutation for this single TT. - sunDistanceAU := EarthAway(jd) - sunLongitude := (HSunTrueLoN(jd, -1) + nutationLongitude - 20.49552/sunDistanceAU/3600) * rad - sunLatitude := HSunTrueBo(jd) * rad + sunDistanceAU := EarthAway(jde) + sunLongitude := (HSunTrueLoN(jde, -1) + nutationLongitude - 20.49552/sunDistanceAU/3600) * rad + sunLatitude := HSunTrueBo(jde) * rad sunDistance := sunDistanceAU * solarEclipseAstronomicalUnitKM - moonLongitude := solarEclipseNormalizeRadians((HMoonTrueLoN(jd, -1)+nutationLongitude)*rad + solarEclipseMoonLonAberrRad) - moonLatitude := HMoonTrueBo(jd)*rad + moonLatitudeAberrationRad(julianCentury) - moonDistance := HMoonAway(jd) + moonLongitude := solarEclipseNormalizeRadians((HMoonTrueLoN(jde, -1)+nutationLongitude)*rad + solarEclipseMoonLonAberrRad) + moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury) + moonDistance := HMoonAway(jde) sunEquatorial := solarEclipseRotateLLR(sunLongitude, sunLatitude, sunDistance, obliquity) moonEquatorial := solarEclipseRotateLLR(moonLongitude, moonLatitude, moonDistance, obliquity) @@ -801,8 +885,8 @@ func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) { [3]float64{moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]} } -func solarEclipseSunAltitudeAtGreatest(jd, lonDeg, latDeg, gst float64) float64 { - sun, _ := solarEclipseSunMoonEquatorial(jd) +func solarEclipseSunAltitudeAtGreatest(jde, lonDeg, latDeg, gst float64) float64 { + sun, _ := solarEclipseSunMoonEquatorial(jde) return solarEclipseSunAltitudeFromEquatorial(sun, lonDeg, latDeg, gst) } diff --git a/basic/solar_eclipse_11360601_regression_test.go b/basic/solar_eclipse_11360601_regression_test.go index d44f32c..0b36174 100644 --- a/basic/solar_eclipse_11360601_regression_test.go +++ b/basic/solar_eclipse_11360601_regression_test.go @@ -6,6 +6,8 @@ import ( "b612.me/astro/internal/geodata" ) +// 宋高宗时代的极区日食,伽马卡在全食边缘上,比较极限 + // TestSolarEclipseGrazingClosureRootsAreRecovered pins the events whose // greatest-at-horizon closure arcs the analytic seedings miss: a grazing // closure root can sit outside the sampled horizon branches, and the local @@ -14,7 +16,7 @@ import ( // annulus better than the sampled union that used to replace it. func TestSolarEclipseGrazingClosureRootsAreRecovered(t *testing.T) { for _, date := range [][3]int{{1136, 6, 1}, {-1480, 12, 27}, {5705, 6, 17}} { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }) @@ -37,13 +39,15 @@ func TestSolarEclipseGrazingClosureRootsAreRecovered(t *testing.T) { // of the criterion: a two-limit grazing event whose caps are not bounded by the // greatest-at-horizon condition has no closure arc at all, and its band must // stay a valid closed reconstruction instead of silently disappearing. The -// reasons are measured, not assumed: 4862-09-28 ends 40 km inside the horizon -// (+0.36 degrees at the cap, so the umbral rim bounds it) and 1552-07-21 has its -// boundary running along the horizon (+0.004 then -0.000 degrees), which makes -// the arc degenerate. +// reason is measured, not assumed: 1552-07-21 has its boundary running along the +// horizon (+0.004 then -0.000 degrees), which makes the arc degenerate. Its +// closure system yields one root per side, so no pair can be formed. +// 4862-09-28 used to fall in this group only because the seeding heuristics +// missed one of its four roots; the scan-based enumeration finds it and the +// event is covered by the closure cross-check instead. func TestSolarEclipseGrazingEventsWithoutClosuresStaySampled(t *testing.T) { - for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}} { - seed := JDECalc(date[0], date[1], float64(date[2])) + for _, date := range [][3]int{{1552, 7, 21}} { + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }) @@ -74,7 +78,7 @@ func TestSolarEclipseGrazingEventsWithoutClosuresStaySampled(t *testing.T) { // chordal ribbon hundreds of kilometres smaller than the umbra it describes. func TestSolarEclipse11360601GrazingAnnularBandContainsSweep(t *testing.T) { result := SolarEclipsePartialFootprints( - JDECalc(1136, 6, 1), + JDCalc(1136, 6, 1), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }, diff --git a/basic/solar_eclipse_15001121_regression_test.go b/basic/solar_eclipse_15001121_regression_test.go index 34e3972..f3733b6 100644 --- a/basic/solar_eclipse_15001121_regression_test.go +++ b/basic/solar_eclipse_15001121_regression_test.go @@ -4,7 +4,7 @@ import "testing" func TestSolarEclipse15001121ShallowTotalBandCloses(t *testing.T) { result := SolarEclipsePartialFootprints( - JDECalc(1500, 11, 21), + JDCalc(1500, 11, 21), SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0, BoundaryPoints: 96}, ) if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { diff --git a/basic/solar_eclipse_18741010_regression_test.go b/basic/solar_eclipse_18741010_regression_test.go index 936fdeb..e13b595 100644 --- a/basic/solar_eclipse_18741010_regression_test.go +++ b/basic/solar_eclipse_18741010_regression_test.go @@ -12,7 +12,7 @@ import ( // be rebuilt from those samples instead of being exported as the short ribbon. func TestSolarEclipse18741010GrazingAnnularBandContainsSweep(t *testing.T) { result := SolarEclipsePartialFootprints( - JDECalc(1874, 10, 10), + JDCalc(1874, 10, 10), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }, diff --git a/basic/solar_eclipse_43290612_regression_test.go b/basic/solar_eclipse_43290612_regression_test.go index 8290703..504e433 100644 --- a/basic/solar_eclipse_43290612_regression_test.go +++ b/basic/solar_eclipse_43290612_regression_test.go @@ -4,7 +4,7 @@ import "testing" func TestSolarEclipse43290612PolarAnnularCentralBandCloses(t *testing.T) { result := SolarEclipsePartialFootprints( - JDECalc(4329, 6, 12), + JDCalc(4329, 6, 12), SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0, BoundaryPoints: 96}, ) if result.Eclipse.Type != SolarEclipseAnnular || result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { diff --git a/basic/solar_eclipse_band.go b/basic/solar_eclipse_band.go index f34575a..f4114be 100644 --- a/basic/solar_eclipse_band.go +++ b/basic/solar_eclipse_band.go @@ -835,11 +835,11 @@ func (solver solarEclipseSolver) centralBandSampledFootprintUnion( lastKept := 0.0 var lastCenter geodata.GeoPoint speedKMperSecond := 0.0 - for index, jd := range times { + for index, jde := range times { anchored := index < solarEclipseCentralBandUnionAnchorSamples || index >= len(times)-solarEclipseCentralBandUnionAnchorSamples if !anchored && lastKept > 0 { - elapsed := (jd - lastKept) * 86400 + elapsed := (jde - lastKept) * 86400 if elapsed < solarEclipseCentralBandUnionMinStepSeconds { continue } @@ -852,21 +852,21 @@ func (solver solarEclipseSolver) centralBandSampledFootprintUnion( if elapsed > solarEclipseCentralBandUnionMaxStepSeconds { // Never let the stride stretch past the cap, so a stalled centre // cannot leave a gap in the sweep. - if lastKept+solarEclipseCentralBandUnionMaxStepSeconds/86400 < jd { - jd = lastKept + solarEclipseCentralBandUnionMaxStepSeconds/86400 + if lastKept+solarEclipseCentralBandUnionMaxStepSeconds/86400 < jde { + jde = lastKept + solarEclipseCentralBandUnionMaxStepSeconds/86400 } } } for existingIndex < len(solved) && - solved[existingIndex].JDE < jd-solarEclipseCentralBandUnionReuseDays { + solved[existingIndex].JDE < jde-solarEclipseCentralBandUnionReuseDays { existingIndex++ } var ring []SolarEclipsePathPoint if existingIndex < len(solved) && - math.Abs(solved[existingIndex].JDE-jd) <= solarEclipseCentralBandUnionReuseDays { + math.Abs(solved[existingIndex].JDE-jde) <= solarEclipseCentralBandUnionReuseDays { ring = solarEclipseFootprintBoundaryRing(solved[existingIndex]) } else { - ring = solver.centralBandSampledFootprintRingAt(jd) + ring = solver.centralBandSampledFootprintRingAt(jde) } if len(ring) < 4 { continue @@ -885,19 +885,19 @@ func (solver solarEclipseSolver) centralBandSampledFootprintUnion( } } rings = append(rings, polygon) - if lastKept > 0 && jd > lastKept { + if lastKept > 0 && jde > lastKept { center := solarEclipseRingCenter(polygon) if speedKMperSecond <= 0 { speedKMperSecond = solarEclipsePathDistanceKM( SolarEclipsePathPoint{Longitude: lastCenter.Longitude, Latitude: lastCenter.Latitude}, SolarEclipsePathPoint{Longitude: center.Longitude, Latitude: center.Latitude}, - ) / ((jd - lastKept) * 86400) + ) / ((jde - lastKept) * 86400) } lastCenter = center } else { lastCenter = solarEclipseRingCenter(polygon) } - lastKept = jd + lastKept = jde } if len(rings) == 0 { return nil @@ -969,10 +969,10 @@ func unionSolarEclipseCentralBandRings(rings [][]geodata.GeoPoint) [][]geodata.G // chord between its two rim ends, and the neighbouring samples cover the // horizon side. func (solver solarEclipseSolver) centralBandSampledFootprintRingAt( - jd float64, + jde float64, ) []SolarEclipsePathPoint { return solarEclipseFootprintBoundaryRing(solver.shadowFootprintAtWithSpacing( - jd, solarEclipseCentralBandUnionBoundaryPoints, solarEclipseCentralShadow, + jde, solarEclipseCentralBandUnionBoundaryPoints, solarEclipseCentralShadow, solarEclipseCentralBandUnionSpacingKM, )) } @@ -1319,8 +1319,8 @@ func (solver solarEclipseSolver) nonCentralBandContactExtension( } const stepDays = 5.0 / 86400.0 times := make([]float64, 0, int((endJDE-startJDE)/stepDays)+20) - for jd := startJDE + stepDays; jd < endJDE-solarEclipsePathDuplicateTimeDays; jd += stepDays { - times = append(times, jd) + for jde := startJDE + stepDays; jde < endJDE-solarEclipsePathDuplicateTimeDays; jde += stepDays { + times = append(times, jde) } for second := 1.0; second <= 10; second++ { offset := second / 86400.0 @@ -1334,8 +1334,8 @@ func (solver solarEclipseSolver) nonCentralBandContactExtension( sort.Float64s(times) times = uniqueSolarEclipsePathTimes(times) samples := make([]solarEclipseCentralBandSweepSample, 0, len(times)) - for _, jd := range times { - if sample, ok := solver.centralBandSweepSampleAt(jd); ok { + for _, jde := range times { + if sample, ok := solver.centralBandSweepSampleAt(jde); ok { samples = append(samples, sample) } } @@ -1381,34 +1381,34 @@ func solarEclipseCentralBandContactSampleTimes(times []float64, startJDE, endJDE } func (solver solarEclipseSolver) centralBandSweepSampleAt( - jd float64, + jde float64, ) (solarEclipseCentralBandSweepSample, bool) { - moon, axis, sun := solver.besselGeometryAt(jd) + moon, axis, sun := solver.besselGeometryAt(jde) samples := make([]solarEclipsePartialBoundarySample, solarEclipseCentralBandBoundaryPoints) for index := range samples { angle := 2 * math.Pi * float64(index) / float64(len(samples)) point, ok := solver.shadowFootprintPointAt( - jd, moon, axis, sun, angle, solarEclipseCentralShadow, + jde, moon, axis, sun, angle, solarEclipseCentralShadow, ) samples[index] = solarEclipsePartialBoundarySample{point: point, ok: ok, angle: angle} } samples = solver.refineShadowFootprintTransitions( - jd, moon, axis, sun, samples, solarEclipseCentralShadow, + jde, moon, axis, sun, samples, solarEclipseCentralShadow, ) samples = solver.refineShadowFootprintSpacing( - jd, moon, axis, sun, samples, solarEclipseCentralShadow, + jde, moon, axis, sun, samples, solarEclipseCentralShadow, solarEclipseCentralBandTargetSpacingKM, ) arc := solarEclipseLongestOpenShadowArc(samples) if len(arc) < 2 { return solarEclipseCentralBandSweepSample{}, false } - geometry := solarEclipseCentralBandGeometry{jde: jd, moon: moon, axis: axis, sun: sun} + geometry := solarEclipseCentralBandGeometry{jde: jde, moon: moon, axis: axis, sun: sun} angle, ok := solver.centralBandEnvelopeAngle(geometry, arc) if !ok { return solarEclipseCentralBandSweepSample{}, false } - envelope, ok := solver.centralShadowPointAt(jd, angle) + envelope, ok := solver.centralShadowPointAt(jde, angle) if !ok { return solarEclipseCentralBandSweepSample{}, false } @@ -1438,7 +1438,7 @@ func (solver solarEclipseSolver) centralBandSweepSampleAt( return solarEclipseCentralBandSweepSample{}, false } return solarEclipseCentralBandSweepSample{ - jde: jd, envelope: envelope, + jde: jde, envelope: envelope, first: firstCap[len(firstCap)-1], second: secondCap[len(secondCap)-1], firstCap: firstCap, secondCap: secondCap, }, true @@ -1567,10 +1567,10 @@ func (solver solarEclipseSolver) centralShadowPointAtGeometry( ) } -func (solver solarEclipseSolver) centralShadowPointAt(jd, angle float64) (SolarEclipsePathPoint, bool) { - moon, axis, sun := solver.besselGeometryAt(jd) +func (solver solarEclipseSolver) centralShadowPointAt(jde, angle float64) (SolarEclipsePathPoint, bool) { + moon, axis, sun := solver.besselGeometryAt(jde) return solver.shadowFootprintPointAt( - jd, moon, axis, sun, math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow, + jde, moon, axis, sun, math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow, ) } @@ -1722,8 +1722,8 @@ func (solver solarEclipseSolver) centralPathPoints( ) points := make([]SolarEclipsePathPoint, 0, len(times)) - for _, jd := range times { - point, ok := solver.centralPathPointAt(jd) + for _, jde := range times { + point, ok := solver.centralPathPointAt(jde) if ok { points = append(points, point) } @@ -1735,14 +1735,14 @@ func (solver solarEclipseSolver) centralPathPoints( if len(points) < 2 { fallbackCount := 32 for index := 0; index <= fallbackCount; index++ { - jd := startJDE + (endJDE-startJDE)*float64(index)/float64(fallbackCount) - point, ok := solver.centralPathPointAt(jd) + jde := startJDE + (endJDE-startJDE)*float64(index)/float64(fallbackCount) + point, ok := solver.centralPathPointAt(jde) if !ok { continue } duplicate := false for _, existing := range points { - if math.Abs(existing.JDE-jd) <= solarEclipsePathDuplicateTimeDays { + if math.Abs(existing.JDE-jde) <= solarEclipsePathDuplicateTimeDays { duplicate = true break } diff --git a/basic/solar_eclipse_band_closure_scan.go b/basic/solar_eclipse_band_closure_scan.go new file mode 100644 index 0000000..90f57dc --- /dev/null +++ b/basic/solar_eclipse_band_closure_scan.go @@ -0,0 +1,283 @@ +package basic + +import ( + "math" + "sort" +) + +// 地平闭包根的扫描式枚举。闭包根的定义是三个条件同时成立:站点落在 central limit 上 +// (gap=0)、该站点的间隙在时间上取极值(∂gap/∂t=0)、站点落在地平线上(alt=0)。 +// 于是可以先把前两个条件在固定时刻化成一维周期求根(「地平线上的食甚点」),再让这些 +// 点上的 gap 随时刻穿越零——闭包根就是那次穿越。这样既不需要种子,也不需要三维牛顿的 +// 有限差分雅可比;采样分支恰好终止在根上时不会漏根。 + +const ( + // 地平圈整圈求根的采样数,与升落曲线同口径。 + solarEclipseClosureScanBoundaryPoints = solarEclipseRiseSetBoundaryPoints + // 时间导数沿地平圈找根时的折点容差,单位与 ∂gap/∂t(每天)一致。 + solarEclipseClosureScanRateFoldTolerance = 1e-3 + // 行数下限与上限;窗口很短时按比例加密,很长时按比例放稀。 + solarEclipseClosureScanMinimumRows = 24 + solarEclipseClosureScanMaximumRows = 720 + // 默认行距 1 min:闭包根之间的间隙可达数十分钟,够给每个符号变化留出样本。 + solarEclipseClosureScanStepDays = 60.0 / 86400.0 + // 相邻行配对的距离上限:同一条闭合弧上的点一行之内不会超过该距离。 + solarEclipseClosureScanMatchKM = 900.0 + // 时间二分上限,1e-9 天约 1e-4 s。 + solarEclipseClosureScanBisectionSteps = 40 + // 牛顿抛光后允许偏离扫描根的上限,超过说明落到了别的分支。 + solarEclipseClosureScanPolishKM = 2.0 +) + +type solarEclipseClosureScanCandidate struct { + jde float64 + longitude float64 + latitude float64 + gap float64 +} + +type solarEclipseClosureScanRow struct { + jde float64 + candidates []solarEclipseClosureScanCandidate +} + +// centralLimitHorizonRootsByScan 返回一侧窗口内全部地平闭包根,按时间排序。 +func (solver solarEclipseSolver) centralLimitHorizonRootsByScan( + shadowContactJDE, innerContactJDE float64, +) []SolarEclipsePathPoint { + low, high := math.Min(shadowContactJDE, innerContactJDE), math.Max(shadowContactJDE, innerContactJDE) + if low <= 0 || high <= low { + return nil + } + // 闭包弧可以把一个根放在接触窗口之外,窗口按既有口径外扩。 + margin := solarEclipseCentralLimitHorizonContactMarginDays + if span := high - low; span > 0 { + margin = math.Max(margin, span*0.15) + } + start, end := low-margin, high+margin + rows := solarEclipseClosureScanRowCount(end - start) + step := (end - start) / float64(rows) + scan := make([]solarEclipseClosureScanRow, rows+1) + for index := range scan { + scan[index] = solarEclipseClosureScanRow{ + jde: start + float64(index)*step, + candidates: solver.closureScanCandidatesAt(start + float64(index)*step), + } + } + roots := make([]SolarEclipsePathPoint, 0, 2) + for index := 1; index < len(scan); index++ { + for _, bracket := range solarEclipseClosureScanBrackets(scan[index-1], scan[index]) { + root, ok := solver.refineSolarEclipseClosureScanRoot(bracket) + if !ok || root.JDE < low-margin || root.JDE > high+margin || + solarEclipseRiseSetPointExists(roots, root) { + continue + } + roots = append(roots, root) + } + } + sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE }) + return roots +} + +func solarEclipseClosureScanRowCount(spanDays float64) int { + rows := int(spanDays/solarEclipseClosureScanStepDays + 0.5) + if rows < solarEclipseClosureScanMinimumRows { + rows = solarEclipseClosureScanMinimumRows + } + if rows > solarEclipseClosureScanMaximumRows { + rows = solarEclipseClosureScanMaximumRows + } + return rows +} + +// closureScanCandidatesAt 枚举该时刻地平线上全部 ∂gap/∂t=0 的点,并给出各点的 gap。 +func (solver solarEclipseSolver) closureScanCandidatesAt(jde float64) []solarEclipseClosureScanCandidate { + evaluation := solver.closureScanEvaluationAt(jde) + sun := solarEclipseXYZToLLR( + evaluation.center.sunXYZ[0], evaluation.center.sunXYZ[1], evaluation.center.sunXYZ[2], + ) + centerLongitude := normalizeLongitude((sun[0] - evaluation.center.gst) / rad) + centerLatitude := sun[1] / rad + valueAt := func(angle float64) (float64, bool) { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + rate := evaluation.centralContactDerivative(longitude, latitude) + return rate, finite(rate) + } + angles := riseSetCyclicRootsWithFoldTolerance( + solarEclipseClosureScanBoundaryPoints, solarEclipseClosureScanRateFoldTolerance, valueAt, + ) + candidates := make([]solarEclipseClosureScanCandidate, 0, len(angles)) + for _, angle := range angles { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + candidate, ok := solver.closureScanCandidateAt(evaluation, longitude, latitude) + if !ok { + continue + } + candidates = append(candidates, candidate) + } + return candidates +} + +// closureScanCandidateAt 把固定时刻的地理起点修正到 {∂gap/∂t=0, alt=0} 上。 +func (solver solarEclipseSolver) closureScanCandidateAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, +) (solarEclipseClosureScanCandidate, bool) { + // 不能沿用 riseSetRefineGeographicRoot:∂gap/∂t 是 5 s 有限差分,残差噪声约 1e-7, + // 该函数的收尾判据 1e-8 永远达不到;这里的容差与三维牛顿的收敛判据保持一致。 + const stepDegrees = 1e-4 + residualAt := func(longitude, latitude float64) (float64, float64) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + return evaluation.centralContactDerivative(longitude, latitude), state.sunAltitudeRad + } + for iteration := 0; iteration < 8; iteration++ { + rate, altitude := residualAt(longitude, latitude) + if !finite(rate) || !finite(altitude) { + return solarEclipseClosureScanCandidate{}, false + } + if math.Abs(rate) <= 1e-7 && math.Abs(altitude) <= 1e-9 { + break + } + shiftedLongitudeRate, shiftedLongitudeAltitude := residualAt(longitude+stepDegrees, latitude) + shiftedLatitudeRate, shiftedLatitudeAltitude := residualAt(longitude, latitude+stepDegrees) + a := (shiftedLongitudeRate - rate) / stepDegrees + b := (shiftedLatitudeRate - rate) / stepDegrees + c := (shiftedLongitudeAltitude - altitude) / stepDegrees + d := (shiftedLatitudeAltitude - altitude) / stepDegrees + determinant := a*d - b*c + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return solarEclipseClosureScanCandidate{}, false + } + deltaLongitude := (-rate*d + b*altitude) / determinant + deltaLatitude := (c*rate - a*altitude) / determinant + if scale := math.Max(math.Abs(deltaLongitude), math.Abs(deltaLatitude)); scale > 5 { + deltaLongitude *= 5 / scale + deltaLatitude *= 5 / scale + } + longitude += deltaLongitude + latitude += deltaLatitude + if !finite(longitude) || !finite(latitude) || latitude <= -89.999 || latitude >= 89.999 { + return solarEclipseClosureScanCandidate{}, false + } + } + rate, altitude := residualAt(longitude, latitude) + if !finite(rate) || !finite(altitude) || math.Abs(rate) > 1e-6 || math.Abs(altitude) > 1e-8 { + return solarEclipseClosureScanCandidate{}, false + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + gap := solarEclipseCentralContactGap(state) + if !finite(gap) { + return solarEclipseClosureScanCandidate{}, false + } + return solarEclipseClosureScanCandidate{ + jde: evaluation.jd, longitude: longitude, latitude: latitude, gap: gap, + }, true +} + +// closureScanEvaluationAt 同时给出中心与前后时刻的星历态:时间导数需要前后两点。 +func (solver solarEclipseSolver) closureScanEvaluationAt(jde float64) solarEclipseRiseSetEvaluation { + return solarEclipseRiseSetEvaluation{ + jd: jde, + center: solver.localStateContextAt(jde), + before: solver.localStateContextAt(jde - solarEclipseRiseSetDerivativeStepDays), + after: solver.localStateContextAt(jde + solarEclipseRiseSetDerivativeStepDays), + } +} + +// solarEclipseClosureScanBrackets 在相邻两行之间按最近距离配对,返回 gap 变号的分支区间。 +func solarEclipseClosureScanBrackets( + previous, current solarEclipseClosureScanRow, +) [][2]solarEclipseClosureScanCandidate { + if len(previous.candidates) == 0 || len(current.candidates) == 0 { + return nil + } + type pair struct { + previous int + current int + distance float64 + } + pairs := make([]pair, 0, len(previous.candidates)*len(current.candidates)) + for first := range previous.candidates { + for second := range current.candidates { + distance := solarEclipsePathDistanceKM( + SolarEclipsePathPoint{ + Longitude: previous.candidates[first].longitude, + Latitude: previous.candidates[first].latitude, + }, + SolarEclipsePathPoint{ + Longitude: current.candidates[second].longitude, + Latitude: current.candidates[second].latitude, + }, + ) + if distance > solarEclipseClosureScanMatchKM { + continue + } + pairs = append(pairs, pair{first, second, distance}) + } + } + sort.Slice(pairs, func(first, second int) bool { return pairs[first].distance < pairs[second].distance }) + usedPrevious := make([]bool, len(previous.candidates)) + usedCurrent := make([]bool, len(current.candidates)) + brackets := make([][2]solarEclipseClosureScanCandidate, 0, 2) + for _, candidate := range pairs { + if usedPrevious[candidate.previous] || usedCurrent[candidate.current] { + continue + } + usedPrevious[candidate.previous] = true + usedCurrent[candidate.current] = true + left, right := previous.candidates[candidate.previous], current.candidates[candidate.current] + if (left.gap <= 0) != (right.gap <= 0) { + brackets = append(brackets, [2]solarEclipseClosureScanCandidate{left, right}) + } + } + return brackets +} + +// refineSolarEclipseClosureScanRoot 在时间上二分 gap 的零点,再用既有三维牛顿抛光到同一容差。 +func (solver solarEclipseSolver) refineSolarEclipseClosureScanRoot( + bracket [2]solarEclipseClosureScanCandidate, +) (SolarEclipsePathPoint, bool) { + start, end := bracket[0], bracket[1] + if start.gap == 0 { + return solver.closureScanPointAt(start) + } + for iteration := 0; iteration < solarEclipseClosureScanBisectionSteps; iteration++ { + if end.jde-start.jde <= 1e-9 { + break + } + middle := 0.5 * (start.jde + end.jde) + longitude := 0.5 * (start.longitude + end.longitude) + latitude := 0.5 * (start.latitude + end.latitude) + candidate, ok := solver.closureScanCandidateAt( + solver.closureScanEvaluationAt(middle), longitude, latitude, + ) + if !ok { + return SolarEclipsePathPoint{}, false + } + if (start.gap <= 0) == (candidate.gap <= 0) { + start = candidate + } else { + end = candidate + } + } + return solver.closureScanPointAt(start) +} + +// closureScanPointAt 把扫描候选抛光到三维系统的同一容差;扫描只负责给出种子。 +func (solver solarEclipseSolver) closureScanPointAt( + candidate solarEclipseClosureScanCandidate, +) (SolarEclipsePathPoint, bool) { + polished, ok := solveSolarEclipseCentralLimitHorizonRoot( + solver, [3]float64{candidate.longitude, candidate.latitude, candidate.jde}, + ) + if !ok { + return SolarEclipsePathPoint{}, false + } + point := SolarEclipsePathPoint{ + JDE: candidate.jde, Longitude: candidate.longitude, Latitude: candidate.latitude, + } + if solarEclipsePathDistanceKM(polished, point) > solarEclipseClosureScanPolishKM { + return SolarEclipsePathPoint{}, false + } + return polished, true +} diff --git a/basic/solar_eclipse_band_closure_scan_test.go b/basic/solar_eclipse_band_closure_scan_test.go new file mode 100644 index 0000000..70f8e96 --- /dev/null +++ b/basic/solar_eclipse_band_closure_scan_test.go @@ -0,0 +1,108 @@ +package basic + +import ( + "math" + "testing" +) + +// 扫描式枚举是与三种子牛顿链彼此独立的取根路径:两边解出的地平闭包根必须一致。 +func TestSolarEclipseClosureRootsMatchScan(t *testing.T) { + testCases := []struct { + name string + date [3]int + }{ + {"1136-06-01", [3]int{1136, 6, 1}}, + {"-1480-12-27", [3]int{-1480, 12, 27}}, + {"5705-06-17", [3]int{5705, 6, 17}}, + {"4862-09-28", [3]int{4862, 9, 28}}, + {"2024-04-08", [3]int{2024, 4, 8}}, + {"2024-10-02", [3]int{2024, 10, 2}}, + {"2026-08-12", [3]int{2026, 8, 12}}, + {"2009-07-22", [3]int{2009, 7, 22}}, + {"2012-05-21", [3]int{2012, 5, 21}}, + } + const ( + distanceToleranceKM = 0.2 + timeTolerance = 0.5 + ) + for _, tc := range testCases { + seed := JDCalc(tc.date[0], tc.date[1], float64(tc.date[2])) + footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if len(footprints.CentralBandHorizonClosures) != 2 { + t.Fatalf("%s horizon closures=%d, want two", tc.name, len(footprints.CentralBandHorizonClosures)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + riseSetCurves, _ := solver.centralBandRiseSetCurves(footprints.Eclipse, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }, nil, true) + sides := solarEclipseCentralBandClosureSides(footprints, footprints.Eclipse) + for index, side := range sides { + scanned := solver.centralLimitHorizonRootsByScan(side.shadowContactJDE, side.innerContactJDE) + if len(scanned) != 2 { + t.Fatalf("%s side%d scan roots=%d, want two", tc.name, index, len(scanned)) + } + // 扫描必须覆盖三种子牛顿链解出的每一个根:两条路径彼此独立。 + first, last, axisOK := solver.centralLimitHorizonRootsNearAxisContact( + side.axisContactJDE, side.shadowContactJDE, side.innerContactJDE, side.direction, + ) + curveRoots, _ := solver.centralLimitHorizonRootsFromCurves( + riseSetCurves, side.shadowContactJDE, side.innerContactJDE, + ) + unionRoots, _ := solver.centralLimitHorizonRootsFromSampledBoundary( + side.shadowContactJDE, side.innerContactJDE, footprints.Eclipse.GreatestEclipse, + footprints.CentralBandFootprints, riseSetCurves, + ) + chained := append([]SolarEclipsePathPoint{}, curveRoots...) + chained = append(chained, unionRoots...) + if axisOK { + chained = append(chained, first, last) + } + for _, root := range chained { + nearest := math.Inf(1) + for _, candidate := range scanned { + nearest = math.Min(nearest, solarEclipsePathDistanceKM(candidate, root)) + } + if nearest > distanceToleranceKM { + t.Fatalf("%s side%d chain root %.3f km from every scan root", tc.name, index, nearest) + } + } + closure := footprints.CentralBandHorizonClosures[index] + for _, want := range []SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} { + nearest, nearestJDE := math.Inf(1), 0.0 + for _, root := range scanned { + if distance := solarEclipsePathDistanceKM(root, want); distance < nearest { + nearest, nearestJDE = distance, root.JDE + } + } + if nearest > distanceToleranceKM { + t.Fatalf("%s side%d scan root %.3f km from the closure root", tc.name, index, nearest) + } + if math.Abs(nearestJDE-want.JDE)*86400 > timeTolerance { + t.Fatalf("%s side%d scan root %.3f s from the closure root", + tc.name, index, math.Abs(nearestJDE-want.JDE)*86400) + } + } + } + } +} + +// 扫描式枚举同样要复现「凑不齐一对根」的判定:不成对的奇点不能凭空造出解析中心带。 +func TestSolarEclipseClosureScanRejectsIncompletePairs(t *testing.T) { + seed := JDCalc(1552, 7, 21) + footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if len(footprints.CentralBandHorizonClosures) != 0 { + t.Fatalf("horizon closures=%d, want none", len(footprints.CentralBandHorizonClosures)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for index, side := range solarEclipseCentralBandClosureSides(footprints, footprints.Eclipse) { + if scanned := solver.centralLimitHorizonRootsByScan( + side.shadowContactJDE, side.innerContactJDE, + ); len(scanned) >= 2 { + t.Fatalf("side%d scan roots=%d, want fewer than two", index, len(scanned)) + } + } +} diff --git a/basic/solar_eclipse_bessel.go b/basic/solar_eclipse_bessel.go new file mode 100644 index 0000000..2dd30b3 --- /dev/null +++ b/basic/solar_eclipse_bessel.go @@ -0,0 +1,257 @@ +package basic + +import "math" + +//下游研究需要,改成直接导出 + +const ( + solarEclipseBesselianDefaultValidHours = 3.0 + solarEclipseBesselianSampleCount = 5 + // 恒星时每秒的角度增量,用于把 ΔT 换算成影轴时角的平移量。 + solarEclipseBesselianSiderealDegreesPerSecond = 15.041067 / 3600 +) + +// SolarEclipseBesselianPolynomial 是三次多项式系数,索引 n 对应 t 的 n 次幂,t 为自 T0 起算的 TT 小时数。 +// SolarEclipseBesselianPolynomial holds the cubic coefficients; index n multiplies t^n with t in TT hours from T0. +type SolarEclipseBesselianPolynomial [4]float64 + +// At 在自 T0 起 t 小时处求值 / evaluates the polynomial at t TT hours from T0. +func (polynomial SolarEclipseBesselianPolynomial) At(hours float64) float64 { + return polynomial[0] + hours*(polynomial[1]+hours*(polynomial[2]+hours*polynomial[3])) +} + +// SolarEclipseBesselianElementsOptions 是贝塞尔根数表的生成选项 / options for a Besselian element table. +type SolarEclipseBesselianElementsOptions struct { + // Model 月亮半径模型;只有显式取 IAU Single-K 才切换,其余取值一律按 NASA bulletin Split-K。 + // Model is the lunar radius model; only an explicit IAU Single-K switches it. + Model SolarEclipseRadiusModel + // SunRadiusModel 太阳半径口径;零值为标准档。 + // SunRadiusModel is the solar radius convention; the zero value is the standard one. + SunRadiusModel SolarEclipseSunRadiusModel + // DeltaTSeconds 显式 ΔT(秒),非正值用进程级模型;它只改变地球自转相位,不改变任何 TT 时刻。 + // DeltaTSeconds is an explicit ΔT in seconds, non-positive uses the process model; it only sets Earth rotation. + DeltaTSeconds float64 + // ReferenceJDE 多项式参考时刻 T0(TT 儒略日),非正值取食甚最近的整 TT 小时(四舍五入),与已发布根数表一致。 + // ReferenceJDE is the TT reference instant T0; non-positive uses the whole TT hour nearest to greatest eclipse. + ReferenceJDE float64 + // ValidHours 多项式有效窗口半径(小时),非正值取 3;窗口内取 5 个等距时刻做三次最小二乘。 + // ValidHours is the half-width of the validity window in hours, non-positive uses 3. + ValidHours float64 +} + +// SolarEclipseBesselianElementsResult 是一次日食的多项式贝塞尔根数及其口径 / polynomial Besselian elements and the conventions behind them. +type SolarEclipseBesselianElementsResult struct { + // T0JDE 多项式参考时刻(TT 儒略日),t = (jde - T0JDE) * 24。 + // T0JDE is the TT reference instant; t = (jde - T0JDE) * 24. + T0JDE float64 + // ValidHours 有效窗口半径(小时),超出该窗口不应使用本多项式。 + // ValidHours is the half-width of the validity window in hours. + ValidHours float64 + + // X 与 Y 是月心在基本面内的坐标,单位地球赤道半径。 + // X and Y are the Moon's fundamental-plane coordinates in equatorial Earth radii. + X, Y SolarEclipseBesselianPolynomial + // D 是影轴赤纬,单位度。 + // D is the declination of the shadow axis in degrees. + D SolarEclipseBesselianPolynomial + // L1 与 L2 是基本面内的半影、本影半径,单位地球赤道半径;本影为负表示月心尚未越过本影锥顶点。 + // L1 and L2 are the penumbral and umbral radii in the fundamental plane, in equatorial Earth radii. + L1, L2 SolarEclipseBesselianPolynomial + // Mu 是影轴格林时角,单位度,窗口内连续、不折回 [0,360)。 + // + // 口径与已发布根数表不同:本库的恒星时取自 UT = TT - ΔT,得到的是真实格林时角;已发布表改用 + // T0 本身的恒星时(不含 ΔT 自转),两者相差 ΔT × 15.041067/3600 度。要对表先用 + // SolarEclipseBesselianMuForPublishedTable 换算。 + // Mu is the Greenwich hour angle of the shadow axis in degrees, continuous and not folded into [0,360). + Mu SolarEclipseBesselianPolynomial + // TanF1 与 TanF2 是半影、本影锥半顶角正切,本次日食内为常数。 + // TanF1 and TanF2 are the penumbral and umbral cone half-angle tangents, constant over the eclipse. + TanF1, TanF2 float64 + // Gamma 是食甚时刻影轴到地心的距离,单位地球赤道半径。 + // Gamma is the shadow-axis distance from the Earth's centre at greatest eclipse, in equatorial Earth radii. + Gamma float64 + // Magnitude 是食甚时刻的全局食分。 + // Magnitude is the global eclipse magnitude at greatest eclipse. + Magnitude float64 + + // 下列字段是决定上述数值的口径,随结果一起保留。 + // The fields below are the conventions that fix the numbers above. + Model SolarEclipseRadiusModel + SunRadiusModel SolarEclipseSunRadiusModel + PenumbralK float64 + UmbralK float64 + DeltaTSeconds float64 +} + +// SolarEclipseBesselianMuForPublishedTable 把本库的 Mu 换算成与已发布根数表直接可比的取值。 +// 已发布表用 T0 本身的恒星时,本库用 UT = TT - ΔT,两者只差一个常数,因此只有常数项平移。 +// SolarEclipseBesselianMuForPublishedTable shifts Mu onto the argument used by published element tables. +func SolarEclipseBesselianMuForPublishedTable( + mu SolarEclipseBesselianPolynomial, deltaTSeconds float64, +) SolarEclipseBesselianPolynomial { + shift := deltaTSeconds * solarEclipseBesselianSiderealDegreesPerSecond + return SolarEclipseBesselianPolynomial{mu[0] + shift, mu[1], mu[2], mu[3]} +} + +// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false。 +// Polynomial Besselian elements for the solar eclipse near the given new-moon instant; false when there is none. +func SolarEclipseBesselianElements( + seedJDE float64, options SolarEclipseBesselianElementsOptions, +) (SolarEclipseBesselianElementsResult, bool) { + options.Model = normalizeSolarEclipseRadiusModel(options.Model) + options.SunRadiusModel = normalizeSolarEclipseSunRadiusModel(options.SunRadiusModel) + validHours := options.ValidHours + if !(validHours > 0) { + validHours = solarEclipseBesselianDefaultValidHours + } + + solver := newSolarEclipseSolverWithOptions(CalcMoonSHByJDE(seedJDE, 0), SolarEclipseOptions{ + RadiusModel: options.Model, + SunRadiusModel: options.SunRadiusModel, + }). + withDeltaTSeconds(options.DeltaTSeconds) + feature := solver.feature() + if feature.typeCode == "N" { + return SolarEclipseBesselianElementsResult{}, false + } + + t0 := options.ReferenceJDE + if !(t0 > 0) { + // 已发布表按最近整小时取 T0(食甚 02:36 TDT 的表 T0 是 03:00),不是取整点下界。 + t0 = math.Round(feature.greatestEclipseJDE*24) / 24 + } + + step := 2 * validHours / float64(solarEclipseBesselianSampleCount-1) + times := make([]float64, solarEclipseBesselianSampleCount) + columns := [6][]float64{} + for index := range columns { + columns[index] = make([]float64, solarEclipseBesselianSampleCount) + } + for index := range times { + hours := -validHours + step*float64(index) + times[index] = hours + point := solver.besselianElementsAt(t0 + hours/24) + columns[0][index] = point.x + columns[1][index] = point.y + columns[2][index] = point.d + columns[3][index] = point.l1 + columns[4][index] = point.l2 + columns[5][index] = point.mu + } + + // μ 每窗口跨越的角量远小于 180°,可以先展开成连续序列再归一到 [0,360)。 + columns[5] = solarEclipseUnwrapDegrees(columns[5], solarEclipseBesselianSampleCount/2) + + return SolarEclipseBesselianElementsResult{ + T0JDE: t0, + ValidHours: validHours, + X: solarEclipseFitCubic(times, columns[0]), + Y: solarEclipseFitCubic(times, columns[1]), + D: solarEclipseFitCubic(times, columns[2]), + L1: solarEclipseFitCubic(times, columns[3]), + L2: solarEclipseFitCubic(times, columns[4]), + Mu: solarEclipseFitCubic(times, columns[5]), + TanF1: solver.penumbraConeTangent, + TanF2: solver.umbraConeTangent, + Gamma: feature.gamma, + Magnitude: feature.magnitude, + Model: options.Model, + SunRadiusModel: options.SunRadiusModel, + PenumbralK: solver.params.penumbralK, + UmbralK: solver.params.umbralK, + DeltaTSeconds: solver.effectiveDeltaTSeconds(t0), + }, true +} + +// solarEclipseBesselianPoint 是单一 TT 时刻的经典口径贝塞尔根数。 +type solarEclipseBesselianPoint struct { + x, y, z, d, mu, l1, l2 float64 +} + +// besselianElementsAt 按经典口径取该时刻的根数:d 为影轴赤纬,μ 为真实格林时角, +// L1/L2 用含 1/cos f 的 ES 形式,且本影取负号口径。 +func (solver solarEclipseSolver) besselianElementsAt(jde float64) solarEclipseBesselianPoint { + moon, axis, _ := solver.besselGeometryAt(jde) + penumbraHalfAngle := math.Atan(solver.penumbraConeTangent) + umbraHalfAngle := math.Atan(solver.umbraConeTangent) + + return solarEclipseBesselianPoint{ + x: moon[0], + y: moon[1], + z: moon[2], + d: (math.Pi/2 - axis.tilt) / rad, + mu: (axis.gst - (axis.rightAscension - math.Pi/2)) / rad, + l1: moon[2]*solver.penumbraConeTangent + solver.params.penumbralK/math.Cos(penumbraHalfAngle), + l2: moon[2]*solver.umbraConeTangent - solver.params.umbralK/math.Cos(umbraHalfAngle), + } +} + +// solarEclipseUnwrapDegrees 把按时间升序的角量展开成连续序列,并把 reference 号样本归入 [0,360)。 +func solarEclipseUnwrapDegrees(values []float64, reference int) []float64 { + unwrapped := make([]float64, len(values)) + copy(unwrapped, values) + for index := 1; index < len(unwrapped); index++ { + for unwrapped[index]-unwrapped[index-1] > 180 { + unwrapped[index] -= 360 + } + for unwrapped[index]-unwrapped[index-1] < -180 { + unwrapped[index] += 360 + } + } + if reference >= 0 && reference < len(unwrapped) { + shift := 360 * math.Floor(unwrapped[reference]/360) + for index := range unwrapped { + unwrapped[index] -= shift + } + } + return unwrapped +} + +// solarEclipseFitCubic 用样本做三次最小二乘拟合,样本少于 4 个时返回零值。 +func solarEclipseFitCubic(times, values []float64) SolarEclipseBesselianPolynomial { + if len(times) < 4 || len(times) != len(values) { + return SolarEclipseBesselianPolynomial{} + } + + var normal [4][5]float64 + for index := range times { + powers := [7]float64{1} + for n := 1; n < len(powers); n++ { + powers[n] = powers[n-1] * times[index] + } + for row := range normal { + for column := range normal[row][:4] { + normal[row][column] += powers[row+column] + } + normal[row][4] += powers[row] * values[index] + } + } + + for column := range normal { + pivot := column + for row := column + 1; row < len(normal); row++ { + if math.Abs(normal[row][column]) > math.Abs(normal[pivot][column]) { + pivot = row + } + } + normal[column], normal[pivot] = normal[pivot], normal[column] + if normal[column][column] == 0 { + return SolarEclipseBesselianPolynomial{} + } + for row := range normal { + if row == column { + continue + } + factor := normal[row][column] / normal[column][column] + for c := column; c < len(normal[row]); c++ { + normal[row][c] -= factor * normal[column][c] + } + } + } + + var polynomial SolarEclipseBesselianPolynomial + for n := range polynomial { + polynomial[n] = normal[n][4] / normal[n][n] + } + return polynomial +} diff --git a/basic/solar_eclipse_bessel_test.go b/basic/solar_eclipse_bessel_test.go new file mode 100644 index 0000000..6db9006 --- /dev/null +++ b/basic/solar_eclipse_bessel_test.go @@ -0,0 +1,252 @@ +package basic + +import ( + "math" + "testing" +) + +// 根数表口径:经典贝塞尔符号、NASA 式三次拟合,以及与已发布表的 μ 时间自变量换算。 + +const ( + besselianNASAT0JDE = 2460409.25 + besselianNASADeltaT = 70.6 + besselianNASASeed = 2460409.262835 +) + +func nasa2024BesselianElements(t *testing.T) SolarEclipseBesselianElementsResult { + t.Helper() + result, ok := SolarEclipseBesselianElements(besselianNASASeed, SolarEclipseBesselianElementsOptions{ + DeltaTSeconds: besselianNASADeltaT, + ReferenceJDE: besselianNASAT0JDE, + }) + if !ok { + t.Fatal("2024-04-08 should have a solar eclipse") + } + return result +} + +func TestSolarEclipseBesselianElementsMatchesNASA2024(t *testing.T) { + result := nasa2024BesselianElements(t) + + cases := []struct { + label string + got SolarEclipseBesselianPolynomial + want SolarEclipseBesselianPolynomial + tol [4]float64 + }{ + {"x", result.X, SolarEclipseBesselianPolynomial{-0.318157, 0.5117105, 0.0000326, -0.0000085}, [4]float64{2e-4, 1e-5, 1e-6, 1e-6}}, + {"y", result.Y, SolarEclipseBesselianPolynomial{0.219747, 0.2709586, -0.0000594, -0.0000047}, [4]float64{2e-4, 1e-5, 1e-6, 1e-6}}, + {"d", result.D, SolarEclipseBesselianPolynomial{7.58620, 0.014844, -0.000002, 0}, [4]float64{1e-4, 1e-5, 1e-5, 1e-5}}, + {"l1", result.L1, SolarEclipseBesselianPolynomial{0.535813, 0.0000618, -0.0000128, 0}, [4]float64{1e-4, 1e-5, 1e-5, 1e-5}}, + {"l2", result.L2, SolarEclipseBesselianPolynomial{-0.010274, 0.0000615, -0.0000127, 0}, [4]float64{1e-4, 1e-5, 1e-5, 1e-5}}, + } + for _, item := range cases { + for n := range item.want { + if math.Abs(item.got[n]-item.want[n]) > item.tol[n] { + t.Errorf("%s[%d] = %.8f, want %.8f (tol %.1e)", item.label, n, item.got[n], item.want[n], item.tol[n]) + } + } + } + + published := SolarEclipseBesselianMuForPublishedTable(result.Mu, result.DeltaTSeconds) + for n, want := range [4]float64{89.59122, 15.004084, 0, 0} { + if math.Abs(published[n]-want) > 1e-4 { + t.Errorf("published mu[%d] = %.6f, want %.6f", n, published[n], want) + } + } + + if math.Abs(result.TanF1-0.0046683) > 1e-6 || math.Abs(result.TanF2-0.0046450) > 1e-6 { + t.Errorf("tan f = %.8f / %.8f, want 0.0046683 / 0.0046450", result.TanF1, result.TanF2) + } + if math.Abs(result.Gamma-0.3431) > 1e-4 { + t.Errorf("gamma = %.6f, want 0.3431", result.Gamma) + } + if math.Abs(result.Magnitude-1.0566) > 1e-3 { + t.Errorf("magnitude = %.6f, want 1.0566", result.Magnitude) + } + if result.T0JDE != besselianNASAT0JDE || result.ValidHours != 3 { + t.Errorf("t0 = %.6f validHours = %g, want %.6f / 3", result.T0JDE, result.ValidHours, besselianNASAT0JDE) + } + if result.Model != SolarEclipseModelNASABulletinSplitK { + t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelNASABulletinSplitK) + } + if result.PenumbralK != solarEclipsePenumbralK || result.UmbralK != solarEclipseUmbralK { + t.Errorf("k = %g / %g, want %g / %g", result.PenumbralK, result.UmbralK, solarEclipsePenumbralK, solarEclipseUmbralK) + } + if math.Abs(result.DeltaTSeconds-besselianNASADeltaT) > 1e-9 { + t.Errorf("deltaT = %g, want %g", result.DeltaTSeconds, besselianNASADeltaT) + } +} + +func TestSolarEclipseBesselianElementsFollowsSampledElements(t *testing.T) { + result := nasa2024BesselianElements(t) + solver := newSolarEclipseSolver( + CalcMoonSHByJDE(besselianNASASeed, 0), + SolarEclipseModelNASABulletinSplitK, + ).withDeltaTSeconds(besselianNASADeltaT) + + for _, hours := range []float64{-2.7, -0.8, 0.4, 2.3} { + point := solver.besselianElementsAt(result.T0JDE + hours/24) + if math.Abs(result.X.At(hours)-point.x) > 1e-5 || + math.Abs(result.Y.At(hours)-point.y) > 1e-5 || + math.Abs(result.L1.At(hours)-point.l1) > 1e-5 || + math.Abs(result.L2.At(hours)-point.l2) > 1e-5 || + math.Abs(result.D.At(hours)-point.d) > 1e-5 { + t.Errorf("t=%+.2fh polynomial (%g,%g,%g,%g,%g) does not track elements (%g,%g,%g,%g,%g)", + hours, result.X.At(hours), result.Y.At(hours), result.D.At(hours), result.L1.At(hours), result.L2.At(hours), + point.x, point.y, point.d, point.l1, point.l2) + } + if math.Abs(math.Mod(result.Mu.At(hours)-point.mu, 360)) > 1e-4 { + t.Errorf("t=%+.2fh mu = %g does not track %g", hours, result.Mu.At(hours), point.mu) + } + } +} + +func TestSolarEclipseBesselianElementsMuStaysContinuousAcrossZero(t *testing.T) { + // 2013-11-03 的食甚点在格林尼治附近,μ 的采样点会跨过 0°,是展开逻辑的边界用例。 + for _, seed := range []float64{besselianNASASeed, JDCalc(2013, 11, 3)} { + result, ok := SolarEclipseBesselianElements(seed, SolarEclipseBesselianElementsOptions{ + DeltaTSeconds: besselianNASADeltaT, + }) + if !ok { + t.Fatalf("seed %.4f should have a solar eclipse", seed) + } + if result.Mu.At(0) < 0 || result.Mu.At(0) >= 360 { + t.Errorf("seed %.4f: mu(0) = %g, want [0,360)", seed, result.Mu.At(0)) + } + if math.Abs(result.Mu[1]-15.041067) > 0.05 { + t.Errorf("seed %.4f: mu rate = %.6f, want about 15.041067", seed, result.Mu[1]) + } + for _, hours := range []float64{-3, -1.5, 1.5, 3} { + drift := result.Mu.At(hours) - (result.Mu.At(0) + 15.041067*hours) + if math.Abs(drift) > 0.5 { + t.Errorf("seed %.4f: mu(%+.1f) is not a continuous continuation, drift %.4f", seed, hours, drift) + } + } + } +} + +func TestSolarEclipseBesselianElementsDefaultsToWholeHour(t *testing.T) { + result, ok := SolarEclipseBesselianElements(besselianNASASeed, SolarEclipseBesselianElementsOptions{ + DeltaTSeconds: besselianNASADeltaT, + }) + if !ok { + t.Fatal("2024-04-08 should have a solar eclipse") + } + if math.Abs(result.T0JDE*24-math.Round(result.T0JDE*24)) > 1e-9 { + t.Errorf("t0 = %.9f is not a whole TT hour", result.T0JDE) + } + if math.Abs(result.T0JDE*24-besselianNASASeed*24) > 0.5 { + t.Errorf("t0 = %.6f is not the whole hour nearest greatest eclipse %.6f", result.T0JDE, besselianNASASeed) + } + if result.ValidHours != 3 { + t.Errorf("validHours = %g, want 3", result.ValidHours) + } + // 食甚落在半小时之后的事件取下一个整小时:NASA 2009-07-22 表食甚 02:36:24 TDT,t0 记 03:00 TDT。 + forward, ok := SolarEclipseBesselianElements(JDCalc(2009, 7, 22), SolarEclipseBesselianElementsOptions{ + DeltaTSeconds: 65.9, + }) + if !ok { + t.Fatal("2009-07-22 should have a solar eclipse") + } + if math.Abs(forward.T0JDE-2455034.625) > 1e-9 { + t.Errorf("t0 = %.9f, want 2455034.625 (03:00 TDT)", forward.T0JDE) + } +} + +func TestSolarEclipseBesselianElementsMatchesPublishedHours(t *testing.T) { + testCases := []struct { + name string + date [3]int + delta float64 + t0 float64 + want [6]SolarEclipseBesselianPolynomial + }{ + { + name: "2009-07-22 total", date: [3]int{2009, 7, 22}, delta: 65.9, t0: 2455034.625, + want: [6]SolarEclipseBesselianPolynomial{ + {0.240059, 0.5563975, -0.0000583, -0.0000100}, + {-0.003283, -0.1774571, -0.0001346, 0.0000032}, + {20.26424, -0.007873, -0.000004, 0}, + {0.530426, 0.0000063, -0.0000128, 0}, + {-0.015633, 0.0000063, -0.0000127, 0}, + {0, 0, 0, 0}, + }, + }, + { + name: "2013-11-03 hybrid", date: [3]int{2013, 11, 3}, delta: 67.2, t0: 2456600.041666667, + want: [6]SolarEclipseBesselianPolynomial{ + {0.183190, 0.5469478, 0.0000282, -0.0000083}, + {0.294721, -0.1200753, 0.0000790, 0.0000017}, + {-15.20965, -0.012636, 0.000003, 0}, + {0.546301, -0.0001121, -0.0000120, 0}, + {0.000143, -0.0001116, -0.0000120, 0}, + {0, 0, 0, 0}, + }, + }, + } + tolerances := [6][4]float64{ + {2e-4, 1e-5, 1e-6, 1e-6}, + {2e-4, 1e-5, 1e-6, 1e-6}, + {1e-4, 1e-5, 1e-5, 1e-5}, + {1e-4, 1e-5, 1e-5, 1e-5}, + {1e-4, 1e-5, 1e-5, 1e-5}, + {1e-4, 1e-5, 1e-5, 1e-5}, + } + labels := [6]string{"x", "y", "d", "l1", "l2", "mu"} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, ok := SolarEclipseBesselianElements(JDCalc(tc.date[0], tc.date[1], float64(tc.date[2])), + SolarEclipseBesselianElementsOptions{DeltaTSeconds: tc.delta}) + if !ok { + t.Fatalf("%s should have a solar eclipse", tc.name) + } + if math.Abs(result.T0JDE-tc.t0) > 1e-9 { + t.Fatalf("t0 = %.9f, want %.9f", result.T0JDE, tc.t0) + } + got := [6]SolarEclipseBesselianPolynomial{result.X, result.Y, result.D, result.L1, result.L2, result.Mu} + for index := range got { + if index == 5 { + continue + } + for n := range tc.want[index] { + if math.Abs(got[index][n]-tc.want[index][n]) > tolerances[index][n] { + t.Errorf("%s[%d] = %.8f, want %.8f (tol %.1e)", labels[index], n, + got[index][n], tc.want[index][n], tolerances[index][n]) + } + } + } + }) + } +} + +func TestSolarEclipseBesselianElementsIAUSingleKUsesOneK(t *testing.T) { + result, ok := SolarEclipseBesselianElements(besselianNASASeed, SolarEclipseBesselianElementsOptions{ + Model: SolarEclipseModelIAUSingleK, + DeltaTSeconds: besselianNASADeltaT, + }) + if !ok { + t.Fatal("2024-04-08 should have a solar eclipse") + } + if result.Model != SolarEclipseModelIAUSingleK { + t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelIAUSingleK) + } + if result.UmbralK != result.PenumbralK { + t.Errorf("k2 = %g, want k1 = %g", result.UmbralK, result.PenumbralK) + } +} + +func TestSolarEclipseBesselianElementsWithoutEclipse(t *testing.T) { + if _, ok := SolarEclipseBesselianElements(JDCalc(2024, 5, 8), SolarEclipseBesselianElementsOptions{}); ok { + t.Fatal("2024-05-08 has no solar eclipse") + } +} + +func TestSolarEclipseBesselianElementsRejectsShortSampleSet(t *testing.T) { + if got := solarEclipseFitCubic([]float64{-1, 0, 1}, []float64{1, 2, 3}); got != (SolarEclipseBesselianPolynomial{}) { + t.Errorf("fit with three samples = %v, want zero", got) + } + if got := solarEclipseFitCubic([]float64{-3, -1.5, 0, 1.5, 3}, []float64{0, 0, 0, 0, 0}); got != (SolarEclipseBesselianPolynomial{}) { + t.Errorf("fit of a zero column = %v, want zero", got) + } +} diff --git a/basic/solar_eclipse_central_envelope_test.go b/basic/solar_eclipse_central_envelope_test.go index d5fa0e2..c245319 100644 --- a/basic/solar_eclipse_central_envelope_test.go +++ b/basic/solar_eclipse_central_envelope_test.go @@ -6,7 +6,7 @@ import ( ) func TestSolarEclipse20120521CentralBandIsContinuousCriticalEnvelope(t *testing.T) { - seed := JDECalc(2012, 5, 21) + seed := JDCalc(2012, 5, 21) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, }) @@ -72,7 +72,7 @@ func TestSolarEclipse20120521CentralBandIsContinuousCriticalEnvelope(t *testing. } func TestSolarEclipseCentralEnvelopeIndependentOfRiseSetOutput(t *testing.T) { - seed := JDECalc(2012, 5, 21) + seed := JDCalc(2012, 5, 21) withCurves := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96}) withoutCurves := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96, DisableRiseSetCurves: true}) if len(withoutCurves.CentralBandSegments) == 0 { diff --git a/basic/solar_eclipse_diagram.go b/basic/solar_eclipse_diagram.go index 88d0d5f..845cf76 100644 --- a/basic/solar_eclipse_diagram.go +++ b/basic/solar_eclipse_diagram.go @@ -18,6 +18,8 @@ type LocalSolarEclipseDiagramOptions struct { // StepDays 是路径采样步长,单位为日;<=0 时使用 5 分钟。 // StepDays is the path sampling step in days; values <= 0 use five minutes. StepDays float64 + // SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention, standard when zero. + SunRadiusModel SolarEclipseSunRadiusModel } // LocalSolarEclipseDiagramFrame 表示一个时刻的站心日月视圆几何。 @@ -96,7 +98,10 @@ func localSolarEclipseDiagram( options LocalSolarEclipseDiagramOptions, ) LocalSolarEclipseDiagramResult { options = normalizeLocalSolarEclipseDiagramOptions(options) - eclipse := localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters, model) + eclipse := localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters, SolarEclipseOptions{ + RadiusModel: model, + SunRadiusModel: options.SunRadiusModel, + }) result := LocalSolarEclipseDiagramResult{ Eclipse: eclipse, StepDays: options.StepDays, @@ -108,7 +113,7 @@ func localSolarEclipseDiagram( lonRad := lonDeg * rad latRad := latDeg * rad heightKM := heightMeters / 1000.0 - params := solarEclipseModelParams(model) + params := solarEclipseModelParams(model, options.SunRadiusModel) times, stepDays := localSolarEclipseDiagramTimes(eclipse, options.StepDays) result.StepDays = stepDays result.Frames = make([]LocalSolarEclipseDiagramFrame, 0, len(times)) @@ -251,8 +256,8 @@ func localSolarEclipseDiagramFrameAt( sunXYZ := solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]) moonXYZ := solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]) - utJDE := TD2UT(jdTT, false) - gst := ApparentSiderealTime(utJDE) * 15 * rad + ut1JDE := TT2UT1(jdTT) + gst := ApparentSiderealTime(ut1JDE) * 15 * rad observerXYZ := localSolarEclipseObserverXYZ(gst, lonRad, latRad, heightKM) sunTopocentric := solarEclipseXYZToLLR( @@ -273,12 +278,12 @@ func localSolarEclipseDiagramFrameAt( ) sunRadiusRad := math.Asin(localSolarEclipseClampUnit( - solarEclipseEarthEquatorialRadiusKM * solarEclipseSolarRadiusRatio / sunTopocentric[2], + solarEclipseEarthEquatorialRadiusKM * params.sunRadiusRatio / sunTopocentric[2], )) moonRadiusRad := math.Asin(localSolarEclipseClampUnit( - solarEclipseEarthEquatorialRadiusKM * solarEclipsePenumbralK * localSolarMoonRadiusScale / moonTopocentric[2], + solarEclipsePenumbralRadiusNumerator(params) / moonTopocentric[2], )) - if params.umbralK > solarEclipsePenumbralK { + if params.umbralK > params.penumbralK { moonRadiusRad = math.Asin(localSolarEclipseClampUnit( solarEclipseEarthEquatorialRadiusKM * params.umbralK * localSolarMoonRadiusScale / moonTopocentric[2], )) diff --git a/basic/solar_eclipse_duration_width_regression_test.go b/basic/solar_eclipse_duration_width_regression_test.go index b3d3143..3da41e3 100644 --- a/basic/solar_eclipse_duration_width_regression_test.go +++ b/basic/solar_eclipse_duration_width_regression_test.go @@ -23,7 +23,7 @@ var solarEclipseCatalogueCentralDurations = []struct { func TestSolarEclipseCentralDurationMatchesCatalogue(t *testing.T) { for _, item := range solarEclipseCatalogueCentralDurations { - seed := JDECalc(item.date[0], item.date[1], float64(item.date[2])) + seed := JDCalc(item.date[0], item.date[1], float64(item.date[2])) result := SolarEclipseNASABulletinSplitK(seed) if !result.HasCentral { t.Fatalf("%04d-%02d-%02d is not a central eclipse", item.date[0], item.date[1], item.date[2]) @@ -54,7 +54,7 @@ func TestSolarEclipsePathWidthStaysPhysical(t *testing.T) { {[3]int{1874, 10, 10}, true}, } for _, item := range cases { - seed := JDECalc(item.date[0], item.date[1], float64(item.date[2])) + seed := JDCalc(item.date[0], item.date[1], float64(item.date[2])) path := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{}) if len(path.CenterLine) == 0 { t.Fatalf("%04d-%02d-%02d exported no center line", item.date[0], item.date[1], item.date[2]) diff --git a/basic/solar_eclipse_envelope_containment_test.go b/basic/solar_eclipse_envelope_containment_test.go index 35f1b8f..8197b30 100644 --- a/basic/solar_eclipse_envelope_containment_test.go +++ b/basic/solar_eclipse_envelope_containment_test.go @@ -8,7 +8,7 @@ import ( func TestSolarEclipseAnnularEnvelopeContainsShadow(t *testing.T) { for _, date := range [][3]int{{4005, 4, 22}, {4329, 6, 12}} { t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440, BoundaryPoints: 96, CentralShadowStepDays: 1.0 / 1440, }) if len(result.CentralBandSegments) == 0 { @@ -41,7 +41,7 @@ func TestSolarEclipseAnnularEnvelopeContainsShadow(t *testing.T) { func BenchmarkSolarEclipseCentralEnvelopePath(b *testing.B) { for _, date := range [][3]int{{4005, 4, 22}, {4329, 6, 12}, {2024, 4, 8}} { b.Run(fmt.Sprint(date), func(b *testing.B) { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) b.ReportAllocs() for i := 0; i < b.N; i++ { SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440, TargetSpacingKM: 150}) diff --git a/basic/solar_eclipse_hybrid_envelope.go b/basic/solar_eclipse_hybrid_envelope.go index bd50335..4aae51b 100644 --- a/basic/solar_eclipse_hybrid_envelope.go +++ b/basic/solar_eclipse_hybrid_envelope.go @@ -21,7 +21,7 @@ func solarCentralBandSkyOffset(context localSolarEclipseStateContext, longitude, east := [3]float64{-sun[1] / equatorial, sun[0] / equatorial, 0} north := [3]float64{-sun[2] * east[1], sun[2] * east[0], equatorial} radius := math.Asin(solarEclipseEarthEquatorialRadiusKM*context.params.umbralK*localSolarMoonRadiusScale/moonDistance) - - math.Asin(solarEclipseEarthEquatorialRadiusKM*solarEclipseSolarRadiusRatio/sunDistance) + math.Asin(solarEclipseEarthEquatorialRadiusKM*context.params.sunRadiusRatio/sunDistance) return [3]float64{dotSolarEclipse3(moon, east), dotSolarEclipse3(moon, north), math.Sin(radius)} } @@ -85,8 +85,8 @@ func (solver solarEclipseSolver) correctCentralBandVectorBoundary(predictor, tan } plane := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(plane) <= 1e-9 { - jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale - evaluation := solver.magnitudeEvaluationAt(jd) + jde := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + evaluation := solver.magnitudeEvaluationAt(jde) check, valid := solarCentralBandVectorResidual(evaluation, coordinates[0], coordinates[1], side) if !valid || math.Hypot(check[0], check[1]) > solarEclipseCentralVectorTolerance { exact = true @@ -96,7 +96,7 @@ func (solver solarEclipseSolver) correctCentralBandVectorBoundary(predictor, tan state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) return solarEclipseNonCentralBandState{ coordinates: coordinates, tangent: nextTangent, - point: SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, + point: SolarEclipsePathPoint{JDE: jde, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, }, valid } delta, valid := solveSolarEclipse3x3([3][3]float64{jacobian[0], jacobian[1], tangent}, [3]float64{-residual[0], -residual[1], -plane}) @@ -252,19 +252,19 @@ func (solver solarEclipseSolver) hybridCentralBandTransition(seed SolarEclipsePa coordinates := [3]float64{seed.Longitude, seed.Latitude, (seed.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale} steps := [3]float64{1e-4, 1e-4, solarEclipseNonCentralBandTimeScale / 86400} for iteration := 0; iteration < 12; iteration++ { - jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale - context := solver.localStateContextAt(jd) + jde := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + context := solver.localStateContextAt(jde) residual := solarCentralBandSkyOffset(context, coordinates[0], coordinates[1]) if math.Hypot(residual[0], residual[1]) < solarEclipseCentralVectorTolerance/2 && math.Abs(residual[2]) < 1e-12 { state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) - return SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true + return SolarEclipsePathPoint{JDE: jde, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true } var jacobian [3][3]float64 for column := range coordinates { shifted, shiftedContext := coordinates, context shifted[column] += steps[column] if column == 2 { - shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale) + shiftedContext = solver.localStateContextAt(jde + steps[column]/solarEclipseNonCentralBandTimeScale) } value := solarCentralBandSkyOffset(shiftedContext, shifted[0], shifted[1]) for row := range residual { @@ -301,16 +301,16 @@ func (solver solarEclipseSolver) centralBandVectorHorizonRoots(axisContactJDE, d if !valid { break } - jd := axisContactJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale - context := solver.localStateContextAt(jd) + jde := axisContactJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + context := solver.localStateContextAt(jde) state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(state.sunAltitudeRad) < 1e-8 { - roots[i] = SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad} + roots[i] = SolarEclipsePathPoint{JDE: jde, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad} // Grazing horizon roots can be many minutes from axis contact. // Bound them by the shadow's limb-crossing interval, not a fixed // window around the seed. - found = jd >= startJDE-solarEclipseCentralLimitHorizonContactMarginDays && - jd <= endJDE+solarEclipseCentralLimitHorizonContactMarginDays && math.Abs(coordinates[1]) <= 90 + found = jde >= startJDE-solarEclipseCentralLimitHorizonContactMarginDays && + jde <= endJDE+solarEclipseCentralLimitHorizonContactMarginDays && math.Abs(coordinates[1]) <= 90 break } matrix := [3][3]float64{jacobian[0], jacobian[1], {}} @@ -318,7 +318,7 @@ func (solver solarEclipseSolver) centralBandVectorHorizonRoots(axisContactJDE, d shifted, shiftedContext := coordinates, context shifted[column] += steps[column] if column == 2 { - shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale) + shiftedContext = solver.localStateContextAt(jde + steps[column]/solarEclipseNonCentralBandTimeScale) } value := shiftedContext.stateAt(shifted[0]*rad, shifted[1]*rad, 0) matrix[2][column] = (value.sunAltitudeRad - state.sunAltitudeRad) / steps[column] diff --git a/basic/solar_eclipse_hybrid_envelope_test.go b/basic/solar_eclipse_hybrid_envelope_test.go index 708eb22..e270410 100644 --- a/basic/solar_eclipse_hybrid_envelope_test.go +++ b/basic/solar_eclipse_hybrid_envelope_test.go @@ -9,7 +9,7 @@ import ( ) func TestSolarEclipseHybridEnvelope21640323(t *testing.T) { - seed := JDECalc(2164, 3, 23) + seed := JDCalc(2164, 3, 23) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96}) if len(result.CentralBandHorizonClosures) != 2 { t.Fatal("missing hybrid horizon closures") @@ -35,7 +35,7 @@ func TestSolarEclipseHybridEnvelope21640323(t *testing.T) { func TestSolarEclipseHybridSignedEnvelopeEvents(t *testing.T) { for _, date := range [][3]int{{1144, 7, 3}, {1827, 10, 20}, {1854, 11, 20}, {1986, 10, 3}, {2013, 11, 3}, {2023, 4, 20}, {2164, 3, 23}, {2172, 10, 17}} { t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440}) if result.Eclipse.Type != SolarEclipseHybrid || len(result.CentralBandHorizonClosures) != 2 || len(result.CentralBandSegments) < 2 { t.Fatalf("type=%s closures=%d segments=%d", result.Eclipse.Type, len(result.CentralBandHorizonClosures), len(result.CentralBandSegments)) diff --git a/basic/solar_eclipse_isochrone_test.go b/basic/solar_eclipse_isochrone_test.go index 6ceb0e8..5ffb147 100644 --- a/basic/solar_eclipse_isochrone_test.go +++ b/basic/solar_eclipse_isochrone_test.go @@ -8,12 +8,12 @@ import ( func solarEclipseIsochroneTestSeed(t *testing.T, year int, month time.Month, day int) float64 { t.Helper() - return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC))) } func solarEclipseIsochroneTestSeedB(b *testing.B, year int, month time.Month, day int) float64 { b.Helper() - return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) + return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC))) } // 等时线必须与站心食甚定义自洽:支路上任意点由站心算法独立求出的食甚时刻等于该支路电平。 @@ -32,7 +32,7 @@ func TestSolarEclipseGreatestTimeContoursMatchLocalCircumstances(t *testing.T) { t.Run(tc.name, func(t *testing.T) { levels := make([]float64, 0, tc.count) for index := 0; index < tc.count; index++ { - levels = append(levels, TD2UT(Date2JDE(tc.first.Add(time.Duration(index)*30*time.Minute)), true)) + levels = append(levels, UTC2TT(Date2JD(tc.first.Add(time.Duration(index)*30*time.Minute)))) } result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, @@ -124,8 +124,8 @@ func TestSolarEclipseGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) } { t.Run(tc.name, func(t *testing.T) { // 用事件当天同一个世界时小时作为时刻取值。 - seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false) - eventLevel := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true) + seedTime := JD2DateByZone(TT2UTC(tc.seed), time.UTC, false) + eventLevel := UTC2TT(Date2JD(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC))) result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, GreatestTimeValues: []float64{eventLevel}, @@ -188,8 +188,8 @@ func TestSolarEclipseGreatestTimeContoursCoverZeroSet(t *testing.T) { {"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29), 6}, } { t.Run(tc.name, func(t *testing.T) { - seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false) - level := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true) + seedTime := JD2DateByZone(TT2UTC(tc.seed), time.UTC, false) + level := UTC2TT(Date2JD(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC))) solver := newSolarEclipseSolver(CalcMoonSHByJDE(tc.seed, 0), SolarEclipseModelNASABulletinSplitK) segments := solver.greatestTimeContourSegments(level) if len(segments) == 0 { @@ -267,12 +267,12 @@ func TestSolarEclipseGreatestTimeStepAlignsAndCapsLevels(t *testing.T) { t.Fatalf("step request produced %d contours, want %d", len(result.GreatestTimeContours), len(want)) } // 独立重算网格:只用导出的 TT/UTC 换算与整刻度截断,不经过被测的取值生成函数。 - begin := JDE2DateByZone(TD2UT(result.Eclipse.PartialBeginOnEarth, false), time.UTC, false) + begin := JD2DateByZone(TT2UTC(result.Eclipse.PartialBeginOnEarth), time.UTC, false) first := begin.Truncate(30 * time.Minute) if first.Before(begin) { first = first.Add(30 * time.Minute) } - end := JDE2DateByZone(TD2UT(result.Eclipse.PartialEndOnEarth, false), time.UTC, false) + end := JD2DateByZone(TT2UTC(result.Eclipse.PartialEndOnEarth), time.UTC, false) ticked := 0 for current := first; !current.After(end); current = current.Add(30 * time.Minute) { ticked++ @@ -303,7 +303,7 @@ func BenchmarkSolarEclipseGreatestTimeContours(b *testing.B) { seed := solarEclipseIsochroneTestSeedB(b, 2009, time.July, 22) levels := make([]float64, 0, 6) for index := 0; index < 6; index++ { - levels = append(levels, TD2UT(Date2JDE(time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC).Add(time.Duration(index)*30*time.Minute)), true)) + levels = append(levels, UTC2TT(Date2JD(time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC).Add(time.Duration(index)*30*time.Minute)))) } options := SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, diff --git a/basic/solar_eclipse_local.go b/basic/solar_eclipse_local.go index 9830e2e..6c0a24c 100644 --- a/basic/solar_eclipse_local.go +++ b/basic/solar_eclipse_local.go @@ -7,8 +7,11 @@ import "math" // 所有时刻字段都使用力学时儒略日(JDE, TT)。 // 输入 seedJDE 只需要落在目标朔月附近,允许相差数天。 type LocalSolarEclipseResult struct { - Model SolarEclipseRadiusModel - Type SolarEclipseType + // 下列字段是决定上述数值的口径,随结果一起保留。 + // The fields below are the conventions that fix the numbers above. + Model SolarEclipseRadiusModel + SunRadiusModel SolarEclipseSunRadiusModel + Type SolarEclipseType // GreatestEclipse 是站心盘面中心角距最小的时刻。 GreatestEclipse float64 @@ -91,27 +94,40 @@ const ( // LocalSolarEclipse 计算给定近朔时刻附近的一次站心日食,默认使用 NASA bulletin Split-K 模型。 // // seedJDE 为力学时儒略日(TT),只需落在目标朔月附近,允许相差数天。 -// lon 为经度,东正西负;lat 为纬度,北正南负;height 为海拔高度,单位米。 +// LocalSolarEclipse 计算给定近朔时刻附近的一次站心日食,使用 NASA bulletin Split-K 模型与标准太阳半径。 +// +// lon 为经度,东正西负;lat 为纬度,北正南负;height 为观测点高度,单位米,按椭球高(大地高)解读: +// 只有正高 H 时须由调用方先加上大地水准面差距 N,即 height = H + N;本库不建模 N。 +// LocalSolarEclipse computes one local solar eclipse with the NASA bulletin Split-K model and +// the standard solar radius. height is the ellipsoidal height in metres; convert an orthometric +// height H with height = H + N, since the geoid undulation N is not modelled here. func LocalSolarEclipse(seedJDE, lon, lat, height float64) LocalSolarEclipseResult { return LocalSolarEclipseNASABulletinSplitK(seedJDE, lon, lat, height) } +// LocalSolarEclipseWithOptions 计算给定近朔时刻附近的一次站心日食,半径口径由 options 指定 / computes one local solar eclipse with the given radius conventions. +func LocalSolarEclipseWithOptions(seedJDE, lon, lat, height float64, options SolarEclipseOptions) LocalSolarEclipseResult { + return localSolarEclipse(seedJDE, lon, lat, height, options) +} + // LocalSolarEclipseIAUSingleK 计算给定近朔时刻附近的一次站心日食,使用 IAU Single-K 模型。 func LocalSolarEclipseIAUSingleK(seedJDE, lon, lat, height float64) LocalSolarEclipseResult { - return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseModelIAUSingleK) + return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}) } // LocalSolarEclipseNASABulletinSplitK 计算给定近朔时刻附近的一次站心日食,使用 NASA bulletin Split-K 模型。 func LocalSolarEclipseNASABulletinSplitK(seedJDE, lon, lat, height float64) LocalSolarEclipseResult { - return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseModelNASABulletinSplitK) + return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK}) } -func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model SolarEclipseRadiusModel) LocalSolarEclipseResult { +func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, options SolarEclipseOptions) LocalSolarEclipseResult { newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) lonRad := lonDeg * rad latRad := latDeg * rad heightKM := heightMeters / 1000.0 - params := solarEclipseModelParams(model) + options.RadiusModel = normalizeSolarEclipseRadiusModel(options.RadiusModel) + options.SunRadiusModel = normalizeSolarEclipseSunRadiusModel(options.SunRadiusModel) + params := solarEclipseModelParams(options.RadiusModel, options.SunRadiusModel) greatestEclipseJDE := localSolarEclipseGreatest(newMoonJDE, lonRad, latRad, heightKM, params) state := localSolarEclipseStateAt(greatestEclipseJDE, lonRad, latRad, heightKM, params) @@ -122,7 +138,8 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola } result := LocalSolarEclipseResult{ - Model: model, + Model: options.RadiusModel, + SunRadiusModel: options.SunRadiusModel, Type: SolarEclipseNone, GreatestEclipse: greatestEclipseJDE, Separation: state.separationRad / rad, @@ -181,13 +198,24 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola return result } -func solarEclipseModelParams(model SolarEclipseRadiusModel) solarEclipseModelParameters { - params := solarEclipseModelParameters{ - penumbralK: solarEclipsePenumbralK, - umbralK: solarEclipsePenumbralK, +// solarEclipsePenumbralRadiusNumerator 是半影外半径公式的分子(地球赤道半径 × k1 × 月亮半径尺度)。 +// Split-K 的 k1 是编译期常量,这里保持常量折叠后的末位,使该模型的既有输出逐字节不变;IAU Single-K 只能走运行时值。 +func solarEclipsePenumbralRadiusNumerator(params solarEclipseModelParameters) float64 { + if params.penumbralK == solarEclipsePenumbralK { + return solarEclipseEarthEquatorialRadiusKM * solarEclipsePenumbralK * localSolarMoonRadiusScale } - if model == SolarEclipseModelNASABulletinSplitK { - params.umbralK = solarEclipseUmbralK + return solarEclipseEarthEquatorialRadiusKM * params.penumbralK * localSolarMoonRadiusScale +} + +func solarEclipseModelParams(model SolarEclipseRadiusModel, sunRadiusModel SolarEclipseSunRadiusModel) solarEclipseModelParameters { + params := solarEclipseModelParameters{ + penumbralK: solarEclipsePenumbralK, + umbralK: solarEclipseUmbralK, + sunRadiusRatio: solarEclipseSunRadiusRatio(sunRadiusModel), + } + if model == SolarEclipseModelIAUSingleK { + params.penumbralK = solarEclipseIAUSingleRadiusK + params.umbralK = solarEclipseIAUSingleRadiusK } return params } @@ -218,19 +246,19 @@ func localSolarEclipseGreatestWith( // centralPhaseDurationDaysAt 用事件局部插值星历求解某点的中心相时长(日):先求局部食甚, // 再解本影/反本影的内切接触。没有中心相或接触退化时返回 0。 -func (solver solarEclipseSolver) centralPhaseDurationDaysAt(jd, lonDeg, latDeg float64) float64 { +func (solver solarEclipseSolver) centralPhaseDurationDaysAt(jde, lonDeg, latDeg float64) float64 { solver = solver.withLocalEphemeris() lonRad, latRad := lonDeg*rad, latDeg*rad - stateAt := func(jd float64) localSolarEclipseState { - return solver.localStateContextCandidateAt(jd).stateAt(lonRad, latRad, 0) + stateAt := func(jde float64) localSolarEclipseState { + return solver.localStateContextCandidateAt(jde).stateAt(lonRad, latRad, 0) } greatestJDE := localSolarEclipseGreatestWith(solver.newMoonJDE, stateAt) contactState := stateAt(greatestJDE).movingDiskContactState() if !contactState.valid || contactState.internalContactGap() > 0 { return 0 } - evaluator := newMovingDiskContactEvaluator(func(jd float64) (movingDiskContactState, bool) { - contact := stateAt(jd).movingDiskContactState() + evaluator := newMovingDiskContactEvaluator(func(jde float64) (movingDiskContactState, bool) { + contact := stateAt(jde).movingDiskContactState() return contact, contact.valid }) evaluator.prime(greatestJDE, contactState, true) @@ -311,14 +339,14 @@ func newLocalSolarEclipseStateContextWithOverride( params solarEclipseModelParameters, ) localSolarEclipseStateContext { sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdTT) - utJDE := TD2UT(jdTT, false) + ut1JDE := TT2UT1(jdTT) if !math.IsNaN(deltaTSeconds) { - utJDE = jdTT - deltaTSeconds/86400 + ut1JDE = jdTT - deltaTSeconds/86400 } return localSolarEclipseStateContext{ sunXYZ: solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]), moonXYZ: solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]), - gst: ApparentSiderealTime(utJDE) * 15 * rad, + gst: ApparentSiderealTime(ut1JDE) * 15 * rad, params: params, } } @@ -348,10 +376,10 @@ func (context localSolarEclipseStateContext) stateAt(lonRad, latRad, heightKM fl } sunRadiusRad := math.Asin(localSolarEclipseClampUnit( - solarEclipseEarthEquatorialRadiusKM * solarEclipseSolarRadiusRatio / sunTopocentric[2], + solarEclipseEarthEquatorialRadiusKM * context.params.sunRadiusRatio / sunTopocentric[2], )) moonOuterRadiusRad := math.Asin(localSolarEclipseClampUnit( - solarEclipseEarthEquatorialRadiusKM * solarEclipsePenumbralK * localSolarMoonRadiusScale / moonTopocentric[2], + solarEclipsePenumbralRadiusNumerator(context.params) / moonTopocentric[2], )) moonInnerRadiusRad := math.Asin(localSolarEclipseClampUnit( solarEclipseEarthEquatorialRadiusKM * context.params.umbralK * localSolarMoonRadiusScale / moonTopocentric[2], diff --git a/basic/solar_eclipse_local_test.go b/basic/solar_eclipse_local_test.go index 4622921..2779b37 100644 --- a/basic/solar_eclipse_local_test.go +++ b/basic/solar_eclipse_local_test.go @@ -200,7 +200,7 @@ func TestLocalSolarEclipseVisibleAtGreatestRespectsHeight(t *testing.T) { } func solarEclipseUTToTTJDE(date time.Time) float64 { - return TD2UT(Date2JDE(date.UTC()), true) + return UTC2TT(Date2JD(date.UTC())) } func assertLocalSolarEclipseJDEClose( diff --git a/basic/solar_eclipse_magnitude.go b/basic/solar_eclipse_magnitude.go index 6f3ab32..523bcac 100644 --- a/basic/solar_eclipse_magnitude.go +++ b/basic/solar_eclipse_magnitude.go @@ -526,10 +526,10 @@ func (solver solarEclipseSolver) validMagnitudeArcState( magnitude, referenceJDE float64, iterations int, ) (solarEclipseMagnitudeArcState, int, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale longitude := normalizeLongitude(coordinates[0]) latitude := coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) if math.Abs(solarEclipseMagnitudeAtTarget(state, magnitude)-magnitude) > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { @@ -543,7 +543,7 @@ func (solver solarEclipseSolver) validMagnitudeArcState( coordinates: coordinates, tangent: tangent, point: SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, }, }, iterations, true } @@ -552,10 +552,10 @@ func (solver solarEclipseSolver) magnitudeEnvelopeJacobian( coordinates [3]float64, magnitude, referenceJDE float64, ) ([2]float64, [2][3]float64, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale longitude := normalizeLongitude(coordinates[0]) latitude := coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseMagnitudeEnvelopeResidualAt(evaluation, longitude, latitude, magnitude) if !ok { return [2]float64{}, [2][3]float64{}, false @@ -574,14 +574,14 @@ func (solver solarEclipseSolver) magnitudeEnvelopeJacobian( jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] } } - timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseMagnitudeContourTimeScale) + timeEvaluation := solver.magnitudeEvaluationAt(jde + steps[2]/solarEclipseMagnitudeContourTimeScale) timeResidual, timeOK := solarEclipseMagnitudeEnvelopeResidualAt( timeEvaluation, longitude, latitude, magnitude, ) if !timeOK { return [2]float64{}, [2][3]float64{}, false } - beforeEvaluation := solver.magnitudeEvaluationAt(jd - steps[2]/solarEclipseMagnitudeContourTimeScale) + beforeEvaluation := solver.magnitudeEvaluationAt(jde - steps[2]/solarEclipseMagnitudeContourTimeScale) beforeResidual, beforeOK := solarEclipseMagnitudeEnvelopeResidualAt( beforeEvaluation, longitude, latitude, magnitude, ) @@ -645,14 +645,14 @@ func (solver solarEclipseSolver) refineMagnitudeHorizonCrossing( } func (solver solarEclipseSolver) refineMagnitudeHorizonPoint( - jd, longitude, latitude, magnitude float64, + jde, longitude, latitude, magnitude float64, ) (SolarEclipsePathPoint, bool) { const ( geographicStep = 1e-4 timeStep = 5.0 / 86400.0 ) for iteration := 0; iteration < 24; iteration++ { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseMagnitudeHorizonResidualAt(evaluation, longitude, latitude, magnitude) if !ok { return SolarEclipsePathPoint{}, false @@ -666,7 +666,7 @@ func (solver solarEclipseSolver) refineMagnitudeHorizonPoint( latitudeResidual, latOK := solarEclipseMagnitudeHorizonResidualAt( evaluation, longitude, latitude+geographicStep, magnitude, ) - timeResidual, timeOK := solver.magnitudeHorizonResidual(jd+timeStep, longitude, latitude, magnitude) + timeResidual, timeOK := solver.magnitudeHorizonResidual(jde+timeStep, longitude, latitude, magnitude) if !lonOK || !latOK || !timeOK { return SolarEclipsePathPoint{}, false } @@ -690,49 +690,49 @@ func (solver solarEclipseSolver) refineMagnitudeHorizonPoint( } longitude = normalizeLongitude(longitude + delta[0]) latitude += delta[1] - jd += delta[2] + jde += delta[2] } - residual, ok := solver.magnitudeHorizonResidual(jd, longitude, latitude, magnitude) + residual, ok := solver.magnitudeHorizonResidual(jde, longitude, latitude, magnitude) if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { return SolarEclipsePathPoint{}, false } - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) if evaluation.separationSecondDerivative(longitude, latitude) <= 0 { return SolarEclipsePathPoint{}, false } return SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, }, true } -func (solver solarEclipseSolver) magnitudeEvaluationAt(jd float64) solarEclipseRiseSetEvaluation { +func (solver solarEclipseSolver) magnitudeEvaluationAt(jde float64) solarEclipseRiseSetEvaluation { return solarEclipseRiseSetEvaluation{ - jd: jd, - center: solver.localStateContextAt(jd), - before: solver.localStateContextAt(jd - solarEclipseRiseSetDerivativeStepDays), - after: solver.localStateContextAt(jd + solarEclipseRiseSetDerivativeStepDays), + jd: jde, + center: solver.localStateContextAt(jde), + before: solver.localStateContextAt(jde - solarEclipseRiseSetDerivativeStepDays), + after: solver.localStateContextAt(jde + solarEclipseRiseSetDerivativeStepDays), } } -func (solver solarEclipseSolver) magnitudeCandidateEvaluationAt(jd float64) solarEclipseRiseSetEvaluation { +func (solver solarEclipseSolver) magnitudeCandidateEvaluationAt(jde float64) solarEclipseRiseSetEvaluation { return solarEclipseRiseSetEvaluation{ - jd: jd, - center: solver.localStateContextCandidateAt(jd), - before: solver.localStateContextCandidateAt(jd - solarEclipseRiseSetDerivativeStepDays), - after: solver.localStateContextCandidateAt(jd + solarEclipseRiseSetDerivativeStepDays), + jd: jde, + center: solver.localStateContextCandidateAt(jde), + before: solver.localStateContextCandidateAt(jde - solarEclipseRiseSetDerivativeStepDays), + after: solver.localStateContextCandidateAt(jde + solarEclipseRiseSetDerivativeStepDays), } } -func (solver solarEclipseSolver) localStateContextAt(jd float64) localSolarEclipseStateContext { +func (solver solarEclipseSolver) localStateContextAt(jde float64) localSolarEclipseStateContext { if solver.localStateContextCache == nil { - return newLocalSolarEclipseStateContextWithOverride(jd, solver.deltaTSeconds, solver.params) + return newLocalSolarEclipseStateContextWithOverride(jde, solver.deltaTSeconds, solver.params) } - key := math.Float64bits(jd) + key := math.Float64bits(jde) if context, ok := solver.localStateContextCache[key]; ok && context.generation == deltaTGenerationValue() { return context } - context := newLocalSolarEclipseStateContextWithOverride(jd, solver.deltaTSeconds, solver.params) + context := newLocalSolarEclipseStateContextWithOverride(jde, solver.deltaTSeconds, solver.params) return storeLocalSolarEclipseStateContext(solver.localStateContextCache, key, context) } @@ -755,26 +755,26 @@ func storeLocalSolarEclipseStateContext( return context } -func (solver solarEclipseSolver) localStateContextCandidateAt(jd float64) localSolarEclipseStateContext { +func (solver solarEclipseSolver) localStateContextCandidateAt(jde float64) localSolarEclipseStateContext { if solver.localEphemeris == nil { - return solver.localStateContextAt(jd) + return solver.localStateContextAt(jde) } - sun, moon, ok := solver.localEphemeris.equatorialAt(jd) + sun, moon, ok := solver.localEphemeris.equatorialAt(jde) if !ok { - return solver.localStateContextAt(jd) + return solver.localStateContextAt(jde) } return localSolarEclipseStateContext{ sunXYZ: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]), moonXYZ: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]), - gst: solver.siderealTimeAt(jd), + gst: solver.siderealTimeAt(jde), params: solver.params, } } func (solver solarEclipseSolver) magnitudeHorizonResidual( - jd, longitude, latitude, magnitude float64, + jde, longitude, latitude, magnitude float64, ) ([3]float64, bool) { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) return solarEclipseMagnitudeHorizonResidualAt(evaluation, longitude, latitude, magnitude) } @@ -829,13 +829,13 @@ func solveSolarEclipse3x3(matrix [3][3]float64, right [3]float64) ([3]float64, b return result, true } -func (solver solarEclipseSolver) magnitudeContourPointsAt(jd, magnitude float64) []SolarEclipsePathPoint { - moon := solver.besselMoonAt(jd) - axis := solver.besselAxisAt(jd) - evaluation := solver.magnitudeEvaluationAt(jd) +func (solver solarEclipseSolver) magnitudeContourPointsAt(jde, magnitude float64) []SolarEclipsePathPoint { + moon := solver.besselMoonAt(jde) + axis := solver.besselAxisAt(jde) + evaluation := solver.magnitudeEvaluationAt(jde) valueAt := func(angle float64) (float64, bool) { point, ok := solver.magnitudeContourPointAt( - jd, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, + jde, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, ) if !ok { return 0, false @@ -845,7 +845,7 @@ func (solver solarEclipseSolver) magnitudeContourPointsAt(jd, magnitude float64) points := make([]SolarEclipsePathPoint, 0, 2) for _, angle := range riseSetCyclicRoots(solarEclipseMagnitudeContourBoundaryPoints, valueAt) { seed, ok := solver.magnitudeContourPointAt( - jd, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, + jde, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, ) if !ok { continue @@ -861,27 +861,27 @@ func (solver solarEclipseSolver) magnitudeContourPointsAt(jd, magnitude float64) continue } point := SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, } if !solarEclipseRiseSetPointExists(points, point) { points = append(points, point) } } if magnitude == 1 && len(points) < 2 { - points = solver.appendMagnitudeOneLimitSeeds(points, jd, evaluation) + points = solver.appendMagnitudeOneLimitSeeds(points, jde, evaluation) } if len(points) == 0 { - points = solver.magnitudeContourGeographicSeedsAt(jd, magnitude) + points = solver.magnitudeContourGeographicSeedsAt(jde, magnitude) } return points } func (solver solarEclipseSolver) appendMagnitudeOneLimitSeeds( points []SolarEclipsePathPoint, - jd float64, + jde float64, evaluation solarEclipseRiseSetEvaluation, ) []SolarEclipsePathPoint { - center, ok := solver.centralPathPointAt(jd) + center, ok := solver.centralPathPointAt(jde) if !ok { return points } @@ -901,7 +901,7 @@ func (solver solarEclipseSolver) appendMagnitudeOneLimitSeeds( continue } candidate := SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, } if !solarEclipseRiseSetPointExists(points, candidate) { points = append(points, candidate) @@ -916,8 +916,8 @@ func (solver solarEclipseSolver) appendMagnitudeOneLimitSeeds( // total/annular transition. Solving the local-magnitude envelope from the // greatest point keeps those contours available without changing the normal // Bessel path. -func (solver solarEclipseSolver) magnitudeContourGeographicSeedsAt(jd, magnitude float64) []SolarEclipsePathPoint { - center, ok := solver.centralPathPointAt(jd) +func (solver solarEclipseSolver) magnitudeContourGeographicSeedsAt(jde, magnitude float64) []SolarEclipsePathPoint { + center, ok := solver.centralPathPointAt(jde) if !ok { // A non-central eclipse has no Earth-intersecting shadow axis, but its // local maximum still has a well-defined geographic stationary point. @@ -929,12 +929,12 @@ func (solver solarEclipseSolver) magnitudeContourGeographicSeedsAt(jd, magnitude return nil } center = SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: result.GreatestLongitude, Latitude: result.GreatestLatitude, } } - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) centerState := evaluation.center.stateAt(center.Longitude*rad, center.Latitude*rad, 0) maximum := solarEclipseMagnitudeAtTarget(centerState, magnitude) if !finite(maximum) || maximum <= magnitude+1e-9 { @@ -1020,7 +1020,7 @@ func (solver solarEclipseSolver) magnitudeContourGeographicSeedsAt(jd, magnitude if refined { state := evaluation.center.stateAt(seedLongitude*rad, seedLatitude*rad, 0) candidate := SolarEclipsePathPoint{ - JDE: jd, Longitude: seedLongitude, Latitude: seedLatitude, + JDE: jde, Longitude: seedLongitude, Latitude: seedLatitude, SunAltitude: state.sunAltitudeRad / rad, } if !solarEclipseRiseSetPointExists(seeds, candidate) { @@ -1103,14 +1103,14 @@ func solarEclipseMagnitudeAtTarget(state localSolarEclipseState, target float64) } func (solver solarEclipseSolver) magnitudeContourPointAt( - jd float64, + jde float64, moon [3]float64, axis solarEclipseAxis, directionX, directionY, magnitude float64, ) (SolarEclipsePathPoint, bool) { if magnitude == 0 { - _, _, sun := solver.besselGeometryAt(jd) - return solver.shadowFootprintPointAt(jd, moon, axis, sun, math.Atan2(directionY, directionX), solarEclipsePenumbralShadow) + _, _, sun := solver.besselGeometryAt(jde) + return solver.shadowFootprintPointAt(jde, moon, axis, sun, math.Atan2(directionY, directionX), solarEclipsePenumbralShadow) } radii := solver.shadowRadiiAt(moon[2]) radius := solarEclipseMagnitudeContourRadius(radii, magnitude) @@ -1160,9 +1160,9 @@ func (solver solarEclipseSolver) magnitudeContourPointAt( return SolarEclipsePathPoint{}, false } longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) return SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: sunAltitudeRad / rad, diff --git a/basic/solar_eclipse_noncentral_band.go b/basic/solar_eclipse_noncentral_band.go index a2f3dc0..bc18803 100644 --- a/basic/solar_eclipse_noncentral_band.go +++ b/basic/solar_eclipse_noncentral_band.go @@ -441,12 +441,12 @@ func (solver solarEclipseSolver) appendRefinedSolarEclipseCentralHorizonSegment( depth >= 16 || end.JDE-start.JDE <= solarEclipsePathMinStepDays { return append(points, end) } - jd := (start.JDE + end.JDE) / 2 + jde := (start.JDE + end.JDE) / 2 longitude := normalizeLongitude( start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2, ) latitude := (start.Latitude + end.Latitude) / 2 - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) longitude, latitude, ok := riseSetRefineGeographicRoot( longitude, latitude, @@ -465,7 +465,7 @@ func (solver solarEclipseSolver) appendRefinedSolarEclipseCentralHorizonSegment( return append(points, end) } middle := SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, } points = solver.appendRefinedSolarEclipseCentralHorizonSegment(points, start, middle, depth+1) return solver.appendRefinedSolarEclipseCentralHorizonSegment(points, middle, end, depth+1) @@ -558,9 +558,9 @@ func (solver solarEclipseSolver) validNonCentralBandState( referenceJDE float64, iterations int, ) (solarEclipseNonCentralBandState, int, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) if math.Abs(solarEclipseCentralContactGap(state)) > 1e-6 || math.Abs(evaluation.centralContactDerivative(longitude, latitude)) > @@ -576,7 +576,7 @@ func (solver solarEclipseSolver) validNonCentralBandState( coordinates: coordinates, tangent: tangent, point: SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, }, }, iterations, true } @@ -585,9 +585,9 @@ func (solver solarEclipseSolver) nonCentralBandBoundaryJacobian( coordinates [3]float64, referenceJDE float64, ) ([2]float64, [2][3]float64, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseNonCentralBandBoundaryResidualAt(evaluation, longitude, latitude) if !ok { return [2]float64{}, [2][3]float64{}, false @@ -605,7 +605,7 @@ func (solver solarEclipseSolver) nonCentralBandBoundaryJacobian( jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] } } - timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseNonCentralBandTimeScale) + timeEvaluation := solver.magnitudeEvaluationAt(jde + steps[2]/solarEclipseNonCentralBandTimeScale) timeResidual, timeOK := solarEclipseNonCentralBandBoundaryResidualAt( timeEvaluation, longitude, latitude, ) @@ -668,13 +668,13 @@ func (solver solarEclipseSolver) refineNonCentralBandHorizonGapCrossing( best, bestGap = second, math.Abs(secondGap) } for iteration := 0; iteration < 64; iteration++ { - jd := (first.JDE + second.JDE) / 2 - fraction := (jd - first.JDE) / (second.JDE - first.JDE) + jde := (first.JDE + second.JDE) / 2 + fraction := (jde - first.JDE) / (second.JDE - first.JDE) longitude := normalizeLongitude( first.Longitude + fraction*math.Remainder(second.Longitude-first.Longitude, 360), ) latitude := first.Latitude + fraction*(second.Latitude-first.Latitude) - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) longitude, latitude, ok := riseSetRefineGeographicRoot( longitude, latitude, @@ -690,7 +690,7 @@ func (solver solarEclipseSolver) refineNonCentralBandHorizonGapCrossing( state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) middleGap := solarEclipseCentralContactGap(state) middle := SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, } if math.Abs(middleGap) < bestGap { best, bestGap = middle, math.Abs(middleGap) diff --git a/basic/solar_eclipse_path.go b/basic/solar_eclipse_path.go index 82dcf5f..3661ec5 100644 --- a/basic/solar_eclipse_path.go +++ b/basic/solar_eclipse_path.go @@ -6,6 +6,8 @@ import ( "time" ) +//哦~我是一颗小地球~~ + const ( solarEclipsePathDefaultStepDays = 1.0 / 1440.0 solarEclipsePathMinStepDays = 1.0 / 86400.0 @@ -105,6 +107,9 @@ type SolarEclipsePathOptions struct { // DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide // model. The path and the instantaneous footprints of one figure must share it. DeltaTSeconds float64 + // SunRadiusModel 太阳半径口径,零值为标准档。 + // SunRadiusModel is the solar radius convention; the zero value is the standard one. + SunRadiusModel SolarEclipseSunRadiusModel // SkipCentralBand 表示调用方已经持有同一场日食的完整足迹结果(其中包含 // CentralBandSegments),本次只求解中心线、南北限界与地平线端点,不重复重建中心食带。 // 单场日食的中心带足迹是整条链路里最贵的一段,同时取足迹与路径时重复计算会翻倍。 @@ -138,6 +143,13 @@ type SolarEclipsePathPoint struct { type SolarEclipsePathResult struct { // Eclipse 是对应的全局日食结果, related global solar eclipse result. Eclipse SolarEclipseResult + // PathWidthDefined 表示本结果的带宽是否有定义:两限存在且上下两条限界线都非空时才为 true; + // 为 false 时 Eclipse.PathWidthKM 与 Greatest.WidthKM 都是 0,调用方引用带宽前必须先看这里。 + // PathWidthDefined reports whether this result has a defined band width: both + // limits must exist and both limit lines must be non-empty. When it is false, + // Eclipse.PathWidthKM and Greatest.WidthKM are 0 and callers must check this + // flag before quoting a width. + PathWidthDefined bool // Greatest 是食甚点/最佳观测点, greatest eclipse point. Greatest SolarEclipsePathPoint // MaxCentralDurationDays 是中心线上最长的中心食时长(单位为日),并给出其发生位置。 @@ -209,6 +221,9 @@ type SolarEclipsePartialFootprintOptions struct { // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型;与单时刻阴影层同口径。 // DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide model. DeltaTSeconds float64 + // SunRadiusModel 太阳半径口径,零值为标准档。 + // SunRadiusModel is the solar radius convention; the zero value is the standard one. + SunRadiusModel SolarEclipseSunRadiusModel } // SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。 @@ -407,7 +422,10 @@ func SolarEclipsePartialAreaNASABulletinSplitK(seedJDE float64, options SolarEcl func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePathOptions) SolarEclipsePathResult { options = normalizeSolarEclipsePathOptions(options) newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds) + solver := newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{ + RadiusModel: model, + SunRadiusModel: options.SunRadiusModel, + }).withDeltaTSeconds(options.DeltaTSeconds) result := solver.eclipseResult() path := SolarEclipsePathResult{ Eclipse: result, @@ -505,6 +523,16 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt result.CentralEndOnEarth, solarEclipseCentralLimitTargetSpacingKM, ) + path.PathWidthDefined = result.Centrality == SolarEclipseCentralTwoLimits && + len(path.NorthernLimit) > 0 && len(path.SouthernLimit) > 0 + if !path.PathWidthDefined { + // 泛化到路径层:单侧极限只解出一侧限界,成对横截面凑不齐的事件连限界线都为空, + // 两者的解析带宽同样无定义,与中心线逐点宽度一并置 0,避免发散值从路径接口外泄。 + // 后一种情形在当前扫描范围内不可达(1000–3000 年 4773 场、1800–2200 年 584 场 two_limits 均未触发),只是防御。 + path.Eclipse.PathWidthKM = 0 + path.Eclipse.PathWidthDefined = false + path.Greatest.WidthKM = 0 + } if options.SkipCentralBand { return path } @@ -556,7 +584,10 @@ func solarEclipsePartialFootprints( options SolarEclipsePartialFootprintOptions, ) SolarEclipsePartialFootprintsResult { return solarEclipsePartialFootprintsWithResult( - seedJDE, model, options, solarEclipseWithDeltaT(seedJDE, model, options.DeltaTSeconds), + seedJDE, model, options, solarEclipseWithDeltaT(seedJDE, SolarEclipseOptions{ + RadiusModel: model, + SunRadiusModel: options.SunRadiusModel, + }, options.DeltaTSeconds), ) } @@ -578,7 +609,10 @@ func solarEclipsePartialFootprintsWithResult( } newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds) + solver := newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{ + RadiusModel: model, + SunRadiusModel: options.SunRadiusModel, + }).withDeltaTSeconds(options.DeltaTSeconds) footprintsResult, totalMagnitudeOneSegments := solver.centralBandWithResult(result, options) // Partial and central-shadow sweeps used to enforce the point budget // independently. A high-resolution request could therefore allocate @@ -1059,6 +1093,7 @@ func solarEclipseCentralBandClosureSides( // centralBandHorizonClosureForSide 解出中心带一端的地平闭合弧;三种食型各有一条取根链, // 取不到唯一一对根就放弃该端(整条解析包络随之放弃)。 +// 扫描快路径让 4862-09-28 从采样带改判为解析带:闭包是几何真解,且解析带已通过采样足迹包含审计。 func (solver solarEclipseSolver) centralBandHorizonClosureForSide( result SolarEclipseResult, footprintsResult SolarEclipsePartialFootprintsResult, @@ -1080,6 +1115,17 @@ func (solver solarEclipseSolver) centralBandHorizonClosureForSide( firstRoot, lastRoot, ok = solver.centralBandVectorHorizonRoots( side.axisContactJDE, side.direction, side.shadowContactJDE, side.innerContactJDE, ) + } else if scanned := solver.centralLimitHorizonRootsByScan( + side.shadowContactJDE, side.innerContactJDE, + ); len(scanned) == 2 { + // 扫描式枚举直接从闭包条件解出这一对根:它不依赖种子,因此采样分支恰好终止在 + // 根上的掠地事件(1136-06-01)也不会漏根,代价是常数次星历求值。三种子牛顿链 + // 只在扫描凑不齐一对时兜底——那种情形(1552-07-21)本来就该退回采样带。 + firstRoot, lastRoot, ok = scanned[0], scanned[1], true + seededDirection = solarEclipseNearestGreatestDirection(riseSetCurves, firstRoot) + if seededDirection == "" { + seededDirection = solarEclipseNearestGreatestDirection(riseSetCurves, lastRoot) + } } else { // A grazing closure arc routinely splits its two endpoints // between the seeding paths: one sits outside the sampled diff --git a/basic/solar_eclipse_path_geometry.go b/basic/solar_eclipse_path_geometry.go index 680b337..ef4b7a0 100644 --- a/basic/solar_eclipse_path_geometry.go +++ b/basic/solar_eclipse_path_geometry.go @@ -210,9 +210,9 @@ func (solver solarEclipseSolver) appendRefinedCentralPathSegment( return solver.appendRefinedCentralPathSegment(points, mid, end, targetSpacingKM, depth+1) } -func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePathPoint, bool) { - moon := solver.besselMoonAt(jd) - axis := solver.besselAxisAt(jd) +func (solver solarEclipseSolver) centralPathPointAt(jde float64) (SolarEclipsePathPoint, bool) { + moon := solver.besselMoonAt(jde) + axis := solver.besselAxisAt(jde) intersection := solarEclipseLineEar2( moon[0], moon[1], @@ -229,7 +229,7 @@ func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePat } longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) radii := solver.shadowRadiiAt(moon[2] - intersection.r2) widthKM := 0.0 @@ -238,7 +238,7 @@ func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePat } return SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: sunAltitudeRad / rad, @@ -456,14 +456,14 @@ func (solver solarEclipseSolver) besselVelocityXYAt(jd float64) (float64, float6 return vx, vy, math.Hypot(vx, vy) } -func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) { +func solarEclipsePathPointFromBesselXY(jde, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) { longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true) if !ok { return SolarEclipsePathPoint{}, false } - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) return SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: sunAltitudeRad / rad, @@ -534,14 +534,14 @@ func (solver solarEclipseSolver) shadowContactRoot( } func (solver solarEclipseSolver) shadowContactResidual( - jd float64, + jde float64, kind solarEclipseShadowKind, internal bool, ) (float64, bool) { if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal { - return solver.shadowContactResidualExact(jd) + return solver.shadowContactResidualExact(jde) } - moon := solver.besselMoonAt(jd) + moon := solver.besselMoonAt(jde) distanceSquared := moon[0]*moon[0] + moon[1]*moon[1] if distanceSquared <= 0 { return 0, false @@ -561,18 +561,18 @@ func (solver solarEclipseSolver) shadowContactResidual( return math.Sqrt(distanceSquared) - limit, true } -func (solver solarEclipseSolver) shadowContactResidualExact(jd float64) (float64, bool) { - _, value, ok := solver.shadowContactMaximum(jd) +func (solver solarEclipseSolver) shadowContactResidualExact(jde float64) (float64, bool) { + _, value, ok := solver.shadowContactMaximum(jde) return -value, ok } -func (solver solarEclipseSolver) shadowContactMaximum(jd float64) (float64, float64, bool) { +func (solver solarEclipseSolver) shadowContactMaximum(jde float64) (float64, float64, bool) { const samples = 32 step := 2 * math.Pi / samples bestIndex := -1 bestValue := math.Inf(-1) for index := 0; index < samples; index++ { - value, ok := solver.shadowBoundaryDiscriminant(jd, float64(index)*step) + value, ok := solver.shadowBoundaryDiscriminant(jde, float64(index)*step) if !ok { continue } @@ -586,7 +586,7 @@ func (solver solarEclipseSolver) shadowContactMaximum(jd float64) (float64, floa left := float64(bestIndex)*step - step right := float64(bestIndex)*step + step valueAt := func(angle float64) float64 { - value, ok := solver.shadowBoundaryDiscriminant(jd, angle) + value, ok := solver.shadowBoundaryDiscriminant(jde, angle) if !ok { return math.Inf(-1) } @@ -619,8 +619,8 @@ func (solver solarEclipseSolver) shadowContactMaximum(jd float64) (float64, floa return bestAngle, bestValue, true } -func (solver solarEclipseSolver) shadowBoundaryDiscriminant(jd, angle float64) (float64, bool) { - moon, axis, _ := solver.besselGeometryAt(jd) +func (solver solarEclipseSolver) shadowBoundaryDiscriminant(jde, angle float64) (float64, bool) { + moon, axis, _ := solver.besselGeometryAt(jde) radius := solver.shadowRadiusAt(moon[2], solarEclipseCentralShadow) if radius <= 0 { return 0, false @@ -674,37 +674,37 @@ func solarEclipseLineEllipsoidDiscriminant( } func (solver solarEclipseSolver) shadowContactPointAt( - jd float64, + jde float64, kind solarEclipseShadowKind, internal bool, ) (SolarEclipsePathPoint, bool) { if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal { - angle, _, ok := solver.shadowContactMaximum(jd) + angle, _, ok := solver.shadowContactMaximum(jde) if !ok { return SolarEclipsePathPoint{}, false } - if point, pointOK := solver.centralShadowPointAt(jd, angle); pointOK { + if point, pointOK := solver.centralShadowPointAt(jde, angle); pointOK { return point, true } for _, offset := range []float64{-1e-8, 1e-8, -1e-7, 1e-7} { - offsetJDE := jd + offset + offsetJDE := jde + offset offsetAngle, _, maximumOK := solver.shadowContactMaximum(offsetJDE) if !maximumOK { continue } if point, pointOK := solver.centralShadowPointAt(offsetJDE, offsetAngle); pointOK { - point.JDE = jd + point.JDE = jde return point, true } } return SolarEclipsePathPoint{}, false } - moon := solver.besselMoonAt(jd) + moon := solver.besselMoonAt(jde) distance := math.Hypot(moon[0], moon[1]) if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 { return SolarEclipsePathPoint{}, false } - axis := solver.besselAxisAt(jd) + axis := solver.besselAxisAt(jde) unitX, unitY := moon[0]/distance, moon[1]/distance insideScale, outsideScale := 0.0, 1.1 var intersection solarEclipseLineIntersection @@ -726,9 +726,9 @@ func (solver solarEclipseSolver) shadowContactPointAt( return SolarEclipsePathPoint{}, false } longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) return SolarEclipsePathPoint{ - JDE: jd, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: sunAltitudeRad / rad, @@ -743,27 +743,27 @@ func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarE return radii.penumbraRadius } -func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints int) SolarEclipsePartialFootprint { - return solver.shadowFootprintAt(jd, boundaryPoints, solarEclipsePenumbralShadow) +func (solver solarEclipseSolver) partialFootprintAt(jde float64, boundaryPoints int) SolarEclipsePartialFootprint { + return solver.shadowFootprintAt(jde, boundaryPoints, solarEclipsePenumbralShadow) } func (solver solarEclipseSolver) shadowFootprintAt( - jd float64, + jde float64, boundaryPoints int, kind solarEclipseShadowKind, ) SolarEclipsePartialFootprint { - return solver.shadowFootprintAtWithSpacing(jd, boundaryPoints, kind, 0) + return solver.shadowFootprintAtWithSpacing(jde, boundaryPoints, kind, 0) } func (solver solarEclipseSolver) shadowFootprintAtWithSpacing( - jd float64, + jde float64, boundaryPoints int, kind solarEclipseShadowKind, targetSpacingKM float64, ) SolarEclipsePartialFootprint { - moon, axis, sun := solver.besselGeometryAt(jd) + moon, axis, sun := solver.besselGeometryAt(jde) return solver.shadowFootprintAtWithGeometry( - jd, moon, axis, sun, boundaryPoints, kind, targetSpacingKM, + jde, moon, axis, sun, boundaryPoints, kind, targetSpacingKM, ) } diff --git a/basic/solar_eclipse_path_test.go b/basic/solar_eclipse_path_test.go index b669e3a..b54d425 100644 --- a/basic/solar_eclipse_path_test.go +++ b/basic/solar_eclipse_path_test.go @@ -8,7 +8,7 @@ import ( ) func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) { - seedJDE := JDECalc(2024, 4, 8) + seedJDE := JDCalc(2024, 4, 8) global := SolarEclipse(seedJDE) path := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{StepDays: 5.0 / 1440.0}) @@ -43,7 +43,7 @@ func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) { } func TestSolarEclipseCentralPathTargetSpacingRefinesSamples(t *testing.T) { - seedJDE := JDECalc(2024, 4, 8) + seedJDE := JDCalc(2024, 4, 8) coarse := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{StepDays: 20.0 / 1440.0}) refined := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{ StepDays: 20.0 / 1440.0, @@ -65,7 +65,7 @@ func TestSolarEclipseCentralPathTargetSpacingRefinesSamples(t *testing.T) { } func TestSolarEclipseCentralPathAdaptiveCurvature2543(t *testing.T) { - seed := JDECalc(2543, 10, 29) + seed := JDCalc(2543, 10, 29) path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ StepDays: 20.0 / 1440.0, TargetSpacingKM: 700, @@ -96,7 +96,7 @@ func TestSolarEclipseCentralPathAdaptiveCurvature2543(t *testing.T) { } func TestSolarEclipseCentralPathLimitsKeepValidBoundarySamples(t *testing.T) { - path := SolarEclipseCentralPath(JDECalc(2008, 8, 1), SolarEclipsePathOptions{ + path := SolarEclipseCentralPath(JDCalc(2008, 8, 1), SolarEclipsePathOptions{ StepDays: 2.0 / 1440.0, }) if len(path.CenterLine) < 3 || len(path.NorthernLimit) < 3 || len(path.NorthernLimit) != len(path.SouthernLimit) { @@ -121,7 +121,7 @@ func TestSolarEclipseCentralPathLimitsKeepValidBoundarySamples(t *testing.T) { } func TestSolarEclipseCentralPathLimitsRemainStrictlyOrdered19851101(t *testing.T) { - path := SolarEclipseCentralPath(JDECalc(1985, 11, 1), SolarEclipsePathOptions{ + path := SolarEclipseCentralPath(JDCalc(1985, 11, 1), SolarEclipsePathOptions{ StepDays: 10.0 / 1440.0, }) if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || @@ -148,7 +148,7 @@ func TestSolarEclipseCentralPathLimitsRemainStrictlyOrdered19851101(t *testing.T } func TestSolarEclipseRiseSetRawTopologyAvoidsPolarTraceExplosion(t *testing.T) { - seed := JDECalc(2309, 6, 9) + seed := JDCalc(2309, 6, 9) result := SolarEclipse(seed) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) curves, junctions, actualStep := solver.sampleRiseSetCurves( @@ -172,7 +172,7 @@ func TestSolarEclipseNonCentralGreatestHorizonFoldsRemainTimedSegments(t *testin } { date := date t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 2.0 / 1440.0, }) if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseNonCentral { @@ -212,7 +212,7 @@ func TestSolarEclipseNonCentralGreatestHorizonFoldsRemainTimedSegments(t *testin } func TestSolarEclipseCentralPathPartialHasNoCenterLine(t *testing.T) { - path := SolarEclipseCentralPath(JDECalc(2025, 3, 29), SolarEclipsePathOptions{}) + path := SolarEclipseCentralPath(JDCalc(2025, 3, 29), SolarEclipsePathOptions{}) if path.Eclipse.Type != SolarEclipsePartial { t.Fatalf("unexpected eclipse type: got %s want %s", path.Eclipse.Type, SolarEclipsePartial) @@ -231,7 +231,7 @@ func TestSolarEclipseCentralPathPartialHasNoCenterLine(t *testing.T) { } func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) { - seedJDE := JDECalc(2024, 4, 8) + seedJDE := JDCalc(2024, 4, 8) global := SolarEclipse(seedJDE) footprints := SolarEclipsePartialFootprints(seedJDE, SolarEclipsePartialFootprintOptions{ StepDays: 30.0 / 1440.0, @@ -283,7 +283,7 @@ func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) { } func TestSolarEclipsePartialFootprintBoundarySpacing20431003(t *testing.T) { - seedJDE := JDECalc(2043, 10, 3) + seedJDE := JDCalc(2043, 10, 3) global := SolarEclipse(seedJDE) solver := newSolarEclipseSolver( CalcMoonSHByJDE(seedJDE, 0), @@ -350,7 +350,7 @@ func TestSolarEclipseMagnitudeContourInputHasBoundedCardinality(t *testing.T) { } func TestSolarEclipsePartialBandContoursMeetHorizonFootprintTracks(t *testing.T) { - seed := JDECalc(2009, 7, 22) + seed := JDCalc(2009, 7, 22) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, }) @@ -389,7 +389,7 @@ func TestSolarEclipsePartialBandContoursMeetHorizonFootprintTracks(t *testing.T) } func TestSolarEclipseDisableRiseSetAlsoSkipsPartialBandTopology(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, }) if len(result.RiseSetCurves) != 0 || len(result.PartialBandContours) != 0 { @@ -399,7 +399,7 @@ func TestSolarEclipseDisableRiseSetAlsoSkipsPartialBandTopology(t *testing.T) { } func TestSolarEclipseMagnitudeContoursIncludeNonCentralAnnularBand(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2014, 4, 29), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2014, 4, 29), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, BoundaryPoints: 24, CentralShadowStepDays: 2.0 / 1440.0, @@ -433,7 +433,7 @@ func TestSolarEclipseMagnitudeContoursIncludeNonCentralAnnularBand(t *testing.T) } func TestSolarEclipseMagnitudeContoursAllowTotalityValuesAboveOne(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1.01}, }) if len(result.MagnitudeContours) != 1 || result.MagnitudeContours[0].Magnitude != 1.01 { @@ -448,8 +448,8 @@ func TestSolarEclipseMagnitudeContoursCoverHybridAndDeepTotalValues(t *testing.T values []float64 minimumSegmentLen int }{ - {name: "2023 hybrid", seed: JDECalc(2023, 4, 20), values: []float64{1.005, 1.01, 1.012}, minimumSegmentLen: 2}, - {name: "2035 total", seed: JDECalc(2035, 9, 2), values: []float64{1.001, 1.01, 1.02}, minimumSegmentLen: 2}, + {name: "2023 hybrid", seed: JDCalc(2023, 4, 20), values: []float64{1.005, 1.01, 1.012}, minimumSegmentLen: 2}, + {name: "2035 total", seed: JDCalc(2035, 9, 2), values: []float64{1.001, 1.01, 1.02}, minimumSegmentLen: 2}, } { result := SolarEclipsePartialFootprints(test.seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: test.values, DisableRiseSetCurves: true, @@ -494,7 +494,7 @@ func TestSolarEclipseHybridMagnitudeOneContoursMeetCenterLineTransitions(t *test } { name := fmt.Sprintf("%04d-%02d-%02d", test.year, test.month, test.day) t.Run(name, func(t *testing.T) { - seed := JDECalc(test.year, test.month, float64(test.day)) + seed := JDCalc(test.year, test.month, float64(test.day)) partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true, }) @@ -539,7 +539,7 @@ func TestSolarEclipseHybridMagnitudeOneContoursMeetCenterLineTransitions(t *test } func TestSolarEclipseHybridMagnitudeOneContoursKeepTheirCentralPathSide11440703(t *testing.T) { - seed := JDECalc(1144, 7, 3) + seed := JDCalc(1144, 7, 3) partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true, }) @@ -575,7 +575,7 @@ func TestSolarEclipseMagnitudeContoursMatchLocalMaximumMagnitude(t *testing.T) { {2024, 4, 8, 0.2}, } for _, test := range tests { - seed := JDECalc(test.year, test.month, float64(test.day)) + seed := JDCalc(test.year, test.month, float64(test.day)) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude}, }) @@ -600,7 +600,7 @@ func TestSolarEclipseMagnitudeContoursMatchLocalMaximumMagnitude(t *testing.T) { } func TestSolarEclipseMagnitudeContoursReachGreatestRiseSetBoundary(t *testing.T) { - seed := JDECalc(2031, 5, 21) + seed := JDCalc(2031, 5, 21) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{0.8}, }) @@ -654,7 +654,7 @@ func TestSolarEclipseMagnitudeContourEndpointsAcrossEclipseTypes(t *testing.T) { {2025, 3, 29, 0.2}, } for _, test := range tests { - seed := JDECalc(test.year, test.month, float64(test.day)) + seed := JDCalc(test.year, test.month, float64(test.day)) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude}, }) @@ -673,7 +673,7 @@ func TestSolarEclipseMagnitudeContourEndpointsAcrossEclipseTypes(t *testing.T) { } func TestSolarEclipseTotalMagnitudeOneContoursMeetHorizonClosures20260812(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2026, 8, 12), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2026, 8, 12), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, MagnitudeValues: []float64{1}, }) @@ -709,7 +709,7 @@ func TestSolarEclipseTotalMagnitudeOneContoursMeetHorizonClosures20260812(t *tes } func TestSolarEclipseRiseSetJunctionsRemainConnected20100115(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ StepDays: 10.0 / 1440.0, BoundaryPoints: 180, }) if len(result.RiseSetCurves) != 6 { @@ -738,7 +738,7 @@ func TestSolarEclipseRiseSetJunctionsRemainConnected20100115(t *testing.T) { func TestSolarEclipseCentralPathLimitsIncludeExternalContacts20100115(t *testing.T) { path := SolarEclipseCentralPath( - JDECalc(2010, 1, 15), + JDCalc(2010, 1, 15), SolarEclipsePathOptions{StepDays: 10.0 / 1440.0, TargetSpacingKM: 100}, ) if len(path.NorthernLimit) < 2 || len(path.NorthernLimit) != len(path.SouthernLimit) { @@ -759,7 +759,7 @@ func TestSolarEclipseCentralPathLimitsIncludeExternalContacts20100115(t *testing } func TestSolarEclipseCentralPathMeetsGreatestSetCurveAtExactLimit20100115(t *testing.T) { - seed := JDECalc(2010, 1, 15) + seed := JDCalc(2010, 1, 15) path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ StepDays: 2.0 / 1440.0, TargetSpacingKM: 100, }) @@ -809,7 +809,7 @@ func TestSolarEclipseCentralPathContactsShareGreatestRiseSetVerticesAcrossTypes( } { date := date t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ StepDays: 2.0 / 1440.0, TargetSpacingKM: 200, }) @@ -867,7 +867,7 @@ func solarEclipseRiseSetCurvesContainPoint( } func TestSolarEclipseExactNonCentralContactsRecoverAtPolarLimb21410108(t *testing.T) { - seed := JDECalc(2141, 1, 8) + seed := JDCalc(2141, 1, 8) result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) if result.Type != SolarEclipseAnnular || result.Centrality != SolarEclipseNonCentral { t.Fatalf("unexpected eclipse classification: type=%s centrality=%s", result.Type, result.Centrality) @@ -887,7 +887,7 @@ func TestSolarEclipseExactNonCentralContactsRecoverAtPolarLimb21410108(t *testin } func BenchmarkSolarEclipseMagnitudeContours(b *testing.B) { - seed := JDECalc(2031, 5, 21) + seed := JDCalc(2031, 5, 21) global := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) options := SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, @@ -913,7 +913,7 @@ func BenchmarkSolarEclipseMagnitudeContours(b *testing.B) { } func BenchmarkSolarEclipsePartialFootprintsFull(b *testing.B) { - seed := JDECalc(2031, 5, 21) + seed := JDCalc(2031, 5, 21) options := SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 180, @@ -931,7 +931,7 @@ func BenchmarkSolarEclipsePartialFootprintsFull(b *testing.B) { } func BenchmarkSolarEclipseNonCentralPartialFootprints20431003(b *testing.B) { - seed := JDECalc(2043, 10, 3) + seed := JDCalc(2043, 10, 3) options := SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, @@ -951,7 +951,7 @@ func BenchmarkSolarEclipseNonCentralPartialFootprints20431003(b *testing.B) { } func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) { - footprints := SolarEclipsePartialFootprints(JDECalc(2025, 3, 29), SolarEclipsePartialFootprintOptions{ + footprints := SolarEclipsePartialFootprints(JDCalc(2025, 3, 29), SolarEclipsePartialFootprintOptions{ StepDays: 30.0 / 1440.0, BoundaryPoints: 72, }) @@ -1074,7 +1074,7 @@ func TestSolarEclipseCentralBandFootprintsCoverNonCentralAndOneLimitEvents(t *te {2043, 10, 3, SolarEclipseNonCentral}, } { result := SolarEclipsePartialFootprints( - JDECalc(fixture.year, fixture.month, float64(fixture.day)), + JDCalc(fixture.year, fixture.month, float64(fixture.day)), SolarEclipsePartialFootprintOptions{StepDays: 20.0 / 1440.0, BoundaryPoints: 24}, ) if result.Eclipse.Centrality != fixture.centrality { @@ -1091,7 +1091,7 @@ func TestSolarEclipseCentralBandFootprintsCoverNonCentralAndOneLimitEvents(t *te fixture.year, fixture.month, fixture.day, len(result.CentralBandSegments)) } solver := newSolarEclipseSolver( - CalcMoonSHByJDE(JDECalc(fixture.year, fixture.month, float64(fixture.day)), 0), + CalcMoonSHByJDE(JDCalc(fixture.year, fixture.month, float64(fixture.day)), 0), SolarEclipseModelNASABulletinSplitK, ) horizonPoints := 0 @@ -1149,7 +1149,7 @@ func TestSolarEclipseCentralBandFootprintsCoverNonCentralAndOneLimitEvents(t *te func TestSolarEclipseNonCentralBandFallsBackWhenCriticalEnvelopeLeaks(t *testing.T) { result := SolarEclipsePartialFootprints( - JDECalc(1950, 3, 18), + JDCalc(1950, 3, 18), SolarEclipsePartialFootprintOptions{ StepDays: 60.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, }, @@ -1192,7 +1192,7 @@ func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *te } func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) { - footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{}) + footprints := SolarEclipsePartialFootprints(JDCalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{}) if footprints.Eclipse.Type != SolarEclipseNone { t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipseNone) @@ -1203,7 +1203,7 @@ func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) { } func TestSolarEclipsePartialAreaCompatibilityWrapper(t *testing.T) { - seedJDE := JDECalc(2024, 4, 8) + seedJDE := JDCalc(2024, 4, 8) options := SolarEclipsePartialAreaOptions{ StepDays: 30.0 / 1440.0, BoundaryPoints: 72, diff --git a/basic/solar_eclipse_path_width_contract_test.go b/basic/solar_eclipse_path_width_contract_test.go new file mode 100644 index 0000000..d01afbc --- /dev/null +++ b/basic/solar_eclipse_path_width_contract_test.go @@ -0,0 +1,96 @@ +package basic + +import ( + "math" + "testing" +) + +// 带宽契约:解析式 2r/|sin(太阳高度)| 只在南北两限都存在时有定义,单侧极限事件置 0 并标记未定义。 + +func TestSolarEclipsePathWidthRequiresBothLimits(t *testing.T) { + testCases := []struct { + name string + date [3]int + defined bool + widthKM float64 + }{ + {name: "-1404-01-07 single-sided limit without limits", date: [3]int{-1404, 1, 7}}, + {name: "2003-05-31 single-sided limit with limit lines", date: [3]int{2003, 5, 31}}, + {name: "1874-10-10 single-sided limit with limit lines", date: [3]int{1874, 10, 10}}, + {name: "2024-04-08 total", date: [3]int{2024, 4, 8}, defined: true, widthKM: 198.6161}, + {name: "2010-01-15 annular", date: [3]int{2010, 1, 15}, defined: true, widthKM: 335.0298}, + {name: "4862-09-28 annular", date: [3]int{4862, 9, 28}, defined: true, widthKM: 259.4316}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + seed := JDCalc(tc.date[0], tc.date[1], float64(tc.date[2])) + path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{}) + info := SolarEclipse(seed) + if path.Eclipse.PathWidthDefined != tc.defined { + t.Fatalf("path.Eclipse.PathWidthDefined=%v want %v", path.Eclipse.PathWidthDefined, tc.defined) + } + if path.PathWidthDefined != tc.defined { + t.Fatalf("path.PathWidthDefined=%v want %v", path.PathWidthDefined, tc.defined) + } + if info.PathWidthDefined != tc.defined { + t.Fatalf("info.PathWidthDefined=%v want %v", info.PathWidthDefined, tc.defined) + } + if info.PathWidthDefined != (info.Centrality == SolarEclipseCentralTwoLimits) { + t.Fatalf("info flag %v disagrees with centrality %s", info.PathWidthDefined, info.Centrality) + } + if !tc.defined { + if path.Eclipse.PathWidthKM != 0 || path.Greatest.WidthKM != 0 || info.PathWidthKM != 0 { + t.Fatalf("undefined width leaks: eclipse=%.4f greatest=%.4f info=%.4f", + path.Eclipse.PathWidthKM, path.Greatest.WidthKM, info.PathWidthKM) + } + } else if math.Abs(path.Eclipse.PathWidthKM-tc.widthKM) > 0.01 { + t.Fatalf("path width %.4f want %.4f", path.Eclipse.PathWidthKM, tc.widthKM) + } + if path.Eclipse.PathWidthKM > 0 && (len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0) { + t.Fatalf("width %.4f km with limits %d/%d", path.Eclipse.PathWidthKM, + len(path.NorthernLimit), len(path.SouthernLimit)) + } + if (len(path.NorthernLimit) == 0) != (len(path.SouthernLimit) == 0) { + t.Fatalf("limits are not paired: %d/%d", len(path.NorthernLimit), len(path.SouthernLimit)) + } + }) + } +} + +func TestSolarEclipsePathWidthContractAcrossScan(t *testing.T) { + start := JDCalc(1998, 1, 1) + end := JDCalc(2018, 1, 1) + seenOneLimit, seenTwoLimits := 0, 0 + lastGreatest := 0.0 + for jd := start; jd < end; jd += 15 { + path := SolarEclipseCentralPath(jd, SolarEclipsePathOptions{SkipCentralBand: true}) + if path.Eclipse.Type == SolarEclipseNone { + continue + } + if lastGreatest != 0 && path.Eclipse.GreatestEclipse-lastGreatest < 1 { + continue + } + lastGreatest = path.Eclipse.GreatestEclipse + switch path.Eclipse.Centrality { + case SolarEclipseCentralOneLimit: + seenOneLimit++ + case SolarEclipseCentralTwoLimits: + seenTwoLimits++ + } + bothLimits := len(path.NorthernLimit) > 0 && len(path.SouthernLimit) > 0 + if path.PathWidthDefined && (!bothLimits || path.Eclipse.Centrality != SolarEclipseCentralTwoLimits) { + t.Fatalf("defined at %s with limits %d/%d", path.Eclipse.Centrality, + len(path.NorthernLimit), len(path.SouthernLimit)) + } + if !path.PathWidthDefined && path.Eclipse.PathWidthKM != 0 { + t.Fatalf("undefined width %.4f km at %.6f", path.Eclipse.PathWidthKM, path.Eclipse.GreatestEclipse) + } + if path.Eclipse.PathWidthKM > 0 && (len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0) { + t.Fatalf("width %.4f km with limits %d/%d at %.6f", path.Eclipse.PathWidthKM, + len(path.NorthernLimit), len(path.SouthernLimit), path.Eclipse.GreatestEclipse) + } + } + if seenOneLimit == 0 || seenTwoLimits == 0 { + t.Fatalf("scan covered %d one-limit and %d two-limit events, want both kinds", seenOneLimit, seenTwoLimits) + } +} diff --git a/basic/solar_eclipse_perf_bench_test.go b/basic/solar_eclipse_perf_bench_test.go index 54b0ec6..7f14706 100644 --- a/basic/solar_eclipse_perf_bench_test.go +++ b/basic/solar_eclipse_perf_bench_test.go @@ -5,7 +5,7 @@ import "testing" // 性能基线:逐项隔离 §1.4 P2 热点,改动前后跑同一份基准做对照。 func solarEclipseBenchSeed(year, month, day int) float64 { - return JDECalc(year, month, float64(day)) + return JDCalc(year, month, float64(day)) } func solarEclipseBenchCenterLine(b *testing.B, seed float64) (SolarEclipsePathResult, float64, SolarEclipseRadiusModel) { diff --git a/basic/solar_eclipse_polar_envelope_test.go b/basic/solar_eclipse_polar_envelope_test.go index 6e67d5c..f3bb987 100644 --- a/basic/solar_eclipse_polar_envelope_test.go +++ b/basic/solar_eclipse_polar_envelope_test.go @@ -15,7 +15,7 @@ func TestSolarEclipsePolarCentralEnvelope(t *testing.T) { {2026, 2, 17, RiseSetDirectionSet}, } { t.Run(fmt.Sprintf("%04d-%02d-%02d", sample.year, sample.month, sample.day), func(t *testing.T) { - seed := JDECalc(sample.year, sample.month, float64(sample.day)) + seed := JDCalc(sample.year, sample.month, float64(sample.day)) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440, BoundaryPoints: 96, }) diff --git a/basic/solar_eclipse_radius_convention_test.go b/basic/solar_eclipse_radius_convention_test.go new file mode 100644 index 0000000..fe063be --- /dev/null +++ b/basic/solar_eclipse_radius_convention_test.go @@ -0,0 +1,109 @@ +package basic + +import ( + "math" + "testing" +) + +// 半径口径契约:k 常量固定,太阳半径口径选得进、报得出,且只按几何改结果。 + +func TestSolarEclipseRadiusConstants(t *testing.T) { + if SolarEclipsePenumbralK != 0.2724880 || SolarEclipseUmbralK != 0.2722810 || SolarEclipseIAUSingleRadiusK != 0.2725076 { + t.Fatalf("k constants = %.7f/%.7f/%.7f", + SolarEclipsePenumbralK, SolarEclipseUmbralK, SolarEclipseIAUSingleRadiusK) + } + standard := SolarEclipseSunSemidiameter(2451545.0, SolarEclipseSunRadiusStandard) + measured := SolarEclipseSunSemidiameter(2451545.0, SolarEclipseSunRadiusMeasured) + if ratio := measured / standard; math.Abs(ratio-959.95/959.639) > 1e-8 { + t.Fatalf("sun radius ratio = %.12f, want %.12f", ratio, 959.95/959.639) + } +} + +func TestSolarEclipseSunRadiusConventionChangesGeometry(t *testing.T) { + seed := JDCalc(2024, 4, 8) + base := SolarEclipseNASABulletinSplitK(seed) + zero := SolarEclipseWithOptions(seed, SolarEclipseOptions{}) + if base.SunRadiusModel != SolarEclipseSunRadiusStandard || zero.SunRadiusModel != SolarEclipseSunRadiusStandard { + t.Fatalf("standard provenance = %q / %q", base.SunRadiusModel, zero.SunRadiusModel) + } + if zero.PathWidthKM != base.PathWidthKM || zero.CentralDurationDays != base.CentralDurationDays { + t.Fatalf("zero options changed the geometry: %.4f/%.4f", zero.PathWidthKM, base.PathWidthKM) + } + + edge := SolarEclipseWithOptions(seed, SolarEclipseOptions{ + RadiusModel: SolarEclipseModelNASABulletinSplitK, + SunRadiusModel: SolarEclipseSunRadiusMeasured, + }) + if edge.SunRadiusModel != SolarEclipseSunRadiusMeasured { + t.Fatalf("edge provenance = %q", edge.SunRadiusModel) + } + if edge.Type != base.Type || math.Abs(edge.Gamma-base.Gamma) > 1e-9 { + t.Fatalf("type/gamma changed with the solar radius: %s/%s %.9f/%.9f", + base.Type, edge.Type, base.Gamma, edge.Gamma) + } + // 太阳更大 ⇒ 本影更小:全食带变窄、中心食时长变短、食分变小。 + if !(edge.Magnitude < base.Magnitude) { + t.Fatalf("edge magnitude %.7f >= standard %.7f", edge.Magnitude, base.Magnitude) + } + if drop := base.PathWidthKM - edge.PathWidthKM; drop < 0.5 || drop > 2.0 { + t.Fatalf("path width drop = %.4f km, want 0.5..2.0", drop) + } + if drop := (base.CentralDurationDays - edge.CentralDurationDays) * 86400; drop < 1.0 || drop > 2.5 { + t.Fatalf("central duration drop = %.4f s, want 1.0..2.5", drop) + } +} + +func TestSolarEclipsePenumbralModelChangesPartialMagnitude(t *testing.T) { + seed := JDCalc(2022, 10, 25) + split := SolarEclipseNASABulletinSplitK(seed) + single := SolarEclipseIAUSingleK(seed) + if split.Type != SolarEclipsePartial || single.Type != SolarEclipsePartial { + t.Fatalf("types = %s/%s, want two partial eclipses", split.Type, single.Type) + } + if !(single.Magnitude > split.Magnitude) || single.Magnitude-split.Magnitude > 5e-4 { + t.Fatalf("split=%.7f single=%.7f", split.Magnitude, single.Magnitude) + } + edge := SolarEclipseWithOptions(seed, SolarEclipseOptions{SunRadiusModel: SolarEclipseSunRadiusMeasured}) + if edge.Model != SolarEclipseModelNASABulletinSplitK { + t.Fatalf("zero k model resolved to %q", edge.Model) + } + if !(edge.Magnitude < split.Magnitude) { + t.Fatalf("edge magnitude %.7f >= split %.7f", edge.Magnitude, split.Magnitude) + } +} + +func TestLocalSolarEclipseSunRadiusConvention(t *testing.T) { + seed := JDCalc(2023, 10, 14) + base := LocalSolarEclipseNASABulletinSplitK(seed, -100, 40, 0) + edge := LocalSolarEclipseWithOptions(seed, -100, 40, 0, SolarEclipseOptions{ + RadiusModel: SolarEclipseModelNASABulletinSplitK, + SunRadiusModel: SolarEclipseSunRadiusMeasured, + }) + if base.SunRadiusModel != SolarEclipseSunRadiusStandard || edge.SunRadiusModel != SolarEclipseSunRadiusMeasured { + t.Fatalf("local provenance = %q / %q", base.SunRadiusModel, edge.SunRadiusModel) + } + if !(edge.Magnitude < base.Magnitude) { + t.Fatalf("local edge magnitude %.7f >= standard %.7f", edge.Magnitude, base.Magnitude) + } + if shift := math.Abs(edge.PartialStart-base.PartialStart) * 86400; shift <= 0 || shift > 30 { + t.Fatalf("local partial start shift = %.3f s, want 0..30", shift) + } +} +func TestLocalSolarEclipseIAUSingleKUsesModelPenumbralK(t *testing.T) { + seed := JDCalc(2024, 4, 8) + iau := LocalSolarEclipseIAUSingleK(seed, -87.65, 41.85, 0) + split := LocalSolarEclipseNASABulletinSplitK(seed, -87.65, 41.85, 0) + if iau.PartialStart == split.PartialStart || iau.PartialEnd == split.PartialEnd { + t.Fatalf("partial contacts coincide: %.9f/%.9f and %.9f/%.9f", + iau.PartialStart, split.PartialStart, iau.PartialEnd, split.PartialEnd) + } + // k1 更大的模型半影也更大:偏食必然更长、更宽。 + if !(iau.PartialStart < split.PartialStart && iau.PartialEnd > split.PartialEnd) { + t.Fatalf("IAU contacts %.9f/%.9f not wider than Split-K %.9f/%.9f", + iau.PartialStart, iau.PartialEnd, split.PartialStart, split.PartialEnd) + } + if !(iau.Magnitude > split.Magnitude && iau.Obscuration > split.Obscuration) { + t.Fatalf("IAU magnitude/obscuration %.9f/%.9f not above Split-K %.9f/%.9f", + iau.Magnitude, iau.Obscuration, split.Magnitude, split.Obscuration) + } +} diff --git a/basic/solar_eclipse_review_fixes_test.go b/basic/solar_eclipse_review_fixes_test.go index 78bccd1..3a5baaf 100644 --- a/basic/solar_eclipse_review_fixes_test.go +++ b/basic/solar_eclipse_review_fixes_test.go @@ -9,7 +9,7 @@ import ( // 不能整体回落到食甚时刻。 func TestSolarEclipseSampledBandVerticesKeepOwnTime(t *testing.T) { for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}, {1874, 10, 10}} { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }) @@ -39,7 +39,7 @@ func TestSolarEclipseSampledBandVerticesKeepOwnTime(t *testing.T) { // 扫掠兜底的采样标记必须跟着 centralBandSweepPolygons 的返回值走,不能丢。 func TestSolarEclipseCentralBandSweepKeepsSampledFlag(t *testing.T) { for _, date := range [][3]int{{2024, 4, 8}, {1136, 6, 1}, {4862, 9, 28}, {1552, 7, 21}} { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) band := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }) @@ -86,7 +86,7 @@ func TestSolarEclipseArcBranchAngleMapsUnwrappedArcs(t *testing.T) { // 非 greatest 残差只允许算一次 stateAt;结果必须与"先算相位残差再单独算 stateAt"逐位一致。 func TestSolarEclipseRiseSetArcResidualMatchesPhaseResidual(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) solver = solver.withLocalEphemeris() result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) @@ -111,7 +111,7 @@ func TestSolarEclipseRiseSetArcResidualMatchesPhaseResidual(t *testing.T) { // 事件局部插值星历与精确星历的差别必须远小于任何求解容差,这是所有 // "用插值星历迭代 + 精确星历复核"优化的前提。 func TestSolarEclipseCandidateEvaluationMatchesExact(t *testing.T) { - seed := JDECalc(1136, 6, 1) + seed := JDCalc(1136, 6, 1) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) solver = solver.withLocalEphemeris() center := solver.newMoonJDE @@ -138,7 +138,7 @@ func TestSolarEclipseCandidateEvaluationMatchesExact(t *testing.T) { // 中心相时长的插值求解必须与完整站心解一致到远小于目录精度的量级。 func TestSolarEclipseCentralDurationMatchesExactLocalSolve(t *testing.T) { for _, date := range [][3]int{{2024, 4, 8}, {2009, 7, 22}, {2017, 8, 21}, {2020, 6, 21}, {1136, 6, 1}} { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipseNASABulletinSplitK(seed) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) solver = solver.withLocalEphemeris() @@ -168,7 +168,7 @@ func TestSolarEclipseCentralDurationMatchesExactLocalSolve(t *testing.T) { // 0.4651·|ΔΔT|·cosφ 千米。 func TestSolarEclipsePathHonoursDeltaTOverride(t *testing.T) { const override = 200.0 - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) options := SolarEclipsePathOptions{StepDays: 2.0 / 1440.0, DeltaTSeconds: override} path := SolarEclipseCentralPathNASABulletinSplitK(seed, options) defaultPath := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0}) @@ -225,7 +225,7 @@ func solarEclipseRingCenterDistanceKM( // 限界线必须来自与宽度同一份配对横截面;这里用独立的两次求解复算一遍作为对照。 func TestSolarEclipseLimitPairsMatchIndependentSolve(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) path := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0}) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) referenceNorth, _ := solver.centralPathLimits(path.CenterLine) @@ -259,7 +259,7 @@ func TestSolarEclipseLimitPairsMatchIndependentSolve(t *testing.T) { // 事件级状态缓存必须有界,且 ΔT 世代变化后旧条目必须视为未命中。 func TestSolarEclipseLocalStateContextCacheIsBounded(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) base := solver.newMoonJDE for index := 0; index < 3*solarEclipseBesselGeometryCacheMaximumEntries; index++ { @@ -297,7 +297,7 @@ func TestSolarEclipseRiseSetTopologyDegradedContract(t *testing.T) { if solarEclipseRiseSetCurveTopologyComplete(overSegmented) { t.Fatal("a 17-segment curve must not pass the topology audit") } - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, }) diff --git a/basic/solar_eclipse_rise_set.go b/basic/solar_eclipse_rise_set.go index ee60533..e8013a2 100644 --- a/basic/solar_eclipse_rise_set.go +++ b/basic/solar_eclipse_rise_set.go @@ -424,17 +424,17 @@ func (solver solarEclipseSolver) refineRiseSetPhaseJunctionBetweenSamples( } func (solver solarEclipseSolver) riseSetPointsAt( - jd float64, + jde float64, boundaryPoints int, ) map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint { - return solver.riseSetPointsAtEvaluation(jd, boundaryPoints, solver.magnitudeEvaluationAt(jd)) + return solver.riseSetPointsAtEvaluation(jde, boundaryPoints, solver.magnitudeEvaluationAt(jde)) } func (solver solarEclipseSolver) riseSetCandidatePointsAt( - jd float64, + jde float64, boundaryPoints int, ) map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint { - return solver.riseSetPointsAtEvaluation(jd, boundaryPoints, solver.magnitudeCandidateEvaluationAt(jd)) + return solver.riseSetPointsAtEvaluation(jde, boundaryPoints, solver.magnitudeCandidateEvaluationAt(jde)) } func (solver solarEclipseSolver) riseSetPointsAtEvaluation( diff --git a/basic/solar_eclipse_rise_set_arc.go b/basic/solar_eclipse_rise_set_arc.go index b8850ee..5fca1b5 100644 --- a/basic/solar_eclipse_rise_set_arc.go +++ b/basic/solar_eclipse_rise_set_arc.go @@ -743,9 +743,9 @@ func (solver solarEclipseSolver) validRiseSetArcState( referenceJDE float64, iterations int, ) (solarEclipseRiseSetArcState, int, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) if !ok { @@ -755,7 +755,7 @@ func (solver solarEclipseSolver) validRiseSetArcState( coordinates: coordinates, tangent: tangent, point: SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, }, }, iterations, true } @@ -765,9 +765,9 @@ func (solver solarEclipseSolver) riseSetArcJacobian( greatest bool, referenceJDE float64, ) ([2]float64, [2][3]float64, bool) { - jd := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale + jde := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseRiseSetArcResidualAt(evaluation, longitude, latitude, greatest) if !ok { return [2]float64{}, [2][3]float64{}, false @@ -783,7 +783,7 @@ func (solver solarEclipseSolver) riseSetArcJacobian( jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] } } - timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseRiseSetArcTimeScale) + timeEvaluation := solver.magnitudeEvaluationAt(jde + steps[2]/solarEclipseRiseSetArcTimeScale) timeResidual, timeOK := solarEclipseRiseSetArcResidualAt(timeEvaluation, longitude, latitude, greatest) if !timeOK { return [2]float64{}, [2][3]float64{}, false diff --git a/basic/solar_eclipse_rise_set_refine.go b/basic/solar_eclipse_rise_set_refine.go index 13a4bcc..09f94bd 100644 --- a/basic/solar_eclipse_rise_set_refine.go +++ b/basic/solar_eclipse_rise_set_refine.go @@ -3,14 +3,14 @@ package basic import "math" func (solver solarEclipseSolver) refineRiseSetPhaseJunction( - jd, longitude, latitude float64, + jde, longitude, latitude float64, ) (SolarEclipsePathPoint, bool) { const ( geographicStep = 1e-4 timeStep = 1.0 / 86400.0 ) for iteration := 0; iteration < 24; iteration++ { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude) if !ok { return SolarEclipsePathPoint{}, false @@ -24,7 +24,7 @@ func (solver solarEclipseSolver) refineRiseSetPhaseJunction( latitudeResidual, latitudeOK := solarEclipseRiseSetPhaseJunctionResidualAt( evaluation, longitude, latitude+geographicStep, ) - timeResidual, timeOK := solver.riseSetPhaseJunctionResidual(jd+timeStep, longitude, latitude) + timeResidual, timeOK := solver.riseSetPhaseJunctionResidual(jde+timeStep, longitude, latitude) if !longitudeOK || !latitudeOK || !timeOK { return SolarEclipsePathPoint{}, false } @@ -48,21 +48,21 @@ func (solver solarEclipseSolver) refineRiseSetPhaseJunction( } longitude = normalizeLongitude(longitude + delta[0]) latitude += delta[1] - jd += delta[2] + jde += delta[2] if latitude <= -89.999999 || latitude >= 89.999999 { return SolarEclipsePathPoint{}, false } } - residual, ok := solver.riseSetPhaseJunctionResidual(jd, longitude, latitude) + residual, ok := solver.riseSetPhaseJunctionResidual(jde, longitude, latitude) if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 || math.Abs(residual[2]) > 1e-8 { return SolarEclipsePathPoint{}, false } - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) if evaluation.partialContactSecondDerivative(longitude, latitude) <= 0 { return SolarEclipsePathPoint{}, false } return SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, }, true } @@ -74,15 +74,15 @@ func (solver solarEclipseSolver) refineRiseSetPhaseJunctionOnHorizon( 0, } residualAt := func(value [2]float64) ([2]float64, float64, float64, float64, bool) { - jd := seed.JDE + value[1]/1440 - longitude, latitude := solver.riseSetHorizonPointAt(jd, value[0]) - evaluation := solver.magnitudeEvaluationAt(jd) + jde := seed.JDE + value[1]/1440 + longitude, latitude := solver.riseSetHorizonPointAt(jde, value[0]) + evaluation := solver.magnitudeEvaluationAt(jde) state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) residual := [2]float64{ solarEclipsePartialContactGap(state), evaluation.partialContactDerivative(longitude, latitude), } - return residual, jd, longitude, latitude, finite(residual[0]) && finite(residual[1]) + return residual, jde, longitude, latitude, finite(residual[0]) && finite(residual[1]) } const ( angleStep = 1e-4 @@ -124,11 +124,11 @@ func (solver solarEclipseSolver) refineRiseSetPhaseJunctionOnHorizon( coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) coordinates[1] += delta[1] } - residual, jd, longitude, latitude, ok := residualAt(coordinates) + residual, jde, longitude, latitude, ok := residualAt(coordinates) if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 { return SolarEclipsePathPoint{}, false } - return solver.refineRiseSetPhaseJunction(jd, longitude, latitude) + return solver.refineRiseSetPhaseJunction(jde, longitude, latitude) } func (solver solarEclipseSolver) riseSetPhaseSegmentIsContinuous( @@ -143,9 +143,9 @@ func (solver solarEclipseSolver) riseSetPhaseSegmentIsContinuous( continuityToleranceKM := math.Max(50, 0.05*totalDistance) candidate := end for divisor := 2.0; divisor <= 1024; divisor *= 2 { - jd := start.JDE + (end.JDE-start.JDE)/divisor - seedAngle := solver.riseSetHorizonAngle(jd, candidate.Longitude, candidate.Latitude) - next, ok := solver.riseSetPhasePointOnHorizon(jd, seedAngle, phase, direction) + jde := start.JDE + (end.JDE-start.JDE)/divisor + seedAngle := solver.riseSetHorizonAngle(jde, candidate.Longitude, candidate.Latitude) + next, ok := solver.riseSetPhasePointOnHorizon(jde, seedAngle, phase, direction) if !ok { return solarEclipsePathDistanceKM(start, candidate) <= continuityToleranceKM } @@ -155,14 +155,14 @@ func (solver solarEclipseSolver) riseSetPhaseSegmentIsContinuous( } func (solver solarEclipseSolver) riseSetPhasePointOnHorizon( - jd, angle float64, + jde, angle float64, phase RiseSetPhase, direction RiseSetDirection, ) (SolarEclipsePathPoint, bool) { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) greatest := phase == RiseSetPhaseGreatest valueAt := func(candidateAngle float64) (float64, float64, float64, bool) { - longitude, latitude := solver.riseSetHorizonPointAt(jd, candidateAngle) + longitude, latitude := solver.riseSetHorizonPointAt(jde, candidateAngle) value, ok := solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) return value, longitude, latitude, ok && finite(value) } @@ -252,8 +252,8 @@ func (solver solarEclipseSolver) refineRiseSetFoldNearPhaseJunction( for _, sign := range []float64{-1, 1} { for divisor := 1024.0; divisor >= 1; divisor /= 2 { fraction := 1 / divisor - jd := junction.JDE + sign*fraction*stepDays - roots := solver.riseSetPointsAt(jd, solarEclipseRiseSetBoundaryPoints)[key] + jde := junction.JDE + sign*fraction*stepDays + roots := solver.riseSetPointsAt(jde, solarEclipseRiseSetBoundaryPoints)[key] if len(roots) < 2 { continue } @@ -273,9 +273,9 @@ func (solver solarEclipseSolver) refineRiseSetFoldNearPhaseJunction( } func (solver solarEclipseSolver) riseSetPhaseJunctionResidual( - jd, longitude, latitude float64, + jde, longitude, latitude float64, ) ([3]float64, bool) { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) return solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude) } @@ -350,9 +350,9 @@ func (solver solarEclipseSolver) refineRiseSetFold( coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) coordinates[1] += delta[1] } - jd := seedJDE + coordinates[1]/1440.0 - longitude, latitude := solver.riseSetHorizonPointAt(jd, coordinates[0]) - return solver.refineRiseSetFoldPoint(jd, longitude, latitude, greatest) + jde := seedJDE + coordinates[1]/1440.0 + longitude, latitude := solver.riseSetHorizonPointAt(jde, coordinates[0]) + return solver.refineRiseSetFoldPoint(jde, longitude, latitude, greatest) } func (solver solarEclipseSolver) riseSetFoldHorizonResidual( @@ -361,8 +361,8 @@ func (solver solarEclipseSolver) riseSetFoldHorizonResidual( greatest bool, ) ([2]float64, bool) { const derivativeStep = 1e-4 - jd := seedJDE + coordinates[1]/1440.0 - evaluation := solver.magnitudeEvaluationAt(jd) + jde := seedJDE + coordinates[1]/1440.0 + evaluation := solver.magnitudeEvaluationAt(jde) centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) valueAt := func(angle float64) (float64, bool) { longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) @@ -375,16 +375,16 @@ func (solver solarEclipseSolver) riseSetFoldHorizonResidual( return residual, centerOK && beforeOK && afterOK && finite(residual[1]) } -func (solver solarEclipseSolver) riseSetHorizonPointAt(jd, angle float64) (float64, float64) { - evaluation := solver.magnitudeEvaluationAt(jd) +func (solver solarEclipseSolver) riseSetHorizonPointAt(jde, angle float64) (float64, float64) { + evaluation := solver.magnitudeEvaluationAt(jde) centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) return riseSetHorizonPoint(centerLongitude, centerLatitude, angle) } func (solver solarEclipseSolver) riseSetHorizonAngle( - jd, longitude, latitude float64, + jde, longitude, latitude float64, ) float64 { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) centerLon, centerLat := centerLongitude*rad, centerLatitude*rad center := [3]float64{ @@ -482,10 +482,10 @@ func (solver solarEclipseSolver) refineRiseSetFoldPoint( } func (solver solarEclipseSolver) riseSetFoldResidual( - jd, longitude, latitude float64, + jde, longitude, latitude float64, greatest bool, ) ([3]float64, bool) { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) return solarEclipseRiseSetFoldResidualAt(evaluation, longitude, latitude, greatest) } @@ -564,7 +564,7 @@ func (solver solarEclipseSolver) appendRefinedRiseSetSegment( if depth >= 12 || end.JDE-start.JDE <= solarEclipsePathMinStepDays { return append(points, end) } - jd := (start.JDE + end.JDE) / 2 + jde := (start.JDE + end.JDE) / 2 longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2) latitude := (start.Latitude + end.Latitude) / 2 // A comfortably straight candidate needs no new output vertex. Reserve @@ -572,13 +572,13 @@ func (solver solarEclipseSolver) appendRefinedRiseSetSegment( // uses the exact ephemeris and the original phase residual checks. short := solarEclipsePathDistanceKM(start, end) <= solarEclipseRiseSetTargetSpacingKM if short && solver.localEphemeris != nil { - candidate, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeCandidateEvaluationAt(jd), longitude, latitude, phase) + candidate, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeCandidateEvaluationAt(jde), longitude, latitude, phase) if valid && key.phase == phase && key.direction == direction && solarEclipseRiseSetChordDeviationKM(candidate, start, end) <= solarEclipseRiseSetChordToleranceKM/2 { return append(points, end) } } - middle, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeEvaluationAt(jd), longitude, latitude, phase) + middle, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeEvaluationAt(jde), longitude, latitude, phase) if !valid || key.phase != phase || key.direction != direction { return append(points, end) } diff --git a/basic/solar_eclipse_rise_set_scan_test.go b/basic/solar_eclipse_rise_set_scan_test.go index a2470f9..13cd724 100644 --- a/basic/solar_eclipse_rise_set_scan_test.go +++ b/basic/solar_eclipse_rise_set_scan_test.go @@ -10,12 +10,12 @@ import ( func TestSolarEclipsePathTopologyAcrossSarosAnchors(t *testing.T) { for _, year := range []int{1526, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400, 2526} { - events := solarEclipseScanEvents(JDECalc(year, 1, 1), JDECalc(year+1, 1, 1)) + events := solarEclipseScanEvents(JDCalc(year, 1, 1), JDCalc(year+1, 1, 1)) if len(events) == 0 { t.Fatalf("%d has no solar eclipse candidate", year) } eventJDE := events[0] - name := JDE2Date(eventJDE).Format("2006-01-02") + name := JD2Date(eventJDE).Format("2006-01-02") t.Run(name, func(t *testing.T) { assertSolarEclipsePathTopology(t, eventJDE, 60.0/1440.0) }) diff --git a/basic/solar_eclipse_rise_set_topology.go b/basic/solar_eclipse_rise_set_topology.go index 8703014..61ca084 100644 --- a/basic/solar_eclipse_rise_set_topology.go +++ b/basic/solar_eclipse_rise_set_topology.go @@ -531,7 +531,7 @@ func solarEclipseRiseSetEndpointTimeDirectionValid( } func (solver solarEclipseSolver) refineRiseSetDirectionJunction( - jd, longitude, latitude float64, + jde, longitude, latitude float64, greatest bool, ) (SolarEclipsePathPoint, bool) { const ( @@ -539,7 +539,7 @@ func (solver solarEclipseSolver) refineRiseSetDirectionJunction( timeStep = 1.0 / 86400.0 ) for iteration := 0; iteration < 24; iteration++ { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) residual, ok := solarEclipseRiseSetDirectionJunctionResidualAt(evaluation, longitude, latitude, greatest) if !ok { return SolarEclipsePathPoint{}, false @@ -554,7 +554,7 @@ func (solver solarEclipseSolver) refineRiseSetDirectionJunction( evaluation, longitude, latitude+geographicStep, greatest, ) timeResidual, timeOK := solver.riseSetDirectionJunctionResidual( - jd+timeStep, longitude, latitude, greatest, + jde+timeStep, longitude, latitude, greatest, ) if !longitudeOK || !latitudeOK || !timeOK { return SolarEclipsePathPoint{}, false @@ -579,16 +579,16 @@ func (solver solarEclipseSolver) refineRiseSetDirectionJunction( } longitude = normalizeLongitude(longitude + delta[0]) latitude += delta[1] - jd += delta[2] + jde += delta[2] if latitude <= -89.999999 || latitude >= 89.999999 { return SolarEclipsePathPoint{}, false } } - residual, ok := solver.riseSetDirectionJunctionResidual(jd, longitude, latitude, greatest) + residual, ok := solver.riseSetDirectionJunctionResidual(jde, longitude, latitude, greatest) if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { return SolarEclipsePathPoint{}, false } - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) if greatest { if solarEclipsePartialContactGap(state) > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { @@ -598,15 +598,15 @@ func (solver solarEclipseSolver) refineRiseSetDirectionJunction( return SolarEclipsePathPoint{}, false } return SolarEclipsePathPoint{ - JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[1] / rad, + JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: residual[1] / rad, }, true } func (solver solarEclipseSolver) riseSetDirectionJunctionResidual( - jd, longitude, latitude float64, + jde, longitude, latitude float64, greatest bool, ) ([3]float64, bool) { - evaluation := solver.magnitudeEvaluationAt(jd) + evaluation := solver.magnitudeEvaluationAt(jde) return solarEclipseRiseSetDirectionJunctionResidualAt(evaluation, longitude, latitude, greatest) } diff --git a/basic/solar_eclipse_shadow.go b/basic/solar_eclipse_shadow.go index ba30d92..7cfbb00 100644 --- a/basic/solar_eclipse_shadow.go +++ b/basic/solar_eclipse_shadow.go @@ -16,6 +16,8 @@ const ( type SolarEclipseShadowSolverOptions struct { // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. Model SolarEclipseRadiusModel + // SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention, standard when zero. + SunRadiusModel SolarEclipseSunRadiusModel // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型,只改变地球自转相位 / explicit ΔT in seconds. DeltaTSeconds float64 // BoundaryPoints 边界角向采样点数,<=0 用 96 / boundary sample count. @@ -100,6 +102,8 @@ type SolarEclipseShadowInstant struct { JDE float64 // Model 本次使用的月亮半径模型 / lunar radius model used. Model SolarEclipseRadiusModel + // SunRadiusModel 本次使用的太阳半径口径 / solar radius convention used. + SunRadiusModel SolarEclipseSunRadiusModel // DeltaTSeconds 实际使用的 ΔT / ΔT actually used. DeltaTSeconds float64 // Kind 本次计算的阴影类型 / shadow kind of this computation. @@ -130,9 +134,8 @@ type SolarEclipseShadowSolver struct { // NewSolarEclipseShadowSolver 构造单时刻求解器 / builds a single-instant solver. func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver { - if options.Model != SolarEclipseModelIAUSingleK { - options.Model = SolarEclipseModelNASABulletinSplitK - } + options.Model = normalizeSolarEclipseRadiusModel(options.Model) + options.SunRadiusModel = normalizeSolarEclipseSunRadiusModel(options.SunRadiusModel) if options.BoundaryPoints <= 0 { options.BoundaryPoints = solarEclipseShadowDefaultBoundaryPoints } @@ -170,19 +173,20 @@ func (solver *SolarEclipseShadowSolver) ShadowAtJDE(jdeTT float64) (SolarEclipse ) if len(footprint.Boundaries) == 0 { return SolarEclipseShadowInstant{ - JDE: jdeTT, Model: solver.options.Model, + JDE: jdeTT, Model: solver.options.Model, SunRadiusModel: solver.options.SunRadiusModel, Kind: solver.options.Kind, DeltaTSeconds: deltaT, }, false } return SolarEclipseShadowInstant{ - JDE: jdeTT, - Model: solver.options.Model, - Kind: solver.options.Kind, - DeltaTSeconds: deltaT, - Closed: footprint.Closed, - Boundaries: footprint.Boundaries, - HorizonEnds: footprint.HorizonEnds, - Topology: solarEclipseShadowFootprintTopology(footprint, solver.options.Kind), + JDE: jdeTT, + Model: solver.options.Model, + SunRadiusModel: solver.options.SunRadiusModel, + Kind: solver.options.Kind, + DeltaTSeconds: deltaT, + Closed: footprint.Closed, + Boundaries: footprint.Boundaries, + HorizonEnds: footprint.HorizonEnds, + Topology: solarEclipseShadowFootprintTopology(footprint, solver.options.Kind), }, true } @@ -196,8 +200,8 @@ func (options SolarEclipseShadowSolverOptions) shadowKind() solarEclipseShadowKi func (solver *SolarEclipseShadowSolver) effectiveDeltaT(jdeTT float64) float64 { override := solver.options.DeltaTSeconds if override <= 0 { - // 选项 0/负值表示"未覆盖",用模型;显式 ΔT=0 需走 DeltaTSecondsAt 的直接调用。 - override = math.NaN() + // ΔT 模型的自变量是 UT1,须先从输入的 TT 反解。 + return ut1ToTTOffsetSeconds(ttToUT1JDE(jdeTT)) } return DeltaTSecondsAt(jdeTT, override) } @@ -209,7 +213,10 @@ func (solver *SolarEclipseShadowSolver) solverFor(jdeTT float64) solarEclipseSol anchor := CalcMoonSHByJDE(jdeTT, 0) solver.anchorSet = true solver.anchorJDE = anchor - solver.anchorSolver = newSolarEclipseSolver(anchor, solver.options.Model) + solver.anchorSolver = newSolarEclipseSolverWithOptions(anchor, SolarEclipseOptions{ + RadiusModel: solver.options.Model, + SunRadiusModel: solver.options.SunRadiusModel, + }) return solver.anchorSolver } @@ -289,7 +296,7 @@ type SolarEclipseStationState struct { // HasTotalPhase 与 HasAnnularPhase 表示该瞬时是否处于全食或环食 / total or annular now. HasTotalPhase bool HasAnnularPhase bool - // Visible 太阳中心高于几何地平,海拔用俯仰角修正阈值 / Sun center above the horizon. + // Visible 太阳中心高于几何地平,高度用俯仰角修正阈值 / Sun center above the horizon. Visible bool } @@ -303,7 +310,8 @@ func (solver *SolarEclipseShadowSolver) StationStateAtJDE( deltaT := solver.effectiveDeltaT(jdeTT) heightKM := heightMeters / 1000 state := localSolarEclipseStateAtWithDeltaT( - jdeTT, deltaT, lonDeg*rad, latDeg*rad, heightKM, solarEclipseModelParams(solver.options.Model), + jdeTT, deltaT, lonDeg*rad, latDeg*rad, heightKM, + solarEclipseModelParams(solver.options.Model, solver.options.SunRadiusModel), ) contact := state.movingDiskContactState() central := contact.internalContactGap() <= 0 diff --git a/basic/solar_eclipse_shadow_batch_test.go b/basic/solar_eclipse_shadow_batch_test.go index cca5252..839d152 100644 --- a/basic/solar_eclipse_shadow_batch_test.go +++ b/basic/solar_eclipse_shadow_batch_test.go @@ -4,7 +4,7 @@ import "testing" // 批量入口是导出契约的一部分,必须与逐时刻入口给出同一条数值链。 func TestSolarEclipseShadowBatchEntriesMatchPerInstantCalls(t *testing.T) { - footprints := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + footprints := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 6.0 / 1440.0, BoundaryPoints: 48, DisableRiseSetCurves: true, CentralShadowStepDays: 6.0 / 1440.0, }) diff --git a/basic/solar_eclipse_shadow_test.go b/basic/solar_eclipse_shadow_test.go index 87b2624..555f340 100644 --- a/basic/solar_eclipse_shadow_test.go +++ b/basic/solar_eclipse_shadow_test.go @@ -6,7 +6,7 @@ import ( ) func TestSolarEclipseShadowInstantMatchesPackagedSamples(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -54,7 +54,7 @@ func TestSolarEclipseShadowInstantMatchesPackagedSamples(t *testing.T) { func TestSolarEclipseShadowInstantEmptyOffPath(t *testing.T) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) // 2009-07-22 食甚前后 12 小时已经远离地球上的本影。 - instant, ok := solver.ShadowAtJDE(JDECalc(2009, 7, 22) + 0.6) + instant, ok := solver.ShadowAtJDE(JDCalc(2009, 7, 22) + 0.6) if ok || !instant.Empty() { t.Fatalf("off-path instant reported ok=%v empty=%v", ok, instant.Empty()) } @@ -67,7 +67,7 @@ func TestSolarEclipseShadowInstantEmptyOffPath(t *testing.T) { } func TestSolarEclipseShadowTopologySignatureChangesAtHorizonCut(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -95,7 +95,7 @@ func TestSolarEclipseShadowTopologySignatureChangesAtHorizonCut(t *testing.T) { } func TestSolarEclipseStationStateMatchesLocalEclipse(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) const lon, lat = -96.8, 32.8 local := LocalSolarEclipse(seed, lon, lat, 0) if !local.HasTotal { @@ -122,7 +122,7 @@ func TestSolarEclipseStationStateMatchesLocalEclipse(t *testing.T) { } func TestSolarEclipseShadowDeltaTMovesOnlyEarthRotation(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -171,7 +171,7 @@ func TestSolarEclipseShadowDeltaTMovesOnlyEarthRotation(t *testing.T) { func BenchmarkSolarEclipseShadowAtJDE(b *testing.B) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -186,7 +186,7 @@ func BenchmarkSolarEclipseShadowAtJDE(b *testing.B) { func BenchmarkSolarEclipseStationStateAtJDE(b *testing.B) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) - jde := JDECalc(2024, 4, 8) + 0.78 + jde := JDCalc(2024, 4, 8) + 0.78 b.ResetTimer() for index := 0; index < b.N; index++ { _ = solver.StationStateAtJDE(jde+float64(index)*1e-9, -96.8, 32.8, 0) @@ -194,7 +194,7 @@ func BenchmarkSolarEclipseStationStateAtJDE(b *testing.B) { } func TestSolarEclipseShadowClampsBoundaryPoints(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -243,7 +243,7 @@ func TestSolarEclipseShadowHandleSeesDeltaTChange(t *testing.T) { SetDeltaTFn(DefaultDeltaTv2) // 取 2009-07-22 本影阶段内的一个真实时刻(与相邻测试同一取法)。 // Use a real instant inside the 2009-07-22 umbral phase, derived like the neighbour test. - samples := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + samples := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -298,7 +298,7 @@ func TestSolarEclipseShadowHandleSeesDeltaTChange(t *testing.T) { } func TestSolarEclipseShadowPenumbraMatchesPackagedSamples(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) @@ -339,7 +339,7 @@ func TestSolarEclipseShadowPenumbraMatchesPackagedSamples(t *testing.T) { } func TestSolarEclipseShadowKindChangesFootprint(t *testing.T) { - result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) diff --git a/basic/solar_eclipse_test.go b/basic/solar_eclipse_test.go index 0f841f1..087de9f 100644 --- a/basic/solar_eclipse_test.go +++ b/basic/solar_eclipse_test.go @@ -29,7 +29,7 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { testCases := []solarEclipseBaseline{ { name: "2023-04-20 hybrid", - jde: JDECalc(2023, 4, 20), + jde: JDCalc(2023, 4, 20), expectedType: SolarEclipseHybrid, expectedCentrality: SolarEclipseCentralTwoLimits, expectedGreatestTT: solarEclipseTTJDE(2023, time.April, 20, 4, 17, 56), @@ -41,7 +41,7 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { }, { name: "2024-04-08 total", - jde: JDECalc(2024, 4, 8), + jde: JDCalc(2024, 4, 8), expectedType: SolarEclipseTotal, expectedCentrality: SolarEclipseCentralTwoLimits, expectedGreatestTT: solarEclipseTTJDE(2024, time.April, 8, 18, 18, 29), @@ -53,7 +53,7 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { }, { name: "2024-10-02 annular", - jde: JDECalc(2024, 10, 2), + jde: JDCalc(2024, 10, 2), expectedType: SolarEclipseAnnular, expectedCentrality: SolarEclipseCentralTwoLimits, expectedGreatestTT: solarEclipseTTJDE(2024, time.October, 2, 18, 46, 13), @@ -65,7 +65,7 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { }, { name: "2025-03-29 partial", - jde: JDECalc(2025, 3, 29), + jde: JDCalc(2025, 3, 29), expectedType: SolarEclipsePartial, expectedCentrality: SolarEclipseNonCentral, expectedGreatestTT: solarEclipseTTJDE(2025, time.March, 29, 10, 48, 36), @@ -120,7 +120,7 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { } func TestSolarEclipseBesselGeometryCacheSeparatesExactAndCandidate(t *testing.T) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) tt := solver.newMoonJDE + 0.125 exactMoon, exactAxis, exactSun := solver.besselGeometryAt(tt) @@ -165,7 +165,7 @@ func TestSolarEclipseBesselGeometryCacheIsBounded(t *testing.T) { func BenchmarkSolarEclipseBesselGeometryCache(b *testing.B) { solver := newSolarEclipseSolver( - CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0), + CalcMoonSHByJDE(JDCalc(2024, 4, 8), 0), SolarEclipseModelNASABulletinSplitK, ) tt := solver.newMoonJDE + 0.125 @@ -180,7 +180,7 @@ func BenchmarkSolarEclipseBesselGeometryCache(b *testing.B) { } func BenchmarkSolarEclipseRepresentativePath(b *testing.B) { - seed := JDECalc(2024, 4, 8) + seed := JDCalc(2024, 4, 8) options := SolarEclipsePathOptions{StepDays: 5.0 / 1440.0, TargetSpacingKM: 200} b.ReportAllocs() for index := 0; index < b.N; index++ { @@ -189,7 +189,7 @@ func BenchmarkSolarEclipseRepresentativePath(b *testing.B) { } func TestSolarEclipseDefaultUsesNASABulletinSplitK(t *testing.T) { - jde := JDECalc(2024, 4, 8) + jde := JDCalc(2024, 4, 8) defaultResult := SolarEclipse(jde) nasaResult := SolarEclipseNASABulletinSplitK(jde) iauResult := SolarEclipseIAUSingleK(jde) @@ -229,7 +229,7 @@ func TestSolarEclipseNoEvent(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := tc.calc(JDECalc(2023, 5, 15)) + result := tc.calc(JDCalc(2023, 5, 15)) if result.Type != SolarEclipseNone { t.Fatalf("Type mismatch: got %s want %s", result.Type, SolarEclipseNone) } @@ -244,7 +244,7 @@ func TestSolarEclipseNoEvent(t *testing.T) { } func BenchmarkSolarEclipseGlobal(b *testing.B) { - seed := JDECalc(2010, 1, 15) + seed := JDCalc(2010, 1, 15) b.ReportAllocs() for iteration := 0; iteration < b.N; iteration++ { result := SolarEclipse(seed) @@ -255,7 +255,7 @@ func BenchmarkSolarEclipseGlobal(b *testing.B) { } func solarEclipseTTJDE(year int, month time.Month, day, hour, minute, second int) float64 { - return Date2JDE(time.Date(year, month, day, hour, minute, second, 0, time.UTC)) + return Date2JD(time.Date(year, month, day, hour, minute, second, 0, time.UTC)) } func assertSolarEclipseJDEClose(t *testing.T, name string, got, want, tolerance float64) { diff --git a/basic/solar_eclipse_total_envelope_test.go b/basic/solar_eclipse_total_envelope_test.go index 02bf829..d3a2116 100644 --- a/basic/solar_eclipse_total_envelope_test.go +++ b/basic/solar_eclipse_total_envelope_test.go @@ -12,7 +12,7 @@ func TestSolarEclipseTotalEnvelopeHorizonEndpoints(t *testing.T) { {2008, 8, 1}, {2024, 4, 8}, {2026, 8, 12}, } { t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { - seed := JDECalc(date[0], date[1], float64(date[2])) + seed := JDCalc(date[0], date[1], float64(date[2])) result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440, BoundaryPoints: 96, MagnitudeValues: []float64{1}, }) @@ -53,7 +53,7 @@ func TestSolarEclipseTotalEnvelopeHorizonEndpoints(t *testing.T) { } func BenchmarkSolarEclipsePolarTotalBand(b *testing.B) { - seed := JDECalc(2003, 11, 23) + seed := JDCalc(2003, 11, 23) for i := 0; i < b.N; i++ { SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440, BoundaryPoints: 96, DisableRiseSetCurves: true, diff --git a/basic/solar_terms.go b/basic/solar_terms.go index bc389c0..ee0362f 100644 --- a/basic/solar_terms.go +++ b/basic/solar_terms.go @@ -14,9 +14,9 @@ func GetMoonLoops(year float64, loop int) []float64 { i := 1 for j := 0; j < loop; j++ { if year > 3000 { - newMoon = TD2UT(CalcMoonSH(start+float64(i-1)/12.5, 0)+8.0/24.0, false) + newMoon = TT2UTC(CalcMoonSH(start+float64(i-1)/12.5, 0) + 8.0/24.0) } else { - newMoon = TD2UT(CalcMoonS(start+float64(i-1)/12.5, 0)+8.0/24.0, false) + newMoon = TT2UTC(CalcMoonS(start+float64(i-1)/12.5, 0) + 8.0/24.0) } if i != 1 { if newMoon == lastNewMoon { @@ -78,7 +78,7 @@ func GetJQTime(year, angle int) float64 { } // Calculate initial Julian date - initialJD := JDECalc(year, int(initialMonth), initialDay) + initialJD := JDCalc(year, int(initialMonth), initialDay) // Set target angle for iteration; if angle is 0, use 360 targetAngle := float64(angle) @@ -87,9 +87,9 @@ func GetJQTime(year, angle int) float64 { } // Newton-Raphson iteration to find precise Julian date - currentJD := initialJD + currentJDE := initialJD var ok bool - currentJD, ok = eventNewtonRefine(currentJD, 0.00001, func(previousJD float64) float64 { + currentJDE, ok = eventNewtonRefine(currentJDE, 0.00001, func(previousJD float64) float64 { errorValue := JQLospec(previousJD, targetAngle) - targetAngle derivative := (JQLospec(previousJD+0.000005, targetAngle) - JQLospec(previousJD-0.000005, targetAngle)) / 0.00001 return errorValue / derivative @@ -99,11 +99,11 @@ func GetJQTime(year, angle int) float64 { } // Convert to UT and return - return TD2UT(currentJD, false) + return TT2UTC(currentJDE) } -func JQLospec(jd float64, target float64) float64 { - sunLo := HSunApparentLo(jd) +func JQLospec(jde float64, target float64) float64 { + sunLo := HSunApparentLo(jde) if target >= 345 { if sunLo <= 12 { sunLo += 360 diff --git a/basic/star.go b/basic/star.go index e383d93..2f257e6 100644 --- a/basic/star.go +++ b/basic/star.go @@ -8,11 +8,11 @@ import ( // StarHeight 星体的高度角 // 传入 jde时间、瞬时赤经、瞬时赤纬、经度、纬度、时区,jde时间应为时区时间 // 返回高度角,单位为度 -func StarHeight(jde, ra, dec, lon, lat, timezone float64) float64 { +func StarHeight(localJD, ra, dec, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -24,11 +24,11 @@ func StarHeight(jde, ra, dec, lon, lat, timezone float64) float64 { // StarAzimuth 星体的方位角 // 传入 jde时间、瞬时赤经、瞬时赤纬、经度、纬度、时区,jde时间应为时区时间 // 返回方位角,单位为度,正北为0,度数顺时针增加,取值范围[0-360) -func StarAzimuth(jde, ra, dec, lon, lat, timezone float64) float64 { +func StarAzimuth(localJD, ra, dec, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -49,11 +49,11 @@ func StarAzimuth(jde, ra, dec, lon, lat, timezone float64) float64 { // StarHourAngle 星体的时角 // 传入 jde时间、瞬时赤经、瞬时赤纬、经度、时区,jde时间应为时区时间 // 返回时角 -func StarHourAngle(jde, ra, lon, timezone float64) float64 { +func StarHourAngle(localJD, ra, lon, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 return Limit360(st - ra) } @@ -101,7 +101,7 @@ func EarthRotationAngle(jd_ut1 float64) float64 { // jd_tt: TT 时间的儒略日 // 返回值: 格林尼治平恒星时 (弧度) func MeanSiderealTime2006(jd_ut1 float64) float64 { - jd_tt := TD2UT(jd_ut1, true) + jd_tt := UT12TT(jd_ut1) t := (jd_tt - 2451545.0) / 36525.0 era := EarthRotationAngle(jd_ut1) @@ -135,23 +135,23 @@ func ApparentSiderealTime2006(jd float64) float64 { return value } -func StarRiseTime(jde, ra, dec, lon, lat, height, timezone float64, aero bool) (float64, error) { - return StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone, aero, true) +func StarRiseTime(localJD, ra, dec, lon, lat, height, timezone float64, aero bool) (float64, error) { + return StarRiseSetTime(localJD, ra, dec, lon, lat, height, timezone, aero, true) } -func StarSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero bool) (float64, error) { - return StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone, aero, false) +func StarSetTime(localJD, ra, dec, lon, lat, height, timezone float64, aero bool) (float64, error) { + return StarRiseSetTime(localJD, ra, dec, lon, lat, height, timezone, aero, false) } -func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isRise bool) (float64, error) { - if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(dec) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(height) || !isFiniteFloat(timezone) { +func StarRiseSetTime(localJD, ra, dec, lon, lat, height, timezone float64, aero, isRise bool) (float64, error) { + if !isFiniteFloat(localJD) || !isFiniteFloat(ra) || !isFiniteFloat(dec) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(height) || !isFiniteFloat(timezone) { return 0, ErrInvalidObservationInput } - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 + localJD = math.Floor(localJD) + 0.5 targetAltitude := StandardAltitudeStar(aero, height, lat) - sct := StarCulminationTime(jde, ra, lon, timezone) + sct := StarCulminationTime(localJD, ra, lon, timezone) tmp := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat)) if math.Abs(tmp) > 1 { if StarHeight(sct, ra, dec, lon, lat, timezone) < 0 { @@ -177,16 +177,16 @@ func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isR return estimateJD, nil } -func StarCulminationTime(jde, ra, lon, timezone float64) float64 { - if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) { +func StarCulminationTime(localJD, ra, lon, timezone float64) float64 { + if !isFiniteFloat(localJD) || !isFiniteFloat(ra) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) { return math.NaN() } - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-StarHourAngle(jde, ra, lon, timezone))/15.0/24.0*0.99726851851851851851 - limitStarHA := func(jde, ra, lon, timezone float64) float64 { - ha := StarHourAngle(jde, ra, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-StarHourAngle(localJD, ra, lon, timezone))/15.0/24.0*0.99726851851851851851 + limitStarHA := func(localJD, ra, lon, timezone float64) float64 { + ha := StarHourAngle(localJD, ra, lon, timezone) if ha < 180 { ha += 360 } diff --git a/basic/star_catalog.go b/basic/star_catalog.go index 253a3e2..3b30794 100644 --- a/basic/star_catalog.go +++ b/basic/star_catalog.go @@ -252,9 +252,38 @@ func StarDataByHR(hr int) (StarData, error) { return cachedStarData[hr-1], nil } +// RaDecByJde 按 TT 儒略日归算恒星位置,单位为度 / star position in degrees for a TT Julian date. +// Pc>0 时按三维空间运动推进(自行 + RadVel),否则退回只推进角分量的二维。 +// With Pc>0 the position advances in three dimensions (proper motion plus radial velocity); otherwise only the angular components advance. func (s InnerStarData) RaDecByJde(jde float64) (float64, float64) { - // BSC 的 pmRA 是投影自行 cos(Dec)*dRA/dt,而不是 dRA/dt / BSC pmRA is the projected motion cos(Dec)*dRA/dt, not dRA/dt. - year := ((jde - 2451545.0) / 365.2422) + if s.distanceAU() > 0 { + return s.raDecByJde3D(jde) + } + return s.raDecByJde2D(jde) +} + +// RaDecByDate 按给定时刻归算恒星位置,单位为度 / star position in degrees at the given instant. +// 传入时刻按 UTC 解释并换算成 TT 儒略日,与 RaDecByJde 同口径。 +// The instant is read as UTC and converted to a TT Julian date, the same convention as RaDecByJde. +func (s StarData) RaDecByDate(date time.Time) (float64, float64) { + // 星表历元与岁差都按 TT 度量,自行归算的历元差必须用 TT 儒略日。 + jde := UTC2TT(Date2JD(date.UTC())) + return s.RaDecByJde(jde) +} + +func (s InnerStarData) distanceAU() float64 { + if s.Pc <= 0 { + return 0 + } + // 秒差距到天文单位:648000/π 为定义值。 + return s.Pc * 648000 / math.Pi +} + +// raDecByJde2D 只推进赤经赤纬两个角分量,等价于把恒星当作无穷远。 +func (s InnerStarData) raDecByJde2D(jde float64) (float64, float64) { + // 自行按儒略年计:J2000 是儒略历元,年长 365.25 日而非回归年。 + // pmRA 是投影自行 cos(Dec)*dRA/dt,不是 dRA/dt。 + year := (jde - 2451545.0) / 365.25 dec := s.Dec + year*s.PmDec/3600 cosDec := math.Cos(s.Dec * math.Pi / 180) ra := s.Ra @@ -264,7 +293,28 @@ func (s InnerStarData) RaDecByJde(jde float64) (float64, float64) { return Precess(ra, dec, 2451545.0, jde) } -func (s StarData) RaDecByDate(date time.Time) (float64, float64) { - jde := Date2JDE(date.UTC()) - return s.RaDecByJde(jde) +// raDecByJde3D 按三维匀速直线运动推进位置矢量,赤经赤纬只是其方向。 +func (s InnerStarData) raDecByJde3D(jde float64) (float64, float64) { + year := (jde - 2451545.0) / 365.25 + raRad := s.Ra * math.Pi / 180 + decRad := s.Dec * math.Pi / 180 + sinDec, cosDec := math.Sin(decRad), math.Cos(decRad) + distance := s.distanceAU() + // 切向速度 AU/年 = 角速度(rad/年) × 距离;径向速度 km/s 换算为 AU/年。 + pmRA := s.PmRA * math.Pi / (180 * 3600) * distance + pmDec := s.PmDec * math.Pi / (180 * 3600) * distance + radial := s.RadVel * 365.25 * 86400 / 149597870.7 + cosRA, sinRA := math.Cos(raRad), math.Sin(raRad) + position := [3]float64{ + distance*cosDec*cosRA + year*(pmDec*(-sinDec*cosRA)-pmRA*sinRA+radial*cosDec*cosRA), + distance*cosDec*sinRA + year*(pmDec*(-sinDec*sinRA)+pmRA*cosRA+radial*cosDec*sinRA), + distance*sinDec + year*(pmDec*cosDec+radial*sinDec), + } + norm := math.Sqrt(position[0]*position[0] + position[1]*position[1] + position[2]*position[2]) + ra := math.Atan2(position[1], position[0]) * 180 / math.Pi + if ra < 0 { + ra += 360 + } + dec := math.Asin(position[2]/norm) * 180 / math.Pi + return Precess(ra, dec, 2451545.0, jde) } diff --git a/basic/star_catalog_test.go b/basic/star_catalog_test.go index 996a1db..d26be33 100644 --- a/basic/star_catalog_test.go +++ b/basic/star_catalog_test.go @@ -63,7 +63,7 @@ func TestGetRaDecByDate(t *testing.T) { func TestRaDecByJdeUsesProjectedRightAscensionMotion(t *testing.T) { star := InnerStarData{Ra: 10, Dec: 60, PmRA: 3600, PmDec: 0} - jde := 2451545.0 + 365.2422 + jde := 2451545.0 + 365.25 ra, dec := star.RaDecByJde(jde) // 投影坐标每年 1 度在 Dec=60 度时对应赤经每年 2 度 / 1 deg/year in the projected coordinate is 2 deg/year in RA at Dec=60. wantRA, wantDec := Precess(12, 60, 2451545.0, jde) diff --git a/basic/star_motion_3d_test.go b/basic/star_motion_3d_test.go new file mode 100644 index 0000000..717e253 --- /dev/null +++ b/basic/star_motion_3d_test.go @@ -0,0 +1,135 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +// 本文件锁定恒星自行的口径:无距离时二维、有距离时三维、两者只差二阶项。 + +func starSepArcsec(ra1, dec1, ra2, dec2 float64) float64 { + ra1Rad := ra1 * math.Pi / 180 + dec1Rad := dec1 * math.Pi / 180 + ra2Rad := ra2 * math.Pi / 180 + dec2Rad := dec2 * math.Pi / 180 + cosine := math.Sin(dec1Rad)*math.Sin(dec2Rad) + math.Cos(dec1Rad)*math.Cos(dec2Rad)*math.Cos(ra1Rad-ra2Rad) + return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi * 3600 +} + +func TestRaDecByJdeFallsBackToTwoDimensionsWithoutDistance(t *testing.T) { + stars := []InnerStarData{ + {Ra: 10, Dec: 60, PmRA: 3600, PmDec: -1800, RadVel: 50}, + {Ra: 359.5, Dec: -89.99, PmRA: -1200, PmDec: 900, RadVel: -80}, + {Ra: 0, Dec: 90, PmRA: 1000, PmDec: 1000}, + } + for _, star := range stars { + for _, jde := range []float64{2451545.0, 2451545.0 + 26*365.25, 2451545.0 - 100*365.25} { + gotRA, gotDec := star.RaDecByJde(jde) + wantRA, wantDec := star.raDecByJde2D(jde) + if gotRA != wantRA || gotDec != wantDec { + t.Fatalf("no-distance path = %.12f %.12f, want bit-identical to 2D %.12f %.12f", gotRA, gotDec, wantRA, wantDec) + } + } + } +} + +func TestRaDecByJdeUsesJulianYear(t *testing.T) { + // 高自行近星才让儒略年与回归年的年长差在三坐标上可测。 + star := InnerStarData{Ra: 10, Dec: 0, PmRA: 3600, Pc: 1, RadVel: -100} + jde := 2451545.0 + 365.25 + gotRA, gotDec := star.RaDecByJde(jde) + wantRA, wantDec := star.raDecByJde3D(jde) + if gotRA != wantRA || gotDec != wantDec { + t.Fatalf("RaDecByJde = %.12f %.12f, want 3D path %.12f %.12f", gotRA, gotDec, wantRA, wantDec) + } + tropicalRA, tropicalDec := star.raDecByJde3D(2451545.0 + 365.2422) + // 两种年长每年差 11.23 分钟,乘以 2 度/年的自行量级实测约 0.078 角秒。 + if separation := starSepArcsec(gotRA, gotDec, tropicalRA, tropicalDec); separation < 0.05 { + t.Fatalf("Julian and tropical year lengths differ by only %.6f arcsec, want a distinguishable epoch step", separation) + } +} + +func TestRaDecByJdeThreeDimensionsMatchesIntegration(t *testing.T) { + stars := []InnerStarData{ + {Ra: 224.366667, Dec: -21.415556, PmRA: 1.045, PmDec: -1.729, Pc: 5.7803, RadVel: 20}, + {Ra: 101.287083, Dec: -16.716111, PmRA: -0.553, PmDec: -1.205, Pc: 2.6667, RadVel: -8}, + {Ra: 44.565278, Dec: 23.6, PmRA: 2.01, PmDec: -1.9, Pc: 1.83, RadVel: -110.6}, + } + for _, star := range stars { + for _, years := range []float64{26, 100, 1000} { + jde := 2451545.0 + years*365.25 + gotRA, gotDec := star.RaDecByJde(jde) + // 真值只替代自行推进这一段,岁差链必须与主路径一致。 + wantRA, wantDec := integrateInnerStarMotion(star, years) + wantRA, wantDec = Precess(wantRA, wantDec, 2451545.0, jde) + if separation := starSepArcsec(gotRA, gotDec, wantRA, wantDec); separation > 0.01 { + t.Fatalf("motion at %.0f years = %.12f %.12f, want %.12f %.12f (%.6f arcsec apart)", + years, gotRA, gotDec, wantRA, wantDec, separation) + } + } + } +} + +// integrateInnerStarMotion 用极小步长数值积分三维匀速直线运动,作为解析式的独立真值。 +func integrateInnerStarMotion(star InnerStarData, years float64) (float64, float64) { + const steps = 20000 + raRad := star.Ra * math.Pi / 180 + decRad := star.Dec * math.Pi / 180 + cosDec, sinDec := math.Cos(decRad), math.Sin(decRad) + distance := star.distanceAU() + pmRA := star.PmRA * math.Pi / (180 * 3600) * distance + pmDec := star.PmDec * math.Pi / (180 * 3600) * distance + radial := star.RadVel * 365.25 * 86400 / 149597870.7 + velocity := [3]float64{ + pmDec*(-sinDec*math.Cos(raRad)) - pmRA*math.Sin(raRad) + radial*cosDec*math.Cos(raRad), + pmDec*(-sinDec*math.Sin(raRad)) + pmRA*math.Cos(raRad) + radial*cosDec*math.Sin(raRad), + pmDec*cosDec + radial*sinDec, + } + position := [3]float64{distance * cosDec * math.Cos(raRad), distance * cosDec * math.Sin(raRad), distance * sinDec} + step := years / steps + for i := 0; i < steps; i++ { + for axis := range position { + position[axis] += step * velocity[axis] + } + } + norm := math.Sqrt(position[0]*position[0] + position[1]*position[1] + position[2]*position[2]) + ra := math.Atan2(position[1], position[0]) * 180 / math.Pi + if ra < 0 { + ra += 360 + } + return ra, math.Asin(position[2]/norm) * 180 / math.Pi +} + +func TestRaDecByDateUsesTTEpoch(t *testing.T) { + star := StarData{InnerStarData: InnerStarData{Ra: 120, Dec: 20, PmRA: 3600, PmDec: 3600, Pc: 0.5, RadVel: -60}} + date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + gotRA, gotDec := star.RaDecByDate(date) + wantRA, wantDec := star.InnerStarData.RaDecByJde(UTC2TT(Date2JD(date.UTC()))) + if gotRA != wantRA || gotDec != wantDec { + t.Fatalf("RaDecByDate = %.12f %.12f, want TT propagation %.12f %.12f", gotRA, gotDec, wantRA, wantDec) + } + // 一千年跨度下径向项把 TT 与民用时的历元差放大到可测,务必不要退回 UTC。 + longDate := time.Date(3026, 1, 1, 0, 0, 0, 0, time.UTC) + longTTRA, longTTDec := star.RaDecByDate(longDate) + longUTCRA, longUTCDec := star.InnerStarData.RaDecByJde(Date2JD(longDate.UTC())) + // 3026 年时 TT-UTC 为 1.28 小时,实测该口径差约 0.0043 角秒。 + if separation := starSepArcsec(longTTRA, longTTDec, longUTCRA, longUTCDec); separation < 0.002 { + t.Fatalf("TT and UTC epochs differ by only %.9f arcsec at 1000 years, want a distinguishable TT path", separation) + } +} + +func TestRaDecByJdeDistanceGateUsesCatalogDistance(t *testing.T) { + star := InnerStarData{Ra: 224.366667, Dec: -21.415556, PmRA: 1.045, PmDec: -1.729, Pc: 5.7803, RadVel: -100} + jde := 2451545.0 + 100*365.25 + if star.distanceAU() == 0 { + t.Fatal("catalog distance should be positive for a star with Pc > 0") + } + withDistanceRA, withDistanceDec := star.RaDecByJde(jde) + star.Pc = 0 + withoutDistanceRA, withoutDistanceDec := star.RaDecByJde(jde) + separation := starSepArcsec(withDistanceRA, withDistanceDec, withoutDistanceRA, withoutDistanceDec) + if separation < 0.1 || separation > 1.5 { + t.Fatalf("radial term should separate the two branches by a few tenths of an arcsec at 100 years, got %.6f", separation) + } +} diff --git a/basic/station_truth_test.go b/basic/station_truth_test.go index edc8f70..2907e81 100644 --- a/basic/station_truth_test.go +++ b/basic/station_truth_test.go @@ -29,7 +29,7 @@ func mustJST(value string) time.Time { } func toUTJD(t time.Time) float64 { - return TD2UT(Date2JDE(t.UTC()), true) + return UTC2TT(Date2JD(t.UTC())) } func TestStationTruthAgainstNAOJ(t *testing.T) { @@ -140,8 +140,8 @@ func TestStationTruthAgainstNAOJ(t *testing.T) { case "P2R": before := event.when.Add(-24 * time.Hour) after := event.when.Add(24 * time.Hour) - nextP2R := JDE2DateByZone(tc.nextP2R(toUTJD(before)), event.when.Location(), false) - lastP2R := JDE2DateByZone(tc.lastP2R(toUTJD(after)), event.when.Location(), false) + nextP2R := JD2DateByZone(tc.nextP2R(toUTJD(before)), event.when.Location(), false) + lastP2R := JD2DateByZone(tc.lastP2R(toUTJD(after)), event.when.Location(), false) if !sameMinute(nextP2R, event.when) { t.Fatalf("%s next P2R mismatch: got %s want %s", tc.name, nextP2R, event.when) } @@ -151,8 +151,8 @@ func TestStationTruthAgainstNAOJ(t *testing.T) { case "R2P": before := event.when.Add(-24 * time.Hour) after := event.when.Add(24 * time.Hour) - nextR2P := JDE2DateByZone(tc.nextR2P(toUTJD(before)), event.when.Location(), false) - lastR2P := JDE2DateByZone(tc.lastR2P(toUTJD(after)), event.when.Location(), false) + nextR2P := JD2DateByZone(tc.nextR2P(toUTJD(before)), event.when.Location(), false) + lastR2P := JD2DateByZone(tc.lastR2P(toUTJD(after)), event.when.Location(), false) if !sameMinute(nextR2P, event.when) { t.Fatalf("%s next R2P mismatch: got %s want %s", tc.name, nextR2P, event.when) } @@ -166,8 +166,8 @@ func TestStationTruthAgainstNAOJ(t *testing.T) { continue } query := event.when - lastP2R := JDE2DateByZone(tc.lastP2R(toUTJD(query)), query.Location(), false) - nextR2P := JDE2DateByZone(tc.nextR2P(toUTJD(query)), query.Location(), false) + lastP2R := JD2DateByZone(tc.lastP2R(toUTJD(query)), query.Location(), false) + nextR2P := JD2DateByZone(tc.nextR2P(toUTJD(query)), query.Location(), false) if !sameMinute(lastP2R, prev) { t.Fatalf("%s opposition last P2R mismatch: got %s want %s", tc.name, lastP2R, prev) } diff --git a/basic/sun.go b/basic/sun.go index 2312f44..e6a67c2 100644 --- a/basic/sun.go +++ b/basic/sun.go @@ -6,50 +6,50 @@ import ( ) // SunLo 太阳几何黄经 -func SunLo(jd float64) float64 { - return planet.SunLo(jd) +func SunLo(jde float64) float64 { + return planet.SunLo(jde) } -func SunM(jd float64) float64 { - return planet.SunM(jd) +func SunM(jde float64) float64 { + return planet.SunM(jde) } /* @name 地球偏心率 */ -func Earthe(jd float64) float64 { //'地球偏心率 - return planet.Earthe(jd) +func Earthe(jde float64) float64 { //'地球偏心率 + return planet.Earthe(jde) } -func EarthPI(jd float64) float64 { //近日點經度 - return planet.EarthPI(jd) +func EarthPI(jde float64) float64 { //近日點經度 + return planet.EarthPI(jde) } -func SunMidFun(jd float64) float64 { //'太阳中间方程 - return planet.SunMidFun(jd) +func SunMidFun(jde float64) float64 { //'太阳中间方程 + return planet.SunMidFun(jde) } -func SunTrueLo(jd float64) float64 { // '太阳真黄经 - return planet.SunTrueLo(jd) +func SunTrueLo(jde float64) float64 { // '太阳真黄经 + return planet.SunTrueLo(jde) } -func SunApparentLo(jd float64) float64 { //'太阳视黄经 - return planet.SunApparentLo(jd) +func SunApparentLo(jde float64) float64 { //'太阳视黄经 + return planet.SunApparentLo(jde) } -func SunApparentRa(jd float64) float64 { // '太阳视赤经 - return LoToRa(jd, SunApparentLo(jd), 0) +func SunApparentRa(jde float64) float64 { // '太阳视赤经 + return LoToRa(jde, SunApparentLo(jde), 0) } -func SunApparentRaDec(jd float64) (float64, float64) { - return LoBoToRaDec(jd, SunApparentLo(jd), 0) +func SunApparentRaDec(jde float64) (float64, float64) { + return LoBoToRaDec(jde, SunApparentLo(jde), 0) } -func SunTrueRa(jd float64) float64 { //'太阳真赤经 - eps := TrueObliquity(jd) - sunTrueRa := ArcTan(Cos(eps) * Sin(SunTrueLo(jd)) / Cos(SunTrueLo(jd))) +func SunTrueRa(jde float64) float64 { //'太阳真赤经 + eps := TrueObliquity(jde) + sunTrueRa := ArcTan(Cos(eps) * Sin(SunTrueLo(jde)) / Cos(SunTrueLo(jde))) //Select Case SunTrueLo(JD) - sunTrueLo := SunTrueLo(jd) + sunTrueLo := SunTrueLo(jde) if sunTrueLo >= 90 && sunTrueLo < 180 { sunTrueRa = 180 + sunTrueRa } else if sunTrueLo >= 180 && sunTrueLo < 270 { @@ -60,30 +60,34 @@ func SunTrueRa(jd float64) float64 { //'太阳真赤经 return sunTrueRa } -func SunApparentDec(jd float64) float64 { // '太阳视赤纬 +func SunApparentDec(jde float64) float64 { // '太阳视赤纬 // TrueObliquity 已是“平交角 + 交角章动”,不能再加 0.00256*cos(Ω): // 那一项就是交角章动的近似值(见《天文算法》24 章译者注),加了会把章动计两次。 - eps := TrueObliquity(jd) - sunApparentDec := ArcSin(Sin(eps) * Sin(SunApparentLo(jd))) + eps := TrueObliquity(jde) + sunApparentDec := ArcSin(Sin(eps) * Sin(SunApparentLo(jde))) return sunApparentDec } -func SunTrueDec(jd float64) float64 { // '太阳真赤纬 - eps := TrueObliquity(jd) - sunTrueDec := ArcSin(Sin(eps) * Sin(SunTrueLo(jd))) +func SunTrueDec(jde float64) float64 { // '太阳真赤纬 + eps := TrueObliquity(jde) + sunTrueDec := ArcSin(Sin(eps) * Sin(SunTrueLo(jde))) return sunTrueDec } -func SunTime(jd float64) float64 { //均时差 - tm := (SunLo(jd) - 0.0057183 - (HSunApparentRa(jd)) + (Nutation2000Bi(jd))*Cos(TrueObliquity(jd))) / 15 +// SunTime 均时差,单位小时 / equation of time in hours. +// +// jde 按力学时取用;CulminationTime 传的是本地民用日锚点,历元差最大半天, +// 对均时差的影响在 10 s 量级,是该近似入口的既有取舍。 +func SunTime(jde float64) float64 { //均时差 + tm := (SunLo(jde) - 0.0057183 - (HSunApparentRa(jde)) + (Nutation2000Bi(jde))*Cos(TrueObliquity(jde))) / 15 if tm > 23 { tm = -24 + tm } return tm } -func SunTimeN(jd float64, n int) float64 { //均时差 - tm := (SunLo(jd) - 0.0057183 - (HSunApparentRaN(jd, n)) + (Nutation2000Bi(jd))*Cos(TrueObliquity(jd))) / 15 +func SunTimeN(jde float64, n int) float64 { //均时差 + tm := (SunLo(jde) - 0.0057183 - (HSunApparentRaN(jde, n)) + (Nutation2000Bi(jde))*Cos(TrueObliquity(jde))) / 15 if tm > 23 { tm = -24 + tm } @@ -99,84 +103,84 @@ func SunSC(lo, jd float64) float64 { //黄道上的岁差,仅黄纬=0时 } // 高精度,使用VSOP87 -func HSunTrueLo(jd float64) float64 { - return HSunTrueLoN(jd, -1) +func HSunTrueLo(jde float64) float64 { + return HSunTrueLoN(jde, -1) } // HSunTrueLoN 高精度太阳真黄经,n<0 时取全量 VSOP 项。 -func HSunTrueLoN(jd float64, n int) float64 { - return planet.WherePlanetN(0, 0, jd, n) +func HSunTrueLoN(jde float64, n int) float64 { + return planet.WherePlanetN(0, 0, jde, n) } -func HSunTrueBo(jd float64) float64 { - return HSunTrueBoN(jd, -1) +func HSunTrueBo(jde float64) float64 { + return HSunTrueBoN(jde, -1) } // HSunTrueBoN 高精度太阳真黄纬,n<0 时取全量 VSOP 项。 -func HSunTrueBoN(jd float64, n int) float64 { - return planet.WherePlanetN(0, 1, jd, n) +func HSunTrueBoN(jde float64, n int) float64 { + return planet.WherePlanetN(0, 1, jde, n) } -func HSunApparentLo(jd float64) float64 { - return HSunApparentLoN(jd, -1) +func HSunApparentLo(jde float64) float64 { + return HSunApparentLoN(jde, -1) } -func HSunApparentLoN(jd float64, n int) float64 { - lo := HSunTrueLoN(jd, n) - lo = lo + Nutation2000Bi(jd) + SunLoGXCN(jd, n) +func HSunApparentLoN(jde float64, n int) float64 { + lo := HSunTrueLoN(jde, n) + lo = lo + Nutation2000Bi(jde) + SunLoGXCN(jde, n) return lo } -func SunLoGXC(jd float64) float64 { - return SunLoGXCN(jd, -1) +func SunLoGXC(jde float64) float64 { + return SunLoGXCN(jde, -1) } -func SunLoGXCN(jd float64, n int) float64 { - radius := EarthAwayN(jd, n) +func SunLoGXCN(jde float64, n int) float64 { + radius := EarthAwayN(jde, n) return -20.49552 / radius / 3600 } -func EarthAway(jd float64) float64 { - return EarthAwayN(jd, -1) +func EarthAway(jde float64) float64 { + return EarthAwayN(jde, -1) } -func EarthAwayN(jd float64, n int) float64 { - return planet.WherePlanetN(0, 2, jd, n) +func EarthAwayN(jde float64, n int) float64 { + return planet.WherePlanetN(0, 2, jde, n) } -func HSunApparentRaDec(jd float64) (float64, float64) { - return HSunApparentRaDecN(jd, -1) +func HSunApparentRaDec(jde float64) (float64, float64) { + return HSunApparentRaDecN(jde, -1) } -func HSunApparentRaDecN(jd float64, n int) (float64, float64) { - ra, dec, _ := hSunApparentRaDecDistanceN(jd, n) +func HSunApparentRaDecN(jde float64, n int) (float64, float64) { + ra, dec, _ := hSunApparentRaDecDistanceN(jde, n) return ra, dec } -func hSunApparentRaDecDistanceN(jd float64, n int) (ra, dec, distanceAU float64) { - trueLongitude := HSunTrueLoN(jd, n) - trueLatitude := HSunTrueBoN(jd, n) - distanceAU = EarthAwayN(jd, n) - apparentLongitude := trueLongitude + Nutation2000Bi(jd) - 20.49552/distanceAU/3600 - ra, dec = LoBoToRaDec(jd, apparentLongitude, trueLatitude) +func hSunApparentRaDecDistanceN(jde float64, n int) (ra, dec, distanceAU float64) { + trueLongitude := HSunTrueLoN(jde, n) + trueLatitude := HSunTrueBoN(jde, n) + distanceAU = EarthAwayN(jde, n) + apparentLongitude := trueLongitude + Nutation2000Bi(jde) - 20.49552/distanceAU/3600 + ra, dec = LoBoToRaDec(jde, apparentLongitude, trueLatitude) return ra, dec, distanceAU } -func HSunApparentRa(jd float64) float64 { // '太阳视赤经 - return HSunApparentRaN(jd, -1) +func HSunApparentRa(jde float64) float64 { // '太阳视赤经 + return HSunApparentRaN(jde, -1) } -func HSunApparentRaN(jd float64, n int) float64 { // '太阳视赤经 - return LoToRa(jd, HSunApparentLoN(jd, n), HSunTrueBoN(jd, n)) +func HSunApparentRaN(jde float64, n int) float64 { // '太阳视赤经 + return LoToRa(jde, HSunApparentLoN(jde, n), HSunTrueBoN(jde, n)) } -func HSunTrueRa(jd float64) float64 { - return HSunTrueRaN(jd, -1) +func HSunTrueRa(jde float64) float64 { + return HSunTrueRaN(jde, -1) } -func HSunTrueRaN(jd float64, n int) float64 { - sunTrueLo := HSunTrueLoN(jd, n) - eps := TrueObliquity(jd) +func HSunTrueRaN(jde float64, n int) float64 { + sunTrueLo := HSunTrueLoN(jde, n) + eps := TrueObliquity(jde) numerator := Cos(eps) * Sin(sunTrueLo) denominator := Cos(sunTrueLo) @@ -184,22 +188,22 @@ func HSunTrueRaN(jd float64, n int) float64 { return ArcTan2(numerator, denominator) } -func HSunApparentDec(jd float64) float64 { // '太阳视赤纬 - return HSunApparentDecN(jd, -1) +func HSunApparentDec(jde float64) float64 { // '太阳视赤纬 + return HSunApparentDecN(jde, -1) } -func HSunApparentDecN(jd float64, n int) float64 { // '太阳视赤纬 - return ArcSin(Sin(EclipticObliquity(jd, true)) * Sin(HSunApparentLoN(jd, n))) +func HSunApparentDecN(jde float64, n int) float64 { // '太阳视赤纬 + return ArcSin(Sin(EclipticObliquity(jde, true)) * Sin(HSunApparentLoN(jde, n))) } -func HSunTrueDec(jd float64) float64 { // '太阳真赤纬 - return HSunTrueDecN(jd, -1) +func HSunTrueDec(jde float64) float64 { // '太阳真赤纬 + return HSunTrueDecN(jde, -1) } -func HSunTrueDecN(jd float64, n int) float64 { // '太阳真赤纬 - return ArcSin(Sin(EclipticObliquity(jd, false)) * Sin(HSunTrueLoN(jd, n))) +func HSunTrueDecN(jde float64, n int) float64 { // '太阳真赤纬 + return ArcSin(Sin(EclipticObliquity(jde, false)) * Sin(HSunTrueLoN(jde, n))) } -func Distance(jd float64) float64 { //ri di ju li - return planet.Distance(jd) +func Distance(jde float64) float64 { //ri di ju li + return planet.Distance(jde) } diff --git a/basic/sun_observation.go b/basic/sun_observation.go index 4bb66e5..11f1765 100644 --- a/basic/sun_observation.go +++ b/basic/sun_observation.go @@ -8,20 +8,21 @@ import ( // CulminationTime 太阳中天时刻(按均时差计算)/ solar culmination time from the equation of time. // -// 日期锚点是 floor(jd)(JD 整数 = 12:00 UT 的正午锚点),不是午夜;调用方要传“本地 0 时对应 JD + 0.5” +// localJD 是本地民用日锚点,只取 floor(localJD)(JD 整数 = 12:00 的正午锚点),既不是午夜也不是力学时; +// 调用方要传“本地 0 时对应 JD + 0.5” // 才能落在同一本地日(sun/sun.go 的 CulminationTime 就是这么补的)。地方时相对世界时的偏移按角度归化到 // ±180°:超过 ±12 小时(如 UTC+14 配西经)时不归化会把中天推到相邻的一天。 -func CulminationTime(jd, lon, tz float64) float64 { //实际中天时间 - jd = math.Floor(jd) +func CulminationTime(localJD, lon, tz float64) float64 { //实际中天时间 + localJD = math.Floor(localJD) tmp := longitudeOffsetDegrees(tz*15-lon) * 4 / 60 - return jd + tmp/24.0 - SunTime(jd)/24.0 + return localJD + tmp/24.0 - SunTime(localJD)/24.0 } // CulminationTimeN 截断项太阳中天时刻 / truncated solar culmination time. -func CulminationTimeN(jd, lon, tz float64, n int) float64 { //实际中天时间 - jd = math.Floor(jd) +func CulminationTimeN(localJD, lon, tz float64, n int) float64 { //实际中天时间 + localJD = math.Floor(localJD) tmp := longitudeOffsetDegrees(tz*15-lon) * 4 / 60 - return jd + tmp/24.0 - SunTimeN(jd, n)/24.0 + return localJD + tmp/24.0 - SunTimeN(localJD, n)/24.0 } func longitudeOffsetDegrees(offset float64) float64 { @@ -34,13 +35,11 @@ func longitudeOffsetDegrees(offset float64) float64 { return offset } -/* - * 昏朦影传入 当天0时时刻 - */ -func EveningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error) { - jd = math.Floor(jd) + 1.5 +// EveningTwilight 昏朦影;localJD 是本地民用日锚点(当地 0 时),只取整数日。 +func EveningTwilight(localJD, lon, lat, tz, targetAltitude float64) (float64, error) { + localJD = math.Floor(localJD) + 1.5 localTimeZone := math.Round(lon / 15) - culminationTime := CulminationTime(jd, lon, localTimeZone) + culminationTime := CulminationTime(localJD, lon, localTimeZone) if SunHeight(culminationTime, lon, lat, localTimeZone) < targetAltitude { return 0, ErrNeverRise } @@ -76,10 +75,10 @@ func EveningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error) return estimateJD - localTimeZone/24 + tz/24, nil } -func EveningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64, error) { - jd = math.Floor(jd) + 1.5 +func EveningTwilightN(localJD, lon, lat, tz, targetAltitude float64, n int) (float64, error) { + localJD = math.Floor(localJD) + 1.5 localTimeZone := math.Round(lon / 15) - culminationTime := CulminationTimeN(jd, lon, localTimeZone, n) + culminationTime := CulminationTimeN(localJD, lon, localTimeZone, n) if SunHeightN(culminationTime, lon, lat, localTimeZone, n) < targetAltitude { return 0, ErrNeverRise } @@ -115,15 +114,15 @@ func EveningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64, return estimateJD - localTimeZone/24 + tz/24, nil } -func MorningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error) { +func MorningTwilight(localJD, lon, lat, tz, targetAltitude float64) (float64, error) { // 调整到中午12点 - jd = math.Floor(jd) + 1.5 + localJD = math.Floor(localJD) + 1.5 // 计算时区 localTimeZone := math.Round(lon / 15) // 计算太阳上中天时间 - culminationTime := CulminationTime(jd, lon, localTimeZone) + culminationTime := CulminationTime(localJD, lon, localTimeZone) // 检查极夜和极昼条件 if SunHeight(culminationTime, lon, lat, localTimeZone) < targetAltitude { @@ -162,10 +161,10 @@ func MorningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error) return estimateJD - localTimeZone/24 + tz/24, nil } -func MorningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64, error) { - jd = math.Floor(jd) + 1.5 +func MorningTwilightN(localJD, lon, lat, tz, targetAltitude float64, n int) (float64, error) { + localJD = math.Floor(localJD) + 1.5 localTimeZone := math.Round(lon / 15) - culminationTime := CulminationTimeN(jd, lon, localTimeZone, n) + culminationTime := CulminationTimeN(localJD, lon, localTimeZone, n) if SunHeightN(culminationTime, lon, lat, localTimeZone, n) < targetAltitude { return 0, ErrNeverRise } @@ -205,8 +204,8 @@ func MorningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64, * 太阳时角 */ func SunTimeAngle(jd, lon, lat, tz float64) float64 { - startime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon) - timeangle := startime - HSunApparentRa(TD2UT(jd-tz/24, true)) + startime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon) + timeangle := startime - HSunApparentRa(UTC2TT(jd-tz/24)) if timeangle < 0 { timeangle += 360 } @@ -214,8 +213,8 @@ func SunTimeAngle(jd, lon, lat, tz float64) float64 { } func SunTimeAngleN(jd, lon, lat, tz float64, n int) float64 { - startime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon) - timeangle := startime - HSunApparentRaN(TD2UT(jd-tz/24, true), n) + startime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon) + timeangle := startime - HSunApparentRaN(UTC2TT(jd-tz/24), n) if timeangle < 0 { timeangle += 360 } @@ -229,8 +228,8 @@ type sunObservationState struct { func sunObservationStateN(jd, lon, lat, tz float64, n int) sunObservationState { calculationJD := jd - tz/24.0 - tt := TD2UT(calculationJD, true) - siderealTime := Limit360(ApparentSiderealTime(calculationJD)*15 + lon) + tt := UTC2TT(calculationJD) + siderealTime := Limit360(ApparentSiderealTime(UTC2UT1(calculationJD))*15 + lon) ra, dec, distanceAU := hSunApparentRaDecDistanceN(tt, n) hourAngle := Limit360(siderealTime - ra) altitudeSine := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle) @@ -262,7 +261,7 @@ func sunRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, }) } -// GetSunRiseTime 精确计算日出时间,传入当日0时JDE +// GetSunRiseTime 精确计算日出时间,传入本地民用日 0 时锚点 func GetSunRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) { return calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height, true) } @@ -271,7 +270,7 @@ func GetSunRiseTimeN(julianDay, longitude, latitude, timeZone, zenithShift, heig return calculateSunRiseSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, height, true, n) } -// GetSunSetTime 精确计算日落时间,传入当日0时JDE +// GetSunSetTime 精确计算日落时间,传入本地民用日 0 时锚点 func GetSunSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) { return calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height, false) } @@ -501,9 +500,9 @@ func LowSunHeight(jd, lon, lat, tz float64) float64 { //tmp := (tz*15 - lon) * 4 / 60 //truejd := jd - tmp/24 calcjd := jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) - hourAngle := Limit360(st - SunApparentRa(TD2UT(calcjd, true))) - dec := SunApparentDec(TD2UT(calcjd, true)) + st := Limit360(ApparentSiderealTime(UTC2UT1(calcjd))*15 + lon) + hourAngle := Limit360(st - SunApparentRa(UTC2TT(calcjd))) + dec := SunApparentDec(UTC2TT(calcjd)) tmp2 := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle) return ArcSin(tmp2) } @@ -519,9 +518,9 @@ func SunAzimuth(jd, lon, lat, tz float64) float64 { //tmp := (tz*15 - lon) * 4 / 60 //truejd := jd - tmp/24 calcjd := jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) - hourAngle := Limit360(st - HSunApparentRa(TD2UT(calcjd, true))) - tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(HSunApparentDec(TD2UT(calcjd, true)))*Cos(lat)) + st := Limit360(ApparentSiderealTime(UTC2UT1(calcjd))*15 + lon) + hourAngle := Limit360(st - HSunApparentRa(UTC2TT(calcjd))) + tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(HSunApparentDec(UTC2TT(calcjd)))*Cos(lat)) azimuth := ArcTan(tmp2) if azimuth < 0 { if hourAngle/15 < 12 { @@ -537,9 +536,9 @@ func SunAzimuth(jd, lon, lat, tz float64) float64 { func SunAzimuthN(jd, lon, lat, tz float64, n int) float64 { calcjd := jd - tz/24 - st := Limit360(ApparentSiderealTime(calcjd)*15 + lon) - hourAngle := Limit360(st - HSunApparentRaN(TD2UT(calcjd, true), n)) - tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(HSunApparentDecN(TD2UT(calcjd, true), n))*Cos(lat)) + st := Limit360(ApparentSiderealTime(UTC2UT1(calcjd))*15 + lon) + hourAngle := Limit360(st - HSunApparentRaN(UTC2TT(calcjd), n)) + tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(HSunApparentDecN(UTC2TT(calcjd), n))*Cos(lat)) azimuth := ArcTan(tmp2) if azimuth < 0 { if hourAngle/15 < 12 { diff --git a/basic/sun_observation_n_test.go b/basic/sun_observation_n_test.go index b7b2d98..06cb909 100644 --- a/basic/sun_observation_n_test.go +++ b/basic/sun_observation_n_test.go @@ -10,8 +10,8 @@ import ( func TestBasicSunObservationNFullMatchesDefault(t *testing.T) { date := time.Date(2026, 4, 26, 9, 30, 45, 123456789, time.FixedZone("CST", 8*3600)) - ttJD := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - jde := basic.Date2JDE(date) + ttJD := basic.UTC2TT(basic.Date2JD(date.UTC())) + jde := basic.Date2JD(date) lon := 116.391 lat := 39.907 tz := 8.0 diff --git a/basic/sun_physical.go b/basic/sun_physical.go index d14a6ee..de7f4c7 100644 --- a/basic/sun_physical.go +++ b/basic/sun_physical.go @@ -26,16 +26,16 @@ type SunPhysicalInfo struct { } // SunPhysical 太阳物理观测参数 / physical observing parameters of the Sun. -func SunPhysical(jd float64) SunPhysicalInfo { - return SunPhysicalN(jd, -1) +func SunPhysical(jde float64) SunPhysicalInfo { + return SunPhysicalN(jde, -1) } // SunPhysicalN 太阳物理观测参数(截断版) / truncated physical observing parameters of the Sun. -func SunPhysicalN(jd float64, n int) SunPhysicalInfo { - lambda := HSunApparentLoN(jd, n) - epsilon := TrueObliquity(jd) - k := sunPhysicalKBaseDeg + sunPhysicalKRateDeg*(jd-sunPhysicalKEpochJD)/36525.0 - theta := (jd - sunCarringtonStartJD) * 360.0 / sunCarringtonRotationDays +func SunPhysicalN(jde float64, n int) SunPhysicalInfo { + lambda := HSunApparentLoN(jde, n) + epsilon := TrueObliquity(jde) + k := sunPhysicalKBaseDeg + sunPhysicalKRateDeg*(jde-sunPhysicalKEpochJD)/36525.0 + theta := (jde - sunCarringtonStartJD) * 360.0 / sunCarringtonRotationDays x := math.Atan(-Cos(lambda)*Tan(epsilon)) * 180.0 / math.Pi y := math.Atan(-Cos(lambda-k)*Tan(sunPhysicalInclinationDeg)) * 180.0 / math.Pi diff --git a/basic/sun_physical_test.go b/basic/sun_physical_test.go index 0594400..09d6a7b 100644 --- a/basic/sun_physical_test.go +++ b/basic/sun_physical_test.go @@ -35,7 +35,7 @@ func TestSunPhysicalMatchesHorizonsBaseline(t *testing.T) { if err != nil { t.Fatalf("parse sample time %q: %v", sample.InputUTC, err) } - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) got := SunPhysical(jd) pDiff := angleDiffAbs(got.P, sample.P) @@ -60,7 +60,7 @@ func TestSunPhysicalMatchesHorizonsBaseline(t *testing.T) { } func TestSunPhysicalNFullMatchesDefault(t *testing.T) { - jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true) + jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC))) got := SunPhysical(jd) gotN := SunPhysicalN(jd, -1) @@ -81,7 +81,7 @@ func TestSunPhysicalSampleSweepFiniteAndInRange(t *testing.T) { } for _, date := range dates { - jd := TD2UT(Date2JDE(date.UTC()), true) + jd := UTC2TT(Date2JD(date.UTC())) got := SunPhysical(jd) prefix := date.Format(time.RFC3339) diff --git a/basic/sun_test.go b/basic/sun_test.go index 9aed0a1..1ae8685 100644 --- a/basic/sun_test.go +++ b/basic/sun_test.go @@ -19,14 +19,14 @@ func TestNutationRegression(t *testing.T) { } func Benchmark_SunRise(b *testing.B) { - jde := GetNowJDE() + jde := GetNowJD() for i := 0; i < b.N; i++ { _, _ = GetSunRiseTime(jde, 115, 32, 8, 0, 10) } } func Benchmark_SunLo(b *testing.B) { - jde := GetNowJDE() + jde := GetNowJD() for i := 0; i < b.N; i++ { HSunApparentLo(jde) } diff --git a/basic/timescale.go b/basic/timescale.go new file mode 100644 index 0000000..bf35f7c --- /dev/null +++ b/basic/timescale.go @@ -0,0 +1,448 @@ +package basic + +import ( + "math" + "sync" +) + +// TimeScaleFuturePolicy 民用时标换算政策,零值为闰秒情景外推 / civil-time conversion policy, defaulting to leap-second extrapolation. +type TimeScaleFuturePolicy int + +const ( + // TimeScaleLeapSecond 默认按 ΔT 越限施加整数秒校正,不代表闰秒公告 / default integer-second corrections driven by extrapolated ΔT, not announcements. + TimeScaleLeapSecond TimeScaleFuturePolicy = iota + // TimeScaleAssumeUT1Tracking 窗口外固定末端 DUT1,平滑跟随 UT1 / holds the last observed DUT1 beyond the window. + TimeScaleAssumeUT1Tracking + // TimeScaleFreezeUTCOffset 窗口外固定末端 TT−UTC / holds the last TT−UTC offset beyond the window. + TimeScaleFreezeUTCOffset + // TimeScaleLeapHour 按 ΔT 越限施加整小时校正,仅作情景演算 / applies hour-sized corrections as a scenario assumption. + TimeScaleLeapHour + // TimeScaleUT1Civil 全时轴民用时标等同 UT1,TT−UTC 覆盖仍优先 / uses UT1 as civil time everywhere unless TT−UTC is overridden. + TimeScaleUT1Civil +) + +// 闰时情景的校正步长与容限,单位秒。 +const timeScaleLeapHourSeconds = 3600.0 + +// 闰秒情景的 DUT1 容限,单位秒。 +const utcDUT1ToleranceSeconds = 0.9 + +// utcEraStartJDE 是 UTC 按 SI 秒运行的起点(1972-01-01),此前民用时标即 UT1。 +const utcEraStartJDE = 2441317.5 + +// 实测窗口末端,必须与月度 ΔT 表末项同步。 +const timeScaleExactEndJDE = 2461284.5 + +// 闰秒生效的儒略日按降序排列,与 utcLeapOffsets 一一对应。 +var utcLeapJDEs = [...]float64{ + 2457754.5, 2457204.5, 2456109.5, 2454832.5, + 2453736.5, 2451179.5, 2450630.5, 2450083.5, + 2449534.5, 2449169.5, 2448804.5, 2448257.5, + 2447892.5, 2447161.5, 2446247.5, 2445516.5, + 2445151.5, 2444786.5, 2444239.5, 2443874.5, + 2443509.5, 2443144.5, 2442778.5, 2442413.5, + 2442048.5, 2441683.5, 2441499.5, 2441133.5, +} + +// utcLeapOffsets[i] 是 utcLeapJDEs[i] 起生效的 TT−UTC(秒),与上表顺序一致。 +var utcLeapOffsets = [...]float64{ + 69.184, 68.184, 67.184, 66.184, + 65.184, 64.184, 63.184, 62.184, + 61.184, 60.184, 59.184, 58.184, + 57.184, 56.184, 55.184, 54.184, + 53.184, 52.184, 51.184, 50.184, + 49.184, 48.184, 47.184, 46.184, + 45.184, 44.184, 43.184, 42.184, +} + +// utcLeapBaseSeconds 是 1972-01-01 起算的 TT−UTC 基值:32.184 + (TAI−UTC 的 10 秒)。 +const utcLeapBaseSeconds = 42.184 + +var ( + timeScaleMu sync.RWMutex + ttMinusUTCFn func(float64) float64 + futurePolicy TimeScaleFuturePolicy +) + +// SetTTMinusUTCFn 覆盖 TT−UTC,nil 恢复内置表与政策 / overrides TT−UTC; nil restores the built-in table and policy. +func SetTTMinusUTCFn(fn func(float64) float64) { + timeScaleMu.Lock() + ttMinusUTCFn = fn + timeScaleMu.Unlock() + deltaTFnMu.Lock() + deltaTGeneration++ + deltaTFnMu.Unlock() +} + +// GetTTMinusUTCFn 返回当前 TT−UTC 覆盖函数,未设置时为 nil / current TT−UTC override, or nil. +func GetTTMinusUTCFn() func(float64) float64 { + timeScaleMu.RLock() + defer timeScaleMu.RUnlock() + return ttMinusUTCFn +} + +// SetTimeScaleFuturePolicy 设置民用时标换算政策 / sets the civil-time conversion policy. +func SetTimeScaleFuturePolicy(policy TimeScaleFuturePolicy) { + timeScaleMu.Lock() + futurePolicy = policy + timeScaleMu.Unlock() + deltaTFnMu.Lock() + deltaTGeneration++ + deltaTFnMu.Unlock() +} + +// GetTimeScaleFuturePolicy 返回当前民用时标换算政策 / current civil-time conversion policy. +func GetTimeScaleFuturePolicy() TimeScaleFuturePolicy { + timeScaleMu.RLock() + defer timeScaleMu.RUnlock() + return futurePolicy +} + +// TTMinusUTCSeconds 返回 TT−UTC 覆盖值或内置表值,不应用未来政策 / TT−UTC override or leap-table seconds, without future policy. +func TTMinusUTCSeconds(jd float64) float64 { + timeScaleMu.RLock() + fn := ttMinusUTCFn + timeScaleMu.RUnlock() + if fn != nil { + return fn(jd) + } + return ttMinusUTCSecondsDefault(jd) +} + +// UTC2TT 民用时刻转 TT,1972 年前按 UT1、之后按覆盖或所选政策 / converts civil time to TT using UT1 before 1972, then the override or selected policy. +func UTC2TT(jd float64) float64 { + return utcToTTJDE(jd) +} + +// TT2UTC 反解 UTC2TT,阶跃区间内往返不唯一 / inverts UTC2TT, with ambiguous round trips within step intervals. +func TT2UTC(ttJDE float64) float64 { + return ttToUTCJDE(ttJDE) +} + +// UT12TT 按当前 ΔT 模型将 UT1 转为 TT / converts UT1 to TT using the active ΔT model. +func UT12TT(jd float64) float64 { + return ut1ToTTJDE(jd) +} + +// TT2UT1 是 UT12TT 的逆 / inverts UT12TT. +func TT2UT1(ttJDE float64) float64 { + return ttToUT1JDE(ttJDE) +} + +// UTC2UT1 民用时刻转 UT1,窗口之后按当前未来政策 / converts civil time to UT1 under the active future policy. +func UTC2UT1(jd float64) float64 { + return ttToUT1JDE(utcToTTJDE(jd)) +} + +// UT12UTC 是 UTC2UT1 的逆 / inverts UTC2UT1. +func UT12UTC(ut1JDE float64) float64 { + return ttToUTCJDE(ut1ToTTJDE(ut1JDE)) +} + +// DUT1Seconds 返回 UT1−UTC(秒),采用当前覆盖与政策 / UT1−UTC in seconds under the active overrides and policy. +func DUT1Seconds(jd float64) float64 { + return utcToTTOffsetSeconds(jd) - ut1ToTTOffsetSeconds(jd) +} + +// 表外沿用最近的已知偏移,不应用未来政策。 +func ttMinusUTCSecondsDefault(jd float64) float64 { + for i := range utcLeapJDEs { + if jd >= utcLeapJDEs[i] { + return utcLeapOffsets[i] + } + } + return utcLeapBaseSeconds +} + +// TTMinusUTCSecondsDefault 返回内置闰秒表的 TT−UTC(秒),忽略覆盖 / built-in TT−UTC seconds, ignoring overrides. +func TTMinusUTCSecondsDefault(jd float64) float64 { + return ttMinusUTCSecondsDefault(jd) +} + +// deltaTModelSecondsAtUT 是内置 ΔT 模型(秒):覆盖期内用逐月实测表(毫秒级),表外用外推样条。 +func deltaTModelSecondsAtUT(jd float64) float64 { + if seconds, ok := deltaTMonthlyAt(jd); ok { + return seconds + } + // 两端按常值锚定,避免 ΔT 跳变使反解落到窗口另一侧。 + if jd < deltaTMonthlyJDE[0] { + return deltaTSplineAtJDE(jd) + deltaTMonthlyObserved[0] - deltaTSplineAtJDE(deltaTMonthlyJDE[0]) + } + last := len(deltaTMonthlyJDE) - 1 + return deltaTSplineAtJDE(jd) + deltaTMonthlyObserved[last] - deltaTSplineAtJDE(deltaTMonthlyJDE[last]) +} + +// deltaTMonthlyAt 在逐月实测表覆盖范围内线性插值,范围外 ok 为 false。 +func deltaTMonthlyAt(jd float64) (float64, bool) { + count := len(deltaTMonthlyJDE) + if count == 0 || jd < deltaTMonthlyJDE[0] || jd > deltaTMonthlyJDE[count-1] { + return 0, false + } + low, high := 0, count-1 + for high-low > 1 { + mid := (low + high) / 2 + if deltaTMonthlyJDE[mid] <= jd { + low = mid + } else { + high = mid + } + } + span := deltaTMonthlyJDE[high] - deltaTMonthlyJDE[low] + if span <= 0 { + return deltaTMonthlyObserved[low], true + } + ratio := (jd - deltaTMonthlyJDE[low]) / span + return deltaTMonthlyObserved[low] + ratio*(deltaTMonthlyObserved[high]-deltaTMonthlyObserved[low]), true +} + +// deltaTYearAtJDE 把 UT 儒略日换成十进制年:1582 改历之后按格里高利年平均长度,之前按儒略年。 +func deltaTYearAtJDE(jd float64) float64 { + if jd >= 2299160.5 { + return (jd-2451544.5)/365.2425 + 2000 + } + return (jd+0.5)/365.25 - 4712 +} + +// deltaTSplineAtJDE 用外推样条求 TT−UT1(秒),覆盖表外的古代与未来。 +func deltaTSplineAtJDE(jd float64) float64 { + year := deltaTYearAtJDE(jd) + return DeltaTSplineY(year) +} + +// 直接求秒差,避免两个大 JD 相减导致精度损失。 +func utcToTTOffsetSeconds(jd float64) float64 { + if jd < utcEraStartJDE { + // 1972 前民用时标即 UT1。 + return ut1ToTTOffsetSeconds(jd) + } + // 1972 年后显式覆盖优先于全部政策。 + if fn := GetTTMinusUTCFn(); fn != nil { + return fn(jd) + } + // UT1Civil 替换全时轴定义,须先于窗口内闰秒表判断。 + if GetTimeScaleFuturePolicy() == TimeScaleUT1Civil { + return ut1ToTTOffsetSeconds(jd) + } + if jd <= timeScaleExactEndJDE { + return TTMinusUTCSeconds(jd) + } + switch GetTimeScaleFuturePolicy() { + case TimeScaleAssumeUT1Tracking: + return ut1ToTTOffsetSeconds(jd) + dut1AtTimeScaleExactEnd() + case TimeScaleFreezeUTCOffset: + return TTMinusUTCSeconds(timeScaleExactEndJDE) + case TimeScaleLeapHour: + return steppedUTCOffsetSeconds(jd, timeScaleLeapHourSeconds, timeScaleLeapHourSeconds) + } + return steppedUTCOffsetSeconds(jd, 1, utcDUT1ToleranceSeconds) +} + +// 相对窗口末端偏移施加最少整数步校正,使 DUT1 落回容限内。 +func steppedUTCOffsetSeconds(jd, step, tolerance float64) float64 { + base := TTMinusUTCSeconds(timeScaleExactEndJDE) + drift := base - ut1ToTTOffsetSeconds(jd) + if math.IsNaN(drift) || math.IsInf(drift, 0) { + return math.NaN() + } + switch { + case drift <= -tolerance: + return base + step*(math.Floor((-drift-tolerance)/step)+1) + case drift >= tolerance: + return base - step*(math.Floor((drift-tolerance)/step)+1) + } + return base +} + +// dut1AtTimeScaleExactEnd 是窗口末端的实测 UT1−UTC(秒),TimeScaleAssumeUT1Tracking 沿用此值。 +func dut1AtTimeScaleExactEnd() float64 { + return TTMinusUTCSeconds(timeScaleExactEndJDE) - ut1ToTTOffsetSeconds(timeScaleExactEndJDE) +} + +func ut1ToTTOffsetSeconds(jd float64) float64 { + return DeltaT(jd, true) +} + +func utcToTTJDE(jd float64) float64 { + return jd + utcToTTOffsetSeconds(jd)/86400 +} + +func ut1ToTTJDE(jd float64) float64 { + return jd + ut1ToTTOffsetSeconds(jd)/86400 +} + +// ttToUT1JDE 解 TT = UT12TT(ut),与正向使用同一模型,保证往返一致。 +func ttToUT1JDE(ttJDE float64) float64 { + ut := ttJDE - ut1ToTTOffsetSeconds(ttJDE)/86400 + for iteration := 0; iteration < 4; iteration++ { + next := ttJDE - ut1ToTTOffsetSeconds(ut)/86400 + if next == ut { + break + } + ut = next + } + return ut +} + +// ttToUTCJDE 解 TT = UTC2TT(utc):分支由候选时刻判定,否则边界处会来回振荡。 +func ttToUTCJDE(ttJDE float64) float64 { + if math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) { + return math.NaN() + } + utc := ttJDE - utcToTTOffsetSeconds(ttJDE)/86400 + for iteration := 0; iteration < 4; iteration++ { + next := ttJDE - utcToTTOffsetSeconds(utc)/86400 + if next == utc { + break + } + utc = next + } + return utc +} + +var defDeltaTFn = DefaultDeltaTv2 +var deltaTFnMu sync.RWMutex +var activeDeltaTModel = DeltaTModelDefault +var activeDeltaTKeepObserved = true + +// 配置变化使缓存世代递增,起始为 1 以排除零值缓存。 +var deltaTGeneration uint64 = 1 + +// 两种输入口径共用的 ΔT 模型年限。 +const deltaTValidYearSpan = 40000.0 + +// DeltaT 返回 TT−UT1(秒),julianDay 为真取 UT JD、否则取十进制年,超出 ±40000 年返回 NaN / TT−UT1 seconds from a UT JD if julianDay, otherwise a decimal year; NaN beyond ±40000 years. +func DeltaT(date float64, julianDay bool) float64 { + if !math.IsNaN(date) && !math.IsInf(date, 0) && math.Abs(deltaTArgumentYear(date, julianDay)) > deltaTValidYearSpan { + return math.NaN() + } + deltaTFnMu.RLock() + fn := defDeltaTFn + deltaTFnMu.RUnlock() + return fn(date, julianDay) +} + +// deltaTArgumentYear 把两种自变量口径统一成十进制年,只用于越界判定。 +func deltaTArgumentYear(date float64, julianDay bool) float64 { + if julianDay { + return deltaTYearAtJDE(date) + } + return math.Floor(date) +} + +// SetDeltaTFn 注入秒单位的 ΔT 模型并标记为 Manual,nil 恢复默认 / installs a manual ΔT model in seconds; nil restores the default. +func SetDeltaTFn(fn func(float64, bool) float64) { + if fn == nil { + installDeltaTFn(DefaultDeltaTv2, DeltaTModelDefault, true) + return + } + installDeltaTFn(fn, DeltaTModelManual, false) +} + +// installDeltaTFn 是 ΔT 钩子的唯一写入口:函数、模型标记与世代号一起更新。 +func installDeltaTFn(fn func(float64, bool) float64, model DeltaTModel, keepObserved bool) { + deltaTFnMu.Lock() + defDeltaTFn = fn + activeDeltaTModel = model + activeDeltaTKeepObserved = keepObserved + deltaTGeneration++ + deltaTFnMu.Unlock() +} + +func deltaTGenerationValue() uint64 { + deltaTFnMu.RLock() + value := deltaTGeneration + deltaTFnMu.RUnlock() + return value +} + +// GetDeltaTFn 返回当前生效的 ΔT 计算函数,其入参为年或儒略日 / current ΔT function, taking a year or a Julian day. +func GetDeltaTFn() func(float64, bool) float64 { + deltaTFnMu.RLock() + fn := defDeltaTFn + deltaTFnMu.RUnlock() + return fn +} + +// DefaultDeltaTv2 库内默认 ΔT 计算,date 为年或儒略日(isJd),返回秒 / default ΔT computation in seconds. +func DefaultDeltaTv2(date float64, isJd bool) float64 { + if math.IsNaN(date) || math.IsInf(date, 0) { + return math.NaN() + } + if !isJd { + year := math.Floor(date) + start := JDCalc(int(year), 1, 1) + end := JDCalc(int(year)+1, 1, 1) + date = start + (date-year)*(end-start) + } + return DeltaTv2(date) +} + +// DeltaTSplineY 按十进制年计算样条与长期外推 ΔT(秒)/ spline and long-term ΔT in seconds for a decimal year. +func DeltaTSplineY(y float64) float64 { + if math.IsNaN(y) || math.IsInf(y, 0) { + return math.NaN() + } + // 日长偏差积分后的秒差,积分常数在两侧分别锚定。 + integratedLod := func(x float64) float64 { + u := x - 1825 + return 3.14115e-3*u*u + 284.8435805251424*math.Cos(0.4487989505128276*(0.01*u+0.75)) + } + + if y < -720 { + const c = 1.007739546148514 + return integratedLod(y) + c + } + if y > 2025 { + const c = -150.56787057979514 + return integratedLod(y) + c + } + + n := len(deltaTSplineY0) + var i int + for i = n - 1; i >= 0; i-- { + if y >= deltaTSplineY0[i] { + break + } + } + t := (y - deltaTSplineY0[i]) / (deltaTSplineY1[i] - deltaTSplineY0[i]) + dT := deltaTSplineA0[i] + t*(deltaTSplineA1[i]+t*(deltaTSplineA2[i]+t*deltaTSplineA3[i])) + return dT +} + +// 区间内 t = (year−Y0)/(Y1−Y0),ΔT = A0 + t·(A1 + t·(A2 + t·A3))。 +var deltaTSplineY0 = [...]float64{-720, -100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022} +var deltaTSplineY1 = [...]float64{-100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022, 2025} +var deltaTSplineA0 = [...]float64{20371.848, 11557.668, 6535.116, 1650.393, 1056.647, 681.149, 292.343, 109.127, 43.952, 12.068, 18.367, 15.678, 16.516, 10.804, 7.634, 9.338, 10.357, 9.04, 8.255, 2.371, -1.126, -3.21, -4.388, -3.884, -5.017, -1.977, 4.923, 11.142, 17.479, 21.617, 23.789, 24.418, 24.164, 24.426, 27.05, 28.932, 30.002, 30.76, 32.652, 33.621, 35.093, 37.956, 40.951, 44.244, 47.291, 50.361, 52.936, 54.984, 56.373, 58.453, 60.678, 62.898, 64.083, 64.553, 65.197, 66.061, 66.919, 68.130, 69.250, 69.296} +var deltaTSplineA1 = [...]float64{-9999.586, -5822.27, -5671.519, -753.21, -459.628, -421.345, -192.841, -78.697, -68.089, 2.507, -3.481, 0.021, -2.157, -6.018, -0.416, 1.642, -0.486, -0.591, -3.456, -5.593, -2.314, -1.893, 0.101, -0.531, 0.134, 5.715, 6.828, 6.33, 5.518, 3.02, 1.333, 0.052, -0.419, 1.645, 2.499, 1.127, 0.737, 1.409, 1.577, 0.868, 2.275, 3.035, 3.157, 3.199, 3.069, 2.878, 2.354, 1.577, 1.648, 2.235, 2.324, 1.804, 0.674, 0.466, 0.804, 0.839, 1.005, 1.348, 0.594, -0.227} +var deltaTSplineA2 = [...]float64{776.247, 1303.151, -298.291, 184.811, 108.771, 61.953, -6.572, 10.505, 38.333, 41.731, -1.126, 4.629, -6.806, 2.944, 2.658, 0.261, -2.389, 2.284, -5.148, 3.011, 0.269, 0.152, 1.842, -2.474, 3.138, 2.443, -1.329, 0.831, -1.643, -0.856, -0.831, -0.449, -0.022, 2.086, -1.232, 0.22, -0.61, 1.282, -1.115, 0.406, 1.002, -0.242, 0.364, -0.323, 0.193, -0.384, -0.14, -0.637, 0.708, -0.121, 0.21, -0.729, -0.402, 0.194, 0.144, -0.109, 0.275, 0.068, -0.822, 0.001} +var deltaTSplineA3 = [...]float64{409.16, -503.433, 1085.087, -25.346, -24.641, -29.414, 16.197, 3.018, -2.127, -37.939, 1.918, -3.812, 3.25, -0.096, -0.539, -0.883, 1.558, -2.477, 2.72, -0.914, -0.039, 0.563, -1.438, 1.871, -0.232, -1.257, 0.72, -0.825, 0.262, 0.008, 0.127, 0.142, 0.702, -1.106, 0.614, -0.277, 0.631, -0.799, 0.507, 0.199, -0.414, 0.202, -0.229, 0.172, -0.192, 0.081, -0.165, 0.448, -0.276, 0.11, -0.313, 0.109, 0.199, -0.017, -0.084, 0.128, -0.069, -0.297, 0.274, 0.086} + +// DeltaTv2 返回内置模型的 TT−UT1(秒),实测表外采用外推样条 / built-in TT−UT1 seconds, with spline extrapolation outside the observed table. +func DeltaTv2(jd float64) float64 { + if math.IsNaN(jd) || math.IsInf(jd, 0) { + return math.NaN() + } + return deltaTModelSecondsAtUT(jd) +} + +// DeltaTSecondsAt 返回 TT 时刻的 ΔT,有限覆盖值优先,NaN/Inf 选择当前模型 / ΔT at a TT instant, using a finite override or the active model. +func DeltaTSecondsAt(jdeTT, overrideSeconds float64) float64 { + if !math.IsNaN(overrideSeconds) && !math.IsInf(overrideSeconds, 0) { + return overrideSeconds + } + return deltaTModelSecondsAtTT(jdeTT) +} + +func deltaTModelSecondsAtTT(jdeTT float64) float64 { + // ΔT 模型以 UT 为自变量,不能直接传 TT。 + return ut1ToTTOffsetSeconds(ttToUT1JDE(jdeTT)) +} + +// DeltaTGroundShiftKM 将 ΔT 误差换算为站点相对影子的地面横移距离(千米)/ ground displacement in kilometres from a ΔT error. +func DeltaTGroundShiftKM(deltaTSeconds, latitudeDeg float64) float64 { + if math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) || + math.IsNaN(latitudeDeg) || math.IsInf(latitudeDeg, 0) { + return math.NaN() + } + return solarEclipseEarthEquatorialRotationKMPerSecond * math.Abs(deltaTSeconds) * math.Cos(latitudeDeg*rad) +} diff --git a/basic/timescale_table.go b/basic/timescale_table.go new file mode 100644 index 0000000..dd6371c --- /dev/null +++ b/basic/timescale_table.go @@ -0,0 +1,334 @@ +package basic + +// 由月度实测 TT−UT1 序列生成,请勿手改;表尾同时是 UTC2TT 精确窗口的末端。 +// Generated from the monthly observed TT−UT1 series; the last entry also ends the exact window. + +// deltaTMonthlyJDE 是逐月实测 TT−UT1 的儒略日,与 deltaTMonthlyObserved 一一对应。 +var deltaTMonthlyJDE = [...]float64{ + 2441714.5, 2441742.5, 2441773.5, 2441803.5, + 2441834.5, 2441864.5, 2441895.5, 2441926.5, + 2441956.5, 2441987.5, 2442017.5, 2442048.5, + 2442079.5, 2442107.5, 2442138.5, 2442168.5, + 2442199.5, 2442229.5, 2442260.5, 2442291.5, + 2442321.5, 2442352.5, 2442382.5, 2442413.5, + 2442444.5, 2442472.5, 2442503.5, 2442533.5, + 2442564.5, 2442594.5, 2442625.5, 2442656.5, + 2442686.5, 2442717.5, 2442747.5, 2442778.5, + 2442809.5, 2442838.5, 2442869.5, 2442899.5, + 2442930.5, 2442960.5, 2442991.5, 2443022.5, + 2443052.5, 2443083.5, 2443113.5, 2443144.5, + 2443175.5, 2443203.5, 2443234.5, 2443264.5, + 2443295.5, 2443325.5, 2443356.5, 2443387.5, + 2443417.5, 2443448.5, 2443478.5, 2443509.5, + 2443540.5, 2443568.5, 2443599.5, 2443629.5, + 2443660.5, 2443690.5, 2443721.5, 2443752.5, + 2443782.5, 2443813.5, 2443843.5, 2443874.5, + 2443905.5, 2443933.5, 2443964.5, 2443994.5, + 2444025.5, 2444055.5, 2444086.5, 2444117.5, + 2444147.5, 2444178.5, 2444208.5, 2444239.5, + 2444270.5, 2444299.5, 2444330.5, 2444360.5, + 2444391.5, 2444421.5, 2444452.5, 2444483.5, + 2444513.5, 2444544.5, 2444574.5, 2444605.5, + 2444636.5, 2444664.5, 2444695.5, 2444725.5, + 2444756.5, 2444786.5, 2444817.5, 2444848.5, + 2444878.5, 2444909.5, 2444939.5, 2444970.5, + 2445001.5, 2445029.5, 2445060.5, 2445090.5, + 2445121.5, 2445151.5, 2445182.5, 2445213.5, + 2445243.5, 2445274.5, 2445304.5, 2445335.5, + 2445366.5, 2445394.5, 2445425.5, 2445455.5, + 2445486.5, 2445516.5, 2445547.5, 2445578.5, + 2445608.5, 2445639.5, 2445669.5, 2445700.5, + 2445731.5, 2445760.5, 2445791.5, 2445821.5, + 2445852.5, 2445882.5, 2445913.5, 2445944.5, + 2445974.5, 2446005.5, 2446035.5, 2446066.5, + 2446097.5, 2446125.5, 2446156.5, 2446186.5, + 2446217.5, 2446247.5, 2446278.5, 2446309.5, + 2446339.5, 2446370.5, 2446400.5, 2446431.5, + 2446462.5, 2446490.5, 2446521.5, 2446551.5, + 2446582.5, 2446612.5, 2446643.5, 2446674.5, + 2446704.5, 2446735.5, 2446765.5, 2446796.5, + 2446827.5, 2446855.5, 2446886.5, 2446916.5, + 2446947.5, 2446977.5, 2447008.5, 2447039.5, + 2447069.5, 2447100.5, 2447130.5, 2447161.5, + 2447192.5, 2447221.5, 2447252.5, 2447282.5, + 2447313.5, 2447343.5, 2447374.5, 2447405.5, + 2447435.5, 2447466.5, 2447496.5, 2447527.5, + 2447558.5, 2447586.5, 2447617.5, 2447647.5, + 2447678.5, 2447708.5, 2447739.5, 2447770.5, + 2447800.5, 2447831.5, 2447861.5, 2447892.5, + 2447923.5, 2447951.5, 2447982.5, 2448012.5, + 2448043.5, 2448073.5, 2448104.5, 2448135.5, + 2448165.5, 2448196.5, 2448226.5, 2448257.5, + 2448288.5, 2448316.5, 2448347.5, 2448377.5, + 2448408.5, 2448438.5, 2448469.5, 2448500.5, + 2448530.5, 2448561.5, 2448591.5, 2448622.5, + 2448653.5, 2448682.5, 2448713.5, 2448743.5, + 2448774.5, 2448804.5, 2448835.5, 2448866.5, + 2448896.5, 2448927.5, 2448957.5, 2448988.5, + 2449019.5, 2449047.5, 2449078.5, 2449108.5, + 2449139.5, 2449169.5, 2449200.5, 2449231.5, + 2449261.5, 2449292.5, 2449322.5, 2449353.5, + 2449384.5, 2449412.5, 2449443.5, 2449473.5, + 2449504.5, 2449534.5, 2449565.5, 2449596.5, + 2449626.5, 2449657.5, 2449687.5, 2449718.5, + 2449749.5, 2449777.5, 2449808.5, 2449838.5, + 2449869.5, 2449899.5, 2449930.5, 2449961.5, + 2449991.5, 2450022.5, 2450052.5, 2450083.5, + 2450114.5, 2450143.5, 2450174.5, 2450204.5, + 2450235.5, 2450265.5, 2450296.5, 2450327.5, + 2450357.5, 2450388.5, 2450418.5, 2450449.5, + 2450480.5, 2450508.5, 2450539.5, 2450569.5, + 2450600.5, 2450630.5, 2450661.5, 2450692.5, + 2450722.5, 2450753.5, 2450783.5, 2450814.5, + 2450845.5, 2450873.5, 2450904.5, 2450934.5, + 2450965.5, 2450995.5, 2451026.5, 2451057.5, + 2451087.5, 2451118.5, 2451148.5, 2451179.5, + 2451210.5, 2451238.5, 2451269.5, 2451299.5, + 2451330.5, 2451360.5, 2451391.5, 2451422.5, + 2451452.5, 2451483.5, 2451513.5, 2451544.5, + 2451575.5, 2451604.5, 2451635.5, 2451665.5, + 2451696.5, 2451726.5, 2451757.5, 2451788.5, + 2451818.5, 2451849.5, 2451879.5, 2451910.5, + 2451941.5, 2451969.5, 2452000.5, 2452030.5, + 2452061.5, 2452091.5, 2452122.5, 2452153.5, + 2452183.5, 2452214.5, 2452244.5, 2452275.5, + 2452306.5, 2452334.5, 2452365.5, 2452395.5, + 2452426.5, 2452456.5, 2452487.5, 2452518.5, + 2452548.5, 2452579.5, 2452609.5, 2452640.5, + 2452671.5, 2452699.5, 2452730.5, 2452760.5, + 2452791.5, 2452821.5, 2452852.5, 2452883.5, + 2452913.5, 2452944.5, 2452974.5, 2453005.5, + 2453036.5, 2453065.5, 2453096.5, 2453126.5, + 2453157.5, 2453187.5, 2453218.5, 2453249.5, + 2453279.5, 2453310.5, 2453340.5, 2453371.5, + 2453402.5, 2453430.5, 2453461.5, 2453491.5, + 2453522.5, 2453552.5, 2453583.5, 2453614.5, + 2453644.5, 2453675.5, 2453705.5, 2453736.5, + 2453767.5, 2453795.5, 2453826.5, 2453856.5, + 2453887.5, 2453917.5, 2453948.5, 2453979.5, + 2454009.5, 2454040.5, 2454070.5, 2454101.5, + 2454132.5, 2454160.5, 2454191.5, 2454221.5, + 2454252.5, 2454282.5, 2454313.5, 2454344.5, + 2454374.5, 2454405.5, 2454435.5, 2454466.5, + 2454497.5, 2454526.5, 2454557.5, 2454587.5, + 2454618.5, 2454648.5, 2454679.5, 2454710.5, + 2454740.5, 2454771.5, 2454801.5, 2454832.5, + 2454863.5, 2454891.5, 2454922.5, 2454952.5, + 2454983.5, 2455013.5, 2455044.5, 2455075.5, + 2455105.5, 2455136.5, 2455166.5, 2455197.5, + 2455228.5, 2455256.5, 2455287.5, 2455317.5, + 2455348.5, 2455378.5, 2455409.5, 2455440.5, + 2455470.5, 2455501.5, 2455531.5, 2455562.5, + 2455593.5, 2455621.5, 2455652.5, 2455682.5, + 2455713.5, 2455743.5, 2455774.5, 2455805.5, + 2455835.5, 2455866.5, 2455896.5, 2455927.5, + 2455958.5, 2455987.5, 2456018.5, 2456048.5, + 2456079.5, 2456109.5, 2456140.5, 2456171.5, + 2456201.5, 2456232.5, 2456262.5, 2456293.5, + 2456324.5, 2456352.5, 2456383.5, 2456413.5, + 2456444.5, 2456474.5, 2456505.5, 2456536.5, + 2456566.5, 2456597.5, 2456627.5, 2456658.5, + 2456689.5, 2456717.5, 2456748.5, 2456778.5, + 2456809.5, 2456839.5, 2456870.5, 2456901.5, + 2456931.5, 2456962.5, 2456992.5, 2457023.5, + 2457054.5, 2457082.5, 2457113.5, 2457143.5, + 2457174.5, 2457204.5, 2457235.5, 2457266.5, + 2457296.5, 2457327.5, 2457357.5, 2457388.5, + 2457419.5, 2457448.5, 2457479.5, 2457509.5, + 2457540.5, 2457570.5, 2457601.5, 2457632.5, + 2457662.5, 2457693.5, 2457723.5, 2457754.5, + 2457785.5, 2457813.5, 2457844.5, 2457874.5, + 2457905.5, 2457935.5, 2457966.5, 2457997.5, + 2458027.5, 2458058.5, 2458088.5, 2458119.5, + 2458150.5, 2458178.5, 2458209.5, 2458239.5, + 2458270.5, 2458300.5, 2458331.5, 2458362.5, + 2458392.5, 2458423.5, 2458453.5, 2458484.5, + 2458515.5, 2458543.5, 2458574.5, 2458604.5, + 2458635.5, 2458665.5, 2458696.5, 2458727.5, + 2458757.5, 2458788.5, 2458818.5, 2458849.5, + 2458880.5, 2458909.5, 2458940.5, 2458970.5, + 2459001.5, 2459031.5, 2459062.5, 2459093.5, + 2459123.5, 2459154.5, 2459184.5, 2459215.5, + 2459246.5, 2459274.5, 2459305.5, 2459335.5, + 2459366.5, 2459396.5, 2459427.5, 2459458.5, + 2459488.5, 2459519.5, 2459549.5, 2459580.5, + 2459611.5, 2459639.5, 2459670.5, 2459700.5, + 2459731.5, 2459761.5, 2459792.5, 2459823.5, + 2459853.5, 2459884.5, 2459914.5, 2459945.5, + 2459976.5, 2460004.5, 2460035.5, 2460065.5, + 2460096.5, 2460126.5, 2460157.5, 2460188.5, + 2460218.5, 2460249.5, 2460279.5, 2460310.5, + 2460341.5, 2460370.5, 2460401.5, 2460431.5, + 2460462.5, 2460492.5, 2460523.5, 2460554.5, + 2460584.5, 2460615.5, 2460645.5, 2460676.5, + 2460707.5, 2460735.5, 2460766.5, 2460796.5, + 2460827.5, 2460857.5, 2460888.5, 2460919.5, + 2460949.5, 2460980.5, 2461010.5, 2461041.5, + 2461072.5, 2461100.5, 2461131.5, 2461161.5, + 2461192.5, 2461222.5, 2461253.5, 2461284.5, +} + +// deltaTMonthlyObserved 是对应月份的 TT−UT1(秒),来自权威逐月实测序列。 +var deltaTMonthlyObserved = [...]float64{ + 43.4724, 43.5648, 43.6737, 43.7782, + 43.8763, 43.9562, 44.0315, 44.1132, + 44.1982, 44.2952, 44.3936, 44.4841, + 44.5646, 44.6425, 44.7386, 44.8370, + 44.9302, 44.9986, 45.0584, 45.1284, + 45.2064, 45.2980, 45.3897, 45.4761, + 45.5632, 45.6450, 45.7375, 45.8284, + 45.9133, 45.9820, 46.0407, 46.1067, + 46.1825, 46.2789, 46.3713, 46.4567, + 46.5445, 46.6311, 46.7302, 46.8284, + 46.9247, 46.9970, 47.0709, 47.1451, + 47.2362, 47.3413, 47.4319, 47.5214, + 47.6049, 47.6837, 47.7781, 47.8771, + 47.9687, 48.0348, 48.0942, 48.1608, + 48.2460, 48.3439, 48.4355, 48.5344, + 48.6325, 48.7294, 48.8365, 48.9353, + 49.0319, 49.1013, 49.1591, 49.2286, + 49.3070, 49.4018, 49.4945, 49.5861, + 49.6805, 49.7602, 49.8556, 49.9489, + 50.0347, 50.1019, 50.1622, 50.2260, + 50.2968, 50.3831, 50.4599, 50.5387, + 50.6160, 50.6866, 50.7658, 50.8454, + 50.9187, 50.9761, 51.0278, 51.0843, + 51.1538, 51.2319, 51.3063, 51.3808, + 51.4526, 51.5160, 51.5985, 51.6809, + 51.7573, 51.8133, 51.8532, 51.9014, + 51.9603, 52.0328, 52.0985, 52.1668, + 52.2316, 52.2938, 52.3680, 52.4465, + 52.5180, 52.5751, 52.6178, 52.6668, + 52.7340, 52.8056, 52.8792, 52.9565, + 53.0445, 53.1268, 53.2197, 53.3024, + 53.3747, 53.4335, 53.4778, 53.5300, + 53.5845, 53.6523, 53.7256, 53.7882, + 53.8367, 53.8830, 53.9443, 54.0042, + 54.0536, 54.0856, 54.1084, 54.1463, + 54.1914, 54.2452, 54.2958, 54.3427, + 54.3911, 54.4320, 54.4898, 54.5456, + 54.5977, 54.6355, 54.6532, 54.6776, + 54.7174, 54.7741, 54.8253, 54.8713, + 54.9161, 54.9581, 54.9997, 55.0476, + 55.0912, 55.1132, 55.1328, 55.1532, + 55.1898, 55.2416, 55.2838, 55.3222, + 55.3613, 55.4063, 55.4629, 55.5111, + 55.5524, 55.5812, 55.6004, 55.6262, + 55.6656, 55.7168, 55.7698, 55.8197, + 55.8615, 55.9130, 55.9663, 56.0220, + 56.0700, 56.0939, 56.1105, 56.1314, + 56.1611, 56.2068, 56.2583, 56.3000, + 56.3399, 56.3790, 56.4283, 56.4804, + 56.5352, 56.5697, 56.5983, 56.6328, + 56.6739, 56.7332, 56.7972, 56.8553, + 56.9111, 56.9755, 57.0471, 57.1136, + 57.1738, 57.2226, 57.2597, 57.3073, + 57.3643, 57.4334, 57.5016, 57.5653, + 57.6333, 57.6973, 57.7711, 57.8407, + 57.9058, 57.9576, 57.9975, 58.0426, + 58.1043, 58.1679, 58.2389, 58.3092, + 58.3833, 58.4537, 58.5401, 58.6228, + 58.6917, 58.7410, 58.7836, 58.8406, + 58.8986, 58.9714, 59.0438, 59.1218, + 59.2003, 59.2747, 59.3574, 59.4434, + 59.5242, 59.5850, 59.6343, 59.6928, + 59.7588, 59.8386, 59.9111, 59.9845, + 60.0564, 60.1231, 60.2042, 60.2804, + 60.3530, 60.4012, 60.4440, 60.4900, + 60.5578, 60.6324, 60.7059, 60.7853, + 60.8664, 60.9387, 61.0277, 61.1103, + 61.1870, 61.2454, 61.2881, 61.3378, + 61.4036, 61.4760, 61.5525, 61.6287, + 61.6846, 61.7433, 61.8132, 61.8823, + 61.9497, 61.9969, 62.0343, 62.0714, + 62.1202, 62.1810, 62.2382, 62.2950, + 62.3506, 62.3995, 62.4754, 62.5463, + 62.6136, 62.6571, 62.6942, 62.7383, + 62.7926, 62.8567, 62.9146, 62.9659, + 63.0217, 63.0807, 63.1462, 63.2053, + 63.2599, 63.2844, 63.2961, 63.3126, + 63.3422, 63.3871, 63.4339, 63.4673, + 63.4979, 63.5319, 63.5679, 63.6104, + 63.6444, 63.6642, 63.6739, 63.6926, + 63.7147, 63.7518, 63.7927, 63.8285, + 63.8557, 63.8804, 63.9075, 63.9393, + 63.9691, 63.9799, 63.9833, 63.9938, + 64.0093, 64.0400, 64.0670, 64.0908, + 64.1068, 64.1282, 64.1584, 64.1833, + 64.2094, 64.2117, 64.2073, 64.2116, + 64.2223, 64.2500, 64.2761, 64.2998, + 64.3192, 64.3450, 64.3735, 64.3943, + 64.4151, 64.4132, 64.4118, 64.4097, + 64.4168, 64.4329, 64.4511, 64.4734, + 64.4893, 64.5053, 64.5269, 64.5471, + 64.5597, 64.5512, 64.5371, 64.5359, + 64.5415, 64.5544, 64.5654, 64.5736, + 64.5891, 64.6015, 64.6176, 64.6374, + 64.6549, 64.6530, 64.6379, 64.6372, + 64.6400, 64.6543, 64.6723, 64.6876, + 64.7052, 64.7313, 64.7575, 64.7811, + 64.8001, 64.7995, 64.7876, 64.7831, + 64.7921, 64.8096, 64.8311, 64.8452, + 64.8597, 64.8850, 64.9175, 64.9480, + 64.9794, 64.9895, 65.0028, 65.0138, + 65.0371, 65.0773, 65.1122, 65.1464, + 65.1833, 65.2145, 65.2494, 65.2921, + 65.3279, 65.3413, 65.3452, 65.3496, + 65.3711, 65.3972, 65.4296, 65.4573, + 65.4868, 65.5152, 65.5450, 65.5781, + 65.6127, 65.6288, 65.6370, 65.6493, + 65.6760, 65.7097, 65.7461, 65.7768, + 65.8025, 65.8237, 65.8595, 65.8973, + 65.9323, 65.9509, 65.9534, 65.9628, + 65.9839, 66.0147, 66.0420, 66.0699, + 66.0961, 66.1310, 66.1683, 66.2072, + 66.2356, 66.2409, 66.2335, 66.2349, + 66.2441, 66.2751, 66.3054, 66.3246, + 66.3406, 66.3624, 66.3957, 66.4289, + 66.4619, 66.4749, 66.4751, 66.4829, + 66.5056, 66.5383, 66.5706, 66.6030, + 66.6340, 66.6569, 66.6925, 66.7289, + 66.7579, 66.7708, 66.7740, 66.7846, + 66.8103, 66.8400, 66.8779, 66.9069, + 66.9443, 66.9763, 67.0258, 67.0716, + 67.1100, 67.1266, 67.1331, 67.1458, + 67.1717, 67.2091, 67.2460, 67.2810, + 67.3136, 67.3457, 67.3890, 67.4318, + 67.4666, 67.4858, 67.4989, 67.5111, + 67.5353, 67.5711, 67.6070, 67.6439, + 67.6765, 67.7117, 67.7591, 67.8012, + 67.8402, 67.8606, 67.8822, 67.9120, + 67.9546, 68.0055, 68.0514, 68.1024, + 68.1577, 68.2044, 68.2665, 68.3188, + 68.3704, 68.3964, 68.4094, 68.4305, + 68.4630, 68.5078, 68.5537, 68.5927, + 68.6298, 68.6671, 68.7135, 68.7623, + 68.8033, 68.8245, 68.8373, 68.8477, + 68.8689, 68.9006, 68.9355, 68.9676, + 68.9875, 69.0176, 69.0499, 69.0823, + 69.1070, 69.1134, 69.1142, 69.1207, + 69.1356, 69.1646, 69.1964, 69.2202, + 69.2452, 69.2733, 69.3032, 69.3326, + 69.3541, 69.3582, 69.3442, 69.3376, + 69.3377, 69.3432, 69.3540, 69.3612, + 69.3752, 69.3890, 69.4092, 69.4265, + 69.4386, 69.4241, 69.3921, 69.3693, + 69.3575, 69.3593, 69.3630, 69.3594, + 69.3510, 69.3538, 69.3582, 69.3673, + 69.3679, 69.3514, 69.3273, 69.3033, + 69.2892, 69.2881, 69.2908, 69.2945, + 69.2914, 69.2861, 69.2835, 69.2816, + 69.2799, 69.2527, 69.2213, 69.1975, + 69.1891, 69.1942, 69.2036, 69.2039, + 69.1986, 69.1993, 69.2084, 69.2183, + 69.2300, 69.2201, 69.1988, 69.1814, + 69.1723, 69.1727, 69.1724, 69.1752, + 69.1797, 69.1874, 69.1983, 69.2018, + 69.2044, 69.1879, 69.1588, 69.1322, + 69.1250, 69.1304, 69.1345, 69.1377, + 69.1366, 69.1384, 69.1471, 69.1542, + 69.1550, 69.1406, 69.1219, 69.0994, + 69.0909, 69.0909, 69.1042, 69.1099, + 69.1133, 69.1168, 69.1330, 69.1511, + 69.1662, 69.1695, 69.1713, 69.1816, +} diff --git a/basic/timescale_test.go b/basic/timescale_test.go new file mode 100644 index 0000000..d75a1c1 --- /dev/null +++ b/basic/timescale_test.go @@ -0,0 +1,714 @@ +package basic + +import ( + "math" + "testing" +) + +// 尺度换算的锚定契约:闰秒表、月度实测 ΔT、恒等式 TT−UTC = ΔT + DUT1、边界与政策、钩子与世代号。 + +func resetTimeScaleState(t *testing.T) { + t.Helper() + SetTTMinusUTCFn(nil) + SetTimeScaleFuturePolicy(TimeScaleLeapSecond) + SetDeltaTFn(nil) + t.Cleanup(func() { + SetTTMinusUTCFn(nil) + SetTimeScaleFuturePolicy(TimeScaleLeapSecond) + SetDeltaTFn(nil) + }) +} + +func TestTimeScaleCalendarAnchors(t *testing.T) { + if got := JDCalc(1972, 1, 1); got != 2441317.5 { + t.Fatalf("JDCalc(1972,1,1)=%v, want 2441317.5", got) + } + if got := JDCalc(2026, 9, 1); got != timeScaleExactEndJDE { + t.Fatalf("JDCalc(2026,9,1)=%v, want %v", got, timeScaleExactEndJDE) + } + if got := deltaTMonthlyJDE[len(deltaTMonthlyJDE)-1]; got != timeScaleExactEndJDE { + t.Fatalf("月度表末项=%v, want 精确窗口末端 %v", got, timeScaleExactEndJDE) + } + if len(deltaTMonthlyJDE) != len(deltaTMonthlyObserved) { + t.Fatalf("月度表长度不一致: %d vs %d", len(deltaTMonthlyJDE), len(deltaTMonthlyObserved)) + } +} + +// 闰时进位量由 ΔT 决定,步长为一小时,校正后 DUT1 必须落回容限内。 +func TestTimeScaleLeapHourPolicyCarriesFromDeltaT(t *testing.T) { + resetTimeScaleState(t) + SetTimeScaleFuturePolicy(TimeScaleLeapHour) + base := TTMinusUTCSeconds(timeScaleExactEndJDE) + if got := TTMinusUTCSeconds(timeScaleExactEndJDE); got != base { + t.Fatalf("交接处应连续:%v vs %v", got, base) + } + prev := utcToTTOffsetSeconds(timeScaleExactEndJDE) + carries := 0 + firstCarryYear := 0.0 + for year := 2027; year <= 3500; year++ { + jd := JDCalc(year, 1, 1) + if dut1 := DUT1Seconds(jd); math.Abs(dut1) >= timeScaleLeapHourSeconds { + t.Fatalf("%d 年 |DUT1|=%v 越过容限 %v", year, math.Abs(dut1), timeScaleLeapHourSeconds) + } + offset := utcToTTOffsetSeconds(jd) + if offset == prev { + continue + } + step := offset - prev + if step <= 0 || math.Abs(step-timeScaleLeapHourSeconds) > 1e-9 { + t.Fatalf("%d 年进位量 %v, want +%v", year, step, timeScaleLeapHourSeconds) + } + if carries == 0 { + firstCarryYear = float64(year) + } + carries++ + prev = offset + } + if carries < 1 || carries > 3 { + t.Fatalf("2027..3500 进位次数 %d 不合理", carries) + } + if math.Abs(firstCarryYear-2909) > 3 { + t.Errorf("首次进位应在 2909 年前后, got %.0f", firstCarryYear) + } + for _, jd := range []float64{JDCalc(2400, 1, 1), JDCalc(2909, 1, 1), JDCalc(3200, 1, 1)} { + if got := TT2UTC(UTC2TT(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("jd=%v 往返差 %g 秒", jd, (got-jd)*86400) + } + } +} + +// 默认与零值均为整数秒外推,首次越限前保持末端偏移。 +func TestTimeScaleDefaultFuturePolicyIsLeapSecondRegime(t *testing.T) { + resetTimeScaleState(t) + if got := GetTimeScaleFuturePolicy(); got != TimeScaleLeapSecond { + t.Fatalf("默认未来政策=%v, want TimeScaleLeapSecond", got) + } + var zero TimeScaleFuturePolicy + if zero != TimeScaleLeapSecond { + t.Fatalf("零值政策=%v, 应即默认", zero) + } + base := TTMinusUTCSeconds(timeScaleExactEndJDE) + for _, jd := range []float64{JDCalc(2027, 1, 1), JDCalc(2029, 7, 1), JDCalc(2035, 7, 1)} { + if got := utcToTTOffsetSeconds(jd); math.Abs(got-base) > 1e-9 { + t.Errorf("%v 年默认政策 TT−UTC=%v, want %v(首次触限前应与已宣告偏移相同)", + jd, got, base) + } + if got := DUT1Seconds(jd); math.Abs(got) > utcDUT1ToleranceSeconds+1e-9 { + t.Errorf("%v 年默认政策 |DUT1|=%v 越过容限", jd, math.Abs(got)) + } + } + // 平滑跟随 UT1 的政策必须给出不同的值,否则这条守卫会与它混同。 + SetTimeScaleFuturePolicy(TimeScaleAssumeUT1Tracking) + if got := utcToTTOffsetSeconds(JDCalc(2035, 7, 1)); math.Abs(got-base) < 1e-3 { + t.Fatalf("跟随 UT1 政策在 2035 年给出 %v,与已宣告偏移 %v 混同,测试失去区分度", got, base) + } + SetTimeScaleFuturePolicy(TimeScaleLeapSecond) +} + +func TestTimeScaleLeapTableMagnitudes(t *testing.T) { + resetTimeScaleState(t) + if len(utcLeapJDEs) != len(utcLeapOffsets) { + t.Fatalf("闰秒表长度不一致: %d vs %d", len(utcLeapJDEs), len(utcLeapOffsets)) + } + for i := range utcLeapJDEs { + if got := TTMinusUTCSeconds(utcLeapJDEs[i]); got != utcLeapOffsets[i] { + t.Fatalf("第 %d 条生效时刻 %v 起 TT−UTC=%v, want %v", i, utcLeapJDEs[i], got, utcLeapOffsets[i]) + } + if i > 0 { + if descending := utcLeapJDEs[i-1] > utcLeapJDEs[i]; !descending { + t.Fatalf("闰秒表应按时刻降序, 第 %d 条顺序错误", i) + } + if step := utcLeapOffsets[i-1] - utcLeapOffsets[i]; step != 1 { + t.Fatalf("历史闰秒步长应为 1 秒, 第 %d 条为 %v", i, step) + } + } + } + if got := TTMinusUTCSeconds(utcLeapJDEs[len(utcLeapJDEs)-1] - 1); got != utcLeapBaseSeconds { + t.Fatalf("1972 年以前应回退基值 %v, got %v", utcLeapBaseSeconds, got) + } +} + +// 若将来采用"闰时"(|UTC−UT1| 容限放宽到 H 秒、积满一次才校正),本库不需要改代码: +// 注入一条规则驱动的偏移即可。这里把这个能力锁死——交接连续、|DUT1| 不越界、往返一致、可还原。 +func TestLeapHourScheduleIsExpressibleThroughHook(t *testing.T) { + resetTimeScaleState(t) + const hour = 3600.0 + base := TTMinusUTCSeconds(timeScaleExactEndJDE) + SetTTMinusUTCFn(func(jd float64) float64 { + drift := base - DeltaT(jd, true) // 未校正的 DUT1 + steps := math.Floor(math.Abs(drift) / hour) + if drift < 0 { + return base + hour*steps + } + return base - hour*steps + }) + if got := TTMinusUTCSeconds(timeScaleExactEndJDE); got != base { + t.Fatalf("交接处应连续:got %v, want %v", got, base) + } + for _, year := range []int{2030, 2100, 2500, 2909, 3000, 3500} { + dut1 := DUT1Seconds(JDCalc(year, 1, 1)) + if math.Abs(dut1) >= hour { + t.Errorf("%d 年 |DUT1|=%v 越过容限 %v", year, math.Abs(dut1), hour) + } + } + for _, jd := range []float64{JDCalc(2050, 1, 1), JDCalc(2909, 1, 1), JDCalc(3000, 1, 1)} { + if got := TT2UTC(UTC2TT(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("jd=%v 往返差 %g 秒", jd, (got-jd)*86400) + } + } + SetTTMinusUTCFn(nil) + if got := TTMinusUTCSeconds(timeScaleExactEndJDE); got != base { + t.Fatalf("还原默认后 got %v, want %v", got, base) + } +} + +func TestTimeScaleLeapTableAnchors(t *testing.T) { + resetTimeScaleState(t) + cases := []struct { + year, month int + want float64 + }{ + {1972, 1, 42.184}, + {1972, 7, 43.184}, + {2017, 1, 69.184}, + {2026, 4, 69.184}, + } + for _, item := range cases { + if got := TTMinusUTCSeconds(JDCalc(item.year, item.month, 1)); got != item.want { + t.Errorf("TT−UTC(%d-%02d)=%v, want %v", item.year, item.month, got, item.want) + } + } +} + +func TestTimeScaleMonthlyDeltaTAnchors(t *testing.T) { + resetTimeScaleState(t) + // 秒级偏移直接锚定;JDE 级取值受 jd≈2.5e6 的浮点抵消限制(约 2e-5 秒)。 + if got := DeltaT(timeScaleExactEndJDE, true); math.Abs(got-69.1816) > 1e-12 { + t.Errorf("2026-09 TT−UT1=%v, want 69.1816", got) + } + if got := DeltaT(JDCalc(1973, 2, 1), true); math.Abs(got-43.4724) > 1e-12 { + t.Errorf("1973-02 TT−UT1=%v, want 43.4724", got) + } + if got := (UT12TT(timeScaleExactEndJDE) - timeScaleExactEndJDE) * 86400; math.Abs(got-69.1816) > 1e-4 { + t.Errorf("2026-09 UT12TT 偏移=%v, want 69.1816", got) + } + mid := (deltaTMonthlyJDE[10] + deltaTMonthlyJDE[11]) / 2 + want := (deltaTMonthlyObserved[10] + deltaTMonthlyObserved[11]) / 2 + if got := DeltaT(mid, true); math.Abs(got-want) > 1e-12 { + t.Errorf("中点插值=%v, want %v", got, want) + } + if _, ok := deltaTMonthlyAt(deltaTMonthlyJDE[0] - 0.5); ok { + t.Error("月度表不应覆盖首项之前") + } +} + +// 月度表首与表尾一样要做常值锚定:表外外推在表首处必须与实测值连续, +// 否则表首两侧 DUT1 跳 0.227 秒、UT1 往返在 TT 侧丢掉同样多。 +func TestTimeScaleMonthlyDeltaTTableHeadAnchored(t *testing.T) { + resetTimeScaleState(t) + head := deltaTMonthlyJDE[0] + if got := deltaTModelSecondsAtUT(head); math.Abs(got-deltaTMonthlyObserved[0]) > 1e-9 { + t.Errorf("表首 ΔT=%v, want 实测值 %v", got, deltaTMonthlyObserved[0]) + } + if gap := deltaTModelSecondsAtUT(head-1e-9) - deltaTModelSecondsAtUT(head+1e-9); math.Abs(gap) > 1e-3 { + t.Errorf("表首两侧 ΔT 跳变 %g 秒", gap) + } + // 表首之前的取值是一个常值平移:平移量等于表首实测值与样条之差,量级 0.227 秒。 + shift := deltaTModelSecondsAtUT(head-1) - deltaTSplineAtJDE(head-1) + if want := deltaTMonthlyObserved[0] - deltaTSplineAtJDE(head); math.Abs(shift-want) > 1e-9 { + t.Errorf("表首之前的 ΔT 平移量=%v, want %v", shift, want) + } + if math.Abs(shift) > 0.227 { + t.Errorf("表首之前的 ΔT 平移量 %v 超出预期量级 0.227 秒", shift) + } + worst, worstAt := 0.0, 0.0 + for x := head - 0.02; x <= head+0.02; x += 2e-7 { + for _, err := range []float64{ + (UT12TT(TT2UT1(x)) - x) * 86400, + (UT12UTC(UTC2UT1(x)) - x) * 86400, + } { + if e := math.Abs(err); e > worst { + worst, worstAt = e, x + } + } + } + if worst > 1e-6 { + t.Errorf("表首 ±0.02 天 UT1 往返最大误差 %g 秒 @ %v, want ≤1e-6 秒", worst, worstAt) + } +} + +// 容限是整个窗口的历史事实:按 1e-3 天全窗扫描,抽样点会漏掉月度 ΔT 表首之前的越界。 +func TestTimeScaleDUT1Identity(t *testing.T) { + resetTimeScaleState(t) + if got := DUT1Seconds(timeScaleExactEndJDE); math.Abs(got-0.0024) > 1e-12 { + t.Errorf("DUT1(2026-09)=%v, want 0.0024", got) + } + worst, worstAt := 0.0, 0.0 + for jd := utcEraStartJDE; jd <= timeScaleExactEndJDE; jd += 1e-3 { + dut1 := DUT1Seconds(jd) + if want := TTMinusUTCSeconds(jd) - DeltaT(jd, true); dut1 != want { + t.Fatalf("恒等式不成立 jd=%v: DUT1=%v want=%v", jd, dut1, want) + } + if mag := math.Abs(dut1); mag > worst { + worst, worstAt = mag, jd + } + } + if worst > utcDUT1ToleranceSeconds { + t.Errorf("窗口内 |DUT1| 最大 %v 秒 @ %v, 超过现行容限 %v", worst, worstAt, utcDUT1ToleranceSeconds) + } + if got := math.Abs(DUT1Seconds(JDCalc(1973, 1, 1))); got > utcDUT1ToleranceSeconds { + t.Errorf("1973-01-01 |DUT1|=%v 超过容限 %v", got, utcDUT1ToleranceSeconds) + } +} + +// 窗口内 UTC 方向只做整数秒跳变:ΔT 的小数部分不得混进 TT−UTC。 +func TestTimeScaleUTCOffsetJumps(t *testing.T) { + resetTimeScaleState(t) + const wantJumps = 27 + jumps, maxFractional := 0, 0.0 + prev := utcToTTOffsetSeconds(utcEraStartJDE) + for jd := utcEraStartJDE + 1e-3; jd <= timeScaleExactEndJDE; jd += 1e-3 { + current := utcToTTOffsetSeconds(jd) + if current == prev { + continue + } + jumps++ + if fractional := math.Abs((current - prev) - math.Round(current-prev)); fractional > maxFractional { + maxFractional = fractional + } + prev = current + } + if jumps != wantJumps { + t.Errorf("窗口内跳变次数=%d, want %d", jumps, wantJumps) + } + if maxFractional != 0 { + t.Errorf("跳变的非整数残差最大 %v, want 0", maxFractional) + } +} + +// 闰秒让 UTC2TT 不连续:每个闰秒在 TT 侧都有一段没有原像的区间,宽度恰好 1 秒。 +// 宽度只能测到 jd≈2.4e6 的 1 ULP(约 4e-5 秒),故容差取 1e-4 秒。 +func TestTimeScaleLeapSecondUnrepresentableBand(t *testing.T) { + resetTimeScaleState(t) + const ulpToleranceSeconds = 1e-4 + inBand := func(tt float64) bool { return math.Abs((UTC2TT(TT2UTC(tt))-tt)*86400) > 1e-6 } + bands := 0 + for _, leap := range utcLeapJDEs { + before, after := TTMinusUTCSeconds(leap-1), TTMinusUTCSeconds(leap) + if before == after { + continue + } + if step := after - before; step != 1 { + t.Errorf("闰秒 %v 的偏移步长=%v, want 1 秒", leap, step) + continue + } + bands++ + lower, upper := leap+(before-0.001)/86400, leap+(after+0.001)/86400 + if inBand(lower) || inBand(upper) { + t.Errorf("闰秒 %v:带外 1 毫秒处往返应精确", leap) + continue + } + center := leap + (before+0.5)/86400 + first := bisectFloat(lower, center, inBand) + last := bisectFloat(center, upper, inBand) + if width := (last - first) * 86400; math.Abs(width-1) > ulpToleranceSeconds { + t.Errorf("闰秒 %v 的 TT 不可表示带宽=%v 秒, want 1±%v", leap, width, ulpToleranceSeconds) + } + } + if bands != 27 { + t.Errorf("可测带宽的闰秒 %d 个, want 27", bands) + } +} + +// bisectFloat 定位 f 在 [lo, hi] 上的单调跳变点:f(lo) 为假时返回首个为真的点,反之返回最后一个为真的点。 +func bisectFloat(lo, hi float64, f func(float64) bool) float64 { + loTrue := f(lo) + for i := 0; i < 80; i++ { + mid := (lo + hi) / 2 + if mid <= lo || mid >= hi { + break + } + if f(mid) == loTrue { + lo = mid + } else { + hi = mid + } + } + if loTrue { + return lo + } + return hi +} + +func TestTimeScaleRoundTrips(t *testing.T) { + resetTimeScaleState(t) + dates := [][3]float64{{-1000, 1, 1}, {1000, 6, 15}, {1582, 10, 15}, {1972, 1, 1}, {2000, 1, 1}, {2026, 4, 1}, {2030, 1, 1}, {2100, 1, 1}} + for _, item := range dates { + roundTripTimeScales(t, JDCalc(int(item[0]), int(item[1]), item[2])) + } + // 闰秒前后各 0.5 秒:TT 侧不可表示带之外必须可逆,样本要压在跳变旁而不是远离它。 + for _, leap := range []float64{utcLeapJDEs[0], utcLeapJDEs[4], utcLeapJDEs[len(utcLeapJDEs)-2]} { + roundTripTimeScales(t, leap-0.5/86400) + roundTripTimeScales(t, leap+0.5/86400) + } +} + +func roundTripTimeScales(t *testing.T, jd float64) { + t.Helper() + if got := TT2UT1(UT12TT(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("UT1 往返 %v: 差 %g 秒", jd, (got-jd)*86400) + } + if got := TT2UTC(UTC2TT(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("UTC 往返 %v: 差 %g 秒", jd, (got-jd)*86400) + } + if got, want := UTC2UT1(jd), TT2UT1(UTC2TT(jd)); got != want { + t.Errorf("UTC2UT1 与复合式不一致 %v: %v vs %v", jd, got, want) + } + if got := UT12UTC(UTC2UT1(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("UTC↔UT1 往返 %v: 差 %g 秒", jd, (got-jd)*86400) + } +} + +func TestTimeScaleCivilEqualsUT1Before1972(t *testing.T) { + resetTimeScaleState(t) + for _, jd := range []float64{JDCalc(1900, 1, 1), JDCalc(1971, 12, 31)} { + if UTC2TT(jd) != UT12TT(jd) { + t.Errorf("1972 前 UTC2TT 应等于 UT12TT, jd=%v", jd) + } + if got := DUT1Seconds(jd); got != 0 { + t.Errorf("1972 前 DUT1 应为 0, jd=%v got=%v", jd, got) + } + } +} + +// 跟随 UT1 政策:窗口外保留末端实测 DUT1,TT−UTC 随外推 ΔT 平滑变化,交界处连续。 +func TestTimeScaleFuturePolicy(t *testing.T) { + resetTimeScaleState(t) + SetTimeScaleFuturePolicy(TimeScaleAssumeUT1Tracking) + beyond := timeScaleExactEndJDE + 1 + frozenDUT1 := dut1AtTimeScaleExactEnd() + if got := DUT1Seconds(beyond); math.Abs(got-frozenDUT1) > 1e-12 { + t.Errorf("跟随 UT1 政策下窗口外 DUT1 应冻结在 %v, got=%v", frozenDUT1, got) + } + if got, want := utcToTTOffsetSeconds(beyond), ut1ToTTOffsetSeconds(beyond)+frozenDUT1; got != want { + t.Errorf("跟随 UT1 政策下窗口外 TT−UTC=%v, want %v", got, want) + } + before := utcToTTOffsetSeconds(timeScaleExactEndJDE - 1e-9) + after := utcToTTOffsetSeconds(timeScaleExactEndJDE + 1e-9) + if math.Abs(after-before) > 1e-6 { + t.Errorf("跟随 UT1 政策在窗口末端留下 %g 秒跳变", after-before) + } + frozen := TTMinusUTCSeconds(timeScaleExactEndJDE) + SetTimeScaleFuturePolicy(TimeScaleFreezeUTCOffset) + if got, want := UTC2TT(beyond), beyond+frozen/86400; got != want { + t.Errorf("冻结政策下 UTC2TT=%v, want %v", got, want) + } + if got := utcToTTOffsetSeconds(beyond); math.Abs(got-frozen) > 1e-12 { + t.Errorf("冻结政策下 TT−UTC=%v, want %v", got, frozen) + } + near, far := DUT1Seconds(beyond), DUT1Seconds(beyond+365) + if !(far < near) { + t.Errorf("冻结政策下 DUT1 应随 ΔT 增大而下降: %v → %v", near, far) + } +} + +// UT1Civil 在全时轴生效,含历史闰秒附近。 +func TestTimeScaleUT1CivilPolicy(t *testing.T) { + resetTimeScaleState(t) + SetTimeScaleFuturePolicy(TimeScaleUT1Civil) + for _, jd := range []float64{ + JDCalc(1950, 1, 1), + JDCalc(1972, 1, 1), + JDCalc(1999, 1, 1), + JDCalc(2017, 1, 1), + JDCalc(2020, 1, 1), + timeScaleExactEndJDE, + JDCalc(2035, 7, 1), + JDCalc(2200, 1, 1), + } { + if got := DUT1Seconds(jd); got != 0 { + t.Errorf("jd=%v DUT1=%v, want 0", jd, got) + } + if got, want := utcToTTOffsetSeconds(jd), ut1ToTTOffsetSeconds(jd); got != want { + t.Errorf("jd=%v TT−UTC=%v, want ΔT=%v", jd, got, want) + } + if got := UTC2UT1(jd); math.Abs(got-jd) > 1e-9 { + t.Errorf("jd=%v UTC2UT1 差 %g 秒,应为恒等映射", jd, (got-jd)*86400) + } + if got := TT2UTC(UTC2TT(jd)); math.Abs(got-jd) > 1e-9 { + t.Errorf("jd=%v 往返差 %g 秒", jd, (got-jd)*86400) + } + if got, want := TT2UT1(UTC2TT(jd)), UTC2UT1(jd); got != want { + t.Errorf("jd=%v TT2UT1(UTC2TT)=%v, UTC2UT1=%v(该政策下两条路应合流)", jd, got, want) + } + } + // 实测年代必须让位给 ΔT:1999 年闰秒表是 64.184 秒,政策下应等于 ΔT 且与 2017 年闰秒前后连续。 + if got := utcToTTOffsetSeconds(JDCalc(1999, 1, 1)); math.Abs(got-64.184) < 1e-9 { + t.Errorf("1999 年 TT−UTC=%v,仍是闰秒表值,政策未覆盖实测窗口", got) + } + before := utcToTTOffsetSeconds(utcLeapJDEs[0] - 1e-6) + after := utcToTTOffsetSeconds(utcLeapJDEs[0] + 1e-6) + if math.Abs(after-before) > 1e-6 { + t.Errorf("2017-01-01 历史闰秒处出现 %g 秒阶跃,该政策下不应有阶跃", after-before) + } + // 注入的 TT−UTC 覆盖仍然优先于政策。 + SetTTMinusUTCFn(func(float64) float64 { return 42.184 }) + if got := utcToTTOffsetSeconds(JDCalc(2035, 7, 1)); math.Abs(got-42.184) > 1e-9 { + t.Errorf("覆盖后 TT−UTC=%v, want 42.184", got) + } +} + +// 现行闰秒政策:偏移只能是末端已宣告值加整数秒;未阶跃 UT1−UTC 越出 ±0.9 秒时补 ±1 秒; +// 首次阶跃之前与冻结政策逐值相同;ΔT 反向漂移时走负闰秒。 +func TestTimeScaleLeapSecondPolicy(t *testing.T) { + resetTimeScaleState(t) + SetTimeScaleFuturePolicy(TimeScaleLeapSecond) + base := TTMinusUTCSeconds(timeScaleExactEndJDE) + unstepped := func(jd float64) float64 { return base - ut1ToTTOffsetSeconds(jd) } + + low, high := JDCalc(2026, 1, 1), JDCalc(2200, 1, 1) + if unstepped(high) > -utcDUT1ToleranceSeconds { + t.Fatal("2026..2200 内没有触限时刻,测试前提失效") + } + for i := 0; i < 200; i++ { + mid := (low + high) / 2 + if unstepped(mid) <= -utcDUT1ToleranceSeconds { + high = mid + } else { + low = mid + } + } + crossing := (low + high) / 2 + for _, sample := range []struct { + jd float64 + want float64 + }{ + {crossing - 30, base}, + {crossing + 30, base + 1}, + {JDCalc(2029, 7, 1), base}, + {JDCalc(2035, 7, 1), base}, + } { + got := utcToTTOffsetSeconds(sample.jd) + if math.Abs(got-sample.want) > 1e-9 { + t.Errorf("jd=%v TT−UTC=%v, want %v", sample.jd, got, sample.want) + } + if steps := got - base; math.Abs(steps-math.Round(steps)) > 1e-9 { + t.Errorf("jd=%v 相对已宣告偏移 %v 不是整数秒", sample.jd, steps) + } + if dut1 := DUT1Seconds(sample.jd); math.Abs(dut1) > utcDUT1ToleranceSeconds+1e-9 { + t.Errorf("jd=%v |DUT1|=%v 越过容限 %v", sample.jd, math.Abs(dut1), utcDUT1ToleranceSeconds) + } + } + // 逐年扫到 2400:偏移始终是 base 加整数秒,DUT1 全程留在容限内,阶跃只升不降。 + prev := utcToTTOffsetSeconds(JDCalc(2027, 1, 1)) + for year := 2028; year <= 2400; year++ { + jd := JDCalc(year, 1, 1) + offset := utcToTTOffsetSeconds(jd) + if steps := offset - base; math.Abs(steps-math.Round(steps)) > 1e-9 { + t.Fatalf("%d 年相对偏移 %v 不是整数秒", year, steps) + } + if offset < prev { + t.Fatalf("%d 年偏移从 %v 降到 %v(本库 ΔT 外推下不应出现负闰秒)", year, prev, offset) + } + prev = offset + if dut1 := DUT1Seconds(jd); math.Abs(dut1) > utcDUT1ToleranceSeconds+1e-9 { + t.Fatalf("%d 年 |DUT1|=%v 越过容限", year, math.Abs(dut1)) + } + } + // 注入 ΔT 随时间下降的模型:未阶跃 UT1−UTC 变为上升,必须走负闰秒。 + SetDeltaTFn(func(jd float64, isJd bool) float64 { + year := jd + if isJd { + year = 2026 + (jd-timeScaleExactEndJDE)/365.25 + } + return 69.1816 - 0.2*(year-2026) + }) + jd := JDCalc(2032, 1, 1) + if got := utcToTTOffsetSeconds(jd); math.Abs(got-(base-1)) > 1e-9 { + t.Errorf("反向漂移下 TT−UTC=%v, want %v(负闰秒)", got, base-1) + } + if dut1 := DUT1Seconds(jd); math.Abs(dut1) > utcDUT1ToleranceSeconds+1e-9 { + t.Errorf("反向漂移下 |DUT1|=%v 越过容限", math.Abs(dut1)) + } + SetDeltaTFn(nil) +} + +func TestTimeScaleHooksAndGeneration(t *testing.T) { + resetTimeScaleState(t) + before := deltaTGenerationValue() + SetTTMinusUTCFn(func(float64) float64 { return 100 }) + if deltaTGenerationValue() == before { + t.Error("SetTTMinusUTCFn 应递增世代号") + } + if got := TTMinusUTCSeconds(JDCalc(2000, 1, 1)); got != 100 { + t.Errorf("覆盖后 TT−UTC=%v, want 100", got) + } + SetTTMinusUTCFn(nil) + if got := TTMinusUTCSeconds(JDCalc(2000, 1, 1)); math.Abs(got-64.184) > 1e-9 { + t.Errorf("恢复后 TT−UTC=%v, want 64.184", got) + } + + before = deltaTGenerationValue() + SetDeltaTFn(func(float64, bool) float64 { return 100 }) + if deltaTGenerationValue() == before { + t.Error("SetDeltaTFn 应递增世代号") + } + jd := JDCalc(2000, 1, 1) + if got := ut1ToTTOffsetSeconds(jd); math.Abs(got-100) > 1e-12 { + t.Errorf("注入 ΔT 后 UT1 偏移=%v, want 100", got) + } + SetDeltaTFn(nil) + if got := ut1ToTTOffsetSeconds(jd); math.Abs(got-63.83) > 0.05 { + t.Errorf("恢复后 UT12TT 偏移=%v, 应回到月度实测值附近", got) + } + + before = deltaTGenerationValue() + SetTimeScaleFuturePolicy(TimeScaleFreezeUTCOffset) + if deltaTGenerationValue() == before { + t.Error("SetTimeScaleFuturePolicy 应递增世代号") + } +} + +// JD 0(−4713-11-24)是合法时刻,不是零值哨兵:TT2UTC(0) 必须与 TT2UT1(0) 给出同一个有限值并互逆。 +func TestTimeScaleZeroInstantIsNotSentinel(t *testing.T) { + resetTimeScaleState(t) + utc := TT2UTC(0) + if math.IsNaN(utc) || math.IsInf(utc, 0) { + t.Fatalf("TT2UTC(0)=%v, want 有限值", utc) + } + if got := TT2UT1(0); math.Abs(utc-got) > 1e-9 { + t.Errorf("1972 前民用时标即 UT1:TT2UTC(0)=%v, TT2UT1(0)=%v(差 %g 秒)", utc, got, (utc-got)*86400) + } + if back := UTC2TT(utc); math.Abs(back) > 1e-9 { + t.Errorf("UTC2TT(TT2UTC(0))=%v, want 0(差 %g 秒)", back, back*86400) + } +} + +// DeltaT 的第一个参数按 julianDay 解释为 UT 儒略日或十进制年;口径给错时自变量远在模型跨度之外, +// 必须返回 NaN 而不是继续外推成天文数字。 +func TestDeltaTArgumentDomain(t *testing.T) { + resetTimeScaleState(t) + for _, year := range []float64{-40000, -2000, -500, 0, 1000.5, 2000, 3000, 40000} { + if got := DeltaT(year, false); math.IsNaN(got) { + t.Errorf("DeltaT(%v, 十进制年)=NaN, 应给出模型值", year) + } + } + for _, jd := range []float64{-1e7, JDCalc(-2000, 1, 1), JDCalc(0, 1, 1), JDCalc(2000, 1, 1), JDCalc(5000, 1, 1), 1e7} { + if got := DeltaT(jd, true); math.IsNaN(got) { + t.Errorf("DeltaT(%v, 儒略日)=NaN, 应给出模型值", jd) + } + } + if got := DeltaT(JDCalc(2026, 3, 1), false); !math.IsNaN(got) { + t.Errorf("儒略日当十进制年应越界返回 NaN, got %v", got) + } + for _, jd := range []float64{1e8, -1e8} { + if got := DeltaT(jd, true); !math.IsNaN(got) { + t.Errorf("DeltaT(%v, 儒略日)=%v, want NaN", jd, got) + } + } + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + for _, julianDay := range []bool{false, true} { + if got := DeltaT(value, julianDay); !math.IsNaN(got) { + t.Errorf("DeltaT(%v, %v)=%v, want NaN", value, julianDay, got) + } + } + } +} + +func TestTimeScaleSteppedPoliciesRejectInvalidDeltaT(t *testing.T) { + resetTimeScaleState(t) + for _, policy := range []TimeScaleFuturePolicy{TimeScaleLeapSecond, TimeScaleLeapHour} { + SetTimeScaleFuturePolicy(policy) + for _, tc := range []struct { + name string + jd float64 + model func(float64, bool) float64 + }{ + {"nan-model", JDCalc(2030, 1, 1), func(float64, bool) float64 { return math.NaN() }}, + {"positive-infinity-model", JDCalc(2030, 1, 1), func(float64, bool) float64 { return math.Inf(1) }}, + {"negative-infinity-model", JDCalc(2030, 1, 1), func(float64, bool) float64 { return math.Inf(-1) }}, + {"outside-model-range", 1e8, nil}, + } { + SetDeltaTFn(tc.model) + for name, got := range map[string]float64{ + "offset": utcToTTOffsetSeconds(tc.jd), + "UTC2TT": UTC2TT(tc.jd), + "TT2UTC": TT2UTC(tc.jd), + "DUT1": DUT1Seconds(tc.jd), + } { + if !math.IsNaN(got) { + t.Errorf("policy=%v %s %s=%v, want NaN", policy, tc.name, name, got) + } + } + } + } +} + +func TestTimeScalePropagatesNaN(t *testing.T) { + resetTimeScaleState(t) + nan := math.NaN() + for name, got := range map[string]float64{ + "UTC2TT": UTC2TT(nan), + "UT12TT": UT12TT(nan), + "TT2UTC": TT2UTC(nan), + "TT2UT1": TT2UT1(nan), + "UTC2UT1": UTC2UT1(nan), + "UT12UTC": UT12UTC(nan), + "DUT1": DUT1Seconds(nan), + } { + if !math.IsNaN(got) { + t.Errorf("%s(NaN)=%v, want NaN", name, got) + } + } +} + +// 注入的 TT−UTC 覆盖对窗口之后的查询日期同样生效:未来政策只描述未注入时的形状, +// 否则外部时标模型与实际换算在窗口末端之后会各说各话。 +func TestTimeScaleOverrideAppliesBeyondExactWindow(t *testing.T) { + resetTimeScaleState(t) + jd := JDCalc(2027, 1, 1) + SetTTMinusUTCFn(func(at float64) float64 { + if at >= jd { + return 70.184 + } + return 69.184 + }) + for _, policy := range []TimeScaleFuturePolicy{ + TimeScaleAssumeUT1Tracking, TimeScaleFreezeUTCOffset, TimeScaleLeapHour, + TimeScaleUT1Civil, TimeScaleLeapSecond, + } { + SetTimeScaleFuturePolicy(policy) + if got := utcToTTOffsetSeconds(jd); math.Abs(got-70.184) > 1e-12 { + t.Errorf("policy=%v: UTC2TT 使用 %.6f 秒,want 70.184", policy, got) + } + // jd 本身只有约 4e-5 秒的分辨率,往返差按秒比较。 + if got := (TT2UTC(UTC2TT(jd)) - jd) * 86400; math.Abs(got) > 1e-4 { + t.Errorf("policy=%v: 往返差 %.9f 秒", policy, got) + } + if got := DUT1Seconds(jd); math.Abs(got-(70.184-DeltaT(jd, true))) > 1e-9 { + t.Errorf("policy=%v: DUT1=%.6f 秒,want %.6f", policy, got, 70.184-DeltaT(jd, true)) + } + } + SetTTMinusUTCFn(nil) + SetTimeScaleFuturePolicy(TimeScaleFreezeUTCOffset) + if got := utcToTTOffsetSeconds(jd); math.Abs(got-69.184) > 1e-12 { + t.Errorf("未注入时冻结政策应生效:UTC2TT 使用 %.6f 秒", got) + } +} + +// 默认 TT−UTC 函数必须忽略注入的覆盖:它是"取默认口径"的唯一入口。 +func TestTTMinusUTCSecondsDefaultIgnoresOverride(t *testing.T) { + resetTimeScaleState(t) + jd := timeScaleExactEndJDE + want := TTMinusUTCSecondsDefault(jd) + if math.Abs(want-69.184) > 1e-9 { + t.Fatalf("默认 TT−UTC(窗口末端)=%v, want 69.184", want) + } + SetTTMinusUTCFn(func(float64) float64 { return 42.184 }) + if got := TTMinusUTCSeconds(jd); math.Abs(got-42.184) > 1e-9 { + t.Fatalf("覆盖后 TTMinusUTCSeconds=%v, want 42.184", got) + } + if got := TTMinusUTCSecondsDefault(jd); math.Abs(got-want) > 1e-12 { + t.Fatalf("覆盖后默认 TT−UTC=%v, want %v(应忽略覆盖)", got, want) + } +} diff --git a/basic/traditional_calendar.go b/basic/traditional_calendar.go index 7197395..dc7069a 100644 --- a/basic/traditional_calendar.go +++ b/basic/traditional_calendar.go @@ -3,8 +3,8 @@ package basic import "math" // GetShiErCi 十二次 / twelve divisions of the ecliptic in traditional Chinese astronomy. -func GetShiErCi(jd float64) string { //十二次 - tlo := HSunApparentLo(jd) +func GetShiErCi(jde float64) string { //十二次 + tlo := HSunApparentLo(jde) if tlo >= 255 && tlo < 285 { return "星纪" } else if tlo >= 285 && tlo < 315 { @@ -53,23 +53,25 @@ func GetWuHouTime(Year, Angle int) float64 { if Month > 12 { Month -= 12 } - JD1 := JDECalc(Year, Month, float64(Day)) - JD1 += float64(tmp - Angle) + jd := JDCalc(Year, Month, float64(Day)) + jd += float64(tmp - Angle) Angle = tmp if Angle <= 5 { Angle = 360 + Angle } + // 种子是民用时刻,牛顿迭代解出的是力学时事件时刻,两者必须分开命名。 + jde := jd converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - JD0 := JD1 - stDegree := JQLospec(JD0, float64(Angle)) - float64(Angle) - stDegreep := (JQLospec(JD0+0.000005, float64(Angle)) - JQLospec(JD0-0.000005, float64(Angle))) / 0.00001 - nextJD := JD0 - stDegree/stDegreep + jde0 := jde + stDegree := JQLospec(jde0, float64(Angle)) - float64(Angle) + stDegreep := (JQLospec(jde0+0.000005, float64(Angle)) - JQLospec(jde0-0.000005, float64(Angle))) / 0.00001 + nextJD := jde0 - stDegree/stDegreep if !isFiniteFloat(nextJD) { return math.NaN() } - JD1 = nextJD - if math.Abs(nextJD-JD0) <= 0.00001 { + jde = nextJD + if math.Abs(nextJD-jde0) <= 0.00001 { converged = true break } @@ -77,7 +79,7 @@ func GetWuHouTime(Year, Angle int) float64 { if !converged { return math.NaN() } - return TD2UT(JD1, false) + return TT2UTC(jde) } // GetGanZhi 年干支 / ganzhi for a year number. diff --git a/basic/uranus.go b/basic/uranus.go index f44f871..01b2c29 100644 --- a/basic/uranus.go +++ b/basic/uranus.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func UranusL(jd float64) float64 { - return planet.WherePlanet(6, 0, jd) +func UranusL(jde float64) float64 { + return planet.WherePlanet(6, 0, jde) } -func UranusB(jd float64) float64 { - return planet.WherePlanet(6, 1, jd) +func UranusB(jde float64) float64 { + return planet.WherePlanet(6, 1, jde) } -func UranusR(jd float64) float64 { - return planet.WherePlanet(6, 2, jd) +func UranusR(jde float64) float64 { + return planet.WherePlanet(6, 2, jde) } -func AUranusX(jd float64) float64 { - l := UranusL(jd) - b := UranusB(jd) - r := UranusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AUranusX(jde float64) float64 { + l := UranusL(jde) + b := UranusB(jde) + r := UranusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func AUranusY(jd float64) float64 { +func AUranusY(jde float64) float64 { - l := UranusL(jd) - b := UranusB(jd) - r := UranusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := UranusL(jde) + b := UranusB(jde) + r := UranusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func AUranusZ(jd float64) float64 { - //l := UranusL(jd) - b := UranusB(jd) - r := UranusR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AUranusZ(jde float64) float64 { + //l := UranusL(jde) + b := UranusB(jde) + r := UranusR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func AUranusXYZ(jd float64) (float64, float64, float64) { - l := UranusL(jd) - b := UranusB(jd) - r := UranusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AUranusXYZ(jde float64) (float64, float64, float64) { + l := UranusL(jde) + b := UranusB(jde) + r := UranusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func UranusApparentRa(jd float64) float64 { - lo, bo := UranusApparentLoBo(jd) - eps := TrueObliquity(jd) +func UranusApparentRa(jde float64) float64 { + lo, bo := UranusApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func UranusApparentDec(jd float64) float64 { - lo, bo := UranusApparentLoBo(jd) - eps := TrueObliquity(jd) +func UranusApparentDec(jde float64) float64 { + lo, bo := UranusApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func UranusApparentRaDec(jd float64) (float64, float64) { - lo, bo := UranusApparentLoBo(jd) - eps := TrueObliquity(jd) +func UranusApparentRaDec(jde float64) (float64, float64) { + lo, bo := UranusApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -105,22 +105,22 @@ func UranusApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func UranusMag(jd float64) float64 { - sunDistance := UranusR(jd) - earthDistance := EarthUranusAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func UranusMag(jde float64) float64 { + sunDistance := UranusR(jde) + earthDistance := EarthUranusAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -7.19 + 5*math.Log10(sunDistance*earthDistance) + 0.016*i return FloatRound(mag, 2) } -func UranusHeight(jde, lon, lat, timezone float64) float64 { +func UranusHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := UranusApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := UranusApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -129,12 +129,12 @@ func UranusHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func UranusAzimuth(jde, lon, lat, timezone float64) float64 { +func UranusAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := UranusApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := UranusApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -153,21 +153,21 @@ func UranusAzimuth(jde, lon, lat, timezone float64) float64 { } func UranusHourAngle(jd, lon, timezone float64) float64 { - siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon) - hourAngle := siderealLongitude - UranusApparentRa(TD2UT(jd-timezone/24.0, true)) + siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon) + hourAngle := siderealLongitude - UranusApparentRa(UTC2TT(jd-timezone/24.0)) if hourAngle < 0 { hourAngle += 360 } return hourAngle } -func UranusCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func UranusCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-UranusHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - normalizedHourAngle := func(jde, lon, timezone float64) float64 { - currentHourAngle := UranusHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-UranusHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + normalizedHourAngle := func(localJD, lon, timezone float64) float64 { + currentHourAngle := UranusHourAngle(localJD, lon, timezone) if currentHourAngle < 180 { currentHourAngle += 360 } diff --git a/basic/uranus_events.go b/basic/uranus_events.go index 5eba5b0..8e0345e 100644 --- a/basic/uranus_events.go +++ b/basic/uranus_events.go @@ -74,15 +74,15 @@ func uranusConjunctionFull(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := uranusSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (uranusSunLongitudeDelta(prevJDE+0.000005, degree, true) - uranusSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -90,7 +90,7 @@ func uranusConjunctionFull(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func uranusConjunction(jde, degree float64, next uint8) float64 { @@ -105,15 +105,15 @@ func uranusConjunction(jde, degree float64, next uint8) float64 { } else { jde += daysPerDegree * currentDelta } - estimateJD := jde + estimateJDE := jde converged := false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := uranusSunLongitudeDeltaN(prevJD, degree, true, uranusEventSearchN) - longitudeSlope := (uranusSunLongitudeDeltaN(prevJD+0.000005, degree, true, uranusEventSearchN) - uranusSunLongitudeDeltaN(prevJD-0.000005, degree, true, uranusEventSearchN)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= uranusPhaseCoarseTolerance { + prevJDE := estimateJDE + longitudeDelta := uranusSunLongitudeDeltaN(prevJDE, degree, true, uranusEventSearchN) + longitudeSlope := (uranusSunLongitudeDeltaN(prevJDE+0.000005, degree, true, uranusEventSearchN) - uranusSunLongitudeDeltaN(prevJDE-0.000005, degree, true, uranusEventSearchN)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= uranusPhaseCoarseTolerance { converged = true break } @@ -123,12 +123,12 @@ func uranusConjunction(jde, degree float64, next uint8) float64 { } converged = false for i := 0; i < eventNewtonMaxIterations; i++ { - prevJD := estimateJD - longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true) - longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001 - nextJD := prevJD - longitudeDelta/longitudeSlope - estimateJD = nextJD - if math.Abs(nextJD-prevJD) <= 0.00001 { + prevJDE := estimateJDE + longitudeDelta := uranusSunLongitudeDelta(prevJDE, degree, true) + longitudeSlope := (uranusSunLongitudeDelta(prevJDE+0.000005, degree, true) - uranusSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001 + nextJD := prevJDE - longitudeDelta/longitudeSlope + estimateJDE = nextJD + if math.Abs(nextJD-prevJDE) <= 0.00001 { converged = true break } @@ -136,7 +136,7 @@ func uranusConjunction(jde, degree float64, next uint8) float64 { if !converged { return math.NaN() } - return TD2UT(estimateJD, false) + return TT2UTC(estimateJDE) } func LastUranusConjunction(jde float64) float64 { @@ -175,22 +175,22 @@ func uranusRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOppositi if !isFiniteFloat(oppositionJD) { return math.NaN() } - oppositionTT := TD2UT(oppositionJD, true) + oppositionTT := UTC2TT(oppositionJD) startTT := oppositionTT endTT := oppositionTT if searchBeforeOpposition { easternQuadratureUT := uranusConjunction(oppositionTT, 90, 0) - startTT = TD2UT(easternQuadratureUT, true) + startTT = UTC2TT(easternQuadratureUT) } else { westernQuadratureUT := uranusConjunction(oppositionTT, 270, 1) - endTT = TD2UT(westernQuadratureUT, true) + endTT = UTC2TT(westernQuadratureUT) } - bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { + bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { return uranusRADerivativeN(jd, stationDerivativeStepDay, uranusEventSearchN) }, func(jd float64) float64 { return uranusRADerivative(jd, stationDerivativeStepDay) }) - return TD2UT(bestJD, false) + return TT2UTC(bestJDE) } func NextUranusRetrogradeToPrograde(jde float64) float64 { diff --git a/basic/venus.go b/basic/venus.go index 78e4d64..14ae0cf 100644 --- a/basic/venus.go +++ b/basic/venus.go @@ -7,79 +7,79 @@ import ( . "b612.me/astro/tools" ) -func VenusL(jd float64) float64 { - return planet.WherePlanet(2, 0, jd) +func VenusL(jde float64) float64 { + return planet.WherePlanet(2, 0, jde) } -func VenusB(jd float64) float64 { - return planet.WherePlanet(2, 1, jd) +func VenusB(jde float64) float64 { + return planet.WherePlanet(2, 1, jde) } -func VenusR(jd float64) float64 { - return planet.WherePlanet(2, 2, jd) +func VenusR(jde float64) float64 { + return planet.WherePlanet(2, 2, jde) } -func AVenusX(jd float64) float64 { - l := VenusL(jd) - b := VenusB(jd) - r := VenusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AVenusX(jde float64) float64 { + l := VenusL(jde) + b := VenusB(jde) + r := VenusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) return x } -func AVenusY(jd float64) float64 { +func AVenusY(jde float64) float64 { - l := VenusL(jd) - b := VenusB(jd) - r := VenusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) + l := VenusL(jde) + b := VenusB(jde) + r := VenusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) return y } -func AVenusZ(jd float64) float64 { - //l := VenusL(jd) - b := VenusB(jd) - r := VenusR(jd) - // el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AVenusZ(jde float64) float64 { + //l := VenusL(jde) + b := VenusB(jde) + r := VenusR(jde) + // el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) z := r*Sin(b) - er*Sin(eb) return z } -func AVenusXYZ(jd float64) (float64, float64, float64) { - l := VenusL(jd) - b := VenusB(jd) - r := VenusR(jd) - el := planet.WherePlanet(-1, 0, jd) - eb := planet.WherePlanet(-1, 1, jd) - er := planet.WherePlanet(-1, 2, jd) +func AVenusXYZ(jde float64) (float64, float64, float64) { + l := VenusL(jde) + b := VenusB(jde) + r := VenusR(jde) + el := planet.WherePlanet(-1, 0, jde) + eb := planet.WherePlanet(-1, 1, jde) + er := planet.WherePlanet(-1, 2, jde) x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el) y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el) z := r*Sin(b) - er*Sin(eb) return x, y, z } -func VenusApparentRa(jd float64) float64 { - lo, bo := VenusApparentLoBo(jd) - eps := TrueObliquity(jd) +func VenusApparentRa(jde float64) float64 { + lo, bo := VenusApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi return Limit360(ra) } -func VenusApparentDec(jd float64) float64 { - lo, bo := VenusApparentLoBo(jd) - eps := TrueObliquity(jd) +func VenusApparentDec(jde float64) float64 { + lo, bo := VenusApparentLoBo(jde) + eps := TrueObliquity(jde) dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } -func VenusApparentRaDec(jd float64) (float64, float64) { - lo, bo := VenusApparentLoBo(jd) - eps := TrueObliquity(jd) +func VenusApparentRaDec(jde float64) (float64, float64) { + lo, bo := VenusApparentLoBo(jde) + eps := TrueObliquity(jde) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) @@ -105,22 +105,22 @@ func VenusApparentLoBo(jd float64) (float64, float64) { return geo.lo, geo.bo } -func VenusMag(jd float64) float64 { - sunDistance := VenusR(jd) - earthDistance := EarthVenusAway(jd) - earthSunDistance := planet.WherePlanet(-1, 2, jd) +func VenusMag(jde float64) float64 { + sunDistance := VenusR(jde) + earthDistance := EarthVenusAway(jde) + earthSunDistance := planet.WherePlanet(-1, 2, jde) i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance) i = ArcCos(i) mag := -4.40 + 5*math.Log10(sunDistance*earthDistance) + 0.0009*i + 0.000239*i*i - 0.00000065*i*i*i return FloatRound(mag, 2) } -func VenusHeight(jde, lon, lat, timezone float64) float64 { +func VenusHeight(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := VenusApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := VenusApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 高度角、时角与天球座标三角转换公式 @@ -129,12 +129,12 @@ func VenusHeight(jde, lon, lat, timezone float64) float64 { return ArcSin(sinHeight) } -func VenusAzimuth(jde, lon, lat, timezone float64) float64 { +func VenusAzimuth(localJD, lon, lat, timezone float64) float64 { // 转换为世界时 - utcJde := jde - timezone/24.0 + utcJD := localJD - timezone/24.0 // 计算视恒星时 - ra, dec := VenusApparentRaDec(TD2UT(utcJde, true)) - st := Limit360(ApparentSiderealTime(utcJde)*15 + lon) + ra, dec := VenusApparentRaDec(UTC2TT(utcJD)) + st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon) // 计算时角 hourAngle := Limit360(st - ra) // 三角转换公式 @@ -153,21 +153,21 @@ func VenusAzimuth(jde, lon, lat, timezone float64) float64 { } func VenusHourAngle(jd, lon, tz float64) float64 { - startime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon) - timeangle := startime - VenusApparentRa(TD2UT(jd-tz/24.0, true)) + startime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon) + timeangle := startime - VenusApparentRa(UTC2TT(jd-tz/24.0)) if timeangle < 0 { timeangle += 360 } return timeangle } -func VenusCulminationTime(jde, lon, timezone float64) float64 { - //jde 世界时,非力学时,当地时区 0时,无需转换力学时 +func VenusCulminationTime(localJD, lon, timezone float64) float64 { + // localJD 是本地民用日锚点(当地 0 时),不是力学时。 //ra,dec 瞬时天球座标,非J2000等时间天球坐标 - jde = math.Floor(jde) + 0.5 - estimateJD := jde + Limit360(360-VenusHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851 - limitHA := func(jde, lon, timezone float64) float64 { - ha := VenusHourAngle(jde, lon, timezone) + localJD = math.Floor(localJD) + 0.5 + estimateJD := localJD + Limit360(360-VenusHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851 + limitHA := func(localJD, lon, timezone float64) float64 { + ha := VenusHourAngle(localJD, lon, timezone) if ha < 180 { ha += 360 } diff --git a/basic/venus_event_helpers_test.go b/basic/venus_event_helpers_test.go index 0778744..b4747a0 100644 --- a/basic/venus_event_helpers_test.go +++ b/basic/venus_event_helpers_test.go @@ -30,7 +30,7 @@ func venusEventSamples() []time.Time { } func venusEventSampleTTJD(date time.Time) float64 { - return TD2UT(Date2JDE(date.UTC()), true) + return UTC2TT(Date2JD(date.UTC())) } func venusEventCases() []venusEventCase { diff --git a/basic/venus_events.go b/basic/venus_events.go index 33e8ef7..31d5a4a 100644 --- a/basic/venus_events.go +++ b/basic/venus_events.go @@ -144,7 +144,7 @@ func venusConjunction(jde float64, next uint8) float64 { leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN) if math.Abs(venusSunLongitudeDelta(queryTT)) <= venusConjunctionSameInstantDegrees { if exact, ok := venusConjunctionRefine(left, 1.0); ok { - eventUT := TD2UT(exact, false) + eventUT := TT2UTC(exact) if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) { return eventUT } @@ -161,7 +161,7 @@ func venusConjunction(jde float64, next uint8) float64 { center := (left + right) / 2.0 halfWindow := math.Abs(right-left) / 2.0 if exact, ok := venusConjunctionRefine(center, halfWindow); ok { - return TD2UT(exact, false) + return TT2UTC(exact) } // 截断级数在根附近的符号可能与全项不一致(查询几乎正好落在合上), // 此时改用全项级数做方向扫描兜底,而不是直接有界失败。 @@ -198,7 +198,7 @@ func venusConjunctionFullDirectionalScan(queryTT, direction float64) float64 { if leftVal == 0 || rightVal == 0 || leftVal*rightVal < 0 { center := (left + right) / 2.0 if exact, ok := venusConjunctionRefine(center, math.Abs(right-left)/2.0); ok { - return TD2UT(exact, false) + return TT2UTC(exact) } return math.NaN() } @@ -209,16 +209,16 @@ func venusConjunctionFullDirectionalScan(queryTT, direction float64) float64 { } func venusConjunctionRefine(seed, halfWindow float64) (float64, bool) { - leftJD := seed - halfWindow - centerJD := seed - rightJD := seed + halfWindow - leftVal := venusSunLongitudeDelta(leftJD) - centerVal := venusSunLongitudeDelta(centerJD) - rightVal := venusSunLongitudeDelta(rightJD) + leftJDE := seed - halfWindow + centerJDE := seed + rightJDE := seed + halfWindow + leftVal := venusSunLongitudeDelta(leftJDE) + centerVal := venusSunLongitudeDelta(centerJDE) + rightVal := venusSunLongitudeDelta(rightJDE) if !isFiniteFloat(leftVal) || !isFiniteFloat(centerVal) || !isFiniteFloat(rightVal) { return math.NaN(), false } - if _, _, _, _, ok := eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal); !ok { + if _, _, _, _, ok := eventZeroBracket(leftJDE, leftVal, centerJDE, centerVal, rightJDE, rightVal); !ok { return math.NaN(), false } return eventZeroRefine(seed, halfWindow, 0.000005, venusSunLongitudeDelta), true @@ -325,7 +325,7 @@ func venusStationInWindow(start, end float64, progradeToRetrograde bool) float64 if polished, ok := venusStationPolish(best, progradeToRetrograde); ok { best = polished } - return TD2UT(best, false) + return TT2UTC(best) } // venusStationPolish 在抛物顶点结果附近求 RA 变化率的零点。 @@ -441,9 +441,9 @@ func VenusSunElongation(jde float64) float64 { // 窗口两端是世界时,目标函数收力学时,因此逐次换算。 func venusGreatestElongationInWindow(start, end float64) float64 { return maximizeInWindow(start, end, 5.0, func(utJD float64) float64 { - return venusSunElongationN(TD2UT(utJD, true), venusEventSearchN) + return venusSunElongationN(UTC2TT(utJD), venusEventSearchN) }, func(utJD float64) float64 { - return VenusSunElongation(TD2UT(utJD, true)) + return VenusSunElongation(UTC2TT(utJD)) }) } diff --git a/calendar/astro.go b/calendar/astro.go index d05a0bf..2b3bd1e 100644 --- a/calendar/astro.go +++ b/calendar/astro.go @@ -6,18 +6,18 @@ import ( "b612.me/astro/basic" ) -// NowJDE 当前时刻儒略日 / current Julian day. -func NowJDE() float64 { - return basic.GetNowJDE() +// NowJD 当前时刻儒略日 / current Julian day. +func NowJD() float64 { + return basic.GetNowJD() } -// Date2JDE 日期转儒略日 / date to Julian day. -func Date2JDE(date time.Time) float64 { +// Date2JD 日期转儒略日 / date to Julian day. +func Date2JD(date time.Time) float64 { day := float64(date.Day()) + float64(date.Hour())/24.0 + float64(date.Minute())/24.0/60.0 + float64(date.Second())/24.0/3600.0 + float64(date.Nanosecond())/1000000000.0/3600.0/24.0 - return basic.JDECalc(date.Year(), int(date.Month()), day) + return basic.JDCalc(date.Year(), int(date.Month()), day) } -// JDE2Date 儒略日转日期 / Julian day to date. -func JDE2Date(jde float64) time.Time { - return basic.JDE2Date(jde) +// JD2Date 儒略日转日期 / Julian day to date. +func JD2Date(jd float64) time.Time { + return basic.JD2Date(jd) } diff --git a/calendar/chinese.go b/calendar/chinese.go index 8a37507..a1d742d 100644 --- a/calendar/chinese.go +++ b/calendar/chinese.go @@ -73,9 +73,9 @@ func Lunar(year, month, day int, timezone float64) (int, int, int, bool, string) // The current GB/T 33661-2017 lunar-calendar convention is recommended for years 1929-3000. // For ancient dates, the result may differ from historical practice because computed new-moon and solar-term reconstructions are approximate for ancient dates. func Solar(year, month, day int, leap bool, timezone float64) time.Time { - jde := basic.GetSolar(year, month, day, leap, timezone/24.0) + jd := basic.GetSolar(year, month, day, leap, timezone/24.0) zone := time.FixedZone("CST", int(timezone*3600)) - return basic.JDE2DateByZone(jde, zone, true) + return basic.JD2DateByZone(jd, zone, true) } // SolarToLunar 公历转农历 / solar to lunar calendar. @@ -390,7 +390,7 @@ func julianOnlyPhantomJDE(year, month, day int) (float64, bool) { if !julianOnlyCivilDay(year, month, day) { return 0, false } - return basic.JDECalc(year, month, float64(day)), true + return basic.JDCalc(year, month, float64(day)), true } // julianOnlyTextResult 复核字符串入口的结果是否落在儒略历独有闰日上,是则返回该闰日的结果。 @@ -400,10 +400,10 @@ func julianOnlyTextResult(result Time, month, day int, leap bool) (Time, bool) { if !julianOnlyCivilDay(year, 2, 29) { return result, false } - diff := Date2JDE(solar) - basic.JDECalc(year, 2, 29) + diff := Date2JD(solar) - basic.JDCalc(year, 2, 29) switch diff { case 1, -1: - // +1 表示结果是闰日的标签日,-1 表示结果是闰日的前一天。 + // +1 表示结果是闰日的规范日,-1 表示结果是闰日的前一天。 default: return result, false } @@ -418,7 +418,7 @@ func julianOnlyTextResult(result Time, month, day int, leap bool) (Time, bool) { } resultDay := result.Lunar().LunarDay() if diff > 0 { - // 结果是闰日的标签日,农历上比闰日晚一天。 + // 结果是闰日的规范日,农历上比闰日晚一天。 if resultDay != day+1 && !(resultDay == 1 && day >= 29) { return result, false } @@ -431,23 +431,23 @@ func julianOnlyTextResult(result Time, month, day int, leap bool) (Time, bool) { return forward, true } -// markJulianOnly 补记儒略历独有闰日:Solar 取规范标签(后继日),精确日期放入 JDE。 -func markJulianOnly(result Time, phantomJDE float64) Time { - label := basic.JDE2DateByZone(phantomJDE, getCst(), true) - result.solarTime = label +// markJulianOnly 补记儒略历独有闰日:Solar 取后继日作为规范日,精确日期放入 JDE。 +func markJulianOnly(result Time, phantomJD float64) Time { + canonicalDate := basic.JD2DateByZone(phantomJD, getCst(), true) + result.solarTime = canonicalDate for i := range result.lunars { - result.lunars[i].solarDate = label + result.lunars[i].solarDate = canonicalDate result.lunars[i].julianOnly = true - result.lunars[i].jde = phantomJDE + result.lunars[i].jd = phantomJD } return result } -// markJulianOnlyLunar 反向路径用:用正向记录改写停在标签日的农历身份,并补记闰日标志。 -func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJDE float64) Time { - label := basic.JDE2DateByZone(phantomJDE, getCst(), true) - result = markJulianOnly(result, phantomJDE) - forward, err := innerSolarToLunarByYMD(label.Year(), 2, 29) +// markJulianOnlyLunar 反向路径用:用正向记录改写停在规范日的农历身份,并补记闰日标志。 +func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJD float64) Time { + canonicalDate := basic.JD2DateByZone(phantomJD, getCst(), true) + result = markJulianOnly(result, phantomJD) + forward, err := innerSolarToLunarByYMD(canonicalDate.Year(), 2, 29) if err != nil || !lunarDateMatches(forward, year, month, day, leap) { return result } @@ -455,7 +455,7 @@ func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJD copy(lunars, forward.lunars) for i := range lunars { lunars[i].julianOnly = true - lunars[i].jde = phantomJDE + lunars[i].jd = phantomJD } result.lunars = lunars return result @@ -463,8 +463,8 @@ func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJD // markJulianOnlyJDN 在 JDN 层面判定儒略历独有闰日(古历与秦汉反向直接由 JDN 造记录)。 func markJulianOnlyJDN(result Time, jdn int) Time { - label := basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true) - phantom, ok := julianOnlyPhantomJDE(label.Year(), 2, 29) + canonicalDate := basic.JD2DateByZone(float64(jdn)-0.5, getCst(), true) + phantom, ok := julianOnlyPhantomJDE(canonicalDate.Year(), 2, 29) if !ok || int(math.Floor(phantom+0.5)) != jdn { return result } @@ -578,9 +578,9 @@ func lunarToSolarHanQingDefault(year, month, day int, leap bool) (Time, bool) { // 返回传入年份、节气对应的北京时间节气时间。 // Returns the Beijing-time instant of the requested solar term in the supplied year. func JieQi(year, term int) time.Time { - calcJde := basic.GetJQTime(year, term) + calcJD := basic.GetJQTime(year, term) zone := time.FixedZone("CST", 8*3600) - return basic.JDE2DateByZone(calcJde, zone, false) + return basic.JD2DateByZone(calcJD, zone, false) } // CalendricalJieQi 历法相符节气日期(北京时间当天 0 点) / calendrical solar-term date at Beijing midnight. @@ -610,9 +610,9 @@ func CalendricalJieQiWithCalendar(year, term int, system AncientCalendarSystem) // 返回传入年份、物候对应的北京时间物候时间。 // Returns the Beijing-time instant of the requested pentad in the supplied year. func WuHou(year, term int) time.Time { - calcJde := basic.GetWuHouTime(year, term) + calcJD := basic.GetWuHouTime(year, term) zone := time.FixedZone("CST", 8*3600) - return basic.JDE2DateByZone(calcJde, zone, false) + return basic.JD2DateByZone(calcJD, zone, false) } func rapidLunarModern(year, month, day int) (int, int, int, bool, string) { @@ -980,12 +980,12 @@ func GanZhiOfYear(year int) string { // GanZhiOfDay 日干支 / sexagenary day name. func GanZhiOfDay(t time.Time) string { - return ganZhiOfJDE(Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst()))) + return ganZhiOfJD(Date2JD(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst()))) } -// ganZhiOfJDE 由儒略日直接求日干支(儒略历独有闰日无法表示为 time.Time)。 -func ganZhiOfJDE(jde float64) string { - diff := int(jde - 2451550.5) +// ganZhiOfJD 由儒略日直接求日干支(儒略历独有闰日无法表示为 time.Time)。 +func ganZhiOfJD(jd float64) string { + diff := int(jd - 2451550.5) if diff >= 0 { return tiangan[diff%10] + dizhi[diff%12] } @@ -1009,8 +1009,8 @@ func commonGanZhiOfMonth(year, month int) string { } func ganZhiOfDayIndex(t time.Time) (int, int) { - jde := Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst())) - diff := int(jde - 2451550.5) + localJD := Date2JD(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst())) + diff := int(localJD - 2451550.5) if diff >= 0 { return diff % 10, diff % 12 } diff --git a/calendar/chineseAncient.go b/calendar/chineseAncient.go index 5877f80..9837f5d 100644 --- a/calendar/chineseAncient.go +++ b/calendar/chineseAncient.go @@ -709,13 +709,13 @@ func ancientCalendarName(system AncientCalendarSystem) string { } func ancientDateJDN(year, month, day int) int { - return int(math.Floor(basic.JDECalc(year, month, float64(day)) + 0.5)) + return int(math.Floor(basic.JDCalc(year, month, float64(day)) + 0.5)) } func ancientJDAtLocalMidnight(year, month, day int) float64 { - return basic.JDECalc(year, month, float64(day)) + return basic.JDCalc(year, month, float64(day)) } func ancientJDNToDate(jdn int) time.Time { - return basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true) + return basic.JD2DateByZone(float64(jdn)-0.5, getCst(), true) } diff --git a/calendar/chineseCalendricalJieQi.go b/calendar/chineseCalendricalJieQi.go index cd7ad9d..824cf33 100644 --- a/calendar/chineseCalendricalJieQi.go +++ b/calendar/chineseCalendricalJieQi.go @@ -37,8 +37,8 @@ func hanQingCalendricalJieQiDateInRow(rowYear, termIndex int) (time.Time, error) for i := 0; i < termIndex; i++ { offset += hanQingJieQiPatternDelta(patternID, i) } - baseJDN := int(math.Floor(basic.JDECalc(rowYear-1, 12, 31) + 0.5)) - return basic.JDE2DateByZone(float64(baseJDN+offset)-0.5, getCst(), true), nil + baseJDN := int(math.Floor(basic.JDCalc(rowYear-1, 12, 31) + 0.5)) + return basic.JD2DateByZone(float64(baseJDN+offset)-0.5, getCst(), true), nil } func calendricalJieQiTableTermIndex(term int) (int, error) { diff --git a/calendar/chineseQinHan.go b/calendar/chineseQinHan.go index 679a7eb..1088100 100644 --- a/calendar/chineseQinHan.go +++ b/calendar/chineseQinHan.go @@ -166,11 +166,11 @@ func qinHanEpoch(year int) (float64, int, int) { } func qinHanDateJDN(year, month, day int) int { - return int(math.Floor(basic.JDECalc(year, month, float64(day)) + 0.5)) + return int(math.Floor(basic.JDCalc(year, month, float64(day)) + 0.5)) } func qinHanJDNToDate(jdn int) time.Time { - return basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true) + return basic.JD2DateByZone(float64(jdn)-0.5, getCst(), true) } func floorDiv(a, b int) int { diff --git a/calendar/chinese_test.go b/calendar/chinese_test.go index 234db33..027f010 100644 --- a/calendar/chinese_test.go +++ b/calendar/chinese_test.go @@ -858,7 +858,7 @@ func TestRapidLunarAndLunar(t *testing.T) { } t.Fatal(year, month, 24, a1, a2, a3, a4, b1, b2, b3, b4) } - sol := JDE2Date(basic.GetSolar(b1, b2, b3, b4, 8.0/24)) + sol := JD2Date(basic.GetSolar(b1, b2, b3, b4, 8.0/24)) if sol.Year() != year && int(sol.Month()) != month && sol.Day() != 24 { t.Fatal(year, month, sol, b1, b2, b3, b4) } diff --git a/calendar/julian_only_test.go b/calendar/julian_only_test.go index 921393a..526a5b9 100644 --- a/calendar/julian_only_test.go +++ b/calendar/julian_only_test.go @@ -7,8 +7,8 @@ import ( "b612.me/astro/basic" ) -// L2 契约:儒略历独有闰日(1582 年前百年非 400 闰年的 2 月 29 日)的 Solar 取后继日标签, -// JDE 取精确儒略日,JulianOnly 为真,农历身份字段描述这一天本身。 +// L2 契约:儒略历独有闰日(1582 年前百年非 400 闰年的 2 月 29 日)的 Solar 取后继日作为规范日, +// JD 取精确儒略日,JulianOnly 为真,农历身份字段描述这一天本身。 var julianOnlyYears = []int{ 100, 200, 300, 500, 600, 700, 900, 1000, 1100, 1300, 1400, 1500, -700, -600, -500, -300, -200, -100, @@ -40,21 +40,21 @@ func identityOf(t Time) lunarIdentity { func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) { for _, year := range julianOnlyYears { - phantom := basic.JDECalc(year, 2, 29) + phantom := basic.JDCalc(year, 2, 29) if phantom != phantom { // NaN 保护:这些年份必须被历法层承认 - t.Fatalf("year %d: basic.JDECalc(%d,2,29) is NaN", year, year) + t.Fatalf("year %d: basic.JDCalc(%d,2,29) is NaN", year, year) } - label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) + canonicalDate := basic.JD2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) fwd, err := SolarToLunarByYMD(year, 2, 29) if err != nil { t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err) } - if !fwd.JulianOnly() || fwd.JDE() != phantom { - t.Errorf("year %d: forward JulianOnly=%v JDE=%.1f, want true %.1f", - year, fwd.JulianOnly(), fwd.JDE(), phantom) + if !fwd.JulianOnly() || fwd.JD() != phantom { + t.Errorf("year %d: forward JulianOnly=%v JD=%.1f, want true %.1f", + year, fwd.JulianOnly(), fwd.JD(), phantom) } - if got := fwd.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") { - t.Errorf("year %d: forward Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02")) + if got := fwd.Solar().Format("2006-01-02"); got != canonicalDate.Format("2006-01-02") { + t.Errorf("year %d: forward Solar=%s, want canonical day %s", year, got, canonicalDate.Format("2006-01-02")) } want := identityOf(fwd) @@ -63,26 +63,26 @@ func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) { t.Fatalf("year %d: LunarToSolarByYMD(%d,%d,%d,%v) error: %v", year, want.year, want.month, want.day, want.leap, err) } - if !rev.JulianOnly() || rev.JDE() != phantom { - t.Errorf("year %d: reverse JulianOnly=%v JDE=%.1f, want true %.1f", - year, rev.JulianOnly(), rev.JDE(), phantom) + if !rev.JulianOnly() || rev.JD() != phantom { + t.Errorf("year %d: reverse JulianOnly=%v JD=%.1f, want true %.1f", + year, rev.JulianOnly(), rev.JD(), phantom) } - if got := rev.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") { - t.Errorf("year %d: reverse Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02")) + if got := rev.Solar().Format("2006-01-02"); got != canonicalDate.Format("2006-01-02") { + t.Errorf("year %d: reverse Solar=%s, want canonical day %s", year, got, canonicalDate.Format("2006-01-02")) } if got := identityOf(rev); got != want { t.Errorf("year %d: reverse identity %+v, want %+v (requested lunar %d/%d/%d leap=%v)", year, got, want, want.year, want.month, want.day, want.leap) } info := rev.LunarInfo()[0] - if !info.JulianOnly || info.JDE != phantom { - t.Errorf("year %d: LunarInfo JulianOnly=%v JDE=%.1f, want true %.1f", year, info.JulianOnly, info.JDE, phantom) + if !info.JulianOnly || info.JD != phantom { + t.Errorf("year %d: LunarInfo JulianOnly=%v JD=%.1f, want true %.1f", year, info.JulianOnly, info.JD, phantom) } - // 日干支必须取自闰日自己的儒略日,而不是标签日 Solar()。 - // The sexagenary day name must come from the leap day's own Julian day, not from the label day. - if got, labelGanzhi := rev.Lunar().GanZhiDay(), GanZhiOfDay(rev.Solar()); got != ganZhiOfJDE(phantom) { - t.Errorf("year %d: GanZhiDay=%s, want %s (label %s gives %s)", - year, got, ganZhiOfJDE(phantom), rev.Solar().Format("2006-01-02"), labelGanzhi) + // 日干支必须取自闰日自己的儒略日,而不是规范日 Solar()。 + // The sexagenary day name must come from the leap day's own Julian day, not from the canonical day. + if got, canonicalGanzhi := rev.Lunar().GanZhiDay(), GanZhiOfDay(rev.Solar()); got != ganZhiOfJD(phantom) { + t.Errorf("year %d: GanZhiDay=%s, want %s (canonical day %s gives %s)", + year, got, ganZhiOfJD(phantom), rev.Solar().Format("2006-01-02"), canonicalGanzhi) } } } @@ -90,7 +90,7 @@ func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) { // 儒略历闰日两侧仍是普通日子:往返正常且农历日不与闰日重复。 func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) { for _, year := range julianOnlyYears { - phantom := basic.JDECalc(year, 2, 29) + phantom := basic.JDCalc(year, 2, 29) fwd, err := SolarToLunarByYMD(year, 2, 29) if err != nil { t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err) @@ -98,7 +98,7 @@ func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) { leapLunar := identityOf(fwd) for _, c := range []struct { month, day int - wantJDE float64 + wantJD float64 }{ {2, 28, phantom - 1}, {3, 1, phantom + 1}, @@ -120,8 +120,8 @@ func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) { if rev.JulianOnly() { t.Errorf("year %d: %d-%02d flagged JulianOnly", year, year, c.day) } - if rev.JDE() != c.wantJDE { - t.Errorf("year %d: %d-%02d JDE=%.1f, want %.1f", year, year, c.day, rev.JDE(), c.wantJDE) + if rev.JD() != c.wantJD { + t.Errorf("year %d: %d-%02d JD=%.1f, want %.1f", year, year, c.day, rev.JD(), c.wantJD) } if got := identityOf(rev); got != nearID { t.Errorf("year %d: %d-%02d reverse identity %+v, want %+v", year, year, c.day, got, nearID) @@ -154,7 +154,7 @@ func TestJulianOnlyCivilDayClassification(t *testing.T) { } } -// 普通日期不受 L2 影响:JulianOnly 为假、JDE 与 Solar 一致、身份与正向一致。 +// 普通日期不受 L2 影响:JulianOnly 为假、JD 与 Solar 一致、身份与正向一致。 func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) { cases := []struct{ y, m, d int }{ {2025, 1, 1}, {2025, 6, 15}, {1912, 2, 12}, {1900, 5, 5}, {1582, 10, 15}, @@ -174,8 +174,8 @@ func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) { if rev.JulianOnly() { t.Errorf("%d-%02d-%02d flagged JulianOnly", c.y, c.m, c.d) } - if want := Date2JDE(rev.Solar()); rev.JDE() != want { - t.Errorf("%d-%02d-%02d JDE=%.1f, want %.1f (Solar 推出值)", c.y, c.m, c.d, rev.JDE(), want) + if want := Date2JD(rev.Solar()); rev.JD() != want { + t.Errorf("%d-%02d-%02d JD=%.1f, want %.1f (Solar 推出值)", c.y, c.m, c.d, rev.JD(), want) } if got := identityOf(rev); got != id { t.Errorf("%d-%02d-%02d reverse identity %+v, want %+v", c.y, c.m, c.d, got, id) @@ -185,8 +185,8 @@ func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) { if v.JulianOnly { t.Errorf("%d-%02d-%02d LunarInfo flagged JulianOnly", c.y, c.m, c.d) } - if v.JDE != rev.JDE() { - t.Errorf("%d-%02d-%02d LunarInfo JDE=%.1f, want %.1f", c.y, c.m, c.d, v.JDE, rev.JDE()) + if v.JD != rev.JD() { + t.Errorf("%d-%02d-%02d LunarInfo JD=%.1f, want %.1f", c.y, c.m, c.d, v.JD, rev.JD()) } } }) @@ -226,8 +226,8 @@ func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) { {"永元十二年二月初二", 100, 2, 2, false}, } for _, c := range cases { - phantom := basic.JDECalc(c.year, 2, 29) - label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) + phantom := basic.JDCalc(c.year, 2, 29) + canonicalDate := basic.JD2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) results, err := LunarToSolar(c.desc) if err != nil { t.Fatalf("%s: LunarToSolar error: %v", c.desc, err) @@ -237,11 +237,11 @@ func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) { continue } got := results[0] - if !got.JulianOnly() || got.JDE() != phantom { - t.Errorf("%s: JulianOnly=%v JDE=%.1f, want true %.1f", c.desc, got.JulianOnly(), got.JDE(), phantom) + if !got.JulianOnly() || got.JD() != phantom { + t.Errorf("%s: JulianOnly=%v JD=%.1f, want true %.1f", c.desc, got.JulianOnly(), got.JD(), phantom) } - if solar := got.Solar().Format("2006-01-02"); solar != label.Format("2006-01-02") { - t.Errorf("%s: Solar=%s, want canonical label %s", c.desc, solar, label.Format("2006-01-02")) + if solar := got.Solar().Format("2006-01-02"); solar != canonicalDate.Format("2006-01-02") { + t.Errorf("%s: Solar=%s, want canonical day %s", c.desc, solar, canonicalDate.Format("2006-01-02")) } lunar := got.Lunar() if lunar.LunarYear() != c.year || lunar.LunarMonth() != c.month || lunar.LunarDay() != c.day || lunar.IsLeap() != c.leap { @@ -254,10 +254,10 @@ func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) { // 闰日前后的描述必须仍是普通日期。 func TestLunarToSolarTextKeepsLeapDayNeighboursOrdinary(t *testing.T) { for _, year := range julianOnlyYears { - phantom := basic.JDECalc(year, 2, 29) + phantom := basic.JDCalc(year, 2, 29) for _, offset := range []int{-1, 1} { // 取闰日前后各一天,用它们自己的农历日构造描述。 - neighbour := basic.JDE2DateByZone(phantom+float64(offset), time.FixedZone("CST", 8*3600), true) + neighbour := basic.JD2DateByZone(phantom+float64(offset), time.FixedZone("CST", 8*3600), true) fwd, err := SolarToLunarByYMD(neighbour.Year(), int(neighbour.Month()), neighbour.Day()) if err != nil { t.Fatalf("year %d: SolarToLunarByYMD(%s) error: %v", year, neighbour.Format("2006-01-02"), err) @@ -282,8 +282,8 @@ func TestLunarToSolarTextKeepsLeapDayNeighboursOrdinary(t *testing.T) { if got.JulianOnly() { t.Errorf("%s: unexpectedly flagged JulianOnly", desc) } - if want := phantom + float64(offset); got.JDE() != want { - t.Errorf("%s: JDE=%.1f, want %.1f", desc, got.JDE(), want) + if want := phantom + float64(offset); got.JD() != want { + t.Errorf("%s: JD=%.1f, want %.1f", desc, got.JD(), want) } if got := identityOf(results[0]); got != id { t.Errorf("%s: identity %+v, want %+v", desc, got, id) diff --git a/calendar/reform_roundtrip_test.go b/calendar/reform_roundtrip_test.go index d258fe7..f03a583 100644 --- a/calendar/reform_roundtrip_test.go +++ b/calendar/reform_roundtrip_test.go @@ -109,10 +109,10 @@ func TestAncientYearStartCrossingRoundTrips(t *testing.T) { } } -// 王莽改历的 23/24 年交界:同一套月序偏移会把"腊月三十"反解到腊月初一(标签碰撞), +// 王莽改历的 23/24 年交界:同一套月序偏移会把"腊月三十"反解到腊月初一(规范日碰撞), // 此时应改用另一套偏移下能正向回到该农历日的那一天,并把主答案换成它。 // At the Wang Mang 23/24 boundary the primary month offset resolves "12th month, day 30" onto the -// 1st of that month (a label collision); the reverse must switch to the offset whose civil day +// 1st of that month (a canonical-day collision); the reverse must switch to the offset whose civil day // converts forward to the requested lunar date and make that day the primary answer. func TestWangMangBoundaryReverseIsSelfConsistent(t *testing.T) { cases := []struct { @@ -147,8 +147,8 @@ func TestWangMangBoundaryReverseIsSelfConsistent(t *testing.T) { t.Errorf("lunar %d/%d/%d -> %s converts forward to %d/%d/%d", c.ly, c.lm, c.ld, c.wantSolar, l.LunarYear(), l.LunarMonth(), l.LunarDay()) } - if res.JDE() != fwd.JDE() { - t.Errorf("lunar %d/%d/%d: JDE=%.1f, forward gives %.1f", c.ly, c.lm, c.ld, res.JDE(), fwd.JDE()) + if res.JD() != fwd.JD() { + t.Errorf("lunar %d/%d/%d: JDE=%.1f, forward gives %.1f", c.ly, c.lm, c.ld, res.JD(), fwd.JD()) } for _, cand := range res.SolarCandidates() { candFwd, err := SolarToLunarByYMD(cand.Year(), int(cand.Month()), cand.Day()) diff --git a/calendar/solar_candidates_test.go b/calendar/solar_candidates_test.go index a593099..f7daff6 100644 --- a/calendar/solar_candidates_test.go +++ b/calendar/solar_candidates_test.go @@ -105,7 +105,7 @@ func TestGregorianSkippedDaysHaveNoCandidates(t *testing.T) { t.Errorf("lunar 1582/9/18 has %d candidates, want 1", got) } if _, err := time.Parse("2006-01-02", formatAncientSolar(res.Solar())); err != nil { - t.Errorf("solar label %q is not a valid date", formatAncientSolar(res.Solar())) + t.Errorf("solar canonical day %q is not a valid date", formatAncientSolar(res.Solar())) } } diff --git a/calendar/time.go b/calendar/time.go index 1cd28c7..7ba1732 100644 --- a/calendar/time.go +++ b/calendar/time.go @@ -32,8 +32,8 @@ type LunarInfo struct { CalendarSystem AncientCalendarSystem `json:"calendarSystem"` // CalendarName 历法名称 CalendarName string `json:"calendarName"` - // JDE 该农历日精确的儒略日 / exact Julian day. - JDE float64 `json:"jde"` + // JD 该农历日精确的儒略日 / exact Julian day. + JD float64 `json:"jd"` // JulianOnly 是否只存在于儒略历 / Julian-calendar-only date. JulianOnly bool `json:"julianOnly,omitempty"` // Dynasty 朝代,如唐、宋、元、明、清等 @@ -174,8 +174,8 @@ func (t Time) Lunar() LunarTime { func (t Time) Add(d time.Duration) Time { newT := t.solarTime.Add(d) if crossesGregorianReformGap(t.solarTime, newT) { - jde := Date2JDE(t.solarTime) + d.Seconds()/86400.0 - newT = basic.JDE2DateByZone(jde, t.solarTime.Location(), true) + jd := Date2JD(t.solarTime) + d.Seconds()/86400.0 + newT = basic.JD2DateByZone(jd, t.solarTime.Location(), true) } rT, _ := SolarToLunar(newT) return rT @@ -213,10 +213,10 @@ type LunarTime struct { calendarSystem AncientCalendarSystem //历法名称 calendarName string - // julianOnly 该农历日是否只存在于儒略历(如 700-02-29);Solar 取后继日作为标签。 + // julianOnly 该农历日是否只存在于儒略历(如 700-02-29);Solar 取后继日作为规范日。 julianOnly bool - // jde 该农历日精确的儒略日,仅在 julianOnly 时显式保存。 - jde float64 + // jd 该农历日精确的儒略日,仅在 julianOnly 时显式保存。 + jd float64 eras []EraDesc } @@ -226,12 +226,12 @@ func (l LunarTime) JulianOnly() bool { return l.julianOnly } -// JDE 该农历日精确的儒略日;儒略历独有闰日比 Solar() 早一天 / exact Julian day. -func (l LunarTime) JDE() float64 { - if l.julianOnly && l.jde > 0 { - return l.jde +// JD 该农历日精确的儒略日;儒略历独有闰日比 Solar() 早一天 / exact Julian day. +func (l LunarTime) JD() float64 { + if l.julianOnly && l.jd > 0 { + return l.jd } - return Date2JDE(l.solarDate) + return Date2JD(l.solarDate) } // JulianOnly 主历法农历日是否只存在于儒略历 / whether the primary date is Julian-calendar-only. @@ -242,12 +242,12 @@ func (t Time) JulianOnly() bool { return t.lunars[0].JulianOnly() } -// JDE 主历法农历日精确的儒略日 / exact Julian day of the primary lunar date. -func (t Time) JDE() float64 { +// JD 主历法农历日精确的儒略日 / exact Julian day of the primary lunar date. +func (t Time) JD() float64 { if len(t.lunars) == 0 { - return Date2JDE(t.solarTime) + return Date2JD(t.solarTime) } - return t.lunars[0].JDE() + return t.lunars[0].JD() } // SolarCandidates 该农历日的全部合法公历候选,首个恒等于 Solar() / every legal civil date, Solar() first. @@ -287,8 +287,8 @@ func (l LunarTime) GanZhiMonth() string { // GanZhiDay 日干支 / sexagenary day name. func (l LunarTime) GanZhiDay() string { - if l.julianOnly && l.jde > 0 { - return ganZhiOfJDE(l.jde) + if l.julianOnly && l.jd > 0 { + return ganZhiOfJD(l.jd) } return GanZhiOfDay(l.solarDate) } @@ -419,7 +419,7 @@ func (l LunarTime) LunarInfo() []LunarInfo { EraDesc: v.String(), LunarWithEraDesc: v.String() + l.desc, ChineseZodiac: l.ShengXiao(), - JDE: l.JDE(), + JD: l.JD(), JulianOnly: l.julianOnly, } res = append(res, li) @@ -445,7 +445,7 @@ func (l LunarTime) LunarInfo() []LunarInfo { EraDesc: lunarYearDesc(l.year) + "年", LunarWithEraDesc: lunarYearDesc(l.year) + "年" + l.desc, ChineseZodiac: l.ShengXiao(), - JDE: l.JDE(), + JD: l.JD(), JulianOnly: l.julianOnly, } res = append(res, li) diff --git a/civil_event_regression_test.go b/civil_event_regression_test.go new file mode 100644 index 0000000..c409b66 --- /dev/null +++ b/civil_event_regression_test.go @@ -0,0 +1,198 @@ +package astro_test + +import ( + "math" + "testing" + "time" + + "b612.me/astro/jupiter" + litemoon "b612.me/astro/lite/moon" + litesun "b612.me/astro/lite/sun" + "b612.me/astro/mars" + "b612.me/astro/mercury" + "b612.me/astro/moon" + "b612.me/astro/neptune" + "b612.me/astro/orbit" + "b612.me/astro/saturn" + "b612.me/astro/star" + "b612.me/astro/sun" + "b612.me/astro/uranus" + "b612.me/astro/venus" +) + +type civilEventCase struct { + name string + event func(time.Time) (time.Time, error) + altitude func(time.Time) float64 +} + +func civilEventCases(lon, lat float64) []civilEventCase { + elements := orbit.Elements{EpochJD: 2461000.5, A: 2.765615651508659, E: 0.07957631994408416, + I: 10.58788658206854, Omega: 80.24963090816965, W: 73.29975464616518, M0: 231.5397330043706} + return []civilEventCase{ + {"sun/RiseTime", func(d time.Time) (time.Time, error) { return sun.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return sun.Altitude(d, lon, lat) }}, + {"sun/RiseTimeN", func(d time.Time) (time.Time, error) { return sun.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return sun.AltitudeN(d, lon, lat, -1) }}, + {"sun/SetTime", func(d time.Time) (time.Time, error) { return sun.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return sun.Altitude(d, lon, lat) }}, + {"sun/SetTimeN", func(d time.Time) (time.Time, error) { return sun.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return sun.AltitudeN(d, lon, lat, -1) }}, + {"sun/CulminationTime", func(d time.Time) (time.Time, error) { return sun.CulminationTime(d, lon), nil }, nil}, + {"sun/CulminationTimeN", func(d time.Time) (time.Time, error) { return sun.CulminationTimeN(d, lon, -1), nil }, nil}, + {"moon/RiseTime", func(d time.Time) (time.Time, error) { return moon.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return moon.Altitude(d, lon, lat) }}, + {"moon/SetTime", func(d time.Time) (time.Time, error) { return moon.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return moon.Altitude(d, lon, lat) }}, + {"moon/CulminationTime", func(d time.Time) (time.Time, error) { return moon.CulminationTime(d, lon, lat), nil }, nil}, + {"mercury/RiseTime", func(d time.Time) (time.Time, error) { return mercury.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return mercury.Altitude(d, lon, lat) }}, + {"mercury/RiseTimeN", func(d time.Time) (time.Time, error) { return mercury.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return mercury.AltitudeN(d, lon, lat, -1) }}, + {"mercury/SetTime", func(d time.Time) (time.Time, error) { return mercury.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return mercury.Altitude(d, lon, lat) }}, + {"mercury/SetTimeN", func(d time.Time) (time.Time, error) { return mercury.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return mercury.AltitudeN(d, lon, lat, -1) }}, + {"mercury/CulminationTime", func(d time.Time) (time.Time, error) { return mercury.CulminationTime(d, lon), nil }, nil}, + {"mercury/CulminationTimeN", func(d time.Time) (time.Time, error) { return mercury.CulminationTimeN(d, lon, -1), nil }, nil}, + {"venus/RiseTime", func(d time.Time) (time.Time, error) { return venus.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return venus.Altitude(d, lon, lat) }}, + {"venus/RiseTimeN", func(d time.Time) (time.Time, error) { return venus.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return venus.AltitudeN(d, lon, lat, -1) }}, + {"venus/SetTime", func(d time.Time) (time.Time, error) { return venus.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return venus.Altitude(d, lon, lat) }}, + {"venus/SetTimeN", func(d time.Time) (time.Time, error) { return venus.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return venus.AltitudeN(d, lon, lat, -1) }}, + {"venus/CulminationTime", func(d time.Time) (time.Time, error) { return venus.CulminationTime(d, lon), nil }, nil}, + {"venus/CulminationTimeN", func(d time.Time) (time.Time, error) { return venus.CulminationTimeN(d, lon, -1), nil }, nil}, + {"mars/RiseTime", func(d time.Time) (time.Time, error) { return mars.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return mars.Altitude(d, lon, lat) }}, + {"mars/RiseTimeN", func(d time.Time) (time.Time, error) { return mars.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return mars.AltitudeN(d, lon, lat, -1) }}, + {"mars/SetTime", func(d time.Time) (time.Time, error) { return mars.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return mars.Altitude(d, lon, lat) }}, + {"mars/SetTimeN", func(d time.Time) (time.Time, error) { return mars.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return mars.AltitudeN(d, lon, lat, -1) }}, + {"mars/CulminationTime", func(d time.Time) (time.Time, error) { return mars.CulminationTime(d, lon), nil }, nil}, + {"mars/CulminationTimeN", func(d time.Time) (time.Time, error) { return mars.CulminationTimeN(d, lon, -1), nil }, nil}, + {"jupiter/RiseTime", func(d time.Time) (time.Time, error) { return jupiter.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return jupiter.Altitude(d, lon, lat) }}, + {"jupiter/RiseTimeN", func(d time.Time) (time.Time, error) { return jupiter.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return jupiter.AltitudeN(d, lon, lat, -1) }}, + {"jupiter/SetTime", func(d time.Time) (time.Time, error) { return jupiter.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return jupiter.Altitude(d, lon, lat) }}, + {"jupiter/SetTimeN", func(d time.Time) (time.Time, error) { return jupiter.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return jupiter.AltitudeN(d, lon, lat, -1) }}, + {"jupiter/CulminationTime", func(d time.Time) (time.Time, error) { return jupiter.CulminationTime(d, lon), nil }, nil}, + {"jupiter/CulminationTimeN", func(d time.Time) (time.Time, error) { return jupiter.CulminationTimeN(d, lon, -1), nil }, nil}, + {"saturn/RiseTime", func(d time.Time) (time.Time, error) { return saturn.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return saturn.Altitude(d, lon, lat) }}, + {"saturn/RiseTimeN", func(d time.Time) (time.Time, error) { return saturn.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return saturn.AltitudeN(d, lon, lat, -1) }}, + {"saturn/SetTime", func(d time.Time) (time.Time, error) { return saturn.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return saturn.Altitude(d, lon, lat) }}, + {"saturn/SetTimeN", func(d time.Time) (time.Time, error) { return saturn.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return saturn.AltitudeN(d, lon, lat, -1) }}, + {"saturn/CulminationTime", func(d time.Time) (time.Time, error) { return saturn.CulminationTime(d, lon), nil }, nil}, + {"saturn/CulminationTimeN", func(d time.Time) (time.Time, error) { return saturn.CulminationTimeN(d, lon, -1), nil }, nil}, + {"uranus/RiseTime", func(d time.Time) (time.Time, error) { return uranus.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return uranus.Altitude(d, lon, lat) }}, + {"uranus/RiseTimeN", func(d time.Time) (time.Time, error) { return uranus.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return uranus.AltitudeN(d, lon, lat, -1) }}, + {"uranus/SetTime", func(d time.Time) (time.Time, error) { return uranus.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return uranus.Altitude(d, lon, lat) }}, + {"uranus/SetTimeN", func(d time.Time) (time.Time, error) { return uranus.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return uranus.AltitudeN(d, lon, lat, -1) }}, + {"uranus/CulminationTime", func(d time.Time) (time.Time, error) { return uranus.CulminationTime(d, lon), nil }, nil}, + {"uranus/CulminationTimeN", func(d time.Time) (time.Time, error) { return uranus.CulminationTimeN(d, lon, -1), nil }, nil}, + {"neptune/RiseTime", func(d time.Time) (time.Time, error) { return neptune.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return neptune.Altitude(d, lon, lat) }}, + {"neptune/RiseTimeN", func(d time.Time) (time.Time, error) { return neptune.RiseTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return neptune.AltitudeN(d, lon, lat, -1) }}, + {"neptune/SetTime", func(d time.Time) (time.Time, error) { return neptune.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return neptune.Altitude(d, lon, lat) }}, + {"neptune/SetTimeN", func(d time.Time) (time.Time, error) { return neptune.SetTimeN(d, lon, lat, 0, false, -1) }, func(d time.Time) float64 { return neptune.AltitudeN(d, lon, lat, -1) }}, + {"neptune/CulminationTime", func(d time.Time) (time.Time, error) { return neptune.CulminationTime(d, lon), nil }, nil}, + {"neptune/CulminationTimeN", func(d time.Time) (time.Time, error) { return neptune.CulminationTimeN(d, lon, -1), nil }, nil}, + {"litesun/RiseTime", func(d time.Time) (time.Time, error) { return litesun.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return litesun.Altitude(d, lon, lat) }}, + {"litesun/SetTime", func(d time.Time) (time.Time, error) { return litesun.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return litesun.Altitude(d, lon, lat) }}, + {"litemoon/RiseTime", func(d time.Time) (time.Time, error) { return litemoon.RiseTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return litemoon.Altitude(d, lon, lat) }}, + {"litemoon/SetTime", func(d time.Time) (time.Time, error) { return litemoon.SetTime(d, lon, lat, 0, false) }, func(d time.Time) float64 { return litemoon.Altitude(d, lon, lat) }}, + {"star/RiseTime", func(d time.Time) (time.Time, error) { return star.RiseTime(d, 100, -10, lon, lat, 0, false) }, func(d time.Time) float64 { return star.Altitude(d, 100, -10, lon, lat) }}, + {"orbit/RiseTime", func(d time.Time) (time.Time, error) { return orbit.RiseTime(d, elements, lon, lat, 0, false) }, func(d time.Time) float64 { return orbit.Altitude(d, elements, lon, lat, 0) }}, + {"star/SetTime", func(d time.Time) (time.Time, error) { return star.SetTime(d, 100, -10, lon, lat, 0, false) }, func(d time.Time) float64 { return star.Altitude(d, 100, -10, lon, lat) }}, + {"orbit/SetTime", func(d time.Time) (time.Time, error) { return orbit.SetTime(d, elements, lon, lat, 0, false) }, func(d time.Time) float64 { return orbit.Altitude(d, elements, lon, lat, 0) }}, + {"star/CulminationTime", func(d time.Time) (time.Time, error) { return star.CulminationTime(d, 100, lon), nil }, nil}, + {"orbit/CulminationTime", func(d time.Time) (time.Time, error) { return orbit.CulminationTime(d, elements, lon, lat, 0), nil }, nil}, + {"sun/MorningTwilight", func(d time.Time) (time.Time, error) { return sun.MorningTwilight(d, lon, lat, -6) }, func(d time.Time) float64 { return sun.Altitude(d, lon, lat) + 6 }}, + {"sun/MorningTwilightN", func(d time.Time) (time.Time, error) { return sun.MorningTwilightN(d, lon, lat, -6, -1) }, func(d time.Time) float64 { return sun.AltitudeN(d, lon, lat, -1) + 6 }}, + {"sun/EveningTwilight", func(d time.Time) (time.Time, error) { return sun.EveningTwilight(d, lon, lat, -6) }, func(d time.Time) float64 { return sun.Altitude(d, lon, lat) + 6 }}, + {"sun/EveningTwilightN", func(d time.Time) (time.Time, error) { return sun.EveningTwilightN(d, lon, lat, -6, -1) }, func(d time.Time) float64 { return sun.AltitudeN(d, lon, lat, -1) + 6 }}, + } +} + +func TestCivilEventsIgnoreTimeOfDay(t *testing.T) { + loc := time.FixedZone("CST", 8*3600) + base := time.Date(2026, 2, 17, 0, 0, 0, 0, loc) + for _, tc := range civilEventCases(108.93, 34.27) { + t.Run(tc.name, func(t *testing.T) { + want, err := tc.event(base) + if err != nil { + t.Fatal(err) + } + for _, clock := range [][3]int{{11, 59, 59}, {12, 0, 0}, {12, 30, 0}, {12, 59, 59}, {13, 0, 0}, {23, 59, 59}} { + input := time.Date(2026, 2, 17, clock[0], clock[1], clock[2], 0, loc) + got, err := tc.event(input) + if err != nil || !got.Equal(want) || got.Location() != loc { + t.Errorf("input %s: got %s, %v; want %s", input, got, err, want) + } + } + }) + } +} + +func TestCivilEventsAcrossDST(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + for _, md := range [][2]int{{3, 8}, {11, 1}} { + base := time.Date(2026, time.Month(md[0]), md[1], 0, 0, 0, 0, loc) + for _, tc := range civilEventCases(-74.006, 40.7128) { + t.Run(base.Format("2006-01-02")+"/"+tc.name, func(t *testing.T) { + want, wantErr := tc.event(base) + if wantErr == nil && want.Location() != loc { + t.Fatalf("lost location: %s", want) + } + if wantErr == nil && tc.altitude != nil { + if residual := tc.altitude(want); math.IsNaN(residual) || math.Abs(residual) > 0.03 { + t.Errorf("event %s residual altitude %.6f degrees", want, residual) + } + } + for _, hour := range []int{1, 3, 10, 12, 13, 23} { + input := time.Date(base.Year(), base.Month(), base.Day(), hour, 30, 0, 0, loc) + got, err := tc.event(input) + if err != wantErr || !got.Equal(want) { + t.Errorf("input %s: got %s, %v; want %s, %v", input, got, err, want, wantErr) + } + } + }) + } + } +} + +func TestSunriseDSTMatchesFixedOffsetInstant(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + for _, md := range [][2]int{{3, 8}, {11, 1}} { + date := time.Date(2026, time.Month(md[0]), md[1], 10, 0, 0, 0, loc) + got, err := sun.RiseTime(date, -74.006, 40.7128, 0, true) + if err != nil { + t.Fatal(err) + } + _, offset := got.Zone() + fixed := time.FixedZone("fixed", offset) + want, err := sun.RiseTime(time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, fixed), -74.006, 40.7128, 0, true) + if err != nil || math.Abs(got.Sub(want).Seconds()) > 0.001 { + t.Errorf("%s: got %s, want %s, err=%v", date, got, want, err) + } + } +} + +func TestMoonriseInLastHourOfLongCivilDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + date := time.Date(2026, 11, 1, 12, 30, 0, 0, loc) + for _, tc := range []struct { + name string + rise func(time.Time, float64, float64, float64, bool) (time.Time, error) + }{ + {"moon", moon.RiseTime}, {"lite/moon", litemoon.RiseTime}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.rise(date, -74.006, 40.7128, 0, false) + if err != nil { + t.Fatal(err) + } + if got.Day() != 1 || got.Hour() != 23 || got.Location() != loc { + t.Fatalf("expected late local moonrise, got %s", got) + } + fixed := time.FixedZone("EST", -5*3600) + want, err := tc.rise(time.Date(2026, 11, 1, 0, 0, 0, 0, fixed), -74.006, 40.7128, 0, false) + if err != nil || !got.Equal(want) { + t.Fatalf("got %s, want %s, err=%v", got, want, err) + } + }) + } +} diff --git a/coord/coord.go b/coord/coord.go index a55f099..fa7bd87 100644 --- a/coord/coord.go +++ b/coord/coord.go @@ -31,51 +31,55 @@ type Horizontal struct { HourAngle float64 // 时角,单位度 / hour angle in degrees. } -func jdeUTC(date time.Time) float64 { - return basic.Date2JDE(date.UTC()) +// jdUTC 绝对时刻换算成 UTC 民用儒略日,本包所有入口共用这一个口径。 +// +// 黄赤交角、章动与岁差按 TT 取用,这里给的是 UTC 民用 JD:两者的差比本包例程约 0.1″ +// 的精度低三个数量级,故不叠加 UTC2TT。 +func jdUTC(date time.Time) float64 { + return basic.Date2JD(date.UTC()) } // EclipticToEquatorial 黄道坐标转赤道坐标 / converts ecliptic to equatorial coordinates. func EclipticToEquatorial(date time.Time, lon, lat float64) Equatorial { - ra, dec := basic.LoBoToRaDec(jdeUTC(date), lon, lat) + ra, dec := basic.LoBoToRaDec(jdUTC(date), lon, lat) return Equatorial{RA: ra, Dec: dec} } // EquatorialToEcliptic 赤道坐标转黄道坐标 / converts equatorial to ecliptic coordinates. func EquatorialToEcliptic(date time.Time, ra, dec float64) Ecliptic { - lon, lat := basic.RaDecToLoBo(jdeUTC(date), ra, dec) + lon, lat := basic.RaDecToLoBo(jdUTC(date), ra, dec) return Ecliptic{Lon: lon, Lat: lat} } // Precess 岁差修正 / precesses equatorial coordinates from one date to another. func Precess(from, to time.Time, ra, dec float64) Equatorial { - nextRA, nextDec := basic.Precess(ra, dec, jdeUTC(from), jdeUTC(to)) + nextRA, nextDec := basic.Precess(ra, dec, jdUTC(from), jdUTC(to)) return Equatorial{RA: nextRA, Dec: nextDec} } // EclipticObliquity 黄赤交角 / ecliptic obliquity. func EclipticObliquity(date time.Time, nutation bool) float64 { - return basic.EclipticObliquity(jdeUTC(date), nutation) + return basic.EclipticObliquity(jdUTC(date), nutation) } // Nutation2000B IAU 2000B 章动 / IAU 2000B nutation. func Nutation2000B(date time.Time) (longitude, obliquity float64) { - return basic.Nutation2000B(jdeUTC(date)) + return basic.Nutation2000B(jdUTC(date)) } // Nutation1980 IAU 1980 章动 / IAU 1980 nutation. func Nutation1980(date time.Time) (longitude, obliquity float64) { - return basic.Nutation1980(jdeUTC(date)) + return basic.Nutation1980(jdUTC(date)) } // MeanSiderealTime 平恒星时,单位小时 / mean sidereal time in hours. func MeanSiderealTime(date time.Time) float64 { - return basic.MeanSiderealTime(jdeUTC(date)) + return basic.MeanSiderealTime(basic.UTC2UT1(jdUTC(date))) } // ApparentSiderealTime 真恒星时,单位小时 / apparent sidereal time in hours. func ApparentSiderealTime(date time.Time) float64 { - return basic.ApparentSiderealTime(jdeUTC(date)) + return basic.ApparentSiderealTime(basic.UTC2UT1(jdUTC(date))) } // HourAngle 时角 / hour angle. @@ -83,7 +87,7 @@ func ApparentSiderealTime(date time.Time) float64 { // ra 为瞬时赤经;observerLon 为观测者经度,东正西负。 // ra is apparent right ascension; observerLon is east-positive longitude. func HourAngle(date time.Time, ra, observerLon float64) float64 { - return basic.StarHourAngle(jdeUTC(date), ra, observerLon, 0) + return basic.StarHourAngle(jdUTC(date), ra, observerLon, 0) } // EquatorialToHorizontal 赤道坐标转地平坐标 / converts equatorial to horizontal coordinates. @@ -91,33 +95,32 @@ func HourAngle(date time.Time, ra, observerLon float64) float64 { // ra/dec 为瞬时赤经赤纬;observerLon/observerLat 为观测者经纬度,东正西负、北正南负。 // ra/dec are apparent coordinates; observerLon/observerLat are east-positive and north-positive. func EquatorialToHorizontal(date time.Time, ra, dec, observerLon, observerLat float64) Horizontal { - jde := jdeUTC(date) - altitude := basic.StarHeight(jde, ra, dec, observerLon, observerLat, 0) + jd := jdUTC(date) + altitude := basic.StarHeight(jd, ra, dec, observerLon, observerLat, 0) return Horizontal{ - Azimuth: basic.StarAzimuth(jde, ra, dec, observerLon, observerLat, 0), + Azimuth: basic.StarAzimuth(jd, ra, dec, observerLon, observerLat, 0), Altitude: altitude, Zenith: 90 - altitude, - HourAngle: basic.StarHourAngle(jde, ra, observerLon, 0), + HourAngle: basic.StarHourAngle(jd, ra, observerLon, 0), } } // TopocentricEquatorial 地心赤道坐标转站心赤道坐标 / converts geocentric to topocentric equatorial coordinates. // -// distanceAU 为目标天体到地心距离,单位 AU;height 为观测者海拔,单位米。 +// distanceAU 为目标天体到地心距离,单位 AU;height 为观测者椭球高(大地高),单位米。 // distanceAU is geocentric distance in AU; height is observer elevation in meters. func TopocentricEquatorial(date time.Time, ra, dec, observerLon, observerLat, distanceAU, height float64) Equatorial { - topRA, topDec := basic.TopocentricRaDec(ra, dec, observerLat, observerLon, jdeUTC(date), distanceAU, height) + topRA, topDec := basic.TopocentricRaDec(ra, dec, observerLat, observerLon, jdUTC(date), distanceAU, height) return Equatorial{RA: topRA, Dec: topDec} } // TopocentricEcliptic 地心黄道坐标转站心黄道坐标 / converts geocentric to topocentric ecliptic coordinates. // -// distanceAU 为目标天体到地心距离,单位 AU;height 为观测者海拔,单位米。 +// distanceAU 为目标天体到地心距离,单位 AU;height 为观测者椭球高(大地高),单位米。 // distanceAU is geocentric distance in AU; height is observer elevation in meters. func TopocentricEcliptic(date time.Time, lon, lat, observerLon, observerLat, distanceAU, height float64) Ecliptic { - jde := jdeUTC(date) - topLon := basic.TopocentricLo(lon, lat, observerLat, observerLon, jde, distanceAU, height) - topLat := basic.TopocentricBo(lon, lat, observerLat, observerLon, jde, distanceAU, height) + jd := jdUTC(date) + topLon, topLat := basic.TopocentricLoBo(lon, lat, observerLat, observerLon, jd, distanceAU, height) return Ecliptic{Lon: topLon, Lat: topLat} } diff --git a/coord/coord_test.go b/coord/coord_test.go index 59bb6e6..c0e160e 100644 --- a/coord/coord_test.go +++ b/coord/coord_test.go @@ -18,7 +18,7 @@ func assertClose(t *testing.T, name string, got, want, tolerance float64) { func TestEclipticEquatorialWrappers(t *testing.T) { date := time.Date(2026, 4, 27, 10, 30, 45, 0, time.FixedZone("CST", 8*3600)) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) lon := 139.686111 lat := 4.875278 @@ -35,10 +35,10 @@ func TestEclipticEquatorialWrappers(t *testing.T) { func TestTimeAndPrecessionWrappers(t *testing.T) { date := time.Date(2026, 4, 27, 2, 30, 45, 0, time.UTC) to := time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) - assertClose(t, "mean sidereal time", MeanSiderealTime(date), basic.MeanSiderealTime(jde), 1e-12) - assertClose(t, "apparent sidereal time", ApparentSiderealTime(date), basic.ApparentSiderealTime(jde), 1e-12) + assertClose(t, "mean sidereal time", MeanSiderealTime(date), basic.MeanSiderealTime(basic.UTC2UT1(jde)), 1e-12) + assertClose(t, "apparent sidereal time", ApparentSiderealTime(date), basic.ApparentSiderealTime(basic.UTC2UT1(jde)), 1e-12) assertClose(t, "obliquity", EclipticObliquity(date, true), basic.EclipticObliquity(jde, true), 1e-12) gotLon, gotObl := Nutation2000B(date) @@ -47,14 +47,14 @@ func TestTimeAndPrecessionWrappers(t *testing.T) { assertClose(t, "nutation obliquity", gotObl, wantObl, 1e-12) got := Precess(date, to, 101.28715533, -16.71611586) - wantRA, wantDec := basic.Precess(101.28715533, -16.71611586, jde, basic.Date2JDE(to.UTC())) + wantRA, wantDec := basic.Precess(101.28715533, -16.71611586, jde, basic.Date2JD(to.UTC())) assertClose(t, "precess ra", got.RA, wantRA, 1e-12) assertClose(t, "precess dec", got.Dec, wantDec, 1e-12) } func TestHorizontalAndTopocentricWrappers(t *testing.T) { date := time.Date(2026, 4, 27, 2, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) ra := 101.28715533 dec := -16.71611586 observerLon := 115.0 @@ -84,6 +84,37 @@ func TestAngularSeparationWrapper(t *testing.T) { assertClose(t, "angular separation", got, want, 1e-12) } +// 站心黄道纬度必须留在 [-90,90],且与"站心赤道坐标再转黄道"这条独立路径一致。 +// 旧实现把黄经的分母复用到纬度的 Atan2:黄经落在 90°–270° 时纬度会被切到对顶象限 +// (物理 +4.7° 报成约 -176°),且大视差目标(月球)量值也偏。 +func TestTopocentricEclipticLatitudeStaysPhysical(t *testing.T) { + date := time.Date(2026, 1, 15, 4, 0, 0, 0, time.UTC) + const ( + observerLon = 115.0 + observerLat = 40.0 + height = 53.0 + distanceAU = 0.00257 + ) + cases := []struct{ lon, lat float64 }{ + {254.978443, -5.093420}, // 黄经落在 90°–270°,旧实现翻象限 + {109.000000, 4.681960}, // 黄经落在 0°–90°,作对照 + } + for _, tc := range cases { + got := TopocentricEcliptic(date, tc.lon, tc.lat, observerLon, observerLat, distanceAU, height) + if got.Lat < -90 || got.Lat > 90 { + t.Fatalf("黄经 %.6f: 站心黄纬 %.6f 越出 [-90,90]", tc.lon, got.Lat) + } + eq := EclipticToEquatorial(date, tc.lon, tc.lat) + top := TopocentricEquatorial(date, eq.RA, eq.Dec, observerLon, observerLat, distanceAU, height) + want := EquatorialToEcliptic(date, top.RA, top.Dec) + assertClose(t, "topocentric lon", got.Lon, want.Lon, 1e-9) + assertClose(t, "topocentric lat", got.Lat, want.Lat, 1e-9) + if math.Abs(got.Lat-tc.lat) > 1.5 { + t.Fatalf("黄经 %.6f: 站心黄纬 %.6f 与地心黄纬 %.6f 的差超过月球视差量级", tc.lon, got.Lat, tc.lat) + } + } +} + // TopocentricEcliptic 对同一时刻只求一次儒略日:参考实现按旧口径重复求值,逐位对照。 func TestTopocentricEclipticMatchesDuplicatedJDE(t *testing.T) { type sample struct { @@ -154,8 +185,8 @@ func TestTopocentricEclipticMatchesDuplicatedJDE(t *testing.T) { for _, tc := range cases { got := TopocentricEcliptic(tc.date, tc.lon, tc.lat, tc.obsLon, tc.obsLat, tc.distanceAU, tc.height) - wantLon := basic.TopocentricLo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdeUTC(tc.date), tc.distanceAU, tc.height) - wantLat := basic.TopocentricBo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdeUTC(tc.date), tc.distanceAU, tc.height) + wantLon := basic.TopocentricLo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdUTC(tc.date), tc.distanceAU, tc.height) + wantLat := basic.TopocentricBo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdUTC(tc.date), tc.distanceAU, tc.height) if got.Lon != wantLon || got.Lat != wantLat { t.Fatalf("%s %s: got (%.17g, %.17g) want (%.17g, %.17g)", tc.label, tc.date.Format(time.RFC3339Nano), got.Lon, got.Lat, wantLon, wantLat) } diff --git a/coord/parallactic.go b/coord/parallactic.go index 56dd224..6cdee47 100644 --- a/coord/parallactic.go +++ b/coord/parallactic.go @@ -19,5 +19,5 @@ func ParallacticAngleByHourAngle(hourAngle, dec, observerLat float64) float64 { // Returns the signed parallactic angle for the apparent equatorial coordinates // at the observing instant. func ParallacticAngle(date time.Time, ra, dec, observerLon, observerLat float64) float64 { - return basic.StarParallacticAngle(jdeUTC(date), ra, dec, observerLon, observerLat, 0) + return basic.StarParallacticAngle(jdUTC(date), ra, dec, observerLon, observerLat, 0) } diff --git a/coord/perf_bench_test.go b/coord/perf_bench_test.go index cb1ebf1..f318077 100644 --- a/coord/perf_bench_test.go +++ b/coord/perf_bench_test.go @@ -24,15 +24,16 @@ func BenchmarkTopocentricEcliptic(b *testing.B) { benchmarkEclipticSink = sink } -func BenchmarkTopocentricEclipticLegacy(b *testing.B) { +// BenchmarkTopocentricEclipticTwoWrappers 量的是分别调用 Lo/Bo 两个包装(各解一次)与组合入口的差距。 +func BenchmarkTopocentricEclipticTwoWrappers(b *testing.B) { date, lon, lat, observerLon, observerLat, distanceAU, height := benchmarkEclipticInputs() b.ReportAllocs() b.ResetTimer() sink := Ecliptic{} for i := 0; i < b.N; i++ { sink = Ecliptic{ - Lon: basic.TopocentricLo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height), - Lat: basic.TopocentricBo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height), + Lon: basic.TopocentricLo(lon, lat, observerLat, observerLon, jdUTC(date), distanceAU, height), + Lat: basic.TopocentricBo(lon, lat, observerLat, observerLon, jdUTC(date), distanceAU, height), } } benchmarkEclipticSink = sink diff --git a/delta_t_model_public_test.go b/delta_t_model_public_test.go new file mode 100644 index 0000000..f83b636 --- /dev/null +++ b/delta_t_model_public_test.go @@ -0,0 +1,81 @@ +// 根包命名 ΔT 模型的契约:实测段优先、表外走模型、标记可回读。 +package astro_test + +import ( + "math" + "testing" + + "b612.me/astro" + "b612.me/astro/basic" +) + +func TestDeltaTModelFacade(t *testing.T) { + astro.SetDeltaT(nil) + defer astro.SetDeltaT(nil) + + if !astro.SetDeltaTModel(astro.DeltaTModelNASACanon2006, true) { + t.Fatal("NASA canon 模型应可安装") + } + if model, keep := astro.GetDeltaTModel(); model != astro.DeltaTModelNASACanon2006 || !keep { + t.Fatalf("模型标记 = (%q, %v)", model, keep) + } + // 实测段(2026.2 年落在逐月表内)必须给实测值,与内置默认一致。 + withModel := astro.DeltaT()(2026.2, false) + astro.SetDeltaT(nil) + builtin := astro.DeltaT()(2026.2, false) + if math.Abs(withModel-builtin) > 1e-9 { + t.Fatalf("实测段优先失效:模型档 %.6f vs 内置 %.6f", withModel, builtin) + } + + // 表外(公元 0 年)走模型:canon 比未加配对项的 E–M 小约 49.4 秒。 + canon := astro.DeltaTModelSeconds(astro.DeltaTModelNASACanon2006, 1721060.5, false) + espenak := astro.DeltaTModelSeconds(astro.DeltaTModelEspenakMeeus2006, 1721060.5, false) + if diff := espenak - canon; math.Abs(diff-49.43) > 0.05 { + t.Fatalf("公元 0 年配对修正 = %.3f s, want 49.43 s", diff) + } + if !astro.SetDeltaTModel(astro.DeltaTModelMS2004, true) { + t.Fatal("M&S2004 模型应可安装") + } + if got := astro.DeltaT()(0.0, false); math.Abs(got-canon) < 10 { + t.Fatalf("公元 0 年 M&S2004 = %.3f,应与 canon %.3f 明显不同", got, canon) + } + + // 手工注入报 manual;未知模型被拒绝且不改动现状。 + astro.SetDeltaT(func(float64, bool) float64 { return 0 }) + if model, keep := astro.GetDeltaTModel(); model != astro.DeltaTModelManual || keep { + t.Fatalf("手工注入标记 = (%q, %v)", model, keep) + } + if astro.SetDeltaTModel(astro.DeltaTModel("nope"), true) { + t.Fatal("未知模型应被拒绝") + } + if model, _ := astro.GetDeltaTModel(); model != astro.DeltaTModelManual { + t.Fatalf("被拒绝后模型不应改动,got %q", model) + } +} + +func TestDefaultDeltaTConfigurationRoundTrip(t *testing.T) { + astroResetDefaultModel(t) + for _, year := range []int{1800, 2026, 2100} { + jd := basic.JDCalc(year, 3, 1) + before := astro.DeltaT()(jd, true) + model, keep := astro.GetDeltaTModel() + if model != astro.DeltaTModelDefault || !keep { + t.Fatalf("default model metadata=(%q,%t)", model, keep) + } + if !astro.SetDeltaTModel(astro.DeltaTModelMS2004, false) || !astro.SetDeltaTModel(model, keep) { + t.Fatal("model selection failed") + } + if got := astro.DeltaT()(jd, true); got != before { + t.Errorf("year %d: before %.12f after %.12f", year, before, got) + } + if got := astro.DeltaTModelSeconds(model, jd, keep); got != before { + t.Errorf("year %d: model evaluation %.12f, active %.12f", year, got, before) + } + } +} + +func astroResetDefaultModel(t *testing.T) { + t.Helper() + astro.SetDeltaT(nil) + t.Cleanup(func() { astro.SetDeltaT(nil) }) +} diff --git a/doc/img/lunar-eclipse-2026-03-03-detailed-en.svg b/doc/img/lunar-eclipse-2026-03-03-detailed-en.svg new file mode 100644 index 0000000..8095580 --- /dev/null +++ b/doc/img/lunar-eclipse-2026-03-03-detailed-en.svg @@ -0,0 +1,2 @@ + +Total Lunar Eclipse of 2026-03-03Greatest Eclipse = 19:33:42 (CST) | Umbral magnitude = 1.1506Penumbral magnitude = 2.1837 | Gamma = 0.3764P. Radius = 1.2361° | U. Radius = 0.6983° | Axis = 0.3596°Saros series = 133 | member 27 of 71All times are UTC (shown in CST, UTC+08:00)Sun at greatest eclipse赤经 R.A.22h56m57.4s赤纬 Dec.-06°42'58.0"视半径 S.D.00°16'08.0"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.10h56m15.1s赤纬 Dec.+06°24'05.2"视半径 S.D.00°15'36.7"H.P.00°57'18.7"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral338:40Partial207:10Total58:18Eclipse contactsP1 penumbral begins16:44:25U1 partial begins17:50:05U2 total begins19:04:32Greatest19:33:42U3 total ends20:02:50U4 partial ends21:17:16P4 penumbral ends22:23:050100角分Entire eclipseMoonrise during eclipsePenumbra moonriseMoonset during eclipsePenumbra moonsetNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises orsets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/lunar-eclipse-2026-03-03-detailed.svg b/doc/img/lunar-eclipse-2026-03-03-detailed.svg new file mode 100644 index 0000000..3961364 --- /dev/null +++ b/doc/img/lunar-eclipse-2026-03-03-detailed.svg @@ -0,0 +1,2 @@ + +2026-03-03 月全食食甚 = 19:33:42(CST)| 本影食分 = 1.1506半影食分 = 2.1837 | 伽马 = 0.3764半影半径 = 1.2361° | 本影半径 = 0.6983° | 影轴角距 = 0.3596°沙罗序列 = 133 | 第 27 / 71 个成员图中时刻为 UTC(显示时区 CST,UTC+08:00)食甚时的太阳(地心坐标)赤经 R.A.22h56m57.4s赤纬 Dec.-06°42'58.0"视半径 S.D.00°16'08.0"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.10h56m15.1s赤纬 Dec.+06°24'05.2"视半径 S.D.00°15'36.7"地平视差 H.P.00°57'18.7"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食338:40偏食207:10全食58:18接触时刻P1 半影食始16:44:25U1 初亏17:50:05U2 食既19:04:32食甚19:33:42U3 生光20:02:50U4 复圆21:17:16P4 半影食终22:23:050100角分全程可见带食月出半影月出带食月落半影月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03-en.svg b/doc/img/lunar-eclipse-2026-03-03-en.svg similarity index 68% rename from doc/lunar-eclipse-2026-03-03-en.svg rename to doc/img/lunar-eclipse-2026-03-03-en.svg index 93993f1..966156f 100644 --- a/doc/lunar-eclipse-2026-03-03-en.svg +++ b/doc/img/lunar-eclipse-2026-03-03-en.svg @@ -1,2 +1,2 @@ -2026-03-03 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.1837 umbral=1.1506Maximum: 2026-03-03 19:33:42 CSTMoon: RA 10h56m15s Dec +06°24′05″ ecl.lon 162.8597 deg ecl.lat -0.3578 deg LeoPenumbral duration 05:38:40 Umbral duration 03:27:11 Total duration 00:58:19Lunar Saros 133 27/71Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 16:44:25 PA 104.3°U1 Partial begins 17:50:05 PA 96.2°U2 Total begins 19:04:32 PA 243.0°GE Greatest 19:33:42U3 Total ends 20:02:50 PA 173.4°U4 Partial ends 21:17:16 PA 320.3°P4 Penumbral ends 22:23:05 PA 312.1°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file +2026-03-03 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.1837 umbral=1.1506Maximum: 2026-03-03 19:33:42 CSTMoon: RA 10h56m15s Dec +06°24′05″ ecl.lon 162.8597 deg ecl.lat -0.3578 deg LeoPenumbral duration 05:38:40 Umbral duration 03:27:10 Total duration 00:58:18Lunar Saros 133 27/71Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 16:44:25 PA 104.3°U1 Partial begins 17:50:05 PA 96.2°U2 Total begins 19:04:32 PA 243.0°GE Greatest 19:33:42U3 Total ends 20:02:50 PA 173.4°U4 Partial ends 21:17:16 PA 320.3°P4 Penumbral ends 22:23:05 PA 312.1°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03.svg b/doc/img/lunar-eclipse-2026-03-03.svg similarity index 68% rename from doc/lunar-eclipse-2026-03-03.svg rename to doc/img/lunar-eclipse-2026-03-03.svg index 15161bd..2325443 100644 --- a/doc/lunar-eclipse-2026-03-03.svg +++ b/doc/img/lunar-eclipse-2026-03-03.svg @@ -1,2 +1,2 @@ -2026-03-03 月全食食型=月全食 半影食分=2.1837 本影食分=1.1506食甚:2026-03-03 19:33:42 CST月球:赤经 10h56m15s 赤纬 +06°24′05″ 黄经 162.8597° 黄纬 -0.3578° 狮子座半影历时 05:38:40 本影历时 03:27:11 全食历时 00:58:19沙罗 133 第 27/71 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 16:44:25 方位 104.3°U1 初亏 17:50:05 方位 96.2°U2 食既 19:04:32 方位 243.0°GE 食甚 19:33:42U3 生光 20:02:50 方位 173.4°U4 复圆 21:17:16 方位 320.3°P4 半影终 22:23:05 方位 312.1°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file +2026-03-03 月全食食型=月全食 半影食分=2.1837 本影食分=1.1506食甚:2026-03-03 19:33:42 CST月球:赤经 10h56m15s 赤纬 +06°24′05″ 黄经 162.8597° 黄纬 -0.3578° 狮子座半影历时 05:38:40 本影历时 03:27:10 全食历时 00:58:18沙罗 133 第 27/71 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 16:44:25 方位 104.3°U1 初亏 17:50:05 方位 96.2°U2 食既 19:04:32 方位 243.0°GE 食甚 19:33:42U3 生光 20:02:50 方位 173.4°U4 复圆 21:17:16 方位 320.3°P4 半影终 22:23:05 方位 312.1°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-eclipse-2029-01-01-detailed-en.svg b/doc/img/lunar-eclipse-2029-01-01-detailed-en.svg new file mode 100644 index 0000000..dd466b3 --- /dev/null +++ b/doc/img/lunar-eclipse-2029-01-01-detailed-en.svg @@ -0,0 +1,2 @@ + +Total Lunar Eclipse of 2029-01-01Greatest Eclipse = 00:52:05 (CST) | Umbral magnitude = 1.2461Penumbral magnitude = 2.2740 | Gamma = 0.3258P. Radius = 1.2511° | U. Radius = 0.7089° | Axis = 0.3153°Saros series = 125 | member 49 of 72All times are UTC (shown in CST, UTC+08:00)Sun at greatest eclipse赤经 R.A.18h45m53.3s赤纬 Dec.-23°01'01.1"视半径 S.D.00°16'15.9"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.06h46m08.4s赤纬 Dec.+23°19'37.5"视半径 S.D.00°15'49.1"H.P.00°58'04.3"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral336:17Partial208:50Total71:19Eclipse contactsP1 penumbral begins22:03:54U1 partial begins23:07:42U2 total begins00:16:27Greatest00:52:05U3 total ends01:27:46U4 partial ends02:36:32P4 penumbral ends03:40:110100角分Entire eclipseMoonrise during eclipsePenumbra moonriseMoonset during eclipsePenumbra moonsetNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises orsets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/lunar-eclipse-2029-01-01-detailed.svg b/doc/img/lunar-eclipse-2029-01-01-detailed.svg new file mode 100644 index 0000000..7cf3b53 --- /dev/null +++ b/doc/img/lunar-eclipse-2029-01-01-detailed.svg @@ -0,0 +1,2 @@ + +2029-01-01 月全食食甚 = 00:52:05(CST)| 本影食分 = 1.2461半影食分 = 2.2740 | 伽马 = 0.3258半影半径 = 1.2511° | 本影半径 = 0.7089° | 影轴角距 = 0.3153°沙罗序列 = 125 | 第 49 / 72 个成员图中时刻为 UTC(显示时区 CST,UTC+08:00)食甚时的太阳(地心坐标)赤经 R.A.18h45m53.3s赤纬 Dec.-23°01'01.1"视半径 S.D.00°16'15.9"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.06h46m08.4s赤纬 Dec.+23°19'37.5"视半径 S.D.00°15'49.1"地平视差 H.P.00°58'04.3"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食336:17偏食208:50全食71:19接触时刻P1 半影食始22:03:54U1 初亏23:07:42U2 食既00:16:27食甚00:52:05U3 生光01:27:46U4 复圆02:36:32P4 半影食终03:40:110100角分全程可见带食月出半影月出带食月落半影月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-en.svg b/doc/img/lunar-eclipse-2029-01-01-en.svg similarity index 68% rename from doc/lunar-eclipse-2029-01-01-en.svg rename to doc/img/lunar-eclipse-2029-01-01-en.svg index 00c2112..7609c99 100644 --- a/doc/lunar-eclipse-2029-01-01-en.svg +++ b/doc/img/lunar-eclipse-2029-01-01-en.svg @@ -1,2 +1,2 @@ -2029-01-01 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.2740 umbral=1.2461Maximum: 2029-01-01 00:52:05 CSTMoon: RA 06h46m08s Dec +23°19′37″ ecl.lon 100.5810 deg ecl.lat 0.3137 deg GeminiPenumbral duration 05:36:17 Umbral duration 03:28:50 Total duration 01:11:19Lunar Saros 125 49/72Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 22:03:54 PA 112.2°U1 Partial begins 23:07:42 PA 119.1°U2 Total begins 00:16:27 PA 325.4°GE Greatest 00:52:05U3 Total ends 01:27:46 PA 55.2°U4 Partial ends 02:36:32 PA 261.4°P4 Penumbral ends 03:40:11 PA 268.3°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file +2029-01-01 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.2740 umbral=1.2461Maximum: 2029-01-01 00:52:05 CSTMoon: RA 06h46m08s Dec +23°19′37″ ecl.lon 100.5810 deg ecl.lat 0.3137 deg GeminiPenumbral duration 05:36:17 Umbral duration 03:28:50 Total duration 01:11:19Lunar Saros 125 49/72Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 22:03:54 PA 112.2°U1 Partial begins 23:07:42 PA 119.1°U2 Total begins 00:16:27 PA 325.4°GE Greatest 00:52:05U3 Total ends 01:27:46 PA 55.2°U4 Partial ends 02:36:32 PA 261.4°P4 Penumbral ends 03:40:11 PA 268.3°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-eclipse-2029-01-01-global-en.svg b/doc/img/lunar-eclipse-2029-01-01-global-en.svg new file mode 100644 index 0000000..1f76a95 --- /dev/null +++ b/doc/img/lunar-eclipse-2029-01-01-global-en.svg @@ -0,0 +1 @@ +2029-01-01 Total Lunar Eclipse Global VisibilityP1 22:03:54 | Greatest 00:52:05 | P4 03:40:11 (CST) | penumbral magnitude 2.274 | umbral magnitude 1.246Umbral phases U1 23:07:42 | U2 00:16:27 | U3 01:27:46 | U4 02:36:32Entire eclipseMoonrise during eclipsePenumbra moonriseMoonset during eclipsePenumbra moonsetNot visibleEquirectangular projection; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-eclipse-2029-01-01-global.svg b/doc/img/lunar-eclipse-2029-01-01-global.svg new file mode 100644 index 0000000..97af397 --- /dev/null +++ b/doc/img/lunar-eclipse-2029-01-01-global.svg @@ -0,0 +1 @@ +2029-01-01 月全食全球可见图P1 22:03:54 | 食甚 00:52:05 | P4 03:40:11 (CST) | 半影食分 2.274 | 本影食分 1.246本影阶段 U1 23:07:42 | U2 00:16:27 | U3 01:27:46 | U4 02:36:32全程可见带食月出半影月出带食月落半影月落不可见等经纬投影;按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01.svg b/doc/img/lunar-eclipse-2029-01-01.svg similarity index 68% rename from doc/lunar-eclipse-2029-01-01.svg rename to doc/img/lunar-eclipse-2029-01-01.svg index 78ef145..ff892c9 100644 --- a/doc/lunar-eclipse-2029-01-01.svg +++ b/doc/img/lunar-eclipse-2029-01-01.svg @@ -1,2 +1,2 @@ -2029-01-01 月全食食型=月全食 半影食分=2.2740 本影食分=1.2461食甚:2029-01-01 00:52:05 CST月球:赤经 06h46m08s 赤纬 +23°19′37″ 黄经 100.5810° 黄纬 0.3137° 双子座半影历时 05:36:17 本影历时 03:28:50 全食历时 01:11:19沙罗 125 第 49/72 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 22:03:54 方位 112.2°U1 初亏 23:07:42 方位 119.1°U2 食既 00:16:27 方位 325.4°GE 食甚 00:52:05U3 生光 01:27:46 方位 55.2°U4 复圆 02:36:32 方位 261.4°P4 半影终 03:40:11 方位 268.3°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file +2029-01-01 月全食食型=月全食 半影食分=2.2740 本影食分=1.2461食甚:2029-01-01 00:52:05 CST月球:赤经 06h46m08s 赤纬 +23°19′37″ 黄经 100.5810° 黄纬 0.3137° 双子座半影历时 05:36:17 本影历时 03:28:50 全食历时 01:11:19沙罗 125 第 49/72 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 22:03:54 方位 112.2°U1 初亏 23:07:42 方位 119.1°U2 食既 00:16:27 方位 325.4°GE 食甚 00:52:05U3 生光 01:27:46 方位 55.2°U4 复圆 02:36:32 方位 261.4°P4 半影终 03:40:11 方位 268.3°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-detailed-en.svg similarity index 98% rename from doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg rename to doc/img/lunar-occultation-hr4799-2025-06-05-detailed-en.svg index 7372a2e..6d03734 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-detailed-en.svg @@ -1 +1 @@ -2025-06-05 Lunar Occultation of HR 4799Occultation begins 17:45:28 | Greatest 20:02:06 | Occultation ends 22:18:49 (CST)Greatest point 121.5661°E, 6.8071°N | Moon altitude +75.6°18:3019:0019:3020:0020:3021:0021:30StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesMoon (geocentric)R.A.12h38m32.1sDec.-05°46'28.0"S.D.00°14'48.0"H.P.00°54'19.9"Target bodyTargetHR 4799R.A.12h36m47.4sDec.-05°49'55.0"S.D.—Band path pointsPartial begins17:45:2863.9930°E, 39.0894°NGreatest20:02:06121.5661°E, 6.8071°NPartial ends22:18:49172.2912°E, 16.6450°SContactsPartial begins17:45:28Greatest20:02:06Partial ends22:18:49Ephemeris and constantsProjectionOrthographicΔT69.2 sMoon distance403584 kmPartial-band width3666.6 kmLibrationLibration l+3.53°Libration b+2.02°Axis position angle c21.62°Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2025-06-05 Lunar Occultation of HR 4799Occultation begins 17:45:28 | Greatest 20:02:06 | Occultation ends 22:18:49 (CST)Greatest point 121.5660°E, 6.8071°N | Moon altitude +75.6°18:3019:0019:3020:0020:3021:0021:30StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesMoon (geocentric)R.A.12h38m32.1sDec.-05°46'28.0"S.D.00°14'48.0"H.P.00°54'19.9"Target bodyTargetHR 4799R.A.12h36m47.4sDec.-05°49'55.0"S.D.—Band path pointsPartial begins17:45:2863.9929°E, 39.0894°NGreatest20:02:06121.5660°E, 6.8071°NPartial ends22:18:49172.2910°E, 16.6450°SContactsPartial begins17:45:28Greatest20:02:06Partial ends22:18:49Ephemeris and constantsProjectionOrthographicΔT69.2 sMoon distance403584 kmPartial-band width3666.6 kmLibrationLibration l+3.53°Libration b+2.02°Axis position angle c21.62°Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-detailed.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-detailed.svg similarity index 98% rename from doc/lunar-occultation-hr4799-2025-06-05-detailed.svg rename to doc/img/lunar-occultation-hr4799-2025-06-05-detailed.svg index b73a98a..491a6bb 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-detailed.svg +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-detailed.svg @@ -1 +1 @@ -2025-06-05 月掩HR 4799全球掩带掩始 17:45:28 | 掩甚 20:02:06 | 掩终 22:18:49(CST)掩甚点 121.5661°E, 6.8071°N | 月球高度 +75.6°18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线月亮(地心坐标)赤经 R.A.12h38m32.1s赤纬 Dec.-05°46'28.0"视半径 S.D.00°14'48.0"地平视差 H.P.00°54'19.9"目标天体目标HR 4799赤经 R.A.12h36m47.4s赤纬 Dec.-05°49'55.0"视半径 S.D.—掩带路径点外掩始17:45:2863.9930°E, 39.0894°N掩甚20:02:06121.5661°E, 6.8071°N外掩终22:18:49172.2912°E, 16.6450°S接触时刻外掩始17:45:28掩甚20:02:06外掩终22:18:49历表与常数投影正射球面ΔT69.2 s月距403584 km部分掩带宽3666.6 km天平动经天平动 l+3.53°纬天平动 b+2.02°自转轴位置角 c21.62°正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2025-06-05 月掩HR 4799全球掩带掩始 17:45:28 | 掩甚 20:02:06 | 掩终 22:18:49(CST)掩甚点 121.5660°E, 6.8071°N | 月球高度 +75.6°18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线月亮(地心坐标)赤经 R.A.12h38m32.1s赤纬 Dec.-05°46'28.0"视半径 S.D.00°14'48.0"地平视差 H.P.00°54'19.9"目标天体目标HR 4799赤经 R.A.12h36m47.4s赤纬 Dec.-05°49'55.0"视半径 S.D.—掩带路径点外掩始17:45:2863.9929°E, 39.0894°N掩甚20:02:06121.5660°E, 6.8071°N外掩终22:18:49172.2910°E, 16.6450°S接触时刻外掩始17:45:28掩甚20:02:06外掩终22:18:49历表与常数投影正射球面ΔT69.2 s月距403584 km部分掩带宽3666.6 km天平动经天平动 l+3.53°纬天平动 b+2.02°自转轴位置角 c21.62°正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-occultation-hr4799-2025-06-05-global-en.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-global-en.svg new file mode 100644 index 0000000..fa03bd3 --- /dev/null +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-global-en.svg @@ -0,0 +1 @@ +2025-06-05 Lunar Occultation of HR 4799Start 2025-06-05 17:45:28.4 | Greatest 20:02:06.3 | End 22:18:49.9 (CST)Greatest point 121.5660°E, 6.8071°N | path width 3666.6 km | Moon altitude +75.6°Global center line and occultation limits18:3019:0019:3020:0020:3021:0021:30StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesGlobal eventsStart17:45:28.463.9929°E, 39.0894°NMoon alt. +0.3°Greatest20:02:06.3121.5660°E, 6.8071°NMoon alt. +75.6°End22:18:49.9172.2910°E, 16.6450°SMoon alt. +0.3°Orthographic globe projection with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-global.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-global.svg similarity index 64% rename from doc/lunar-occultation-hr4799-2025-06-05-global.svg rename to doc/img/lunar-occultation-hr4799-2025-06-05-global.svg index 35ebe79..c3e6772 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-global.svg +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-global.svg @@ -1 +1 @@ -2025-06-05 月掩HR 4799全球掩带掩始 2025-06-05 17:45:28.4 | 掩甚 20:02:06.3 | 掩终 22:18:49.9 (CST)掩甚点 121.5661°E, 6.8071°N | 掩带宽 3666.6 km | 月球高度 +75.6°全球中心线与掩带边界18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线全球事件掩始17:45:28.463.9930°E, 39.0894°N月球高度 +0.3°掩甚20:02:06.3121.5661°E, 6.8071°N月球高度 +75.6°掩终22:18:49.9172.2912°E, 16.6450°S月球高度 +0.3°正射球面投影;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。 \ No newline at end of file +2025-06-05 月掩HR 4799全球掩带掩始 2025-06-05 17:45:28.4 | 掩甚 20:02:06.3 | 掩终 22:18:49.9 (CST)掩甚点 121.5660°E, 6.8071°N | 掩带宽 3666.6 km | 月球高度 +75.6°全球中心线与掩带边界18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线全球事件掩始17:45:28.463.9929°E, 39.0894°N月球高度 +0.3°掩甚20:02:06.3121.5660°E, 6.8071°N月球高度 +75.6°掩终22:18:49.9172.2910°E, 16.6450°S月球高度 +0.3°正射球面投影;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-local-en.svg similarity index 57% rename from doc/lunar-occultation-hr4799-2025-06-05-local-en.svg rename to doc/img/lunar-occultation-hr4799-2025-06-05-local-en.svg index 86f43ca..593d745 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-local-en.svg @@ -1,2 +1,2 @@ -2025-06-05 Local Lunar Occultation of HR 4799Site 121.5660°E, 6.8071°N | elevation 0 m | total | duration 1h 36m 09.6sGreatest 2025-06-05 20:02:06.3 CST | minimum separation 0.01 arcsec | Moon altitude +75.6° azimuth 207.9°Topocentric star trackNEWSLunar pathImmersionGreatestEmersionLocal contactsImmersion19:14:01.1PA 138.9° | alt 76.4°az 157.7° | above horizonGreatest20:02:06.3PA 213.8° | alt 75.6°az 207.9° | above horizonEmersion20:50:10.7PA 317.4° | alt 67.3°az 235.5° | above horizonContact stagesImmersion19:14:01.1Greatest20:02:06.3Emersion20:50:10.7Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track.The star is a point source; immersion and emersion occur where it crosses the topocentric apparent lunar limb. Lunar texture is schematic. \ No newline at end of file +2025-06-05 Local Lunar Occultation of HR 4799Site 121.5660°E, 6.8071°N | elevation 0 m | total | duration 1h 36m 09.6sGreatest 2025-06-05 20:02:06.3 CST | minimum separation 0.01 arcsec | Moon altitude +75.6° azimuth 207.9°Topocentric star trackNEWSLunar pathImmersionGreatestEmersionLocal contactsImmersion19:14:01.0PA 138.9° | alt 76.4°az 157.7° | above horizonGreatest20:02:06.3PA 223.3° | alt 75.6°az 207.9° | above horizonEmersion20:50:10.7PA 317.4° | alt 67.3°az 235.5° | above horizonContact stagesImmersion19:14:01.0Greatest20:02:06.3Emersion20:50:10.7Moon fixed at center; east is left and north is up. The blue-gray dashedline is the local lunar path; the red dashed line is the star track.The star is a point source; immersion and emersion occur where it crosses the topocentricapparent lunar limb. Lunar texture is schematic. All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-local.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-local.svg similarity index 52% rename from doc/lunar-occultation-hr4799-2025-06-05-local.svg rename to doc/img/lunar-occultation-hr4799-2025-06-05-local.svg index 86addd1..063c124 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-local.svg +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-local.svg @@ -1,2 +1,2 @@ -2025-06-05 指定地点月掩HR 4799观测点 121.5660°E, 6.8071°N | 海拔 0 米 | 全掩 | 掩星历时 1时36分09.6秒掩甚 2025-06-05 20:02:06.3 CST | 最小角距 0.01 角秒 | 月球高度 +75.6° 方位 207.9°站心恒星轨迹北东西南白道掩始掩甚掩终本地接触时刻掩始19:14:01.1PA 138.9° | 高度 76.4°方位 157.7° | 地平线上掩甚20:02:06.3PA 213.8° | 高度 75.6°方位 207.9° | 地平线上掩终20:50:10.7PA 317.4° | 高度 67.3°方位 235.5° | 地平线上掩始、掩甚与掩终视圆掩始19:14:01.1掩甚20:02:06.3掩终20:50:10.7月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。 \ No newline at end of file +2025-06-05 指定地点月掩HR 4799观测点 121.5660°E, 6.8071°N | 椭球高 0 米 | 全掩 | 掩星历时 1时36分09.6秒掩甚 2025-06-05 20:02:06.3 CST | 最小角距 0.01 角秒 | 月球高度 +75.6° 方位 207.9°站心恒星轨迹北东西南白道掩始掩甚掩终本地接触时刻掩始19:14:01.0PA 138.9° | 高度 76.4°方位 157.7° | 地平线上掩甚20:02:06.3PA 223.3° | 高度 75.6°方位 207.9° | 地平线上掩终20:50:10.7PA 317.4° | 高度 67.3°方位 235.5° | 地平线上掩始、掩甚与掩终视圆掩始19:14:01.0掩甚20:02:06.3掩终20:50:10.7月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。 图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar-en.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar-en.svg new file mode 100644 index 0000000..0bfcd9b --- /dev/null +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar-en.svg @@ -0,0 +1 @@ +2025-06-05 Lunar Occultation of HR 4799Start 2025-06-05 17:45:28.4 | Greatest 20:02:06.3 | End 22:18:49.9 (CST)Greatest point 121.5660°E, 6.8071°N | path width 3666.6 km | Moon altitude +75.6°Global center line and occultation limits21:0021:30EndVisible center lineGeometric center lineBand and limitsRise/set phase linesGlobal eventsStart17:45:28.463.9929°E, 39.0894°NMoon alt. +0.3°Greatest20:02:06.3121.5660°E, 6.8071°NMoon alt. +75.6°End22:18:49.9172.2910°E, 16.6450°SMoon alt. +0.3°South-polar azimuthal equidistant projection with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid.All times are UTC (shown in CST, UTC+08:00) \ No newline at end of file diff --git a/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar.svg b/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar.svg new file mode 100644 index 0000000..d7a6223 --- /dev/null +++ b/doc/img/lunar-occultation-hr4799-2025-06-05-southpolar.svg @@ -0,0 +1 @@ +2025-06-05 月掩HR 4799全球掩带掩始 2025-06-05 17:45:28.4 | 掩甚 20:02:06.3 | 掩终 22:18:49.9 (CST)掩甚点 121.5660°E, 6.8071°N | 掩带宽 3666.6 km | 月球高度 +75.6°全球中心线与掩带边界21:0021:30掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线全球事件掩始17:45:28.463.9929°E, 39.0894°N月球高度 +0.3°掩甚20:02:06.3121.5660°E, 6.8071°N月球高度 +75.6°掩终22:18:49.9172.2910°E, 16.6450°S月球高度 +0.3°南极方位等距投影;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。图中时刻为 UTC(显示时区 CST,UTC+08:00) \ No newline at end of file diff --git a/doc/img/solar-eclipse-arctic-2012-global-en.svg b/doc/img/solar-eclipse-arctic-2012-global-en.svg new file mode 100644 index 0000000..33ab32c --- /dev/null +++ b/doc/img/solar-eclipse-arctic-2012-global-en.svg @@ -0,0 +1 @@ +2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828central path width 237.1 km | Saros series 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Geocentric conjunction (equal apparent right ascension) = 23:59:36.1 UT | J.D. = 2456068.499717All times are UTC (shown in CST, UTC+08:00)06:0006:3007:0007:3008:0008:3009:0009:3010:0006:3007:3009:0009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter lineRise/set phase linesGreatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.03h52m43.9s赤纬 Dec.+20°13'18.0"视半径 S.D.00°15'48.1"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.03h52m30.8s赤纬 Dec.+20°39'06.6"视半径 S.D.00°14'43.0"地平视差 H.P.00°54'01.7"Penumbral contactsP1 partial begins04:56:08P4 partial ends10:49:21Umbral contactsU1 umbra begins06:06:18U2 internal contact06:11:48U3 internal contact09:33:42U4 umbra ends09:39:10Local circumstances at greatestGreatest07:52:47Local magnitude0.9439Central path width237.1 kmCentral duration05:46Central begins06:09:02Central ends09:36:27Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.7 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l-1.31°Libration b-0.56°Axis position angle c-13.68°Brown lunation1106020000 kmScaleNorth-polar azimuthal equidistant projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-arctic-2012-global.svg b/doc/img/solar-eclipse-arctic-2012-global.svg new file mode 100644 index 0000000..ca7eeb7 --- /dev/null +++ b/doc/img/solar-eclipse-arctic-2012-global.svg @@ -0,0 +1 @@ +2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828中心食带宽 237.1 km | 沙罗序列 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46地心合(视赤经相等) = 23:59:36.1 UT | J.D. = 2456068.499717图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带06:0006:3007:0007:3008:0008:3009:0009:3010:0006:3007:3008:3009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线初亏/食甚/复圆日升日落线食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.03h52m43.9s赤纬 Dec.+20°13'18.0"视半径 S.D.00°15'48.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.03h52m30.8s赤纬 Dec.+20°39'06.6"视半径 S.D.00°14'43.0"地平视差 H.P.00°54'01.7"半影外切 / 内切接触P1 半影外切04:56:08P4 半影外切10:49:21本影外切 / 内切接触U1 本影外切06:06:18U2 本影内切06:11:48U3 本影内切09:33:42U4 本影外切09:39:10食甚点的地方情况食甚07:52:47站心食分0.9439中心食带宽237.1 km中心食时长05:46中心食始06:09:02中心食终09:36:27历表与常数历表NASA bulletin Split-KΔT66.7 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l-1.31°纬天平动 b-0.56°自转轴位置角 c-13.68°布朗月序数1106020000 km比例尺北极方位等距投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-beijing-2035-en.svg b/doc/img/solar-eclipse-beijing-2035-en.svg new file mode 100644 index 0000000..2c14f77 --- /dev/null +++ b/doc/img/solar-eclipse-beijing-2035-en.svg @@ -0,0 +1 @@ +2035-09-02 Local Solar Eclipselon=116.4074 lat=39.9042 type=total magnitude=1.0255 obscuration=1.0000Greatest: 2035-09-02 08:33:38 CST Sun altitude 31.58 deg Sun in LeoSolar Saros 145 23/77 Totality 00:01:33Overview pathNEWSEclipticC1 287°C2 140°C3 256°C4 109°C1C2GEC3C4Phase disk panelsC107:24:29C2 Total begins08:32:52Greatest08:33:38C3 Total ends08:34:25C409:50:24Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAsare measured from celestial north toward east. All times are UTC (shown in CST, UTC+08:00)Contacts (CST)C1 First contact 07:24:29 PA 286.9°C2 Total begins 08:32:52 PA 139.8°GE Greatest 08:33:38C3 Total ends 08:34:25 PA 255.8°C4 Last contact 09:50:24 PA 108.8° \ No newline at end of file diff --git a/doc/img/solar-eclipse-beijing-2035-global-en.svg b/doc/img/solar-eclipse-beijing-2035-global-en.svg new file mode 100644 index 0000000..90cbc09 --- /dev/null +++ b/doc/img/solar-eclipse-beijing-2035-global-en.svg @@ -0,0 +1 @@ +2035-09-02 Total Solar Eclipse Global VisibilityPartial begins 07:15:36 | Greatest 09:55:36 | Partial ends 12:35:48 (CST) | magnitude 1.032 | Gamma 0.3727central path width 116.6 km | Saros series 145, member 23/77 | Sun alt 67.9° az 198.5° | central duration 02:54Geocentric conjunction (equal apparent right ascension) = 01:43:59.8 UT | J.D. = 2464572.572213All times are UTC (shown in CST, UTC+08:00)Global visibility and central path08:3009:0009:3010:0010:3011:0011:3008:3009:3011:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesGreatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.10h44m07.7s赤纬 Dec.+08°01'07.9"视半径 S.D.00°15'50.9"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.10h44m32.5s赤纬 Dec.+08°22'14.5"视半径 S.D.00°16'06.1"地平视差 H.P.00°59'06.9"Penumbral contactsP1 partial begins07:15:36P2 internal contact09:27:39P3 internal contact10:23:52P4 partial ends12:35:48Umbral contactsU1 umbra begins08:15:56U2 internal contact08:16:57U3 internal contact11:34:28U4 umbra ends11:35:24Local circumstances at greatestGreatest09:55:36Local magnitude1.0320Central path width116.6 kmCentral duration02:54Central begins08:16:26Central ends11:34:56Ephemeris and constantsEphemerisNASA bulletin Split-KΔT69.9 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+4.55°Libration b-0.48°Axis position angle c23.63°Brown lunation1394020000 km比例尺Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-beijing-2035-global.svg b/doc/img/solar-eclipse-beijing-2035-global.svg new file mode 100644 index 0000000..37ff7b1 --- /dev/null +++ b/doc/img/solar-eclipse-beijing-2035-global.svg @@ -0,0 +1 @@ +2035-09-02 日全食全球见食图偏食始 07:15:36 | 食甚 09:55:36 | 偏食终 12:35:48 (CST) | 食分 1.032 | Gamma 0.3727中心食带宽 116.6 km | 沙罗序列 145,第 23/77 个成员 | 食甚点太阳高度 67.9° 方位 198.5° | 中心食持续 02:54地心合(视赤经相等) = 01:43:59.8 UT | J.D. = 2464572.572213图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带08:3009:0009:3010:0010:3011:0011:3008:3009:3011:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.10h44m07.7s赤纬 Dec.+08°01'07.9"视半径 S.D.00°15'50.9"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.10h44m32.5s赤纬 Dec.+08°22'14.5"视半径 S.D.00°16'06.1"地平视差 H.P.00°59'06.9"半影外切 / 内切接触P1 半影外切07:15:36P2 半影内切09:27:39P3 半影内切10:23:52P4 半影外切12:35:48本影外切 / 内切接触U1 本影外切08:15:56U2 本影内切08:16:57U3 本影内切11:34:28U4 本影外切11:35:24食甚点的地方情况食甚09:55:36站心食分1.0320中心食带宽116.6 km中心食时长02:54中心食始08:16:26中心食终11:34:56历表与常数历表NASA bulletin Split-KΔT69.9 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+4.55°纬天平动 b-0.48°自转轴位置角 c23.63°布朗月序数1394020000 km比例尺等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-beijing-2035.svg b/doc/img/solar-eclipse-beijing-2035.svg new file mode 100644 index 0000000..1b89b88 --- /dev/null +++ b/doc/img/solar-eclipse-beijing-2035.svg @@ -0,0 +1 @@ +2035-09-02 站心日全食经度=116.4074 纬度=39.9042 食型=日全食 食分=1.0255 掩食比=1.0000食甚:2035-09-02 08:33:38 CST 太阳高度 31.58 度 太阳位于狮子座沙罗 145 第 23/77 个成员 全食历时 00:01:33全局路径北东西南黄道C1 287°C2 140°C3 256°C4 109°C1C2食甚C3C4阶段视圆图C1 初亏07:24:29C2 食既08:32:52食甚08:33:38C3 生光08:34:25C4 复圆09:50:24太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 CST,UTC+08:00)接触时刻 (CST)C1 初亏 07:24:29 方位 286.9°C2 食既 08:32:52 方位 139.8°GE 食甚 08:33:38C3 生光 08:34:25 方位 255.8°C4 复圆 09:50:24 方位 108.8° \ No newline at end of file diff --git a/doc/img/solar-eclipse-southpolar-2021-12-04-en.svg b/doc/img/solar-eclipse-southpolar-2021-12-04-en.svg new file mode 100644 index 0000000..2acb3e8 --- /dev/null +++ b/doc/img/solar-eclipse-southpolar-2021-12-04-en.svg @@ -0,0 +1 @@ +2021-12-04 Total Solar Eclipse Global VisibilityPartial begins 13:29:18 | Greatest 15:33:29 | Partial ends 17:37:28 (CST) | magnitude 1.037 | Gamma -0.9525central path width 417.4 km | Saros series 152, member 13/70 | Sun alt 17.2° az 114.7° | central duration 01:54Geocentric conjunction (equal apparent right ascension) = 07:56:41.8 UT | J.D. = 2459552.831039All times are UTC (shown in CST, UTC+08:00)14:0014:3015:0015:3016:0016:3015:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesGreatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.16h43m33.8s赤纬 Dec.-22°16'31.4"视半径 S.D.00°16'13.6"地平视差 H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.16h42m35.1s赤纬 Dec.-23°13'22.2"视半径 S.D.00°16'44.4"地平视差 H.P.01°01'27.3"Penumbral contactsP1 partial begins13:29:18P4 partial ends17:37:28Umbral contactsU1 umbra begins15:00:12U2 internal contact15:06:01U3 internal contact16:00:38U4 umbra ends16:06:28Local circumstances at greatestGreatest15:33:29Local magnitude1.0367Central path width417.4 kmCentral duration01:54Central begins15:02:51Central ends16:03:51Ephemeris and constantsEphemerisNASA bulletin Split-KΔT69.3 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l-0.22°Libration b+1.28°Axis position angle c6.09°Brown lunation1224020000 kmScaleSouth-polar azimuthal equidistant projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-southpolar-2021-12-04.svg b/doc/img/solar-eclipse-southpolar-2021-12-04.svg new file mode 100644 index 0000000..12d243d --- /dev/null +++ b/doc/img/solar-eclipse-southpolar-2021-12-04.svg @@ -0,0 +1 @@ +2021-12-04 日全食全球见食图偏食始 13:29:18 | 食甚 15:33:29 | 偏食终 17:37:28 (CST) | 食分 1.037 | Gamma -0.9525中心食带宽 417.4 km | 沙罗序列 152,第 13/70 个成员 | 食甚点太阳高度 17.2° 方位 114.7° | 中心食持续 01:54地心合(视赤经相等) = 07:56:41.8 UT | J.D. = 2459552.831039图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带14:0014:3015:0015:3016:0016:3015:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.16h43m33.8s赤纬 Dec.-22°16'31.4"视半径 S.D.00°16'13.6"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.16h42m35.1s赤纬 Dec.-23°13'22.2"视半径 S.D.00°16'44.4"地平视差 H.P.01°01'27.3"半影外切 / 内切接触P1 半影外切13:29:18P4 半影外切17:37:28本影外切 / 内切接触U1 本影外切15:00:12U2 本影内切15:06:01U3 本影内切16:00:38U4 本影外切16:06:28食甚点的地方情况食甚15:33:29站心食分1.0367中心食带宽417.4 km中心食时长01:54中心食始15:02:51中心食终16:03:51历表与常数历表NASA bulletin Split-KΔT69.3 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l-0.22°纬天平动 b+1.28°自转轴位置角 c6.09°布朗月序数1224020000 km比例尺南极方位等距投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-xiamen-2012-en.svg b/doc/img/solar-eclipse-xiamen-2012-en.svg new file mode 100644 index 0000000..6925519 --- /dev/null +++ b/doc/img/solar-eclipse-xiamen-2012-en.svg @@ -0,0 +1 @@ +2012-05-21 Local Solar Eclipselon=118.0894 lat=24.4798 type=annular magnitude=0.9333 obscuration=0.8724Greatest: 2012-05-21 06:10:25 CST Sun altitude 9.57 deg Sun in TaurusSolar Saros 128 58/73 Annularity 00:04:19Overview pathNEWSEclipticC1 256°C2 272°C3 56°C4 73°C1C2GEC3C4Phase disk panelsC105:08:12C2 Annularity begins06:08:15Greatest06:10:25C3 Annularity ends06:12:34C407:20:54Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAsare measured from celestial north toward east. All times are UTC (shown in CST, UTC+08:00)Contacts (CST)C1 First contact 05:08:12 PA 255.9°C2 Annularity begins 06:08:15 PA 272.3°GE Greatest 06:10:25C3 Annularity ends 06:12:34 PA 56.4°C4 Last contact 07:20:54 PA 72.8° \ No newline at end of file diff --git a/doc/img/solar-eclipse-xiamen-2012-global-en.svg b/doc/img/solar-eclipse-xiamen-2012-global-en.svg new file mode 100644 index 0000000..0f9810c --- /dev/null +++ b/doc/img/solar-eclipse-xiamen-2012-global-en.svg @@ -0,0 +1 @@ +2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828central path width 237.1 km | Saros series 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Geocentric conjunction (equal apparent right ascension) = 23:59:36.1 UT | J.D. = 2456068.499717All times are UTC (shown in CST, UTC+08:00)Global visibility and central path06:0006:3007:0007:3008:0008:3009:0009:3010:0006:3007:3008:3009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter lineRise/set phase linesGreatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.03h52m43.9s赤纬 Dec.+20°13'18.0"视半径 S.D.00°15'48.1"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.03h52m30.8s赤纬 Dec.+20°39'06.6"视半径 S.D.00°14'43.0"地平视差 H.P.00°54'01.7"Penumbral contactsP1 partial begins04:56:08P4 partial ends10:49:21Umbral contactsU1 umbra begins06:06:18U2 internal contact06:11:48U3 internal contact09:33:42U4 umbra ends09:39:10Local circumstances at greatestGreatest07:52:47Local magnitude0.9439Central path width237.1 kmCentral duration05:46Central begins06:09:02Central ends09:36:27Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.7 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l-1.31°Libration b-0.56°Axis position angle c-13.68°Brown lunation1106020000 km比例尺Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-xiamen-2012-global.svg b/doc/img/solar-eclipse-xiamen-2012-global.svg new file mode 100644 index 0000000..e8a2ecf --- /dev/null +++ b/doc/img/solar-eclipse-xiamen-2012-global.svg @@ -0,0 +1 @@ +2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828中心食带宽 237.1 km | 沙罗序列 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46地心合(视赤经相等) = 23:59:36.1 UT | J.D. = 2456068.499717图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带06:0006:3007:0007:3008:0008:3009:0009:3010:0006:3007:3008:3009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线初亏/食甚/复圆日升日落线食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.03h52m43.9s赤纬 Dec.+20°13'18.0"视半径 S.D.00°15'48.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.03h52m30.8s赤纬 Dec.+20°39'06.6"视半径 S.D.00°14'43.0"地平视差 H.P.00°54'01.7"半影外切 / 内切接触P1 半影外切04:56:08P4 半影外切10:49:21本影外切 / 内切接触U1 本影外切06:06:18U2 本影内切06:11:48U3 本影内切09:33:42U4 本影外切09:39:10食甚点的地方情况食甚07:52:47站心食分0.9439中心食带宽237.1 km中心食时长05:46中心食始06:09:02中心食终09:36:27历表与常数历表NASA bulletin Split-KΔT66.7 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l-1.31°纬天平动 b-0.56°自转轴位置角 c-13.68°布朗月序数1106020000 km比例尺等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-xiamen-2012.svg b/doc/img/solar-eclipse-xiamen-2012.svg new file mode 100644 index 0000000..ac60bd6 --- /dev/null +++ b/doc/img/solar-eclipse-xiamen-2012.svg @@ -0,0 +1 @@ +2012-05-21 站心日环食经度=118.0894 纬度=24.4798 食型=日环食 食分=0.9333 掩食比=0.8724食甚:2012-05-21 06:10:25 CST 太阳高度 9.57 度 太阳位于金牛座沙罗 128 第 58/73 个成员 环食历时 00:04:19全局路径北东西南黄道C1 256°C2 272°C3 56°C4 73°C1C2食甚C3C4阶段视圆图C1 初亏05:08:12C2 环食始06:08:15食甚06:10:25C3 环食终06:12:34C4 复圆07:20:54太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 CST,UTC+08:00)接触时刻 (CST)C1 初亏 05:08:12 方位 255.9°C2 环食始 06:08:15 方位 272.3°GE 食甚 06:10:25C3 环食终 06:12:34 方位 56.4°C4 复圆 07:20:54 方位 72.8° \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-en.svg b/doc/img/solar-eclipse-yangshan-2009-en.svg new file mode 100644 index 0000000..e1e8750 --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-en.svg @@ -0,0 +1 @@ +2009-07-22 Local Solar Eclipselon=121.9850 lat=30.6167 type=total magnitude=1.0770 obscuration=1.0000Greatest: 2009-07-22 09:40:20 CST Sun altitude 57.29 deg Sun in CancerSolar Saros 136 37/71 Totality 00:05:57Overview pathNEWSEclipticC1 287°C2 109°C3 291°C4 113°C1C2GEC3C4Phase disk panelsC108:23:55C2 Total begins09:37:23Greatest09:40:20C3 Total ends09:43:19C411:03:13Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAsare measured from celestial north toward east. All times are UTC (shown in CST, UTC+08:00)Contacts (CST)C1 First contact 08:23:55 PA 287.2°C2 Total begins 09:37:23 PA 108.6°GE Greatest 09:40:20C3 Total ends 09:43:19 PA 290.8°C4 Last contact 11:03:13 PA 112.5° \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-global-en.svg b/doc/img/solar-eclipse-yangshan-2009-global-en.svg new file mode 100644 index 0000000..1a39dcb --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-global-en.svg @@ -0,0 +1 @@ +2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:16 | Greatest 10:35:18 | Partial ends 13:12:21 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606302All times are UTC (shown in CST, UTC+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:3011:30Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"Penumbral contactsP1 partial begins07:58:16P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:21Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Central path width258.3 kmCentral duration06:39Central begins08:52:50Central ends12:17:46Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071020000 km比例尺Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-global-ut1-en.svg b/doc/img/solar-eclipse-yangshan-2009-global-ut1-en.svg new file mode 100644 index 0000000..08772aa --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-global-ut1-en.svg @@ -0,0 +1 @@ +2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 23:58:16 | Greatest 02:35:18 | Partial ends 05:12:22 (UTC) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.4 UT | J.D. = 2455034.606302Times are UT1 (Universal Time 1); DUT1 = UT1−UTC = +0.23 s.Global visibility and central path0.20.40.60.801:0002:0003:30Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8P/U shadow contactsSun at greatest eclipse赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"Penumbral contactsP1 partial begins23:58:16P2 internal contact01:47:39P3 internal contact03:23:00P4 partial ends05:12:22Umbral contactsU1 umbra begins00:51:14U2 internal contact00:54:28U3 internal contact04:16:10U4 umbra ends04:19:23Local circumstances at greatestGreatest02:35:18Local magnitude1.0799Central path width258.3 kmCentral duration06:39Central begins00:52:51Central ends04:17:47Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071020000 km比例尺Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-global-ut1.svg b/doc/img/solar-eclipse-yangshan-2009-global-ut1.svg new file mode 100644 index 0000000..1aa7b15 --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-global-ut1.svg @@ -0,0 +1 @@ +2009-07-22 日全食全球见食图偏食始 23:58:16 | 食甚 02:35:18 | 偏食终 05:12:22 (UTC) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.4 UT | J.D. = 2455034.606302图中时刻为 UT1(世界时),DUT1 = UT1−UTC = +0.23 s。全球见食范围与中心食带0.20.40.60.801:0002:0003:30中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"半影外切 / 内切接触P1 半影外切23:58:16P2 半影内切01:47:39P3 半影内切03:23:00P4 半影外切05:12:22本影外切 / 内切接触U1 本影外切00:51:14U2 本影内切00:54:28U3 本影内切04:16:10U4 本影外切04:19:23食甚点的地方情况食甚02:35:18站心食分1.0799中心食带宽258.3 km中心食时长06:39中心食始00:52:51中心食终04:17:47历表与常数历表NASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071020000 km比例尺等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-global.svg b/doc/img/solar-eclipse-yangshan-2009-global.svg new file mode 100644 index 0000000..07756e6 --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-global.svg @@ -0,0 +1 @@ +2009-07-22 日全食全球见食图偏食始 07:58:16 | 食甚 10:35:18 | 偏食终 13:12:21 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606302图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:3011:30中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"半影外切 / 内切接触P1 半影外切07:58:16P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:21本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚点的地方情况食甚10:35:18站心食分1.0799中心食带宽258.3 km中心食时长06:39中心食始08:52:50中心食终12:17:46历表与常数历表NASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071020000 km比例尺等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-globe-en.svg b/doc/img/solar-eclipse-yangshan-2009-globe-en.svg new file mode 100644 index 0000000..a7dc419 --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-globe-en.svg @@ -0,0 +1 @@ +2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:16 | Greatest 10:35:18 | Partial ends 13:12:21 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606302All times are UTC (shown in CST, UTC+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsNESW05000 kmScalePenumbral contactsP1 partial begins07:58:16P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:21Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Central path width258.3 kmCentral duration06:39Central begins08:52:50Central ends12:17:46Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Sun at greatest eclipse赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"H.P.01°01'19.8"Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071Orthographic globe projection, centred on the greatest eclipse; one hemisphere only; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009-globe.svg b/doc/img/solar-eclipse-yangshan-2009-globe.svg new file mode 100644 index 0000000..5503dee --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009-globe.svg @@ -0,0 +1 @@ +2009-07-22 日全食全球见食图偏食始 07:58:16 | 食甚 10:35:18 | 偏食终 13:12:21 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606302图中时刻为 UTC(显示时区 CST,UTC+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触NESW05000 km比例尺半影外切 / 内切接触P1 半影外切07:58:16P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:21食甚点的地方情况食甚10:35:18站心食分1.0799中心食带宽258.3 km中心食时长06:39中心食始08:52:50中心食终12:17:46本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚时的太阳(地心坐标)赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.5"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"历表与常数历表NASA bulletin Split-KΔT66.0 sk10.2724880k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071正射球面投影,视点取食甚点,只画朝向视点的半个地球;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/img/solar-eclipse-yangshan-2009.svg b/doc/img/solar-eclipse-yangshan-2009.svg new file mode 100644 index 0000000..e46ef21 --- /dev/null +++ b/doc/img/solar-eclipse-yangshan-2009.svg @@ -0,0 +1 @@ +2009-07-22 站心日全食经度=121.9850 纬度=30.6167 食型=日全食 食分=1.0770 掩食比=1.0000食甚:2009-07-22 09:40:20 CST 太阳高度 57.29 度 太阳位于巨蟹座沙罗 136 第 37/71 个成员 全食历时 00:05:57全局路径北东西南黄道C1 287°C2 109°C3 291°C4 113°C1C2食甚C3C4阶段视圆图C1 初亏08:23:55C2 食既09:37:23食甚09:40:20C3 生光09:43:19C4 复圆11:03:13太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 CST,UTC+08:00)接触时刻 (CST)C1 初亏 08:23:55 方位 287.2°C2 食既 09:37:23 方位 108.6°GE 食甚 09:40:20C3 生光 09:43:19 方位 290.8°C4 复圆 11:03:13 方位 112.5° \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03-detailed-en.svg b/doc/lunar-eclipse-2026-03-03-detailed-en.svg deleted file mode 100644 index 0892263..0000000 --- a/doc/lunar-eclipse-2026-03-03-detailed-en.svg +++ /dev/null @@ -1,2 +0,0 @@ - -Total Lunar Eclipse of 2026-03-03Greatest Eclipse = 19:33:42 (CST) | Umbral magnitude = 1.1506Penumbral magnitude = 2.1837 | Gamma = 0.3764P. Radius = 1.2361° | U. Radius = 0.6983° | Axis = 0.3596°Saros series = 133 | member 27 of 71All times are CST (UT+08:00)Sun at greatest eclipse赤经 R.A.22h55m46.8s赤纬 Dec.-06°50'13.2"视半径 S.D.00°16'07.7"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.10h41m09.4s赤纬 Dec.+08°21'05.0"视半径 S.D.00°15'39.6"H.P.00°57'29.6"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral338:40Partial207:11Total58:19Eclipse contactsP1 penumbral begins16:44:25U1 partial begins17:50:05U2 total begins19:04:32Greatest19:33:42U3 total ends20:02:50U4 partial ends21:17:16P4 penumbral ends22:23:050100角分Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises or sets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03-detailed.svg b/doc/lunar-eclipse-2026-03-03-detailed.svg deleted file mode 100644 index ae11fb8..0000000 --- a/doc/lunar-eclipse-2026-03-03-detailed.svg +++ /dev/null @@ -1,2 +0,0 @@ - -2026-03-03 月全食食甚 = 19:33:42(CST)| 本影食分 = 1.1506半影食分 = 2.1837 | 伽马 = 0.3764半影半径 = 1.2361° | 本影半径 = 0.6983° | 影轴角距 = 0.3596°沙罗序列 = 133 | 第 27 / 71 个成员图中时刻为 CST(UT+08:00)食甚时的太阳(地心坐标)赤经 R.A.22h55m46.8s赤纬 Dec.-06°50'13.2"视半径 S.D.00°16'07.7"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.10h41m09.4s赤纬 Dec.+08°21'05.0"视半径 S.D.00°15'39.6"地平视差 H.P.00°57'29.6"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食338:40偏食207:11全食58:19接触时刻P1 半影食始16:44:25U1 初亏17:50:05U2 食既19:04:32食甚19:33:42U3 生光20:02:50U4 复圆21:17:16P4 半影食终22:23:050100角分全程可见带食月出带食月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-detailed-en.svg b/doc/lunar-eclipse-2029-01-01-detailed-en.svg deleted file mode 100644 index 1d49934..0000000 --- a/doc/lunar-eclipse-2029-01-01-detailed-en.svg +++ /dev/null @@ -1,2 +0,0 @@ - -Total Lunar Eclipse of 2029-01-01Greatest Eclipse = 00:52:05 (CST) | Umbral magnitude = 1.2461Penumbral magnitude = 2.2740 | Gamma = 0.3258P. Radius = 1.2511° | U. Radius = 0.7089° | Axis = 0.3153°Saros series = 125 | member 49 of 72All times are CST (UT+08:00)Sun at greatest eclipse赤经 R.A.18h45m43.7s赤纬 Dec.-23°01'11.6"视半径 S.D.00°16'15.5"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.06h44m00.7s赤纬 Dec.+23°24'39.0"视半径 S.D.00°15'48.7"H.P.00°58'03.0"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral336:17Partial208:50Total71:19Eclipse contactsP1 penumbral begins22:03:54U1 partial begins23:07:42U2 total begins00:16:27Greatest00:52:05U3 total ends01:27:46U4 partial ends02:36:32P4 penumbral ends03:40:110100角分Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises orsets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-detailed.svg b/doc/lunar-eclipse-2029-01-01-detailed.svg deleted file mode 100644 index cc984fd..0000000 --- a/doc/lunar-eclipse-2029-01-01-detailed.svg +++ /dev/null @@ -1,2 +0,0 @@ - -2029-01-01 月全食食甚 = 00:52:05(CST)| 本影食分 = 1.2461半影食分 = 2.2740 | 伽马 = 0.3258半影半径 = 1.2511° | 本影半径 = 0.7089° | 影轴角距 = 0.3153°沙罗序列 = 125 | 第 49 / 72 个成员图中时刻为 CST(UT+08:00)食甚时的太阳(地心坐标)赤经 R.A.18h45m43.7s赤纬 Dec.-23°01'11.6"视半径 S.D.00°16'15.5"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.06h44m00.7s赤纬 Dec.+23°24'39.0"视半径 S.D.00°15'48.7"地平视差 H.P.00°58'03.0"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食336:17偏食208:50全食71:19接触时刻P1 半影食始22:03:54U1 初亏23:07:42U2 食既00:16:27食甚00:52:05U3 生光01:27:46U4 复圆02:36:32P4 半影食终03:40:110100角分全程可见带食月出带食月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-global-en.svg b/doc/lunar-eclipse-2029-01-01-global-en.svg deleted file mode 100644 index 2743a6b..0000000 --- a/doc/lunar-eclipse-2029-01-01-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2029-01-01 Total Lunar Eclipse Global VisibilityP1 22:03:54 | Greatest 00:52:05 | P4 03:40:11 (CST) | penumbral magnitude 2.274 | umbral magnitude 1.246Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleEquirectangular projection; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-global.svg b/doc/lunar-eclipse-2029-01-01-global.svg deleted file mode 100644 index 92ebb70..0000000 --- a/doc/lunar-eclipse-2029-01-01-global.svg +++ /dev/null @@ -1 +0,0 @@ -2029-01-01 月全食全球可见图P1 22:03:54 | 食甚 00:52:05 | P4 03:40:11 (CST) | 半影食分 2.274 | 本影食分 1.246全程可见带食月出带食月落不可见等经纬投影;按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg b/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg deleted file mode 100644 index 803bda2..0000000 --- a/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2025-06-05 Lunar Occultation of HR 4799Start 2025-06-05 17:45:28.5 | Greatest 20:02:06.3 | End 22:18:49.9 (CST)Greatest point 121.5660°E, 6.8071°N | path width 3582.4 km | Moon altitude +75.6°Global center line and occultation limits-120°-60°0°60°120°-60°-30°0°30°60°18:3019:0020:0021:00StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesGlobal eventsStart17:45:28.564.0381°E, 39.0846°NMoon alt. +0.3°Greatest20:02:06.3121.5660°E, 6.8071°NMoon alt. +75.6°End22:18:49.9172.3168°E, 16.6423°SMoon alt. +0.3°Equirectangular projection with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid. \ No newline at end of file diff --git a/doc/manual/accuracy.md b/doc/manual/accuracy.md new file mode 100644 index 0000000..9b43e94 --- /dev/null +++ b/doc/manual/accuracy.md @@ -0,0 +1,93 @@ +# 精度与性能 + +[English](en/accuracy.md) | [README](../../README.md) + +下表说明内置模型及已有样本的对照结果。样本最大误差不代表所有日期、地点的误差上限。与外部星历比较时,需要先统一时标、参考系、观测点高度和折射模型。 + +UTC、UT1 与 TT 的换算见[时标](timescale.md)。 + +## 目录 + +- [太阳与行星](#太阳与行星) +- [月球](#月球) +- [Lite 轻量链路](#lite-轻量链路) +- [精度校验参考](#精度校验参考) + +## 太阳与行星 + +太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。下表列出相对完整 VSOP87 的截断误差量级: + +| 目标 | 黄经/黄纬 | 距离 | +| --- | --- | --- | +| 太阳/地球 | 约 `0.1"` | 约 `0.1 × 10^-6 AU` | +| 水星、金星 | 约 `0.2"` | 约 `0.2 × 10^-6 AU` | +| 火星 | 约 `0.5"` | 约 `1 × 10^-6 AU` | +| 木星 | 约 `0.5"` | 约 `3 × 10^-6 AU` | +| 土星 | 约 `0.5"` | 约 `5 × 10^-6 AU` | +| 天王星 | 约 `1"` | 约 `20 × 10^-6 AU` | +| 海王星 | 约 `1"` | 约 `40 × 10^-6 AU` | + +这类精度适合常规天文历法、观测辅助、科普展示和个人研究;航天导航、精确掩星预报和严格动力学积分不在该范围内,这类用途通常需要 JPL DE 等专业星历。 + +## 月球 + +月球使用内置的 ELP2000/82 风格截断解析级数,库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;极高精度月球测距、长期物理天平动和专业掩星超出该范围,这类用途以 JPL 星历或专门月球星历为准。 + +## Lite 轻量链路 + +`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或主链的 ELP2000/82 级数,适合 CPU / 内存受限环境。 + +- `lite/sun`:简化太阳真黄经 / 视黄经公式 + 轻量赤道坐标转换 +- `lite/moon`:Schlyter 风格月球近似(约 15 个摄动项)+ 轻量站心修正 +- 升落搜索:固定步长扫描 + 二分,不走主链的高精度章动迭代 +- 计算链路零堆分配(0 allocs/op);相对主链,位置与月相等纯求值接口约快 `7.5–27.1x`,升落接口约 `0.9–3.5x` + +能力边界: + +| 包 | 位置模型 | 升落搜索 | 主要用途 | +| --- | --- | --- | --- | +| `lite/sun` | 简化太阳真/视黄经 + 轻量赤道坐标转换 | `30` 分钟步长扫描 + 二分 | 日出日落、太阳高度角、表盘/前端周期刷新 | +| `lite/moon` | Schlyter / vFPS 月球近似 + 轻量站心修正 | `15` 分钟步长扫描 + 二分 | 月出月落、月相、月龄、轻量月球观测辅助 | + +与 `sun` / `moon` 包的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时): + +| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 | +| --- | --- | --- | --- |-----------------------------------| +| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 | +| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜日期归属上有语义差异 | +| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 | +| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 | +| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 与 `moon.Phase` 对比的结果 | +| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | 约平均 4.3 分钟、P95 14.4 分钟、最大 20.2 分钟 | +| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 | +| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 | + +`Go testing.Benchmark` 参考值(单机测量,仅供比较;绝对值因机器而异): + +| 接口 | 主链 | `lite` | 加速倍数 | 主链分配 | `lite` 分配 | +| --- | --- | --- | --- | --- | --- | +| `Sun ApparentRaDec` | `6.031 µs/op` | `222.4 ns/op` | `27.1x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun Altitude` | `6.127 µs/op` | `672.3 ns/op` | `9.1x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun RiseTime` | `101.874 µs/op` | `29.168 µs/op` | `3.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon ApparentRaDec` | `16.897 µs/op` | `1.070 µs/op` | `15.8x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Phase` | `15.441 µs/op` | `935.6 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Altitude` | `9.714 µs/op` | `1.294 µs/op` | `7.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon RiseTime` | `121.772 µs/op` | `132.312 µs/op` | `0.9x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | + +主链与 `lite` 的差距随场景变化:位置、月相等纯求值接口约 `7.5–27.1x`;升落接口两边都要做时间搜索,差距缩小到 `0.9–3.5x`,其中 `Moon RiseTime` 接近持平。 + +日月食、物理天平动或高纬边界判定使用主链 `sun` / `moon`。 + +## 精度校验参考 + +下面这些函数曾与 JPL Horizons、NASA GSFC 等资料对照,可作为使用时判断结果量级的参考: + +- 太阳/行星/月亮视直径:各天体与外部基线的最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感 +- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°` +- 行星升/中天/落:已用 JPL Horizons 升中落事件做对比校验;该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐 +- 月出/月落:`aero=true` 按动态标准折射和实时月球视半径计算上缘过地平线。7 个地点、14 个海平面事件相对 JPL Horizons DE441 的平均/最大差异约 `0.30s / 0.75s` +- 月出/月落的其他口径:相对固定 `-0.8333°` 的 MET Norway(Skyfield 1.53 + DE440s)约 `38.77s / 76.22s`;相对未公开地平线口径的 IMCCE Miriade 平均约 `2m13.46s`,`61°N` 低仰角样本最大约 `6m41.82s` +- 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU` +- 月球主链位置:当前算法为 ELP2000/82 风格截断解析级数;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km` +- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km` +- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°` diff --git a/doc/manual/calendar.md b/doc/manual/calendar.md new file mode 100644 index 0000000..f0de1ce --- /dev/null +++ b/doc/manual/calendar.md @@ -0,0 +1,678 @@ +# 历法转换与节气 + +[English](en/calendar.md) | [返回 README](../../README.md) + +本package支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(公元前104年为历法表切换点,公元3000年后为纯理论实时计算外推)。 +农历本质上是阴阳合历(Lunisolar Calendar),但为兼顾大众习惯与代码简洁性,相关函数命名采用 `Lunar` 而非更学术的 `Lunisolar`。 + +## 目录 + +- [公历转农历与节气](#公历转农历与节气) +- [API 参考](#api-参考) + - [公历与农历互转](#公历与农历互转) + - [儒略历独有闰日与多候选](#儒略历独有闰日与多候选) + - [节气与物候](#节气与物候) + - [古历系统](#古历系统) + - [干支与年号](#干支与年号) + - [结构与 JSON](#结构与-json) +- [常用场景](#常用场景) + - [公历转农历与干支纪日](#公历转农历与干支纪日) + - [节气时刻与历法节气](#节气时刻与历法节气) + - [自定义时区与儒略历独有闰日](#自定义时区与儒略历独有闰日) + - [改历双纪年的多个候选](#改历双纪年的多个候选) + - [古历系统与年号口径](#古历系统与年号口径) +- [历法说明](#历法说明) +- [使用须知](#使用须知) + - [1. 同一公历日期可能对应多个农历日期](#1-同一公历日期可能对应多个农历日期) + - [2. 同一农历日期可能对应多个公历日期](#2-同一农历日期可能对应多个公历日期) + - [3. 公历历法处理规则](#3-公历历法处理规则) + - [4. 时区说明](#4-时区说明) + - [5. Go 语言特别注意](#5-go-语言特别注意) + - [6. 儒略历独有的闰日(如 700-02-29)](#6-儒略历独有的闰日如-700-02-29) + - [7. 同一农历日的多个公历候选](#7-同一农历日的多个公历候选) +- [历法转换](#历法转换) + - [公历转农历](#公历转农历) + - [农历转公历](#农历转公历) + - [代码示例](#代码示例) +- [节气](#节气) +- [参数与返回值约定](#参数与返回值约定) + - [单位与参数口径](#单位与参数口径) + - [时标](#时标) + - [零值与越界](#零值与越界) + - [精度与适用范围](#精度与适用范围) + - [相关手册](#相关手册) + +## 公历转农历与节气 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/calendar" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 2, 17, 0, 0, 0, 0, cst) + solar, err := calendar.SolarToLunar(date) //公历转农历 + if err != nil { + log.Fatal(err) + } + fmt.Println(solar.Lunar().LunarYear(), solar.Lunar().MonthDay()) + fmt.Println(calendar.JieQi(2026, calendar.JQ_立春)) //节气UTC+08:00时间 +} +``` + +转换失败时先处理 `error`。`Time.Lunar()` 返回首个农历候选;历史并行政权的全部结果由 `Time.Lunars()` 返回。节气计算采用北京时间。 + +## API 参考 + +### 公历与农历互转 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `SolarToLunar` | 公历 `time.Time` → 农历结果 | 输入的时区会被换到东八区再取民用日;返回 `Time`,可能含多个并行政权候选 | +| `SolarToLunarByYMD` | 整型公历年月日 → 农历结果 | 不经过 `time.Time`,因此不丢儒略历独有闰日;公元前用负数年份 | +| `LunarToSolar` | 农历描述字符串 → 公历候选 | 支持“年份+月+日”“年号+月+日”“…+干支日”等写法,返回 `[]Time` | +| `LunarToSolarByYMD` | 农历年月日 + 闰月标志 → 公历 | `month` 是“与正月的距离”,正月为 1;`leap` 为闰月标志 | +| `LunarToSolarSingle` | 同上,返回单个 `Time` | 已弃用,等价于 `LunarToSolarByYMD` | +| `Solar` | 农历年月日 → 公历(自定义时区) | `timezone` 单位小时;返回时刻的时区名固定为 `CST`,偏移等于传入的 `timezone` | +| `Lunar` | 公历年月日 → 农历(自定义时区) | 返回 `(农历年, 农历月, 农历日, 是否闰月, 文字描述)`;`timezone` 单位小时 | +| `Time` / `LunarTime` | 公农历双信息容器 | 字段与 JSON 键见“结构与 JSON” | + +```go +// 前置:date 是一个 time.Time;字符串入口可以返回多个并行政权的结果。 +solar, _ := calendar.SolarToLunar(date) +fmt.Println(solar.LunarDesc(), solar.Lunar().MonthDay()) + +lunar, _ := calendar.LunarToSolar("元丰六年十月十二日") +for _, v := range lunar { + fmt.Println(v.Time().Format("2006-01-02"), v.Eras()) +} + +old, _ := calendar.LunarToSolarByYMD(-202, 1, 1, false) // 公元前用负数年份 +fmt.Println(old.Solar().Format("2006-01-02")) +``` + +### 儒略历独有闰日与多候选 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Time.JulianOnly` | 主历法农历日是否只存在于儒略历 | 布尔;1582 年前“能被 100 整除但不能被 400 整除”的年份的 2 月 29 日属于这一类 | +| `Time.JD` | 主历法农历日精确的儒略日 | 儒略历独有闰日比 `Solar()` 早一天,其余与 `Date2JD(Solar())` 一致 | +| `Time.SolarCandidates` | 该农历日的全部合法公历候选 | 首个恒等于 `Solar()`;单候选时返回只含 `Solar()` 的切片 | +| `LunarTime.JulianOnly` / `LunarTime.JD` | 单个候选上的同一信息 | 口径与 `Time` 上的同名方法一致 | +| `Date2JD` | `time.Time` → 儒略日 | 直接取 `date` 的年月日时分秒字段,不做时区换算 | +| `JD2Date` | 儒略日 → `time.Time` | 返回值落在运行环境的本地时区(底层使用 `Local`) | +| `NowJD` | 当前时刻的儒略日 | 与 `Date2JD` 同一口径 | +| `Time.Add` | 时间偏移 | 跨过 1582 改历空窗时改走儒略日轴,跳过不存在的 10 天 | + +```go +// 整型年月日入口不丢闰日;700-02-29 在 Go 的 time.Time 里不存在。 +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.JulianOnly(), julian.JD(), julian.Solar().Format("2006-01-02")) + +// 改历双纪年会给出多个合法公历候选,首个仍是 Solar()。 +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02"), len(res.SolarCandidates())) + +fmt.Println(calendar.Date2JD(time.Date(2026, 2, 17, 0, 0, 0, 0, time.UTC)), calendar.JD2Date(2461088.5)) +``` + +### 节气与物候 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `JieQi` | 节气时刻 | 北京时间;`term` 是太阳视黄经,单位度 | +| `CalendricalJieQi` | 历法相符节气日期 | 默认历法下节气落在的日期,时间固定为北京时间当天 0 点 | +| `CalendricalJieQiWithCalendar` | 历法相符节气日期(显式古历) | 春秋历及缺少历法节气资料的年份返回错误 | +| `WuHou` | 物候时刻 | 北京时间;七十二候按 5° 一候,入参与 `JieQi` 同口径 | +| `JQ_春分` … `JQ_惊蛰` | 节气常量族 | 15° 档位的太阳视黄经,可直接当 `JieQi`/`WuHou` 的 `term` | + +节气常量一共 24 个,按黄经递增排列: + +| 常量 | 黄经 | 常量 | 黄经 | 常量 | 黄经 | +| --- | --- | --- | --- | --- | --- | +| `JQ_春分` | 0° | `JQ_清明` | 15° | `JQ_谷雨` | 30° | +| `JQ_立夏` | 45° | `JQ_小满` | 60° | `JQ_芒种` | 75° | +| `JQ_夏至` | 90° | `JQ_小暑` | 105° | `JQ_大暑` | 120° | +| `JQ_立秋` | 135° | `JQ_处暑` | 150° | `JQ_白露` | 165° | +| `JQ_秋分` | 180° | `JQ_寒露` | 195° | `JQ_霜降` | 210° | +| `JQ_立冬` | 225° | `JQ_小雪` | 240° | `JQ_大雪` | 255° | +| `JQ_冬至` | 270° | `JQ_小寒` | 285° | `JQ_大寒` | 300° | +| `JQ_立春` | 315° | `JQ_雨水` | 330° | `JQ_惊蛰` | 345° | + +```go +// term 是太阳视黄经(度),也可以直接传数值,例如春分为 0。 +fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) +fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) +fmt.Println(calendar.WuHou(2020, calendar.JQ_立春+5)) + +termDate, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) +fmt.Println(termDate, err) +``` + +### 古历系统 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `AncientCalendarSystem` | 古历系统枚举 | 字符串类型 | +| `AncientCalendarDefault` | 默认路由(零值) | 按年份自动选择历法,显式古历入口遇到它时退回默认实现 | +| `AncientCalendarChunqiu` / `AncientCalendarZhou` / `AncientCalendarLu` | 先秦春秋、周、鲁历 | 值分别为 `chunqiu`/`zhou`/`lu` | +| `AncientCalendarHuangdi` / `AncientCalendarYin` | 先秦黄帝历、殷历 | 值分别为 `huangdi`/`yin` | +| `AncientCalendarXia1` / `AncientCalendarXia2` | 夏历(冬至版)与夏历(雨水版) | 值分别为 `xia1`/`xia2` | +| `AncientCalendarZhuanxu` | 先秦颛顼历 | 值 `zhuanxu` | +| `AncientCalendarQinHan` | 秦汉颛顼历 | 值 `qin_han` | +| `SolarToLunarWithCalendar` | 公历 `time.Time` → 农历(显式古历) | 输入先换到东八区再取民用日 | +| `SolarToLunarByYMDWithCalendar` | 整型公历年月日 → 农历(显式古历) | 不经过 `time.Time` | +| `LunarToSolarWithCalendar` | 农历描述 → 公历(显式古历) | 年号描述只在古历年号表命中时可用,否则报错 | +| `LunarToSolarByYMDWithCalendar` | 农历年月日 + 闰月标志 → 公历(显式古历) | 古历无数据的年份返回错误 | + +```go +// 零值 AncientCalendarDefault 走默认路由;显式古历只在有数据的年份可用。 +zhuanxu, err := calendar.SolarToLunarByYMDWithCalendar(-300, 1, 5, calendar.AncientCalendarZhuanxu) +fmt.Println(zhuanxu.Lunar().CalendarSystem(), zhuanxu.Lunar().CalendarName(), err) + +back, err := calendar.LunarToSolarByYMDWithCalendar( + zhuanxu.Lunar().LunarYear(), zhuanxu.Lunar().LunarMonth(), zhuanxu.Lunar().LunarDay(), + zhuanxu.Lunar().IsLeap(), calendar.AncientCalendarZhuanxu) +fmt.Println(back.Solar().Format("2006-01-02"), err) +``` + +### 干支与年号 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `GanZhiOfYear` | 年干支 | 入参是公历年,返回“甲子”式两字字符串 | +| `GanZhiOfDay` | 日干支 | 取 `t` 的年月日字段,按北京时间当天 0 点计算 | +| `LunarTime.GanZhiYear` / `GanZhiMonth` / `GanZhiDay` | 单个农历候选的年、月、日干支 | 闰月的月干支从上一个月 | +| `Era` | 年号表记录 | 字段 `Year`/`Emperor`/`Nianhao`/`OtherNianHaoStart`/`Dynasty`/`Offset` | +| `EraDesc` | 年号描述 | 字段 `YearOfNianHao`/`Emperor`/`Nianhao`/`Dynasty` | +| `EraDesc.String` | 年号字符串 | 第一年写作“元年”,其余写作中文数字加“年” | +| `Time.Eras` | 全部候选的年号信息 | 返回 `[]EraDesc` | +| `LunarTime.Eras` | 单个候选的年号信息 | 返回 `[]EraDesc` | +| `ERR_NIANHAO_NOT_FOUND` | 未找到年号的哨兵错误 | 文本入口解析年号失败时返回 | + +```go +fmt.Println(calendar.GanZhiOfYear(2026)) +fmt.Println(calendar.GanZhiOfDay(time.Date(2026, 2, 17, 0, 0, 0, 0, time.UTC))) + +res, _ := calendar.LunarToSolarByYMD(1083, 10, 12, false) +fmt.Println(res.Lunar().GanZhiYear(), res.Lunar().GanZhiMonth(), res.Lunar().GanZhiDay()) +for _, era := range res.Eras() { + fmt.Println(era.Dynasty, era.Emperor, era.String()) +} +``` + +### 结构与 JSON + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Time` | 公农历双信息容器 | `Solar()`/`Time()` 取公历,`Lunar()`/`Lunars()` 取农历候选,`LunarInfo()` 取结构化信息 | +| `LunarTime` | 单个农历候选 | `LunarYear`/`LunarMonth`/`LunarDay`/`IsLeap` 与 `MonthDay`、干支、生肖、历法名 | +| `LunarInfo` | 结构化农历信息 | 字段与 JSON 键见下表 | +| `Time.Solar` / `Time.Time` | 内部保存的公历 `time.Time` | 不做时区或历法再计算;两者同义 | +| `Time.Lunar` / `Time.Lunars` | 首个 / 全部农历候选 | 无结果时 `Lunar` 返回零值 `LunarTime` | +| `Time.LunarInfo` / `LunarTime.LunarInfo` | 结构化信息切片 | 存在并行年号时返回多条记录 | +| `Time.LunarDesc` / `Time.LunarDescWithEmperor` / `Time.LunarDescWithDynasty` / `Time.LunarDescWithDynastyAndEmperor` | 全部候选的描述族 | 均返回 `[]string` | +| `LunarTime.LunarDesc` / `LunarDescWithEmperor` / `LunarDescWithDynasty` / `LunarDescWithDynastyAndEmperor` | 单个候选的描述族 | 均返回 `[]string` | +| `LunarTime.LunarYear` / `LunarMonth` / `LunarDay` / `IsLeap` | 农历年、月、日与闰月标志 | `LunarMonth` 是“与正月的距离” | +| `LunarTime.MonthDay` | 月日描述 | 十一月写作“冬月”、十二月写作“腊月” | +| `LunarTime.ShengXiao` / `LunarTime.Zodiac` | 生肖 | 两者同义,按农历年对 12 取模 | +| `LunarTime.CalendarSystem` / `LunarTime.CalendarName` | 所属古历系统与中文名 | 默认路由与现代段为空字符串 | + +`LunarInfo` 的 JSON 键与字段: + +| JSON 键 | 字段 | 口径 | +| --- | --- | --- | +| `solarDate` | `SolarDate` | 公历时刻 | +| `lunarYear` / `lunarYearChn` | `LunarYear` / `LunarYearChn` | 农历年的公历映射及其中文数字写法 | +| `lunarMonth` / `lunarDay` | `LunarMonth` / `LunarDay` | 月是“与正月的距离”,日范围 `[1,30]` | +| `isLeap` | `IsLeap` | 是否闰月 | +| `lunarMonthDayDesc` | `LunarMonthDayDesc` | 月日描述,十一月作“冬月”、十二月作“腊月” | +| `ganzhiYear` / `ganzhiMonth` / `ganzhiDay` | `GanzhiYear` / `GanzhiMonth` / `GanzhiDay` | 年、月、日干支 | +| `calendarSystem` / `calendarName` | `CalendarSystem` / `CalendarName` | 古历系统与名称,默认路由为空串 | +| `jd` | `JD` | 该农历日精确的儒略日 | +| `julianOnly` | `JulianOnly` | 带 `omitempty`,只有儒略历独有闰日才出现 `true` | +| `dynasty` / `emperor` / `nianhao` / `yearOfNianhao` / `eraDesc` | `Dynasty` / `Emperor` / `Nianhao` / `YearOfNianhao` / `EraDesc` | 朝代、皇帝、年号与年号纪年 | +| `lunarWithNianhaoDesc` | `LunarWithEraDesc` | 注意字段名与 JSON 键不同 | +| `chineseZodiac` | `ChineseZodiac` | 生肖 | + +```go +// 前置:需要 import "encoding/json"。 +res, _ := calendar.SolarToLunarByYMD(2026, 2, 17) +for _, info := range res.LunarInfo() { + fmt.Println(info.LunarYear, info.LunarMonthDayDesc, info.GanzhiDay, info.ChineseZodiac, info.JD) +} +data, _ := json.Marshal(res.LunarInfo()[0]) +fmt.Println(string(data)) +``` + +## 常用场景 + +### 公历转农历与干支纪日 + +```go +solar, _ := calendar.SolarToLunar(date) // date = 2026-02-17 00:00:00 CST +lunar := solar.Lunar() +fmt.Println(lunar.MonthDay(), lunar.LunarYear(), lunar.GanZhiDay()) +fmt.Println(calendar.GanZhiOfDay(date)) +``` + +```text +正月初一 2026 壬戌 +壬戌 +``` + +`SolarToLunar` 与 `GanZhiOfDay` 共用同一套纪日口径,所以末行与 `GanZhiDay()` 一致。历史并行政权会让同一个公历日给出多个农历候选(`Lunar()` 取首个、`Lunars()` 取全部),口径见[使用须知](#使用须知);闰月由 `IsLeap()` 单独标记,见[参数与返回值约定](#参数与返回值约定)。 + +### 节气时刻与历法节气 + +```go +fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) // 现代天文时刻 +termDate, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) // 历法日期 +fmt.Println(termDate, err) +``` + +```text +2020-02-04 17:03:20.471614301 +0800 CST +1582-12-22 00:00:00 +0800 CST +``` + +`JieQi` 给的是现代天文算出的精确时刻,`CalendricalJieQi` 给的是历法编排的日期(固定北京时间 0 点),两者不可互换;历法节气只在有资料的年份可用(2026 年会返回错误),细节见[节气](#节气)与[历法说明](#历法说明)。 + +### 自定义时区与儒略历独有闰日 + +```go +fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0), calendar.Solar(1985, 1, 1, false, 7.0)) +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) // 儒略历独有闰日 +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JD()) +``` + +```text +1985-02-20 00:00:00 +0800 CST 1985-01-21 00:00:00 +0700 CST +0700-03-01 true 1.9767915e+06 +``` + +定朔定气默认按北京时间,换到东七区后同一个农历日的公历日期会改变(示例中正月初一由 2 月 20 日变成 1 月 21 日,朔与冬至落点改变会牵动整个月序);`Solar`/`Lunar` 的 `timezone` 参数单位是小时。700-02-29 只存在于儒略历:`Solar()` 固定在次日、精确日期看 `JD()`,入口与口径见[使用须知](#使用须知)。 + +### 改历双纪年的多个候选 + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +for _, c := range res.SolarCandidates() { + fmt.Println(c.Format("2006-01-02")) +} +``` + +```text +0700-12-15 +0700-10-17 +``` + +改历双纪年(王莽、魏明帝、武则天、唐肃宗)与太初改历交接,会让同一个农历日对应两个合法公历日;首行是默认候选 `Solar()`,`SolarCandidates()` 返回全部候选,非改历年份只返回单元素切片,见[使用须知](#使用须知)。 + +### 古历系统与年号口径 + +```go +zhuanxu, err := calendar.SolarToLunarByYMDWithCalendar(-300, 1, 5, calendar.AncientCalendarZhuanxu) +fmt.Println(zhuanxu.Lunar().CalendarSystem(), zhuanxu.Lunar().CalendarName(), err) +era, _ := calendar.LunarToSolarByYMD(1083, 10, 12, false) +fmt.Println(era.LunarDescWithEmperor()) +``` + +```text +zhuanxu 颛顼历 +[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] +``` + +显式古历入口只在有数据的年份可用,`AncientCalendarDefault` 是零值,会退回按年份自动路由;`CalendarSystem()`/`CalendarName()` 在默认路由与现代段返回空字符串,年号以 `Eras()`/`LunarDescWithEmperor()` 为准,口径见[历法说明](#历法说明)与[古历系统](#古历系统)。 + +## 历法说明 + +- **默认路由**:按年份自动选择,先秦段使用春秋/古六历重建,`-220..-104` 使用秦汉颛顼历,`-103..1912` 使用历表,`1913` 年后使用现代算法。 +- **显式古历**:如果需要指定某一古历系统,请使用 `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` 这类 API。 +- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证校验;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。 +- **节气**:`JieQi` 返回现代天文计算的节气时刻;`CalendricalJieQi` 返回历法相符节气日期。 + +--- + +## 使用须知 + +### 1. 同一公历日期可能对应多个农历日期 +在多个政权并存的历史时期(如三国时期),不同政权可能使用不同历法,造成同一公历日期对应多个农历日期。本程序尽可能提供所有可能的转换结果。 + +### 2. 同一农历日期可能对应多个公历日期 +不仅因多个政权历法不同,同一政权在历法改革中也可能出现此类情况。例如,武则天改历后,圣历三年出现了两个腊月。 + +### 3. 公历历法处理规则 +本程序基于儒略日进行计算,公历部分处理规则如下: +- 1582年10月15日之后:使用格里高利历 +- 1582年10月4日之前:使用儒略历 +- 公元8年之前:使用逆推儒略历 +- 1582年10月4日的下一天为1582年10月15日 +- 1582年10月5日到10月14日这10个公历日期不存在,相关接口会直接拒绝 +- 年份表示:0年表示公元前1年,-1年表示公元前2年,以此类推 + +### 4. 时区说明 + +本package主要面向中国历法,因此定气和定朔的计算默认采用北京时间(UTC+8)。对于使用其他时区的地区,若直接套用中国农历的编排规则,可能会产生日期偏差。 + +为方便探索与研究,本包 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)** 进行公历与农历的相互转换。 +如果只需北京时间下的标准转换,请直接使用封装好的 `SolarToLunar` 和 `LunarToSolar` 方法。 + +**示例**:农历规则要求冬至必须落在农历十一月。以1984年冬至为例,计算可得: +```go +ws := calendar.JieQi(1984, 270) +fmt.Println(ws) +fmt.Println(moon.ClosestShuoYue(ws)) +``` + +| 节令 | 东八区 (UTC+8) | 东七区 (UTC+7) | +|------|----------------|----------------| +| 冬至 | 1984-12-22 | 1984-12-21 | +| 朔日 | 1984-12-22 | 1984-12-22 | + +可见,对于东八区(中国),1984年12月22日既是冬至又是朔日,因此该日为农历十一月初一;而在东七区,冬至提前至12月21日,导致12月22日已成为腊月初一。 + +类似地,春节的公历日期也可能相差一个月。按本例的历法口径,1985 年正月初一在东八区对应 2 月 20 日,在东七区对应 1 月 21 日: +```go +fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0)) +fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) +``` + +### 5. Go 语言特别注意 + +⚠️ Go 标准库 `time.Time` 在历法处理上与本程序存在差异: + +- Go 语言在1582年10月15日之前使用逆推格里高利历,而非儒略历。若不使用 `Add` 方法,一般可正常使用。 +- 因此,**在1582年10月15日之前,`time.Time.Weekday()` 返回结果与本程序计算结果不一致**。 + 例如:1582年10月4日,本程序为星期四,Go 语言判断为星期一。 + +#### 计算星期与跨日运算 +如需获得与本程序一致的星期数,可使用如下方法: + +```go +// date 应为当日0时的 time.Time +weekday := int(calendar.Date2JD(date)+1.5) % 7 +// 0表示星期日,1表示星期一,……,6表示星期六 +``` +在 1582 年之前使用 `time.Time` 的 `Add` 或 `AddDate` 会经过逆推格里高利历,跨过儒略历独有的闰日时与儒略历相差一天。 +例如:700年儒略历为闰年,而 Go 使用的逆推格里高利历中700年不是闰年。 + +### 6. 儒略历独有的闰日(如 700-02-29) + +1582 年以前"能被 100 整除但不能被 400 整除"的年份(如 100、700、1500 年)在儒略历中有 2 月 29 日, +而 Go 的`time.Time`使用逆推格里高利历,没有这一天(`time.Date(700, 2, 29, ...)` 会被规范化成 700-03-01)。本库承认 700-02-29 这一天存在,对应的约束如下: + +- `Time.JulianOnly()`:该农历日是否只存在于儒略历(对应 JSON 字段 `julianOnly`); +- `Time.JD()`:该日精确的儒略日;儒略历闰日比 `Solar()` 早一天,其余情况两者一致(对应 JSON 字段 `jd`)。 +- `Time.Solar()` / `LunarTime.SolarDate`:库内标准输出,对于700-02-29Go标准库表示不出来的日期,固定返回为**后一天**(700-02-29 的后一天是 700-03-01),与 `basic.JD2DateByZone` 的约定一致; + +```go +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JD(), julian.Lunar().MonthDay()) +// 0700-03-01 true 1.9767915e+06 二月初五 +``` + +> 涉及这类日期时,不丢闰日的入口有两类:整型年月日入口 `SolarToLunarByYMD` / `LunarToSolarByYMD`,以及直接调用 +> `basic.JDCalc(700, 2, 29)` 得到精确儒略日 `1976791.5`;先构造 `time.Time` 的那一步就会丢掉闰日。 + +### 7. 同一农历日的多个公历候选 + +改历双纪年(王莽 9–23 年、魏明帝 237–240 年、武则天 689–700 年、唐肃宗 761–762 年)与太初改历交接 +(公元前 104 年)会让同一个农历日对应两个合法公历日。`Solar()` 仍是库内默认选择,`SolarCandidates()` +返回全部候选、首个恒等于 `Solar()`: + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02")) +fmt.Println(len(res.SolarCandidates())) +// 0700-12-15 +// 2 +``` + +非改历年份的农历日只有一个候选,`SolarCandidates()` 返回仅含 `Solar()` 的slice;儒略历独有的闰日也只返回 +标准输出,因为它唯一合法的那一天无法表示成 `time.Time`,精确日期见 `JD()`。 + +## 历法转换 + +### 公历转农历 + +- **输入**:公历日期 (`time.Time`) +- **输出**:`calendar.Time` 对象,可能包含多个对应的农历日期 +- **功能**:可从返回对象中获取: + - 农历日期的详细描述 + - 年、月、日的天干地支 + - 所属朝代、皇帝、年号等信息 + - 完整的结构化农历信息 + +### 农历转公历 + +支持两种调用方式: + +#### 方式一:传入农历字符串 +支持以下格式(示例): +1. `年号+年+月+日`:如 **`"元丰六年十月十二"`**(闰月前加"闰",日期格式为"初一"、"二十"等) +2. `年号+年+月+干支日`:如 **`"元嘉二十七年七月庚午"`** +3. `年份+月+日`:如 **`"二零二五年正月初一"`**(闰月前加"闰",适用于现代日期) +4. `年份+月+干支日`:如 **`"二零二五年正月戊戌日"`** +5. `阿拉伯数字+月+日`:可以将中文数字替换为阿拉伯数字,如 **`"2025年1月1日"`**,代表`二零二五年正月初一` +6. 历史场景:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),这类场景下月份名称按汉字数字解释 + +> ⚠️ 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,对应的字符串是 `"二零二四年腊月廿九"`。 + +#### 方式二:传入数字参数 +- **参数**:年份 (`int`)、月份 (`int`)、日期 (`int`)、是否闰月 (`bool`) +- **语义**:按农历年、月、日与闰月标志定位日期,适用于现代农历日期转换 + +### 代码示例 + +```go +package main + +import ( + "b612.me/astro/calendar" + "encoding/json" + "fmt" + "time" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + + // 示例1:公历转农历;这里故意选三国时期,会返回多个政权并行历法结果。 + date := time.Date(240, 1, 1, 8, 8, 8, 8, cst) + lunar, _ := calendar.SolarToLunar(date) + fmt.Println(lunar.LunarDescWithEmperor()) + + info := lunar.LunarInfo() + data, _ := json.MarshalIndent(info, "", " ") + fmt.Println(string(data)) + + // 示例2:农历转公历(字符串格式);这里用苏轼《记承天寺夜游》的日期。 + solar, _ := calendar.LunarToSolar("元丰六年十月十二日") + for _, v := range solar { + fmt.Println(v.Time()) + fmt.Println(v.LunarDescWithEmperor()) + } + + // 示例3:农历转公历(数字参数格式);2026 年正月初一,也就是春节。 + modernDate, _ := calendar.LunarToSolarByYMD(2026, 1, 1, false) + fmt.Println(modernDate.Time()) +} +``` + +输出结果: + +```text +// 同一公历时刻在三国并立时期会映射到多个政权各自的农历结果 +[魏明帝 景初三年腊月二十 蜀后主 延熙二年冬月十九 吴大帝 赤乌二年冬月二十] +// 结构化农历信息输出;每个对象对应一个政权口径下的结果 +[ + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 12, + "lunarDay": 20, + "isLeap": false, + "lunarMonthDayDesc": "腊月二十", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "魏", + "emperor": "魏明帝", + "nianhao": "景初", + "yearOfNianhao": 3, + "eraDesc": "景初三年", + "lunarWithNianhaoDesc": "景初三年腊月二十", + "chineseZodiac": "羊" + }, + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 11, + "lunarDay": 19, + "isLeap": false, + "lunarMonthDayDesc": "冬月十九", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "蜀", + "emperor": "蜀后主", + "nianhao": "延熙", + "yearOfNianhao": 2, + "eraDesc": "延熙二年", + "lunarWithNianhaoDesc": "延熙二年冬月十九", + "chineseZodiac": "羊" + }, + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 11, + "lunarDay": 20, + "isLeap": false, + "lunarMonthDayDesc": "冬月二十", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "吴", + "emperor": "吴大帝", + "nianhao": "赤乌", + "yearOfNianhao": 2, + "eraDesc": "赤乌二年", + "lunarWithNianhaoDesc": "赤乌二年冬月二十", + "chineseZodiac": "羊" + } +] +// “元丰六年十月十二日”对应的公历日期 +1083-11-24 00:00:00 +0800 CST +// 同一天在并行政权下还会命中辽道宗大康九年十月十二 +[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] +// 现代农历日期转换结果;2026 年正月初一对应 2026-02-17 +2026-02-17 00:00:00 +0800 CST +``` + +## 节气 + +`JieQi(year, term)` 返回现代天文算法计算出的节气精确时刻;`CalendricalJieQi(year, term)` 返回默认历法下节气落在的日期,时间固定为北京时间当天 0 点。需要指定古历系统时,使用 `CalendricalJieQiWithCalendar(year, term, system)`。 + +```go +package main + +import ( + "fmt" + + "b612.me/astro/calendar" +) + +func main() { + // 计算 2020 年立春时刻;节气常量本质上对应太阳视黄经。 + fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) + // 计算 2020 年冬至时刻。 + fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) + // 计算 2020 年春分时刻。 + fmt.Println(calendar.JieQi(2020, calendar.JQ_春分)) + // 也可直接传入黄经数值;春分对应太阳视黄经 0°。 + fmt.Println(calendar.JieQi(2020, 0)) +} +``` + +输出结果 + +``` +2020-02-04 17:03:20.471614301 +0800 CST +2020-12-21 18:02:20.648710727 +0800 CST +2020-03-20 11:49:37.149532735 +0800 CST +2020-03-20 11:49:37.149532735 +0800 CST + +``` + +历法相符节气示例: + +```go +date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) +fmt.Println(date, err) + +date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan) +fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err) +``` + +输出结果 + +``` +1582-12-22 00:00:00 +0800 CST +-202-12-25 +``` + +## 参数与返回值约定 + +### 单位与参数口径 + +- 年份沿用天文记年:`0` 表示公元前 1 年,`-1` 表示公元前 2 年;公历结果的支持范围是公元前 721 年到公元 3000 年。 +- `Solar` 与 `Lunar` 的 `timezone` 参数单位是小时:`8.0` 是东八区、`7.0` 是东七区。`JieQi`、`WuHou`、`CalendricalJieQi*` 不接受时区参数,固定按北京时间(UTC+8)输出。 +- 农历月是“与正月的距离”,正月为 1、二月为 2,依此类推;闰月由独立的 `leap`/`IsLeap` 标记,不从月号里推断。 +- 农历年参数用公历年份代替,但岁首取农历岁首:己亥年腊月三十要传 `Solar(2019, 12, 30, false, 8)`,而不是 `Solar(2020, 12, 30, false, 8)`。 +- `Date2JD` 只看 `time.Time` 的年月日时分秒字段,不做时区换算,所以同一民用日期的东八区 0 点与 UTC 0 点得到同一个儒略日;`JD2Date` 则把结果落在运行环境的本地时区。 + +### 时标 + +- 公开 API 的时刻一律按民用时刻处理,也就是把 `time.Time` 的字段直接当作 UTC 标签读取。库内把民用时刻换算到 TT 时,1972-01-01 之前按 UT1 处理,精确窗口内使用内置闰秒表;需要显式换算时用根包的 `astro.UT1FromUTC` / `astro.TTFromUTC` / `astro.DUT1`。 +- `JieQi` 与 `WuHou` 的返回值是北京时间下的民用时刻,小数秒保留完整精度;`CalendricalJieQi*` 固定返回北京时间当天 0 点。 +- `JD`、`Date2JD`、`NowJD` 都是同一个民用口径的儒略日浮点数:`2026-02-17` 的北京民用日对应 `2461088.5`。 + +### 零值与越界 + +- 公历 1582-10-05 到 1582-10-14 这 10 天不存在,相关接口返回错误,不会静默规范化到邻近日期。 +- `SolarToLunar` / `SolarToLunarByYMD` 超出 `[-721,3000]` 时返回错误;`LunarToSolar*` 的公历结果同样限制在 `[-721,3000]`,边界农历年 `-722` 在结果仍落在范围内时可被接受。 +- `CalendricalJieQiWithCalendar` 对春秋历和缺少历法节气资料的年份返回错误;`LunarToSolarWithCalendar` 对年号描述只在古历年号表命中时可用,否则报错。 +- `SolarCandidates()` 在没有多候选时返回只含 `Solar()` 的单元素切片,不返回空切片;`Lunar()` 在没有候选时返回零值 `LunarTime`,对应的 `JulianOnly()` 返回 `false`。 +- `LunarToSolarSingle` 已弃用,新代码用 `LunarToSolarByYMD`;`AncientCalendarDefault` 是零值,显式古历入口遇到它会退回默认实现。 +- `LunarTime.CalendarSystem()` 与 `CalendarName()` 在默认路由和现代段返回空字符串,表示“没有显式古历”,不是错误。 + +### 精度与适用范围 + +- 现代段按现行农历 GB/T 33661-2017 编排,推荐年限是 `[1929,3000]`;`Solar`/`Lunar` 使用同一套定朔定气规则,只是允许自定义时区,所以推荐年限之外可能出现与史籍不同的日期。 +- `[-103,1912]` 使用内置历表,`[-220,-104]` 使用秦汉颛顼历复原算法,`[-721,-221]` 按默认先秦古历重建。这三段都是复原结果,不保证与当时颁行的历日逐日一致。 +- 定朔定气用现代天文算法计算,古代日期的朔与节气时刻与当时实测存在偏差,因此古代农历结果可能与史籍记载不同。 +- 古历重建只到“历日”一级:`CalendricalJieQi*` 返回的是历法相符的日期,`JieQi` 返回的是现代天文算出的节气时刻,两者不可互换。 + +### 相关手册 + +节气的天文定义(太阳视黄经)与朔望判定见[太阳与月亮](sun-moon.md#日月位置)与[太阳与月亮](sun-moon.md#月相);那里给出的是现代天文量,本手册给出的是历法编排结果。 diff --git a/doc/manual/coord.md b/doc/manual/coord.md new file mode 100644 index 0000000..0528380 --- /dev/null +++ b/doc/manual/coord.md @@ -0,0 +1,404 @@ +# 坐标工具 + +[English](en/coord.md) | [返回 README](../../README.md) + +`coord` 提供天球坐标转换与观测辅助计算。没有特殊说明时,角度单位为度;恒星时单位为小时;`time.Time` 按绝对时刻使用,内部转换为 UTC 后计算。 + +高度角、天顶距与地平可见性的语义沿用[观测角语义](sun-moon.md#观测角语义);站心几何的完整应用见[月掩·站心事件图](occultation.md#站心事件图)与[日月位置](sun-moon.md#日月位置);站心日食的初亏、食甚、复圆见[日食](eclipse.md#日食)。时标、观测点高度与整体精度的约定见[时标约定](timescale.md)、[观测点高度约定](coord.md#观测点高度)与[适用范围与精度](accuracy.md)。 + +## 目录 + +- [黄道坐标转赤道与地平坐标](#黄道坐标转赤道与地平坐标) +- [API 参考](#api-参考) + - [黄道 ↔ 赤道](#黄道--赤道) + - [赤道 ↔ 地平](#赤道--地平) + - [站心坐标](#站心坐标) + - [恒星时](#恒星时) + - [岁差与章动](#岁差与章动) + - [银道坐标](#银道坐标) + - [角距离](#角距离) + - [大气折射](#大气折射) + - [大气质量](#大气质量) + - [视差角](#视差角) +- [常用场景](#常用场景) + - [黄道与地平坐标的往返转换](#黄道与地平坐标的往返转换) + - [站心改正与恒星时](#站心改正与恒星时) + - [蒙气差、大气质量与视差角](#蒙气差大气质量与视差角) + - [岁差章动与交角口径](#岁差章动与交角口径) +- [研究型接口与观测辅助](#研究型接口与观测辅助) +- [参数与返回值约定](#参数与返回值约定) + - [单位约定](#单位约定) + - [时标](#时标) + - [角度象限与归一](#角度象限与归一) + - [零值与无效输入](#零值与无效输入) + - [精度与适用范围](#精度与适用范围) +- [观测点高度](#观测点高度) + +## 黄道坐标转赤道与地平坐标 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/coord" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 4, 27, 10, 30, 45, 0, cst) + eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) + hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", eq.RA, eq.Dec) + fmt.Printf("azimuth=%.6f altitude=%.6f deg\n", hz.Azimuth, hz.Altitude) + fmt.Printf("GAST=%.6f h\n", coord.ApparentSiderealTime(date)) +} +``` + +赤经、赤纬和地平角均为度,恒星时为小时。这里的赤道坐标属于观测日期;银道转换要求 ICRS 坐标,不能直接混用。 + +## API 参考 + +下面按计算内容列出接口、单位和返回值。 + +片段沿用首例中的 `date`、`cst` 与 `eq`。恒星时示例还需导入标准库 `math`。 + +### 黄道 ↔ 赤道 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `EclipticToEquatorial` | 黄道 → 赤道 | 黄经、黄纬、赤经、赤纬均为度;交角取该时刻的瞬时真交角,`RA ∈ [0,360)` | +| `EquatorialToEcliptic` | 赤道 → 黄道 | 同一交角的反变换;`Lon ∈ [0,360)`、`Lat ∈ [−90,90]` | +| `EclipticToEquatorialByObliquity` | 黄道 → 赤道(手工交角) | 三个参数均为度;面向不同自转轴倾角的研究推演,不做日期换算 | +| `EquatorialToEclipticByObliquity` | 赤道 → 黄道(手工交角) | 同上;`Lon ∈ [0,360)`、`Lat ∈ [−90,90]` | +| `Ecliptic` | 黄道坐标结果 | 字段 `Lon`、`Lat`,单位度 | +| `Equatorial` | 赤道坐标结果 | 字段 `RA`、`Dec`,单位度 | + +```go +// date 见公共前置;先取该时刻的瞬时黄赤交角,再与按日期的接口对照。 +obliquity := coord.EclipticObliquity(date, true) +auto := coord.EclipticToEquatorial(date, 139.686111, 4.875278) +manual := coord.EclipticToEquatorialByObliquity(139.686111, 4.875278, obliquity) +back := coord.EquatorialToEclipticByObliquity(manual.RA, manual.Dec, obliquity) +fmt.Printf("auto=(%.9f, %.9f) manual=(%.9f, %.9f)\n", + auto.RA, auto.Dec, manual.RA, manual.Dec) +fmt.Printf("back=(%.9f, %.9f)\n", back.Lon, back.Lat) +``` + +### 赤道 ↔ 地平 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `EquatorialToHorizontal` | 瞬时赤道 → 地平 | 经纬度东正西负、北正南负,单位度;用视恒星时(含章动);`Azimuth ∈ [0,360)`、`Altitude ∈ [−90,90]`、`Zenith = 90 − Altitude` | +| `EquatorialToHorizontalByLocalSiderealTime` | 赤道 → 地平(手工恒星时) | 恒星时单位为小时,内部 ×15 转度;不查日期,适合手工推演 | +| `HourAngleDeclinationToHorizontal` | 时角 + 赤纬 → 地平 | 时角输入转 `[0,360)` 后归一输出;`Azimuth ∈ [0,360)` | +| `HorizontalToHourAngleDeclination` | 地平 → 时角 + 赤纬 | 返回时角归一到 `[0,360)`(不是 `[−180,180]`),赤纬 `[−90,90]` | +| `HorizontalToEquatorialByLocalSiderealTime` | 地平 → 赤道(手工恒星时) | 恒星时小时;`RA ∈ [0,360)` | +| `Horizontal` | 地平坐标结果 | 字段 `Azimuth`、`Altitude`、`Zenith`、`HourAngle`,均度 | + +```go +// 观测点取 115°E, 40°N;eq 见公共前置。 +hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) +manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, eq.RA, eq.Dec, 40) +hz2 := coord.HourAngleDeclinationToHorizontal(hz.HourAngle, eq.Dec, 40) +ha, dec := coord.HorizontalToHourAngleDeclination(hz2.Azimuth, hz2.Altitude, 40) +eq2 := coord.HorizontalToEquatorialByLocalSiderealTime(10.5, hz2.Azimuth, hz2.Altitude, 40) +fmt.Printf("auto=(%.6f, %.6f, %.6f) manual=(%.6f, %.6f)\n", + hz.Azimuth, hz.Altitude, hz.Zenith, manual.Azimuth, manual.Altitude) +fmt.Printf("roundtrip ha=%.6f dec=%.6f ra=%.6f\n", ha, dec, eq2.RA) +``` + +### 站心坐标 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `TopocentricEquatorial` | 地心赤道 → 站心赤道 | `distanceAU` 为地心距、AU;`height` 为椭球高、米;`RA` 只加视差小改正、不做 360° 归一 | +| `TopocentricEcliptic` | 地心黄道 → 站心黄道 | 同上;内部先解站心赤道坐标再转黄道,`Lon` 归一到 `[0,360)`、`Lat` 落在 `[−90,90]` | +| `Ecliptic` / `Equatorial` | 返回值类型 | 字段口径同黄道 ↔ 赤道分组 | + +```go +// 目标地心距 0.00257 AU(约月球),观测点 115°E, 40°N,椭球高 53 m。 +top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) +topEcl := coord.TopocentricEcliptic(date, 139.686111, 4.875278, 115, 40, 0.00257, 53) +fmt.Printf("top=(%.9f, %.9f) dRA=%.9f dDec=%.9f\n", + top.RA, top.Dec, top.RA-eq.RA, top.Dec-eq.Dec) +fmt.Printf("topEcl=(%.9f, %.9f)\n", topEcl.Lon, topEcl.Lat) +``` + +站心几何在月掩里用于逐地点接触时刻,见[站心事件图](occultation.md#站心事件图);`sun`、`moon` 的站心物理量见[日月位置](sun-moon.md#日月位置)。 + +### 恒星时 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `MeanSiderealTime` | 格林尼治平恒星时 | 小时;先把民用时刻换成 UT1,模型为 IAU 2006(ERA 路线) | +| `ApparentSiderealTime` | 格林尼治视恒星时 | 小时;平恒星时叠加 IAU 2000B 黄经章动的投影 | +| `HourAngle` | 由瞬时赤经与站经求时角 | 经度东正西负、度;时角 `[0,360)`;基于视恒星时 | + +```go +// 本地恒星时 = 格林尼治恒星时 + 东经/15,再折回 [0,24) 小时;math 仅用于这一步。 +gmst := coord.MeanSiderealTime(date) +gast := coord.ApparentSiderealTime(date) +lst := math.Mod(gmst+115.0/15, 24) +fmt.Printf("GMST=%.9f h GAST=%.9f h LST=%.9f h\n", gmst, gast, lst) +fmt.Printf("HA=%.6f deg\n", coord.HourAngle(date, eq.RA, 115)) +``` + +### 岁差与章动 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Precess` | 赤道坐标从一个日期岁差到另一个日期 | RA/Dec 单位度;两个日期都是民用时刻;只做岁差旋转,不含自行 | +| `EclipticObliquity` | 黄赤交角 | 度;`nutation=false` 给 IAU 1980 平交角,`true` 再叠加 IAU 2000B 交角章动 | +| `Nutation2000B` | IAU 2000B 章动 | 返回 `(黄经章动, 交角章动)`,度 | +| `Nutation1980` | IAU 1980 章动 | 返回 `(黄经章动, 交角章动)`,度 | + +```go +// 把 J2000 的赤道坐标岁差到 date,并列出两套章动供对照。 +j2000 := time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC) +p := coord.Precess(j2000, date, 83.6331, 22.0145) +dLon2000B, dObl2000B := coord.Nutation2000B(date) +dLon1980, dObl1980 := coord.Nutation1980(date) +fmt.Printf("precessed=(%.9f, %.9f)\n", p.RA, p.Dec) +fmt.Printf("eps mean=%.9f true=%.9f\n", + coord.EclipticObliquity(date, false), coord.EclipticObliquity(date, true)) +fmt.Printf("nut 2000B=(%.9f, %.9f) 1980=(%.9f, %.9f)\n", + dLon2000B, dObl2000B, dLon1980, dObl1980) +``` + +### 银道坐标 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `EquatorialToGalactic` | ICRS 赤道 → 银道 | 输入 ICRS 度;`Lon ∈ [0,360)`、`Lat ∈ [−90,90]`;固定旋转矩阵,不做岁差与自行 | +| `GalacticToEquatorial` | 银道 → ICRS 赤道 | 同一矩阵的转置;`RA ∈ [0,360)` | +| `Galactic` | 银道坐标结果 | 字段 `Lon`、`Lat`,单位度 | + +```go +// 银心方向 ICRS (266.4051, -28.936175) 应回到银经 0、银纬 0 附近。 +gal := coord.EquatorialToGalactic(266.4051, -28.936175) +back := coord.GalacticToEquatorial(gal.Lon, gal.Lat) +fmt.Printf("gal=(%.6f, %.6f) back=(%.9f, %.9f)\n", gal.Lon, gal.Lat, back.RA, back.Dec) +``` + +### 角距离 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `AngularSeparation` | 两组赤道坐标的角距离 | 输入、输出均为度;大圆角距,与输入顺序无关 | + +```go +// 蟹状星云脉冲星方向与银心方向的角距。 +sep := coord.AngularSeparation(83.6331, 22.0145, 266.4051, -28.936175) +fmt.Printf("sep=%.9f deg = %.6f arcsec\n", sep, sep*3600) +``` + +### 大气折射 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `ApparentAltitude` | 真高度 → 视高度 | 高度角与返回值均为度;气压 hPa、气温 ℃ | +| `TrueAltitude` | 视高度 → 真高度 | 对 Saemundsson 模型做数值逆解;无解返回 NaN | +| `AtmosphericRefractionFromTrueAltitude` | 真高度处的折射量 | 度;加到真高度上得视高度 | +| `AtmosphericRefractionFromApparentAltitude` | 视高度处的折射量 | 度;从视高度减去得真高度 | +| `EquatorialToApparentHorizontal` | 赤道 → 视地平 | 在 `EquatorialToHorizontal` 结果上叠加折射,只改 `Altitude`/`Zenith`,不动 `Azimuth`/`HourAngle` | + +```go +// 真高度 10°,标准气压 1010 hPa、气温 0 ℃。 +apparent := coord.ApparentAltitude(10, 1010, 0) +ref := coord.AtmosphericRefractionFromTrueAltitude(10, 1010, 0) +appHz := coord.EquatorialToApparentHorizontal(date, eq.RA, eq.Dec, 115, 40, 1010, 0) +fmt.Printf("true=10 apparent=%.9f ref=%.9f\n", apparent, ref) +fmt.Printf("back=%.9f appHz alt=%.6f zen=%.6f\n", + coord.TrueAltitude(apparent, 1010, 0), appHz.Altitude, appHz.Zenith) +``` + +### 大气质量 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `AirmassPlaneParallelFromTrueAltitude` | 平行平板模型 | 输入真高度、度;等价 `sec(z)`,中高空适用,近地平发散 | +| `AirmassKastenYoungFromApparentAltitude` | Kasten-Young 1989 | 输入视高度、度;不自动做折射修正 | +| `AirmassPickeringFromApparentAltitude` | Pickering 2002 | 输入视高度、度;面向低空观测 | +| `AirmassKastenYoungFromTrueAltitude` | 先折射再 Kasten-Young | 真高度、度;气压 hPa、气温 ℃ | +| `AirmassPickeringFromTrueAltitude` | 先折射再 Pickering | 同上 | + +```go +// 真高度 10°,标准气压 1010 hPa、气温 0 ℃;FromTrueAltitude 内部先做折射。 +apparent := coord.ApparentAltitude(10, 1010, 0) +fmt.Printf("plane=%.9f\n", coord.AirmassPlaneParallelFromTrueAltitude(10)) +fmt.Printf("ky true=%.9f apparent=%.9f\n", + coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0), + coord.AirmassKastenYoungFromApparentAltitude(apparent)) +fmt.Printf("pickering true=%.9f apparent=%.9f\n", + coord.AirmassPickeringFromTrueAltitude(10, 1010, 0), + coord.AirmassPickeringFromApparentAltitude(apparent)) +``` + +只剩纯公式、不需要坐标层折射时,用 `formula` 的[大气质量模型](formula.md#大气质量模型)三模型。 + +### 视差角 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `ParallacticAngle` | 由瞬时赤经赤纬求视差角 | 经纬度单位度;内部走时角 + 赤纬,返回 `(−180,180]` | +| `ParallacticAngleByHourAngle` | 由时角求视差角 | 时角、赤纬、纬度单位度;返回 `(−180,180]` | + +```go +// 视差角用于相机旋转、光谱缝方向和视场姿态判断。 +q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) +q2 := coord.ParallacticAngleByHourAngle(coord.HourAngle(date, eq.RA, 115), eq.Dec, 40) +fmt.Printf("q=%.9f qByHA=%.9f\n", q, q2) +``` + +视差角的方向定义沿用[观测角语义](sun-moon.md#观测角语义)。 + +## 常用场景 + +### 黄道与地平坐标的往返转换 + +```go +eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) +hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) +back := coord.EquatorialToEcliptic(date, eq.RA, eq.Dec) +fmt.Println(eq.RA, eq.Dec, hz.Altitude, back.Lon) +``` + +```text +143.72223158223719 19.53512536790277 -17.686511328302952 139.68611100000000 +``` + +只给时刻与坐标就够,库内部按当日视黄赤交角换算;要自己指定交角用 `EclipticToEquatorialByObliquity` / `EquatorialToEclipticByObliquity` 研究入口。往返自洽:`EquatorialToEcliptic(EclipticToEquatorial(...))` 在同口径下回到原值。 + +### 站心改正与恒星时 + +```go +top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) +fmt.Println(top.RA, top.Dec) +fmt.Println(coord.MeanSiderealTime(date), coord.ApparentSiderealTime(date)) +// 已有地方恒星时时可直接用它,不必让库重算: +manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, 83.6331, 22.0145, 31.2) +fmt.Println(manual.Azimuth, manual.Altitude, manual.HourAngle) +``` + +```text +144.25512626944376 18.79025523004319 +16.852455700085 16.852556781127 +281.869347 24.489608 73.8669 +``` + +`distanceAU` 必须给真实地心距(月球约 `0.00257 AU`);较远天体是否可以忽略视差,取决于所需精度。`height` 是椭球高(米),恒星时单位是小时。 + +### 蒙气差、大气质量与视差角 + +```go +fmt.Println(coord.ApparentAltitude(10, 1010, 0)) // 真高 10° -> 视高 +fmt.Println(coord.TrueAltitude(10.093428, 1010, 0)) // 视高 -> 真高(逆解) +fmt.Println(coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0)) // 真高 10° 的大气质量 +fmt.Println(coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40)) // 相机/光谱缝旋转角 +``` + +```text +10.093427592862 +10.000000410649 +5.537933369472 +-34.000957202636 +``` + +蒙气差只在真高度角 `(-5°, 90°)` 内生效,区间外不修正;大气质量族另有只按视高度角的简化入口(`...FromApparentAltitude`)。 + +### 岁差章动与交角口径 + +```go +fmt.Println(coord.EclipticObliquity(date, true)) // 真交角;false 给平交角 +lon, obl := coord.Nutation2000B(date) // IAU 2000B 黄经/交角章动 +pre := coord.Precess(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), date, eq.RA, eq.Dec) +fmt.Println(lon, obl, pre.RA, pre.Dec) +``` + +```text +23.438261476424 +0.001652570533 0.002392788113 144.089973153762 19.416731576422 +``` + +`Nutation1980` 与 `Nutation2000B` 都可显式调用,便于逐项对表;`Precess` 只做岁差,不含自行、章动与光行差。手动恒星时、手动交角这类研究入口集中在[研究型接口与观测辅助](#研究型接口与观测辅助)。 + +## 研究型接口与观测辅助 + +`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规计算可用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。 + +观测辅助接口包括: + +- `ParallacticAngle` / `ParallacticAngleByHourAngle`:视差角(天顶方向角) +- `Airmass...FromApparentAltitude`:已经有视高度角时,直接套经验式 +- `Airmass...FromTrueAltitude`:先按给定气压/气温估算折射,把真高度角换成视高度角后再算 + +```go +// 目标的视差角,常用于旋转相机、光谱缝方向和视场姿态判断。 +q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) + +// 已知真高度角时,可先估算折射,再按经验模型求大气质量。 +x := coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0) +fmt.Printf("q=%.6f airmass=%.6f\n", q, x) +``` + +同样的观测辅助接口在 `sun`、`moon`、`star` 以及七大行星包中都有提供。已有视高度角且只需要纯公式时,`formula.Airmass...` 更直接。 + +## 参数与返回值约定 + +### 单位约定 + +- 角度一律为度:黄经/黄纬、赤经/赤纬、方位/高度/天顶距/时角、视差角、角距离。需要角秒时自行 ×3600(如 `AngularSeparation` 的返回值)。 +- 恒星时一律为小时:`MeanSiderealTime`、`ApparentSiderealTime` 的返回值和 `*ByLocalSiderealTime` 的 `localSiderealTimeHours` 参数都是小时;内部按 ×15 换成度,不要与赤经的小时表示混用。 +- `Equatorial.RA`、`Galactic.Lon`、`Horizontal.Azimuth`、`Horizontal.HourAngle` 都是**度**,不是小时;`Equatorial` 类型不区分 J2000、日期平坐标还是瞬时真坐标,由调用者保证输入口径一致。 +- 距离用 AU(`TopocentricEquatorial`、`TopocentricEcliptic` 的 `distanceAU`);观测点高度用**椭球高(大地高)**,单位米,不建模大地水准面差距,详见[观测点高度约定](coord.md#观测点高度)。 +- 气压 hPa、气温 ℃;折射按标准状态 `1010 hPa`、`10 ℃` 定标。 +- 本包不产生视直径/视半径;日月与日食手册里的视半径字段以角秒计,不要与本包的“度”互换。 + +### 时标 + +- 所有公开接口的 `time.Time` 按绝对时刻(民用时刻)使用,内部先 `date.UTC()` 再转儒略日;数值本身一律按 UTC 标签解释,`1972-01-01` 之前它等于 UT1。完整约定见[时标约定](timescale.md)。 +- 恒星时链路显式做 UT1 换算:`MeanSiderealTime` / `ApparentSiderealTime` 走 `UTC2UT1` 后的 IAU 2006 模型;`HourAngle`、`EquatorialToHorizontal`、`TopocentricEquatorial` 内部的视恒星时同样基于 UT1。 +- `Precess(from, to)` 的 `from`、`to` 都是民用时刻,按各自的绝对时刻取岁差历元。 +- 折射与大气质量不涉及时刻,只依赖高度角与气象参数。 + +### 角度象限与归一 + +- 内部 `normalize360` 把所有需要展示的角归一到 `[0,360)`:赤经、黄经、银经、方位角、时角都落在这个区间。 +- 赤纬、黄纬、银纬、高度角经 `Asin`(部分路径额外做 `[−1,1]` 夹取)后落 `[−90,90]`。 +- `ParallacticAngle` / `ParallacticAngleByHourAngle` 用 `Atan2`,返回 `(−180,180]`,是本包唯一带符号的角。 +- 站心族是例外:`TopocentricEquatorial.RA` 是输入赤经加一个小改正,不做 360° 归一,目标靠近 `0°/360°` 边界时结果可能略越界;`TopocentricEcliptic` 的 `Lon` 归一到 `[0,360)`、`Lat` 经 `Asin` 落在 `[−90,90]`(内部先解站心赤道坐标再转黄道)。 +- `HourAngleDeclinationToHorizontal` 与 `HorizontalToHourAngleDeclination` 的时角都归一到 `[0,360)`,所以子午线以西的负时角在这里表现为 `360−|HA|`;要带符号时角请自行减 360。 +- `EquatorialToHorizontal` 与 `HourAngleDeclinationToHorizontal` 给出的都是**几何**高度角,不含蒙气差;需要视高度用 `EquatorialToApparentHorizontal` 或 `ApparentAltitude`。 + +### 零值与无效输入 + +- 折射族要求 `pressureHPa > 0`、`temperatureC > −273.15`,任一参数为 NaN/Inf 或越界即返回 NaN。 +- 真高度 `≤ −5°` 或 `≥ 90°` 时折射量按 0 处理:`ApparentAltitude` 原样返回真高度,`TrueAltitude` 与 `AtmosphericRefractionFromApparentAltitude` 原样返回输入视高度;`TrueAltitude` 只在 `(−5°, 90°)` 内做数值逆解,逆解失败返回 NaN。 +- 大气质量族把高度角(或天顶距)限制在 `[0,90]`,越界或非有限返回 NaN;平行平板在高度角恰为 `0°`(天顶距恰为 `90°`)时返回 `+Inf`,Kasten-Young 与 Pickering 在 `0°` 处仍是有限值。 +- `EquatorialToApparentHorizontal` 把气压/气温直接透传给折射函数:非法气象参数会让 `Altitude` 变 NaN、`Zenith` 随之为 NaN,而 `Azimuth`、`HourAngle` 仍是几何值。 +- 站心族对 `distanceAU` 与 `height` 不做校验,`distanceAU` 填 `0` 或负值会得到无意义结果;必须传真实地心距(月球约 `0.00257 AU`)。 +- 银道、角距离等纯几何接口不做参数校验,非有限输入会传播成 NaN,黄纬超出 `[−90,90]` 也不会被夹取,而是按球面方向重新解释。 +- 四个坐标类型没有 `Valid` 字段:`Ecliptic{}`、`Equatorial{}`、`Horizontal{}`、`Galactic{}` 表示“全 0 角”而不是“未设置”,不能用零值做缺省判断。 + +### 精度与适用范围 + +- 黄赤交角:平均项取 IAU 1980 模型,`EclipticObliquity(date, true)` 在其上叠加 IAU 2000B 交角章动;`Nutation1980` 与 `Nutation2000B` 都可用,需要逐项对照时显式调用。 +- 恒星时:走 IAU 2006 的 ERA 路线,视恒星时叠加 IAU 2000B 黄经章动;同一儒略日的视恒星时做了有界记忆化,注入 ΔT 覆盖会按世代失效。 +- 岁差:`Precess` 在赤道坐标上做长期岁差旋转(赤道极/黄极向量含周期项),不含自行、章动与光行差;它适合把一个历元的平坐标转到另一个历元,不能代替完整的“J2000 → 当日视位置”链路。 +- 银道:使用固定的 ICRS ↔ Galactic 旋转矩阵,等价于 SOFA 的 `iauIcrs2g`/`iauG2icrs`,只适用于 ICRS 口径的输入。 +- 站心改正:周日视差量级随 `distanceAU` 反比变化,月球最大(约 1°),较远天体是否可忽略应按精度要求判断;`height` 只影响视差项的幅度,量级为米级高程对应的角秒级差异。 +- 站心黄道坐标由站心赤道坐标换算(`basic.TopocentricLoBo` 一次求值给出黄经与黄纬):`TopocentricEcliptic.Lat` 保证落在 `[−90,90]`,并与 `EquatorialToEcliptic(TopocentricEquatorial(...))` 这条独立路径一致。 +- 折射:Saemundsson 公式,有效真高度窗口 `(−5°, 90°)`,窗口外不修正;低空(< 5°)折射变化剧烈,气压 ±1 hPa、气温 ±1 ℃ 都会带来可见误差。 +- 大气质量:三种经验模型在中高空接近,低空差异最大;平行平板只是几何近似,接近地平线发散,低空精细估算用 Kasten-Young 或 Pickering,纯公式见 `formula` 的[大气质量模型](formula.md#大气质量模型)。 +- 视差角:`ParallacticAngle` 用瞬时赤经赤纬与站点经纬度,`ParallacticAngleByHourAngle` 用同一几何的时角形式,两者对同一几何应给出一致结果。 + +## 观测点高度 + +站心坐标、升落、地方日月食与月掩接口的高度参数均为椭球高,单位米。正高(海拔)`H` 与椭球高的关系是 `height = H + N`,其中 `N` 为当地大地水准面差距。 + +库内不提供大地水准面模型,所以 `height=0` 表示参考椭球面,不一定是当地平均海面。需要这一级精度时,应从外部模型取得 `N`;与外部预报对照时也应统一高度定义。 + +作为量级估算,30 米高差在高度角 30° 的光线方向上对应约 52 米水平位移,即 `|Δh|·cot(高度角)`。接触时刻的差异还取决于当地影子速度和接触几何,不能视为固定秒数的修正。 diff --git a/doc/manual/eclipse.md b/doc/manual/eclipse.md new file mode 100644 index 0000000..3f2f894 --- /dev/null +++ b/doc/manual/eclipse.md @@ -0,0 +1,919 @@ +# 日食与月食 + +[English](en/eclipse.md) | [返回 README](../../README.md) + +> 本手册的完整示例以仓库根目录为工作目录执行,生成的图片写入 `doc/img/`。 + +## 目录 + +- [简单示例:2009长江大日食,上海某地见食情况](#简单示例2009长江大日食上海某地见食情况) +- [API 参考](#api-参考) +- [常用场景](#常用场景) + - [本地有没有日食或月食](#本地有没有日食或月食) + - [全球见食图与月食图](#全球见食图与月食图) + - [中心线与偏食足迹](#中心线与偏食足迹) + - [贝塞尔根数与沙罗](#贝塞尔根数与沙罗) +- [日食](#日食) + - [与 NASA 资料的时间对照](#与-nasa-资料的时间对照) + - [2009 年长江大日食:上海洋山附近](#2009-年长江大日食上海洋山附近) + - [2012 年日环食:厦门示例](#2012-年日环食厦门示例) + - [生成日食 SVG](#生成日食-svg) +- [月食](#月食) + - [代码示例](#代码示例) + - [与 NASA 数据对照](#与-nasa-数据对照) + - [月食 SVG](#月食-svg) + - [参考资料](#参考资料) +- [全球见食图与月食出图](#全球见食图与月食出图) + - [全球见食图](#全球见食图) + - [月食出图](#月食出图) + - [时标口径与 UT1](#时标口径与-ut1) + +## 简单示例:2009长江大日食,上海某地见食情况 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 0, 0, 0, 0, cst) + info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) + if !ok { + fmt.Println("no local solar eclipse") + return + } + fmt.Println(info.Type) + fmt.Printf("%+v\n", info) +} +``` + +`ok=false` 表示该日期或地点没有命中所查询的日食,此时不应读取结果字段。全局事件、地方接触时刻和全球路径分别由下面的接口计算。 + +## API 参考 + +SVG 片段使用导入别名 `eclipsesvg "b612.me/astro/eclipse/svg"`;月掩图使用 `moonsvg "b612.me/astro/moon/svg"`。日期和时区沿用首例。 + +| 名称 | 用途 | 备注 | +| --- | --- | --- | +| `LocalSolarEclipseOnDate` / `LocalSolarEclipseOnDateNASABulletinSplitK` | 站心日食查询 | 返回 `(info, bool)`;默认 NASA Split-K 口径 | +| `LunarEclipseOnDate` / `LunarEclipseOnDateDanjon` / `LunarEclipseOnDateChauvenet` | 月食查询 | 同上;后缀强制影半径模型 | +| `SearchLocalCentralSolarEclipse` / `SolarEclipseCandidates` | 跨年搜索中心食 / 候选时刻表 | 前者带 `status.Exhausted` | +| `SolarEclipseCentralPath` / `SolarEclipsePartialFootprints` | 中心线和偏食足迹几何 | 选项见 `SolarEclipsePathOptions` / `SolarEclipsePartialFootprintOptions` | +| `SolarEclipseBesselianElements` / `SolarEclipseBesselianMuForPublishedTable` | 贝塞尔根数与已发布表换算 | 用来对表 | +| `eclipsesvg.SolarEclipseMapSVG` / `LunarEclipseMapSVG` | 全球见食图 / 月食可见区图 | 返回 `(string, bool)` | +| `eclipsesvg.LocalSolarEclipseSVG` / `LunarEclipseSVG` / `LunarEclipseDetailedSVG` | 站心视圆图 / 穿影图 / 详细版式 | 同上 | +| `eclipsesvg.SolarEclipseMapSVGOptions` / `LunarEclipseDetailedSVGOptions` | 出图选项(投影、图层、画布) | 图层开关见「全球见食图与月食出图」 | +| `astro.TimeScaleUT1` | UT1 口径出图 | 此时 `Location` 必须为 UTC | +| `SolarEclipseInfoInUT1` 等 `...InUT1` 系列 | 把结果里的民用时刻换成同一物理时刻的 UT1 读数 | 零值时刻原样保留 | + +## 常用场景 + +### 本地有没有日食或月食 + +```go +solar, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) +fmt.Println(ok, solar.Type) +lunar, ok2 := eclipse.LunarEclipseOnDate(time.Date(2029, 1, 1, 0, 0, 0, 0, cst)) +fmt.Println(ok2, lunar.Type) +``` + +```text +true total +true total +``` + +- `LocalSolarEclipseOnDate` 一次给出食型、各阶段时刻、食分、遮掩比例与食甚太阳高度;只关心"有没有"时看第二个返回值。 +- 要跨年份找"下一次中心食",用 `SearchLocalCentralSolarEclipse`(返回 `status.Exhausted` 区分"跨度内确实没有");只要候选时刻表用 `SolarEclipseCandidates`。 +- 月食侧对应 `LunarEclipseOnDate` 与带 `Danjon` / `Chauvenet` 后缀的强制模型入口。 + +### 全球见食图与月食图 + +```go +solar, ok := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}) +local, ok2 := eclipsesvg.LocalSolarEclipseSVG(date, 121.9850, 30.6167, 0, eclipsesvg.LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Step: 5 * time.Minute, Location: cst}) +lunar, ok3 := eclipsesvg.LunarEclipseSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), eclipsesvg.LunarEclipseSVGOptions{Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst}) +detailed, ok4 := eclipsesvg.LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}) +fmt.Println(ok, len(solar), ok2, len(local), ok3, len(lunar), ok4, len(detailed)) +``` + +```text +true 265827 true 13625 true 19831 true 319721 +``` + +投影切换、图层开关(半影/本影轮廓、食分等值线、等时线)与画布下限都在[全球见食图与月食出图](#全球见食图与月食出图)里;正射球面与极区版式的取舍见[地图投影](map-geojson.md#地图投影)。 + +### 中心线与偏食足迹 + +```go +partial, ok := eclipse.SolarEclipsePartialFootprints(date, + eclipse.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}) +central, hasCentral := eclipse.SolarEclipseCentralPath(date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}) +fmt.Println(ok, hasCentral, len(partial.CentralBandFootprints), len(central.CenterLine)) +``` + +```text +true true 42 1117 +``` + +- 只要几何、不要图(例如自己写数据管线或传给 `geojson`)时用这两个入口;`TargetSpacingKM` 控制中心线加密上限。 +- 偏食足迹是瞬时足迹的并集,`Step` 小于两分钟会夹到两分钟;结果里的 `data-source` 会标注实际几何来源。 + +### 贝塞尔根数与沙罗 + +```go +elements, ok := eclipse.SolarEclipseBesselianElements(2460409.262835, + eclipse.SolarEclipseBesselianElementsOptions{DeltaTSeconds: 70.6, ReferenceJDE: 2460409.25}) +t := 0.5 +fmt.Printf("X=%.6f Y=%.6f D=%.6f L1=%.6f L2=%.6f\n", + elements.X.At(t), elements.Y.At(t), elements.D.At(t), elements.L1.At(t), elements.L2.At(t)) +fmt.Println(info.HasSaros, info.Saros) +``` + +```text +X=-0.062351 Y=0.355150 D=7.593613 L1=0.535842 L2=-0.010245 +true {136 37 71 true} +``` + +- 显式给 `DeltaTSeconds` 与 `ReferenceJDE` 时可直接与已发布的贝塞尔根数表逐项对表;已发布表用的是 T0 本身的恒星时,需要先用 `SolarEclipseBesselianMuForPublishedTable` 换算。 +- 与 NASA 目录/图页的 UT 时刻比较前要先扣掉它们出版时采用的 ΔT 口径,方法见[与 NASA 资料的时间对照](#与-nasa-资料的时间对照);TD 层(不含 ΔT)才是能单独检验星历与几何的一层。 + +## 日食 + +> 图内与图注的时标声明见[时标声明](map-geojson.md#时标声明)。 + +日食计算统一放在 `eclipse` 包;SVG 生成功能放在 `eclipse/svg` 包。月亮半径默认采用 `NASA bulletin Split-K` 口径(半影与偏食 `k = 0.2724880`,本影与反本影 `k = 0.2722810`);需要 IAU 单一 `k = 0.2725076` 时,调用同名的 `...IAUSingleK` 接口。 + +太阳半径有两个口径:**标准档**(1 AU 处 `959.639″`,与已发布星历表、目录一致,默认)与**边缘档**(1 AU 处 `959.95″`,由全食边缘的光变曲线实测得到的食半径),两者相差 `0.31″`。以 2024-04-08 全食为例,改用边缘档会让全食带每侧收窄约 0.6 千米、中心食时长缩短约 1.5 秒;2023-10-14 环食则带变宽约 1.3 千米、时长延长约 2.1 秒;偏食食分变化约 `1e-4`。 + +可据此评估食限与中心时长对太阳半径的敏感性。 + +两个口径都可以选:`eclipse.SolarEclipseOptions{SunRadiusModel: ...}` 配合 `SolarEclipseOnDateWithOptions`、`LocalSolarEclipseOnDateWithOptions`,以及搜索与面板的 `LastSolarEclipseWithOptions` / `NextSolarEclipseWithOptions` / `ClosestSolarEclipseWithOptions` / `SolarEclipseGeocentricPanelWithOptions`,或 `basic` 侧的 `SolarEclipseWithOptions` / `LocalSolarEclipseWithOptions` 与各 `...Options` 结构里的 `SunRadiusModel` 字段;结果里的 `SunRadiusModel` 记录实际使用的口径,`basic.SolarEclipseSunSemidiameter` 与面板里的“视半径 S.D.”都按同一口径计算。 + +常用接口: + +- `SolarEclipseOnDate`:判断某个当地日期附近是否有全局日食 +- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`:搜索全局日食 +- `LocalSolarEclipseOnDate`:判断某地当天是否能看到站心日食 +- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`:搜索某地可见的站心日食 +- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`:搜索某地可见的日全食,返回 `(info, ok)` +- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`:搜索某地可见的日环食,返回 `(info, ok)` +- `SolarEclipseCentralPath`:计算中心线、南北界和食甚点 +- `SolarEclipsePartialFootprints`:计算偏食半影在地球表面的足迹;可选采样本影/反本影瞬时轮廓 +- `SolarEclipseBesselianElements`:计算一次日食的多项式贝塞尔根数(`X`/`Y`/`D`/`L1`/`L2`/`Mu` 的三次系数与 `TanF1`/`TanF2`),窗口内无日食时返回 `(零值, false)` +- `eclipse/svg.LocalSolarEclipseSVG`:生成某地的日面视圆 SVG + +`SolarEclipseBesselianElements` 给出一张与已发布根数表同形的多项式表:`T0JDE` 是参考时刻,`t = (jde - T0JDE) * 24` 是自它起算的 TT 小时数,`X`/`Y`/`D`/`L1`/`L2`/`Mu` 都是 `t` 的三次多项式(`D` 与 `Mu` 用度,其余用地球赤道半径),`TanF1`/`TanF2` 在本次日食内为常数。 + +`L1`/`L2` 用 Explanatory Supplement 的含 `1/cos f` 形式,本影 `L2` 为负表示月心尚未越过本影锥顶点。默认 `T0` 取食甚所在 TT 小时的下整、窗口半径 3 小时、窗口内 5 个等距时刻做三次最小二乘,与已发布表的拟合口径一致。`Model`、`SunRadiusModel`、`PenumbralK`、`UmbralK`、`DeltaTSeconds` 五个字段记录决定这些数值的口径,随结果一起返回。 + +**`Mu` 的时间自变量与已发布表不同,必须换算后才能对表。** 已发布表用 `T0` 本身的恒星时,本库用 `UT = TT - ΔT`,两者相差 `ΔT × 15.041067/3600` 度;`Mu` 在窗口内保持连续、不折回 `[0,360)`,`SolarEclipseBesselianMuForPublishedTable` 只平移常数项。本库取真实格林时角,是为了让 `Mu` 与本库自己的食甚经度、中心线和接触时刻自洽;直接拿已发布表的 `Mu` 配上正确的恒星时,会得到约 0.3° 的经度偏差(2024-04-08 的食甚纬度上约 30 km)。 + +```go +// 贝塞尔根数表,T0 与 ΔT 显式指定时可直接与已发布表对表。 +elements, ok := eclipse.SolarEclipseBesselianElements( + 2460409.262835, // 2024-04-08 日全食附近的 TT 儒略日 + eclipse.SolarEclipseBesselianElementsOptions{DeltaTSeconds: 70.6, ReferenceJDE: 2460409.25}, +) +if ok { + t := 0.5 // 自 T0 起 0.5 TT 小时 + fmt.Println(elements.X.At(t), elements.Y.At(t), elements.D.At(t)) // 基本面坐标与影轴赤纬 + fmt.Println(elements.L1.At(t), elements.L2.At(t)) // 半影、本影半径 + // 已发布表用 T0 本身的恒星时,换算后才能对表。 + fmt.Println(eclipse.SolarEclipseBesselianMuForPublishedTable(elements.Mu, elements.DeltaTSeconds).At(t)) +} +``` + +`SolarEclipsePartialFootprintsInfo` 还给出影锥与地球的全球接触:`P1/P4` 是半影外切,`P2/P3` 是半影内切;`U1/U4` 是本影或反本影外切,`U2/U3` 是内切。某次日食不存在的接触保持 `time.Time` 零值。`CentralBeginOnEarth` / `CentralEndOnEarth` 仍表示影轴进入和离开地球,不等同于 `U1/U4`。 + +需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算;SVG 入口同样只在正值时采样(小于一分钟按一分钟),零值或负值都不会构造。 + +需要在数据层直接取等时线时,可在同一个 `SolarEclipsePartialFootprintOptions` 中设置 `GreatestTimeValues` 或 `GreatestTimeStep`。`GreatestTimeValues []time.Time` 是**食甚时刻取值**,按绝对时刻使用(其 `Location` 不参与换算),最多保留 64 条:重复的时刻取值与偏食可见窗口之外的时刻取值会被跳过,其余按时间先后排序,超出时保留最早的 64 条;没有可用支路的时刻取值不会出现在结果里。它为空时改用 `GreatestTimeStep` 按间隔生成,间隔只在为正值时生效,且对齐到 UTC 整刻度;要按展示时区对齐,请自行生成时刻后传给 `GreatestTimeValues`。 + +结果写入 `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`:`JDE` 是对应的力学时儒略日,`Time` 是该时刻取值在输入时区下的时刻(显式传入的时刻取值原样回显,按步长生成时由 `JDE` 换算并抹到毫秒,避免往返把整分截断成前一分钟),`Segments` 是该时刻的等时线支路。等时线只出现在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。不请求时既有输出完全不变。 + +日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息: + +- `HasSaros`:是否成功匹配到沙罗序列 +- `Saros.Series`:`Verified=true` 时为 NASA 沙罗系列号,否则为推算的暂定系列号 +- `Saros.Member`:这次日食在该系列中的第几个成员,从 `1` 开始 +- `Saros.Count`:该沙罗系列的总成员数 +- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` + +说明: + +- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月、约 `18 年 11 天 8 小时`;系列成员按此周期排列。 +- 沙罗系列是一组按沙罗周期连续排列的日食事件;`Series` 标识该组,`Member` / `Count` 表示当前事件在该组中的序号和总数。 +- 沙罗序列属于整场日食事件,不随观测地点改变,所以全局日食、站心日食、中心路径和偏食足迹中的对应值应当一致。 +- 内置 NASA 锚点优先使用正式编号;天文年份 `-3000` 至 `+6000` 年内(含首尾年,`0` 年为公元前 1 年)未被锚点覆盖的事件使用预计算扩展表,范围外才实时演算外推。预计算与实时推算结果的 `Verified` 都是 `false`,不应视为实际已发布编号。 +- 扩展编号沿用 NASA 的 [Saros/Inex 编号关系](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html),成员按 Split-K 模型计算,计数覆盖完整系列,不在预计算年份边界截断。`3288-11-15` 的推算结果为系列 `202`、第 `1/71` 个成员。 +- 例如 `2024-04-08` 北美日全食属于 `日食沙罗序列139` 的第 `30/71` 个成员。 + +### 与 NASA 资料的时间对照 + +日食时间分两类: + +- **全局日食**:关注整次日食的食甚 UT、食分、Gamma、食甚点经纬度和食带宽度。当前用 NASA GSFC 的日食搜索/贝塞尔根数资料对照了 `2023-04-20` 全环食、`2024-04-08` 日全食、`2024-10-02` 日环食、`2025-03-29` 日偏食。 +- **站心日食**:关注某个观测点看到的初亏、食甚、复圆和中心食持续时间。当前用 NASA GSFC 的 local circumstances / Google map 日食资料对照了芝加哥偏食、2024 日全食食甚点、2024 日环食食甚点。 + + +| 时标类型 | 比较项 | 作用 | 已知量级 | +| --- | --- | --- | --- | +| **TT / TD 层**(力学时,去掉 ΔT) | 几何与星历:贝塞尔根数、影轴位置、接触时刻的 TD 值 | 星历与影子几何本身 | 4 个回归样例中位差约 `0.2 s`;1901–2100 与 NASA 配对 195 次的中位差 `0.40 s`、p99 `2.37 s`、max `2.51 s` | +| **UT 层**(民用时刻,含 ΔT 口径) | 上面再叠一段地球自转换算 | 还额外含出版方选的 ΔT | 系统差 `4.8–5.9 s`,是口径差不是几何误差 | + +**UT 层的差异来自 ΔT 口径,不是几何**:NASA 目录/图页的 UT 时刻用其出版时采用的 ΔT 从 TD 换算(2024 年 `74 s`、2026 年 `75 s`),而本库实测 ΔT 约 `69.1–69.2 s`,两者相差 `4.8–5.9 s`,会整体进入任何 UT 层比较。 + +换算成地面量级用 `basic.DeltaTGroundShiftKM(ΔΔT, 纬度)`(即 `≈0.4651·|ΔΔT|·cos(纬度)` km,ΔT 只改自转相位、不动 TT 几何):实测 `DeltaTGroundShiftKM(5, 36) = 1.881 km`、`DeltaTGroundShiftKM(5.9, 24) = 2.507 km`。 + +**分类对比**: + +| 对照类型 | 样例 | 时间项 | 时标层级 | 对照结果 | +| --- | --- | --- | --- | --- | +| 全局日食 | 4 次现代日食 | 食甚 UT(包含ΔT 口径) | UT 层,`8 s` 阈值**已含**口径差;扣掉后见下一行 | 秒级对齐,在 `8 s` 阈值内(其中 `4.8–5.9 s` 是 ΔT 口径) | +| 全局日食 | 同上扣掉口径后 | 食甚 TD(本库 TT 对 NASA TD) | TT 层 | 中位差约 `0.2 s` | +| 站心日食 | 3 个本地观测点 | 食甚、初亏、复圆 | UT 层,但公开值多为整分钟 | 与公开分钟值一致(分辨率限制,不按秒级解读) | +| 站心日食 | 2 个中心食点 | 全食/环食持续时间 | **TT 层**:两端时刻相减,ΔT 自动抵消 | 秒级对齐,在 `5 s` 阈值内 | + +- **日食持续时间反应模型层面精度**:它是两个接触时刻之差,ΔT 口径自动抵消,所以 `5 s` 阈值反映的是几何与星历(对食带边缘最敏感),与 ΔT 无关。 +- **UT 世界时时刻的 `8 s` 差异主要是口径差异**:不能当成几何精度,它的构成是 `4.8–5.9 s`(ΔT 口径)+ 不到 `1 s`(TD 层几何残差)+ NASA 整秒打印的舍入,所以是个宽松上界而不是精度指标。 +- **更大范围的一致性对比结果**:1901–2100 与 NASA 五千年目录配对 195 次(NASA 页面缺 1986–2000、2088–2100 两段),类型普查 `A145/T139/H13/P155` 与 NASA 逐一相等、类型不一致 `0`;食甚 TD 差中位 `0.40 s`、p99 `2.37 s`、max `2.51 s`;gamma 差中位 `3.2e-5`、食分差中位 `4.2e-5`、带宽差中位 `0.5 km`(max `8.3 km`)、中心食时长差中位 `0.25 s`(max `0.59 s`)。 + +- **复现NASA的 UT 值**:用 `astro.SetDeltaT` 注入NASA采用的 ΔT(例如 2024 年 `74 s`)后取 UT,就能把ΔT差值从比较里去掉;注入只影响换算,不改变 TT 几何。 +- 此外,全局日食资料通常给到秒,适合直接做秒级对照;很多站心日食页面的初亏、复圆和本地食甚只公开到整分钟,因此这类资料只按分钟级核对,公开资料舍入造成的残差不按秒级误差解读。 +- 下面的 2009 上海洋山港和 2012 厦门示例只展示接口调用与 SVG 输出,未承诺地方接触时刻的发布级精度;需要逐项核对时可与 NASA/IMCCE local circumstances 比对。 + +### 2009 年长江大日食:上海洋山附近 + +2009-07-22 “长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,全食持续约 5 分 57 秒。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) + + // 上海洋山附近,东经为正,北纬为正,椭球高取 0 米。 + info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) + fmt.Println(ok, info.Type) // 是否命中本地日食;食型 + fmt.Println(info.HasSaros, info.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 + fmt.Println(info.PartialStart) // 初亏 + fmt.Println(info.CentralStart) // 全食开始 + fmt.Println(info.GreatestEclipse) // 食甚 + fmt.Println(info.CentralEnd) // 全食结束 + fmt.Println(info.PartialEnd) // 复圆 + fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // 全食阶段持续时间 + fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // 食分、遮掩比例、食甚太阳高度 + + // 同一天的中心路径,包含食甚点、中心线和南北界。 + path, _ := eclipse.SolarEclipseCentralPath( + date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 100}, + ) + fmt.Printf("greatest lon=%.4f lat=%.4f width=%.1fkm center=%d\n", + path.Greatest.Longitude, + path.Greatest.Latitude, + path.Greatest.WidthKM, + len(path.CenterLine), + ) +} +``` + +输出结果: + +```text +true total // 洋山站点当天命中日食,食型为日全食 +true {136 37 71 true} // Solar Saros 136,第 37/71 个成员,已核验 +2009-07-22 08:23:55.092397034 +0800 CST // 初亏 +2009-07-22 09:37:23.088684976 +0800 CST // 全食开始 +2009-07-22 09:40:20.87983489 +0800 CST // 食甚 +2009-07-22 09:43:19.723805487 +0800 CST // 全食结束 +2009-07-22 11:03:13.914820253 +0800 CST // 复圆 +5m56.635120511s // 全食持续时间 +magnitude=1.076997 obscuration=1.000000 altitude=57.293 // 食分、遮掩比例、食甚太阳高度 +greatest lon=144.1167 lat=24.2193 width=258.3km center=289 // 全局食甚点经纬度、食带宽度、中心线采样点数 +``` + +### 2012 年日环食:厦门示例 + +2012-05-21 日环食在我国东南沿海可见。下面用厦门做一个本地日环食示例,食甚时太阳高度约 9.6 度,环食阶段持续约 4 分 19 秒。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst) + + info, ok := eclipse.LocalSolarEclipseOnDate(date, 118.0894, 24.4798, 0) + fmt.Println(ok, info.Type) // 是否命中本地日食;食型 + fmt.Println(info.HasSaros, info.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 + fmt.Println(info.PartialStart) // 初亏 + fmt.Println(info.CentralStart) // 环食开始 + fmt.Println(info.GreatestEclipse) // 食甚 + fmt.Println(info.CentralEnd) // 环食结束 + fmt.Println(info.PartialEnd) // 复圆 + fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // 环食阶段持续时间 + fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // 食分、遮掩比例、食甚太阳高度 +} +``` + +输出结果: + +```text +true annular // 厦门站点当天命中日食,食型为日环食 +true {128 58 73 true} // Solar Saros 128,第 58/73 个成员,已核验 +2012-05-21 05:08:12.878718674 +0800 CST // 初亏 +2012-05-21 06:08:15.561088621 +0800 CST // 环食开始 +2012-05-21 06:10:25.180663168 +0800 CST // 食甚 +2012-05-21 06:12:34.80941087 +0800 CST // 环食结束 +2012-05-21 07:20:54.806806147 +0800 CST // 复圆 +4m19.248322249s // 环食持续时间 +magnitude=0.933289 obscuration=0.872354 altitude=9.565 // 食分、遮掩比例、食甚太阳高度 +``` + +### 生成日食 SVG + +现代城市观测示例可以使用 `2035-09-02` 北京日全食。按北京市区近似坐标(东经 `116.4074`,北纬 `39.9042`)计算,这次事件属于 `Solar Saros 145` 的第 `23/77` 个成员,全食阶段持续约 `1m33s`。 + +默认日食 SVG 头部会自动带上沙罗序列和全食/环食历时;更多文案可通过 `LocalSolarEclipseSVGOptions` 覆写: + +- `Title`:主标题 +- `SummaryText` / `GreatestText` / `MetaText`:标题下三行摘要 +- `OverviewTitle` / `PhasePanelsTitle` / `ContactsTitle`:总览、阶段视圆、接触时刻三个分区标题 +- `DirectionText` / `FooterNote`:底部方向说明和补充说明 + +```go +package main + +import ( + "fmt" + "os" + "time" + + "b612.me/astro/eclipse" + eclipsesvg "b612.me/astro/eclipse/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + + // 2009 长江口洋山附近日全食图。 + totalSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + 121.9850, 30.6167, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + }, + ) + fmt.Println(ok, len(totalSVG)) // 是否生成成功;SVG 字节长度 + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-yangshan-2009.svg", []byte(totalSVG), 0o644) + } + + // 2012 厦门日环食图。 + annularSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + time.Date(2012, 5, 21, 12, 0, 0, 0, cst), + 118.0894, 24.4798, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + }, + ) + fmt.Println(ok, len(annularSVG)) // 是否生成成功;SVG 字节长度 + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-xiamen-2012.svg", []byte(annularSVG), 0o644) + } + + // 2035 北京日全食图,同时打印沙罗序列号和全食持续时间。 + beijingDate := time.Date(2035, 9, 2, 12, 0, 0, 0, cst) + beijingInfo, ok := eclipse.LocalSolarEclipseOnDate(beijingDate, 116.4074, 39.9042, 0) + fmt.Println(ok, beijingInfo.Type) // 是否命中本地日食;食型 + fmt.Println(beijingInfo.HasSaros, beijingInfo.Saros) // 是否匹配沙罗序列;系列号、系列内序号、总成员数 + fmt.Println(beijingInfo.CentralEnd.Sub(beijingInfo.CentralStart)) // 全食持续时间 + + beijingSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + beijingDate, + 116.4074, 39.9042, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + }, + ) + fmt.Println(ok, len(beijingSVG)) // 是否生成成功;SVG 字节长度 + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-beijing-2035.svg", []byte(beijingSVG), 0o644) + } +} +``` + +输出结果: + +```text +true 13625 // 洋山日全食 SVG 生成成功,长度 13625 字节 +true 13542 // 厦门日环食 SVG 生成成功,长度 13542 字节 +true total // 北京站点当天命中日食,食型为日全食 +true {145 23 77 true} // Solar Saros 145,第 23/77 个成员,已核验 +1m33.329527974s // 北京市区近似坐标下的全食持续时间 +true 13589 // 北京日全食 SVG 生成成功,长度 13589 字节 +``` + +生成效果: + +![2009 长江口洋山日全食](../img/solar-eclipse-yangshan-2009.svg) + +![2012 厦门日环食](../img/solar-eclipse-xiamen-2012.svg) + +![2035 北京日全食](../img/solar-eclipse-beijing-2035.svg) + +## 月食 + +本库的月食判断与搜索能力统一放在 `eclipse` 包,返回结果会保持传入 `time.Time` 的时区。 +常用接口: + +- `LunarEclipseOnDate`:判断某个当地日期是否有月食 +- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`:搜索全局月食 +- `LocalLunarEclipseOnDate`:判断某地当天是否能看到可见月食 +- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`:搜索某地可见月食 +- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`:搜索某地可见月全食,返回 `(info, ok)` +- `GeometricLocalLunarEclipseOnDate`:判断某地当天是否发生几何月食,不做“月亮在地平线上方”的可见性过滤 +- `eclipse/svg.LunarEclipseSVG`:生成月食穿影图 SVG + +`LocalLunarEclipseInfo.Visibility` 按当地中天情况分为八类:`full`、`moonrise`、`moonset`、`rise-and-set`、`interrupted`、`penumbra-moonrise`、`penumbra-moonset` 与 `invisible`。两种 `penumbra-*` 状态要求本影阶段始终在地平线下,判定检查整个本影区间的月球高度极值,以覆盖接触时刻之间的极区掠射窗口;纯半影月食没有本影接触,不会返回这两类。`interrupted` 表示半影首尾可见而中途落到地平线下,即使本影阶段始终不可见,也仍属于此类。分类使用观测点当地中天,不使用全球食甚时刻。 + +`MarshalLunarEclipse` 导出 `visible-at-p1`、`visible-at-p4` 两个瞬时可见半球,以及 `visible-during-eclipse`(P1 到 P4 任意时刻可见)和 `visible-throughout-eclipse`(P1 到 P4 全程可见)两个时间包络。包络分别使用 `aggregation=union` 和 `aggregation=intersection`;没有全程可见区域时,后者为空 `MultiPolygon`。包络按经度列采样,列数由 `boundary_points` 推导并限制在 360..720,实际值写入 `longitude_points`。 + +同一接口还导出 `penumbra-moonset` 与 `penumbra-moonrise` 两条仅见半影带,分别对应 P1 可见/P4 不可见与 P4 可见/P1 不可见、且本影阶段始终不可见的区域;每条带包含 `phase=penumbral-only` 和自己的接触时刻。纯半影月食没有本影接触,不导出这两条带。 + +`MarshalLunarEclipseWithOptions` 通过 `LunarEclipseOptions` 控制输出内容和采样精度。`SkipRoles` 可跳过时间包络与仅见半影带;跳过两个时间包络时不会执行包络采样。`EnvelopeSweepSamples` 默认 48,显式值限制在 `[2, 192]`;`EnvelopeLongitudePoints` 默认 `max(360, boundaryPoints)`,显式值限制在 `[12, 720]`。 + +`MoonHorizon` 和 `MoonStateAt` 接收 UTC 儒略日;`HMoonHeight(jd, lon, lat, tz)` 接收该时区的民用墙上时间儒略日,并使用小时单位的时区偏移换算。仅当 `tz=0` 时,`jd` 才是 UTC;时标换算由库内部完成。 + +返回结果 `LunarEclipseInfo` 包含: + +- 月食类型 `Type` +- 沙罗序列信息 `HasSaros` / `Saros` +- 半影食分 `PenumbralMagnitude` +- 本影食分 `UmbralMagnitude` +- 半影始、初亏、食既、食甚、生光、复圆、半影终等时刻 + +其中 `Saros` 的含义与日食部分相同: + +- `Saros.Series`:`Verified=true` 时为 NASA 月食沙罗系列号,否则为推算的暂定系列号 +- `Saros.Member`:这次月食在该系列中的第几个成员,从 `1` 开始 +- `Saros.Count`:该沙罗系列的总成员数 +- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` + +月食同样优先使用 NASA 锚点,天文年份 `-3000` 至 `+6000` 年内查扩展表,范围外才实时演算。推算成员按 Danjon 与 Chauvenet 检出的事件并集计数,因此不随调用的月食模型或观测地点改变;极浅成员可能与 NASA 目录不同,`Verified` 保持 `false`。 +例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `月食沙罗序列125` 的第 `49/72` 个成员。 + +当前同时保留两套地影放大口径: + +- **Danjon(默认)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。 +- **Chauvenet(兼容口径)**:先取 `0.99834 × 地球赤道半径`,再把整组影半径统一乘 `51/50`。这与传统旧历表口径更接近,适合做兼容性回归和旧结果对照。 + +两者的直接差异通常表现为: + +- `Chauvenet` 给出的半影和本影都更大,半影食分通常比 `Danjon` 多约 `0.025`,本影食分通常多约 `0.005` +- 对边界月食而言,`Chauvenet` 更容易把结果推向“更深”的食型 +- 与 NASA 目录、现代星历软件或当前主流月食资料对照时,对应的是默认的 `Danjon` +- 兼容既有历史基线时,对应的是显式调用的 `Chauvenet` + +### 代码示例 + +```go +package main + +import ( + "b612.me/astro/eclipse" + "fmt" + "time" +) + +func main() { + date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) + + // 默认使用 Danjon,更接近 NASA + info := eclipse.ClosestLunarEclipse(date) + fmt.Println(info.Type) + fmt.Println(info.HasSaros, info.Saros) + fmt.Println(info.Maximum) + fmt.Println(info.PenumbralMagnitude, info.UmbralMagnitude) + fmt.Println(info.PenumbralStart) + fmt.Println(info.PartialStart) + fmt.Println(info.TotalStart) + fmt.Println(info.TotalEnd) + fmt.Println(info.PartialEnd) + fmt.Println(info.PenumbralEnd) + + // 如需兼容旧口径,可显式使用 Chauvenet + legacy := eclipse.ClosestLunarEclipseChauvenet(date) + fmt.Println(legacy.PenumbralMagnitude, legacy.UmbralMagnitude) + + // 判断某个本地自然日是否发生月食,返回时区与输入保持一致 + local := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) + today, ok := eclipse.LunarEclipseOnDate(local) + fmt.Println(ok) + fmt.Println(today.Type) + fmt.Println(today.Maximum) +} +``` + +输出结果: + +```text +total +true {125 49 72 true} +2028-12-31 16:52:05.603753328 +0000 UTC +2.2739938633996872 1.2461094682708755 +2028-12-31 14:03:54.239418804 +0000 UTC +2028-12-31 15:07:42.171904742 +0000 UTC +2028-12-31 16:16:27.306801974 +0000 UTC +2028-12-31 17:27:46.228030622 +0000 UTC +2028-12-31 18:36:32.270547151 +0000 UTC +2028-12-31 19:40:11.575520038 +0000 UTC +2.299608256177245 1.2511661731458574 +true +total +2029-01-01 00:52:05.603753328 +0800 CST +``` + +### 与 NASA 数据对照 + +对照值取自 NASA GSFC 的月食目录(食甚印为 **TD**、食分为目录值)。**UT 层的比较必须先扣掉时标口径**:目录与单次图页的 UT 时刻是用其出版时采用的 ΔT 从 TD 换算的(2026 年取 `75 s`、2024 年取 `74 s`),而实测 ΔT 只有约 `69.1–69.2 s`,两者相差 `4.8–5.9 s`。所以 UT 层的逐接触比较会整体带上这个时标差,它不是月球几何误差;TD 层(不含 ΔT)才是能单独检验星历与几何的一层。 + +| 样例 | 模型 | 半影食分误差 | 本影食分误差 | 食甚 TD 差(不含 ΔT) | 食甚 UT 差(含 ΔT 口径) | +|------|------|--------------|--------------|----------------------|--------------------------| +| 2026-03-03 月全食 | Danjon | -0.000067208 | -0.000069993 | +0.114 s | +5.99 s | +| 2026-03-03 月全食 | Chauvenet | +0.025599846 | +0.004935007 | +0.114 s | +5.99 s | +| 2026-08-28 月偏食 | Danjon | -0.000113694 | -0.000033624 | +0.090 s | +5.93 s | +| 2026-08-28 月偏食 | Chauvenet | +0.025567662 | +0.004957333 | +0.090 s | +5.93 s | +| 2024-03-25 半影月食 | Danjon | -0.000176555 | 见下说明 | +1.012 s | +5.81 s | +| 2024-03-25 半影月食 | Chauvenet | +0.026044973 | 见下说明 | +1.012 s | +5.81 s | + +以 `2026-03-03` 月全食为例,当前默认 `Danjon` 与 NASA 的逐项差异为: + +- 食型:一致,都是 `total` +- 食甚:本库 TD `11:34:52.113` vs NASA `11:34:52`,差 `+0.114 s`;换算到 UT 后本库比 NASA 图页晚 `5.99 s`,其中 `5.88 s` 来自 NASA 采用 `ΔT = 75 s`、本库实测 `ΔT = 69.12 s` +- 阶段历时:半影 `338.67 min` vs NASA `338.6`、本影 `207.17 min` vs `207.2`、全食 `58.31 min` vs `58.3`,都落在目录 0.1 分钟的印刷精度内 +- 半影食分:`2.183732792` vs NASA `2.1838`,误差 `-0.000067208` +- 本影食分:`1.150630007` vs NASA `1.1507`,误差 `-0.000069993` + +同一例中,`Chauvenet` 的结果为: + +- 食型:一致,都是 `total` +- 半影食分:`2.209399846` vs NASA `2.1838`,误差 `+0.025599846` +- 本影食分:`1.155635007` vs NASA `1.1507`,误差 `+0.004935007` + +`Chauvenet` 是保留给旧历表/旧口径兼容的影半径模型,半影和本影都会比默认 `Danjon` 更大;与 NASA 当前目录对照时,食分与接触时刻都会出现分钟/百分之一量级的偏移。这是模型口径差异,不代表默认月食接口的时间精度。 + +> 说明:NASA 目录的 TD 只印到整秒,因此 ±0.5 s 以内都属于印刷精度;上表 `2024-03-25` 的 `+1.012 s` 已经略超这一精度,是半影食甚这种极浅几何下两条链的差异。 + +> 说明:纯半影月食时,NASA 会给出负的 `umbral magnitude`,表示月面中心距本影边界还有余量;本库也保留这个负值,因此纯半影月食与 NASA 的本影食分已经属于同口径比较。 + +### 月食 SVG + +`LunarEclipseSVG`、`LunarEclipseDetailedSVG` 与 `LunarEclipseMapSVG` 的默认模型与后缀入口口径见下文[月食出图](#月食出图)。 + +默认月食 SVG 头部会自动带上沙罗序列;如果需要自定义更多文字,可以通过 `LunarEclipseSVGOptions` 覆写: + +- `Title`:主标题 +- `SummaryText` / `MaximumText` / `CoordinatesText` / `DurationText` / `MetaText`:标题下五行信息 +- `ContactsTitle`:接触时刻区标题 +- `DirectionText` / `FooterNote`:底部方向说明和补充说明 + +```go +package main + +import ( + "fmt" + "os" + "time" + + eclipsesvg "b612.me/astro/eclipse/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // 生成 2029-01-01 这次跨年月全食的穿影图。 + svg, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{ + Width: 960, + Height: 620, + Step: 10 * time.Minute, + Location: cst, + }, + ) + fmt.Println(ok, len(svg)) + if ok { + _ = os.WriteFile("doc/img/lunar-eclipse-2029-01-01.svg", []byte(svg), 0o644) + } +} +``` + +输出结果: + +```text +true 19831 +``` + +生成效果: + +![2029 跨年月全食穿影图](../img/lunar-eclipse-2029-01-01.svg) + +### 参考资料 + +- NASA 月食 decade 目录: +- NASA 2026-03-03 月全食图页: +- NASA 2026-08-28 月偏食图页: +- NASA 2024-03-25 半影月食图页: +- NASA 月食算法与历史说明: + +## 全球见食图与月食出图 + +`eclipse/svg` 的五个入口都返回 `(string, bool)`:第二个返回值是 `false` 表示这张图按当前参数画不出来(当天没有该事件、画布低于下限、或 UT1 口径配了非 UTC 时区),不是渲染错误。 + +| 入口 | 画面 | 画布建议 | +| --- | --- | --- | +| `LocalSolarEclipseSVG` | 站心日面视圆图(见前文“生成日食 SVG”) | 920×720 起 | +| `SolarEclipseMapSVG` | 全球见食图,可切四种投影 | 1200×800;正射球面 1000×1414 | +| `LunarEclipseSVG` | 月食穿影图 | 960×620 起 | +| `LunarEclipseMapSVG` | 月食世界可见区图 | 1200×800 | +| `LunarEclipseDetailedSVG` | 月食详细版式(示意与底图合成一页) | 1000×1414 或 1414×1000 | + +日食全球图包含偏食可见区、中心带与路径、阶段信息、日升日落线、太阳直射点、影轴进出地球点和接触点;定时半影及本影/反本影轮廓可按需打开。月食图绘制 `P1/P4` 可见半球和月出/月落过渡区,以 `P1`、食甚、`P4` 三刻地平圈近似整场可见区;它不包含 GeoJSON 的时间包络,因此两条地平线之间可能留有窄缝。 + +### 全球见食图 + +#### 等经纬投影 + +最小可用调用只给事件日期与展示时区,其余走默认(`TimeLabelStep` 默认 30 分钟): + +```go +solar, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1414, Height: 1000, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +fmt.Println(ok, len(solar)) +``` + +![2009 长江大日食全球见食图](../img/solar-eclipse-yangshan-2009-global.svg) + +另外两场等经纬示例是 `2012-05-21` 厦门日环食与 `2035-09-02` 北京日全食,两张图与长江口图同口径:不画瞬时半影/本影轮廓、画 30 分钟食甚等时线,并用空切片关闭食分等值线: + +```go +options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, +} +annular, ok := eclipsesvg.SolarEclipseMapSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), options) +total, ok := eclipsesvg.SolarEclipseMapSVG(time.Date(2035, 9, 2, 12, 0, 0, 0, cst), options) +fmt.Println(ok, len(annular), len(total)) +``` + +![2012 厦门日环食全球见食图](../img/solar-eclipse-xiamen-2012-global.svg) + +![2035 北京日全食全球见食图](../img/solar-eclipse-beijing-2035-global.svg) + +#### 正射球面图 + +`EclipseMapProjectionOrthographic` 给出 NASA 版式的**正射球面图**:视点取食甚点,只画朝向视点的半个地球,建议使用 `1000×1414` 画布。投影不改变底层地理结果。 + +```go +globe, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1000, Height: 1414, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionOrthographic, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +``` + +![2009 长江大日食正射球面图](../img/solar-eclipse-yangshan-2009-globe.svg) + +正射球面图在竖版(如 `1000×1414`)下最舒展;横版(`1200×800`)同样能出图,只是球面会明显缩小。 + +#### 南北极方位等距投影 + +`EclipseMapProjectionNorthPolar` 与 `EclipseMapProjectionSouthPolar` 把极点放在画布中心,适合食带整体落在高纬的事件。`EclipseMapProjectionAuto`(零值)会在适合时自动选极图,需要固定版式时才显式指定;投影只影响 SVG 表达,不改变底层 WGS84 地理结果。 + +`2012-05-21` 日环食的偏食可见区覆盖北极点,强制北极投影便于查看跨反经线的见食范围: + +```go +arctic, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2012, 5, 21, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionNorthPolar, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +``` + +![2012 日环食北极区全球见食图](../img/solar-eclipse-arctic-2012-global.svg) + +`2021-12-04` 日全食的食带整条落在南极区,南极投影是最自然的版式;下面这张与横版全球图同一口径,只画 30 分钟食甚等时线: + +```go +south, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2021, 12, 4, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionSouthPolar, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, + }, +) +``` + +![2021 南极日全食南极区全球见食图](../img/solar-eclipse-southpolar-2021-12-04.svg) + +#### 同一事件批量出四种投影 + +四个投影共用一份选项,只是 `Projection` 不同,适合一次生成后挑版式: + +```go +date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + } + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, options) + if !ok { + continue + } + _ = os.WriteFile("solar-eclipse-"+spec.name+".svg", []byte(svg), 0o644) +} +``` + +#### 图层与采样开关 + +| 选项 | 默认 | 语义 | +| --- | --- | --- | +| `TimeLabelStep` | 30 分钟 | 中心线时刻标记间隔;负值关闭 | +| `GreatestTimeStep` | 关闭 | 食甚时刻等时线;必须显式给正值,按展示时区对齐,小于一分钟按一分钟,单次最多 64 条 | +| `MagnitudeValues` | `0.2/0.4/0.6/0.8`(nil 时) | 食分等值线电平;显式空切片关闭,非空切片按给定电平绘制 | +| `PenumbralOutlineStep` | 关闭 | 瞬时半影轮廓采样间隔;零值或负值不画,正值小于一分钟按一分钟 | +| `CentralShadowStep` | 关闭 | 瞬时本影/反本影轮廓采样间隔;同上 | +| `PartialStep` | 2 分钟 | 偏食足迹时间步长;非正值与小于两分钟的正值都夹到两分钟 | +| `BoundaryPoints` | 180 | 每个瞬时偏食足迹的角向采样数;非正值用 180,正值限制在 12..1440 | +| `CentralStep` | 2 分钟 | 中心路径时间步长;非正值用两分钟,正值小于一秒按一秒,长事件会自动放大以保持基础路径不超过 30000 个采样点 | +| `TargetSpacingKM` | 150 km | 中心线地面间距上限;非正值用 150 km,NaN 与 +Inf 禁用加密 | + +半影/本影轮廓默认关闭。食分等值线在 `MagnitudeValues == nil` 时使用表中的默认电平;显式传入空切片可关闭。厦门、北京、北极区与南极点四张图不画瞬时轮廓,只画 30 分钟食甚等时线,并显式关闭食分等值线: + +```go +options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, +} +``` + +等时线不是逐点求食甚再描等值线,而是固定时刻后解 `∂(日月中心角距²)/∂t = 0` 的零集再沿曲线延拓,成本正比于曲线长度;每条支路止于地平线或偏食可见域边界,纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。 + +可降级的图层用 `data-source` 标注实际几何来源,取值词表见 [地图投影](map-geojson.md#地图投影)。 + +#### 画布下限与回落 + +- 日食图下限 **800×560**:宽度小于 800 或高度小于 560 时回落到 960×640;更窄的横版画布会让地图框与右栏数据网格水平重叠、面板行距压到 1 px 以下。 +- 偏食区填充由瞬时足迹的并集生成,因此 `PartialStep` 小于两分钟时会夹到两分钟;更密的请求不会提高结果精度。 +- 月食详细版式按 `Height` 推导版面:`640×420` 与 `800×600` 容不下示意图与底图的下限而返回 `false`,`1000×1414` 与 `1414×1000` 正常出图。 + +### 月食出图 + +#### 三个入口与影半径模型 + +`LunarEclipseSVG`(穿影图)、`LunarEclipseMapSVG`(世界可见区)与 `LunarEclipseDetailedSVG`(详细版式)默认共用同一套模型:以 Danjon 为主,极浅半影按核心默认口径回退 Chauvenet,与 `LunarEclipseOnDate` 一致;带 `Danjon` / `Chauvenet` 后缀的入口强制指定模型,`...Chauvenet` 适合与采用经典影半径的旧表对表。 + +```go +diagram, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{ + Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst, + }, +) +fmt.Println(ok, len(diagram)) +``` + +![2029 跨年月全食穿影图](../img/lunar-eclipse-2029-01-01.svg) + +#### 世界可见区图 + +底图区分全程可见、带食月出、带食月落与不可见四类区域:全程可见要求 `P1`、食甚、`P4` 三刻月球都在地平上(两端可见推不出中途可见,高纬下中天会让月亮在食甚前后落到地平下,这段归带食月落);带食月落覆盖 `P1` 可见而 `P4` 不可见的地点,以及只在食甚前后露出地平、两端都在地平下的极区透镜;带食月出覆盖 `P4` 可见而 `P1` 不可见的地点;三刻都不见才算不可见。`Projection` 可切极区与正射版式;下面这张图是默认输出,半影阶段已纳入分区(口径见下): + +```go +visible, ok := eclipsesvg.LunarEclipseMapSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}, +) +``` + +![2029 跨年月全食全球可见图](../img/lunar-eclipse-2029-01-01-global.svg) + +半影阶段默认纳入分区,画法同 NASA 月食世界图:补画 `U1`、`U2`、`U3`、`U4` 四条地平边界线,把只看得见半影的月出、月落带单独着色,图例里分列「半影月出」与「半影月落」(月出偏蓝、月落偏紫红),并在图上方的时刻行里补一行本影接触时刻。两条带排除**整个本影阶段的可见区**:本影区间内只要有任何时刻月亮在地平上,该地点就归带食月出/带食月落。掩膜按 15 分钟档位取样整球可见半球(圆盘分辨率约 0.035°),残余的掠射窗口深度约 0.05°(约 0.15 像素);深度更浅、只持续几分钟的窗口由站点 API 与 GeoJSON 精确判定。纯半影月食没有本影接触,开关两侧输出一致。`DisablePenumbralPhase: true` 退回只按三刻地平状态分的四类可见区,不画 `U1–U4` 与这两条半影带: + +```go +penumbral, ok := eclipsesvg.LunarEclipseMapSVG( + time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, DisablePenumbralPhase: true, + }, +) +``` + +`LunarEclipseDetailedSVGOptions` 有同名字段,控制详细版式下方那张底图,默认同样画半影阶段。 + +#### 详细版式 + +详细版式把两类月食图合成一页:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时/弧分比例尺/接触时刻三栏,以及下方的世界可见性底图与图例。地影几何由 `basic.LunarEclipseShadowGeometryAt` 给出,其中 **Gamma 用地球赤道半径、半影/本影半径用度**,换算成地球半径要乘以月球处的地球视差。 + +```go +detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, +) +``` + +![2029 跨年月全食详细版式](../img/lunar-eclipse-2029-01-01-detailed.svg) + +第二个事件 `2026-03-03` 月全食用同一批入口,穿影图与详细版式各一张: + +```go +diagram2026, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst}, +) +detailed2026, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2026, 3, 3, 12, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, +) +``` + +![2026 月全食穿影图](../img/lunar-eclipse-2026-03-03.svg) + +![2026 月全食详细版式](../img/lunar-eclipse-2026-03-03-detailed.svg) + +版面由 `Height` 推导:横版把数据块排在地图右侧两栏三行,竖版把数据块排在下方三栏两行。 + +### 时标口径与 UT1 + +四族图默认按 UTC 口径出图,并在图上或图注写明时标;`TimeScale: astro.TimeScaleUT1` 改成 UT1 读数并补上 `DUT1 = UT1−UTC` 差值,此时 `Location` 必须是 UTC,否则返回 `false`。几何一律按民用时刻算完再换口径,换算不会平移等时线。 + +要在程序里直接取 UT1 读数,用 `eclipse` 的 `...InUT1` 系列(`SolarEclipseInfoInUT1`、`LocalSolarEclipseInfoInUT1`、`LunarEclipseInfoInUT1`、`SolarEclipsePathInUT1`、`SolarEclipsePartialFootprintsInUT1`、`SolarEclipseGeocentricPanelInUT1`、`TimeLabelsInUT1`),它们只改时刻字段,零值原样保留。 + +完整约定见[时标声明](map-geojson.md#时标声明)。 + +```go +ut1, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, + TimeScale: astro.TimeScaleUT1, TimeLabelStep: 30 * time.Minute, + }, +) +``` + +![2009 长江大日食全球见食图(UT1 口径)](../img/solar-eclipse-yangshan-2009-global-ut1.svg) diff --git a/doc/manual/en/accuracy.md b/doc/manual/en/accuracy.md new file mode 100644 index 0000000..a3b8b26 --- /dev/null +++ b/doc/manual/en/accuracy.md @@ -0,0 +1,99 @@ +# Accuracy and performance + +[中文](../accuracy.md) | [README](../../../README.en.md) + +These tables describe the built-in models and previously measured comparisons. A sampled maximum is not an error bound for every date or site. + +External comparisons require matching the time scale, coordinate frame, observer height and refraction model. + +See [Time scales](timescale.md) for UTC, UT1 and TT. + +## Contents + +- [Sun and planets](#sun-and-planets) +- [Moon](#moon) +- [Lite lightweight chains](#lite-lightweight-chains) +- [Accuracy references](#accuracy-references) + +## Sun and planets + +The Sun and planets use built-in VSOP87 analytical terms. The current table entries cover roughly 4000 years around J2000. The table below lists truncation errors relative to the complete VSOP87 tables: + +| Target | Longitude / latitude | Distance | +| --- | --- | --- | +| Sun / Earth | about `0.1"` | about `0.1 x 10^-6 AU` | +| Mercury, Venus | about `0.2"` | about `0.2 x 10^-6 AU` | +| Mars | about `0.5"` | about `1 x 10^-6 AU` | +| Jupiter | about `0.5"` | about `3 x 10^-6 AU` | +| Saturn | about `0.5"` | about `5 x 10^-6 AU` | +| Uranus | about `1"` | about `20 x 10^-6 AU` | +| Neptune | about `1"` | about `40 x 10^-6 AU` | + +This is suitable for ordinary calendrical work, observing support, outreach, and personal research; spacecraft navigation, precise occultation prediction, and strict dynamical integration fall outside that range and usually need a professional ephemeris such as JPL DE. + +## Moon + +The Moon uses a built-in truncated ELP2000/82-style analytical series. The package stays lightweight and does not require external ephemeris files. + +It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, amateur occultation prediction, and ordinary positional work; extremely high-precision lunar laser ranging, long-term physical libration, and professional occultation work fall outside that range and are best served by JPL or a dedicated lunar ephemeris. + +## Lite lightweight chains + +`lite/sun` and `lite/moon` are independent approximation chains. They do not depend on the VSOP87 or ELP2000/82 series used by `sun` / `moon`, and are intended for CPU- or memory-constrained environments. + +- `lite/sun`: simplified true/apparent solar longitude formulas plus lightweight equatorial conversion +- `lite/moon`: Schlyter-style lunar approximation with about 15 perturbation terms plus lightweight topocentric correction +- rise/set search: fixed-step scanning plus bisection, without the high-precision nutation iteration used by the main chain +- zero heap allocation in the computation path (0 allocs/op); against the main chain, pure evaluation entry points such as position and phase run about `7.5-27.1x` faster, and rise/set entry points about `0.9-3.5x` + +Capability boundaries: + +| Package | Position model | Rise/set search | Main use | +| --- | --- | --- | --- | +| `lite/sun` | simplified true/apparent solar longitude plus lightweight equatorial conversion | `30 min` scan plus bisection | sunrise/sunset, solar altitude, watch faces, frontend refresh loops | +| `lite/moon` | Schlyter / vFPS lunar approximation plus lightweight topocentric correction | `15 min` scan plus bisection | moonrise/moonset, lunar phase, lunar age, lightweight lunar observing helpers | + +Error against the `sun` / `moon` packages (year 2026, 8 observing sites; rise/set sampled every 7 or 15 days, phase/age every 6 hours): + +| Capability | Mean absolute error | P95 | Max absolute error | Notes | +| --- | --- | --- | --- | --- | +| `lite/sun` sunrise | `0.02 min` | `0.04 min` | `0.31 min` | no event-existence mismatch in the sample set | +| `lite/sun` sunset | `0.02 min` | `0.06 min` | `0.35 min` | `2` high-latitude samples differ only in day-attribution semantics across midnight | +| `lite/moon` moonrise | `0.28 min` | `0.57 min` | `1.44 min` | no event-existence mismatch in the sample set | +| `lite/moon` moonset | `0.36 min` | `0.86 min` | `1.24 min` | `1` high-latitude sample differs on whether the moonset belongs to the same civil day | +| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | compared with `moon.Phase` | +| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | about 4.3 min mean, 14.4 min P95, 20.2 min max | +| `lite/moon` geocentric longitude | `2.41'` | `6.82'` | `9.91'` | relative to the main lunar chain | +| `lite/moon` geocentric latitude | `0.87'` | `1.83'` | `2.92'` | relative to the main lunar chain | + +`Go testing.Benchmark` reference values (single-machine measurements for comparison; absolute values vary with hardware): + +| Entry point | Main chain | `lite` | Speedup | Main-chain allocation | `lite` allocation | +| --- | --- | --- | --- | --- | --- | +| `Sun ApparentRaDec` | `6.031 µs/op` | `222.4 ns/op` | `27.1x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun Altitude` | `6.127 µs/op` | `672.3 ns/op` | `9.1x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun RiseTime` | `101.874 µs/op` | `29.168 µs/op` | `3.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon ApparentRaDec` | `16.897 µs/op` | `1.070 µs/op` | `15.8x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Phase` | `15.441 µs/op` | `935.6 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Altitude` | `9.714 µs/op` | `1.294 µs/op` | `7.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon RiseTime` | `121.772 µs/op` | `132.312 µs/op` | `0.9x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | + +The main-chain/`lite` gap depends on the scenario: pure evaluation entry points (position, phase) run about `7.5-27.1x` faster in `lite`, while rise/set entry points narrow to `0.9-3.5x` because both sides perform a time search; `Moon RiseTime` is close to parity. + +Use the main `sun` / `moon` chains for eclipses, physical libration, or high-latitude edge cases. + +## Accuracy references + +The following entry points have been checked against JPL Horizons, NASA GSFC, and other public references; use them to judge the order of magnitude to expect: + +- apparent diameters of the Sun, planets, and Moon: maximum differences from the external baseline range from `0.000002"` to `0.194598"` depending on the body; the Moon is the most sensitive because of parallax and distance changes +- solar physical ephemerides `P/B0/L0`: maximum differences are about `0.003349° / 0.003986° / 0.047394°` +- planetary rise, transit, and set: checked against JPL Horizons rise/transit/set events; that baseline is generated at a 1-minute step, and current results align with the Horizons event times at the minute level +- Moon rise/set: `aero=true` uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing. + + Across 14 sea-level events at 7 sites, the current mean/maximum differences against JPL Horizons DE441 are about `0.30s / 0.75s`. +- Moon rise/set with other conventions: mean/maximum differences are about `38.77s / 76.22s` against MET Norway's fixed `-0.8333°` convention (Skyfield 1.53 + DE440s). Against IMCCE Miriade, whose horizon convention is not exposed, the mean is about `2m13.46s`; the low-elevation `61°N` sample reaches about `6m41.82s`. +- Earth perihelion and aphelion: maximum time difference about `1m28.84s`, maximum distance difference about `0.000000039837 AU` +- main-chain lunar position: the current algorithm is a truncated ELP2000/82-style analytical series; across four JPL/Horizons `JDTT` samples in year `-2000`, the maximum difference from JPL/Horizons is about `219.6"` in longitude, `25.8"` in latitude, and `34.3 km` in distance +- Moon perigee and apogee: maximum time difference about `15m53.45s`, maximum distance difference about `39.758 km` +- maximum lunar declination: maximum time difference about `2.43s`, maximum declination difference about `0.00006431°` diff --git a/doc/manual/en/calendar.md b/doc/manual/en/calendar.md new file mode 100644 index 0000000..0399874 --- /dev/null +++ b/doc/manual/en/calendar.md @@ -0,0 +1,693 @@ +# Calendar and Solar Terms + +[中文](../calendar.md) | [Back to README](../../../README.en.md) + +The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from 721 BCE through 3000 CE; 104 BCE is the calendar-table switch point, and beyond 3000 CE the result is pure theoretical extrapolation computed on the fly. + +The calendar is lunisolar in the strict sense, but public function names use `Lunar` rather than the more academic `Lunisolar`, for readability and convention. + +For historical input, Chinese era names stay in Chinese. This is part of the API surface, because historical Chinese dates are normally written that way. + +## Contents + +- [Converting a date and calculating a solar term](#converting-a-date-and-calculating-a-solar-term) +- [API Reference](#api-reference) + - [Gregorian and Lunisolar Conversion](#gregorian-and-lunisolar-conversion) + - [Julian-only Leap Days and Multiple Candidates](#julian-only-leap-days-and-multiple-candidates) + - [Solar Terms and Pentads](#solar-terms-and-pentads) + - [Ancient Calendar Systems](#ancient-calendar-systems) + - [Ganzhi and Era Names](#ganzhi-and-era-names) + - [Structure and JSON](#structure-and-json) +- [Usage examples](#usage-examples) + - [Gregorian to lunisolar and the sexagenary day](#gregorian-to-lunisolar-and-the-sexagenary-day) + - [Solar-term instants and calendrical dates](#solar-term-instants-and-calendrical-dates) + - [Custom time zones and Julian-only leap days](#custom-time-zones-and-julian-only-leap-days) + - [Several candidates for a dual-numbering reform](#several-candidates-for-a-dual-numbering-reform) + - [Ancient calendars and era-name conventions](#ancient-calendars-and-era-name-conventions) +- [Calendar notes](#calendar-notes) +- [Usage notes](#usage-notes) + - [1. One Gregorian date may map to several lunisolar dates](#1-one-gregorian-date-may-map-to-several-lunisolar-dates) + - [2. One lunisolar date may map to several Gregorian dates](#2-one-lunisolar-date-may-map-to-several-gregorian-dates) + - [3. Gregorian calendar rules](#3-gregorian-calendar-rules) + - [4. Time zone](#4-time-zone) + - [5. Go-specific note](#5-go-specific-note) + - [6. Julian-only leap days (for example 700-02-29)](#6-julian-only-leap-days-for-example-700-02-29) + - [7. Several Gregorian candidates for one lunar date](#7-several-gregorian-candidates-for-one-lunar-date) +- [Calendar conversion](#calendar-conversion) + - [Gregorian to lunar](#gregorian-to-lunar) + - [Lunar to Gregorian](#lunar-to-gregorian) + - [Code example](#code-example) +- [Solar terms](#solar-terms) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Units and Parameter Conventions](#units-and-parameter-conventions) + - [Time Scale](#time-scale) + - [Zero Values and Out-of-Range Input](#zero-values-and-out-of-range-input) + - [Accuracy and Applicability](#accuracy-and-applicability) + - [Related Manuals](#related-manuals) + +## Converting a date and calculating a solar term + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/calendar" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 2, 17, 0, 0, 0, 0, cst) + solar, err := calendar.SolarToLunar(date) // Gregorian to lunisolar + if err != nil { + log.Fatal(err) + } + fmt.Println(solar.Lunar().LunarYear(), solar.Lunar().MonthDay()) + fmt.Println(calendar.JieQi(2026, calendar.JQ_立春)) // solar-term instant in UTC+08:00 +} +``` + +Handle conversion errors before reading the result. `Time.Lunar()` returns the first lunar-calendar candidate; `Time.Lunars()` returns all candidates for periods with concurrent calendars. Solar terms use Beijing time. + +## API Reference + +### Gregorian and Lunisolar Conversion + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `SolarToLunar` | Gregorian `time.Time` to lunisolar | the input is moved to UTC+8 before the civil day is taken; returns a `Time` that may hold several parallel-regime candidates | +| `SolarToLunarByYMD` | integer Gregorian year/month/day to lunisolar | never goes through `time.Time`, so a Julian-only leap day survives; negative years are BCE | +| `LunarToSolar` | lunisolar description string to Gregorian candidates | accepts `year+month+day`, `era name+month+day`, and the `... + ganzhi day` forms; returns `[]Time` | +| `LunarToSolarByYMD` | lunisolar year/month/day plus leap flag to Gregorian | `month` counts the distance from the first month, which is 1; `leap` is the leap-month flag | +| `LunarToSolarSingle` | the same, returning one `Time` | deprecated, identical to `LunarToSolarByYMD` | +| `Solar` | lunisolar year/month/day to Gregorian under a custom zone | `timezone` is in hours; the returned zone is always named `CST` with the offset of the supplied `timezone` | +| `Lunar` | Gregorian year/month/day to lunisolar under a custom zone | returns `(lunisolar year, month, day, leap flag, text description)`; `timezone` is in hours | +| `Time` / `LunarTime` | Gregorian-plus-lunisolar carriers | fields and JSON keys are listed under "Structure and JSON" | + +```go +// Prelude: date is a time.Time; the string entry point may return several parallel-regime results. +solar, _ := calendar.SolarToLunar(date) +fmt.Println(solar.LunarDesc(), solar.Lunar().MonthDay()) + +lunar, _ := calendar.LunarToSolar("元丰六年十月十二日") +for _, v := range lunar { + fmt.Println(v.Time().Format("2006-01-02"), v.Eras()) +} + +old, _ := calendar.LunarToSolarByYMD(-202, 1, 1, false) // negative years are BCE +fmt.Println(old.Solar().Format("2006-01-02")) +``` + +### Julian-only Leap Days and Multiple Candidates + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `Time.JulianOnly` | whether the primary lunisolar day exists only in the Julian calendar | boolean; before 1582 this covers 29 February of years divisible by 100 but not 400 | +| `Time.JD` | exact Julian day of the primary lunisolar day | for a Julian-only leap day it is one day earlier than `Solar()`, otherwise equal to `Date2JD(Solar())` | +| `Time.SolarCandidates` | every legal Gregorian candidate of that lunisolar day | the first element always equals `Solar()`; with a single candidate the slice holds only `Solar()` | +| `LunarTime.JulianOnly` / `LunarTime.JD` | the same information on one candidate | same convention as the methods on `Time` | +| `Date2JD` | `time.Time` to Julian day | reads the year/month/day/hour/minute/second fields directly, with no time-zone conversion | +| `JD2Date` | Julian day to `time.Time` | the result lands in the local zone of the host (the underlying helper uses `Local`) | +| `NowJD` | current Julian day | same convention as `Date2JD` | +| `Time.Add` | time offset | crossing the 1582 reform gap moves along the Julian-day axis and skips the ten missing days | + +```go +// The integer entry point keeps the leap day; 700-02-29 does not exist in Go's time.Time. +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.JulianOnly(), julian.JD(), julian.Solar().Format("2006-01-02")) + +// A dual-numbering reform yields several legal Gregorian candidates, Solar() still first. +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02"), len(res.SolarCandidates())) + +fmt.Println(calendar.Date2JD(time.Date(2026, 2, 17, 0, 0, 0, 0, time.UTC)), calendar.JD2Date(2461088.5)) +``` + +### Solar Terms and Pentads + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `JieQi` | solar-term instant | Beijing time; `term` is the apparent solar longitude in degrees | +| `CalendricalJieQi` | calendar-compatible solar-term date | the date the term falls on under the default calendar, fixed at 00:00 Beijing time | +| `CalendricalJieQiWithCalendar` | calendar-compatible date with an explicit ancient calendar | the Chunqiu calendar and years without calendrical term data return an error | +| `WuHou` | pentad instant | Beijing time; the 72 pentads step by 5°, and the argument matches `JieQi` | +| `JQ_春分` … `JQ_惊蛰` | solar-term constants | apparent solar longitude on a 15° grid, usable directly as the `term` of `JieQi`/`WuHou` | + +There are 24 constants, ordered by increasing longitude: + +| Constant | Longitude | Constant | Longitude | Constant | Longitude | +| --- | --- | --- | --- | --- | --- | +| `JQ_春分` | 0° | `JQ_清明` | 15° | `JQ_谷雨` | 30° | +| `JQ_立夏` | 45° | `JQ_小满` | 60° | `JQ_芒种` | 75° | +| `JQ_夏至` | 90° | `JQ_小暑` | 105° | `JQ_大暑` | 120° | +| `JQ_立秋` | 135° | `JQ_处暑` | 150° | `JQ_白露` | 165° | +| `JQ_秋分` | 180° | `JQ_寒露` | 195° | `JQ_霜降` | 210° | +| `JQ_立冬` | 225° | `JQ_小雪` | 240° | `JQ_大雪` | 255° | +| `JQ_冬至` | 270° | `JQ_小寒` | 285° | `JQ_大寒` | 300° | +| `JQ_立春` | 315° | `JQ_雨水` | 330° | `JQ_惊蛰` | 345° | + +```go +// term is the apparent solar longitude in degrees; a raw number works too, 0 being the March Equinox. +fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) +fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) +fmt.Println(calendar.WuHou(2020, calendar.JQ_立春+5)) + +termDate, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) +fmt.Println(termDate, err) +``` + +### Ancient Calendar Systems + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `AncientCalendarSystem` | ancient-calendar enumerator | a string type | +| `AncientCalendarDefault` | default routing (zero value) | selects by year; the explicit-calendar entry points fall back to the default implementation | +| `AncientCalendarChunqiu` / `AncientCalendarZhou` / `AncientCalendarLu` | pre-Qin Chunqiu, Zhou, and Lu calendars | values `chunqiu`/`zhou`/`lu` | +| `AncientCalendarHuangdi` / `AncientCalendarYin` | pre-Qin Huangdi and Yin calendars | values `huangdi`/`yin` | +| `AncientCalendarXia1` / `AncientCalendarXia2` | Xia calendar, winter-solstice and rain-water editions | values `xia1`/`xia2` | +| `AncientCalendarZhuanxu` | pre-Qin Zhuanxu calendar | value `zhuanxu` | +| `AncientCalendarQinHan` | Qin/Han Zhuanxu calendar | value `qin_han` | +| `SolarToLunarWithCalendar` | Gregorian `time.Time` to lunisolar with an explicit calendar | the input is moved to UTC+8 before the civil day is taken | +| `SolarToLunarByYMDWithCalendar` | integer Gregorian year/month/day to lunisolar with an explicit calendar | never goes through `time.Time` | +| `LunarToSolarWithCalendar` | lunisolar description to Gregorian with an explicit calendar | an era-name description works only when the ancient era table matches, otherwise it errors | +| `LunarToSolarByYMDWithCalendar` | lunisolar year/month/day plus leap flag with an explicit calendar | years without ancient-calendar data return an error | + +```go +// The zero value AncientCalendarDefault routes by year; an explicit calendar needs years that have data. +zhuanxu, err := calendar.SolarToLunarByYMDWithCalendar(-300, 1, 5, calendar.AncientCalendarZhuanxu) +fmt.Println(zhuanxu.Lunar().CalendarSystem(), zhuanxu.Lunar().CalendarName(), err) + +back, err := calendar.LunarToSolarByYMDWithCalendar( + zhuanxu.Lunar().LunarYear(), zhuanxu.Lunar().LunarMonth(), zhuanxu.Lunar().LunarDay(), + zhuanxu.Lunar().IsLeap(), calendar.AncientCalendarZhuanxu) +fmt.Println(back.Solar().Format("2006-01-02"), err) +``` + +### Ganzhi and Era Names + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `GanZhiOfYear` | sexagenary year name | the argument is a Gregorian year; returns a two-character name such as `甲子` | +| `GanZhiOfDay` | sexagenary day name | reads the year/month/day fields of `t` and evaluates the day at 00:00 Beijing time | +| `LunarTime.GanZhiYear` / `GanZhiMonth` / `GanZhiDay` | sexagenary year, month, and day of one candidate | a leap month inherits the month name of the preceding month | +| `Era` | era-table record | fields `Year`/`Emperor`/`Nianhao`/`OtherNianHaoStart`/`Dynasty`/`Offset` | +| `EraDesc` | era description | fields `YearOfNianHao`/`Emperor`/`Nianhao`/`Dynasty` | +| `EraDesc.String` | era-name string | the first year reads `元年` and later years use Chinese numerals plus `年` | +| `Time.Eras` | era information of every candidate | returns `[]EraDesc` | +| `LunarTime.Eras` | era information of one candidate | returns `[]EraDesc` | +| `ERR_NIANHAO_NOT_FOUND` | sentinel for an unknown era name | returned when the text entry point cannot resolve the era name | + +```go +fmt.Println(calendar.GanZhiOfYear(2026)) +fmt.Println(calendar.GanZhiOfDay(time.Date(2026, 2, 17, 0, 0, 0, 0, time.UTC))) + +res, _ := calendar.LunarToSolarByYMD(1083, 10, 12, false) +fmt.Println(res.Lunar().GanZhiYear(), res.Lunar().GanZhiMonth(), res.Lunar().GanZhiDay()) +for _, era := range res.Eras() { + fmt.Println(era.Dynasty, era.Emperor, era.String()) +} +``` + +### Structure and JSON + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `Time` | Gregorian-plus-lunisolar carrier | `Solar()`/`Time()` give the Gregorian side, `Lunar()`/`Lunars()` the lunisolar candidates, `LunarInfo()` the structured records | +| `LunarTime` | one lunisolar candidate | `LunarYear`/`LunarMonth`/`LunarDay`/`IsLeap` plus `MonthDay`, ganzhi, zodiac, and calendar name | +| `LunarInfo` | structured lunisolar record | fields and JSON keys are listed below | +| `Time.Solar` / `Time.Time` | the stored Gregorian `time.Time` | no further zone or calendar conversion; the two are synonyms | +| `Time.Lunar` / `Time.Lunars` | first / all lunisolar candidates | with no result `Lunar` returns a zero-value `LunarTime` | +| `Time.LunarInfo` / `LunarTime.LunarInfo` | structured record slice | several records when parallel era names exist | +| `Time.LunarDesc` / `Time.LunarDescWithEmperor` / `Time.LunarDescWithDynasty` / `Time.LunarDescWithDynastyAndEmperor` | description family over all candidates | all return `[]string` | +| `LunarTime.LunarDesc` / `LunarDescWithEmperor` / `LunarDescWithDynasty` / `LunarDescWithDynastyAndEmperor` | description family over one candidate | all return `[]string` | +| `LunarTime.LunarYear` / `LunarMonth` / `LunarDay` / `IsLeap` | lunisolar year, month, day, and leap flag | `LunarMonth` counts the distance from the first month | +| `LunarTime.MonthDay` | month-day description | month 11 is written `冬月` and month 12 `腊月` | +| `LunarTime.ShengXiao` / `LunarTime.Zodiac` | Chinese zodiac | synonyms, computed from the lunisolar year modulo 12 | +| `LunarTime.CalendarSystem` / `LunarTime.CalendarName` | the ancient calendar and its Chinese name | empty strings for default routing and the modern range | + +`LunarInfo` JSON keys and fields: + +| JSON key | Field | Convention | +| --- | --- | --- | +| `solarDate` | `SolarDate` | Gregorian instant | +| `lunarYear` / `lunarYearChn` | `LunarYear` / `LunarYearChn` | the civil-year mapping of the lunisolar year and its Chinese numeral form | +| `lunarMonth` / `lunarDay` | `LunarMonth` / `LunarDay` | the month counts the distance from the first month; the day is within `[1,30]` | +| `isLeap` | `IsLeap` | leap-month flag | +| `lunarMonthDayDesc` | `LunarMonthDayDesc` | month-day description, month 11 as `冬月` and month 12 as `腊月` | +| `ganzhiYear` / `ganzhiMonth` / `ganzhiDay` | `GanzhiYear` / `GanzhiMonth` / `GanzhiDay` | sexagenary year, month, and day | +| `calendarSystem` / `calendarName` | `CalendarSystem` / `CalendarName` | ancient calendar and its name; empty strings for default routing | +| `jd` | `JD` | exact Julian day of the lunisolar day | +| `julianOnly` | `JulianOnly` | carries `omitempty`; only a Julian-only leap day emits `true` | +| `dynasty` / `emperor` / `nianhao` / `yearOfNianhao` / `eraDesc` | `Dynasty` / `Emperor` / `Nianhao` / `YearOfNianhao` / `EraDesc` | dynasty, emperor, era name, and era regnal year | +| `lunarWithNianhaoDesc` | `LunarWithEraDesc` | note that the field name differs from the JSON key | +| `chineseZodiac` | `ChineseZodiac` | Chinese zodiac | + +```go +// Prelude: import "encoding/json". +res, _ := calendar.SolarToLunarByYMD(2026, 2, 17) +for _, info := range res.LunarInfo() { + fmt.Println(info.LunarYear, info.LunarMonthDayDesc, info.GanzhiDay, info.ChineseZodiac, info.JD) +} +data, _ := json.Marshal(res.LunarInfo()[0]) +fmt.Println(string(data)) +``` + +## Usage examples + +### Gregorian to lunisolar and the sexagenary day + +```go +solar, _ := calendar.SolarToLunar(date) // date = 2026-02-17 00:00:00 CST +lunar := solar.Lunar() +fmt.Println(lunar.MonthDay(), lunar.LunarYear(), lunar.GanZhiDay()) +fmt.Println(calendar.GanZhiOfDay(date)) +``` + +```text +正月初一 2026 壬戌 +壬戌 +``` + +`SolarToLunar` and `GanZhiOfDay` share one day-counting convention, so the last line equals `GanZhiDay()`. + +Parallel historical regimes can give one Gregorian day several lunisolar candidates (`Lunar()` returns the first and `Lunars()` all of them), see [Usage notes](#usage-notes); a leap month is marked separately by `IsLeap()`, see [Parameter and result conventions](#parameter-and-result-conventions). + +### Solar-term instants and calendrical dates + +```go +fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) // modern astronomical instant +termDate, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) // calendrical date +fmt.Println(termDate, err) +``` + +```text +2020-02-04 17:03:20.471614301 +0800 CST +1582-12-22 00:00:00 +0800 CST +``` + +`JieQi` returns the exact instant from modern astronomy while `CalendricalJieQi` returns the date the calendar assigns (always 00:00 Beijing time); the two are not interchangeable, and calendrical terms exist only for years that have table data (2026 returns an error). See [Solar terms](#solar-terms) and [Calendar notes](#calendar-notes). + +### Custom time zones and Julian-only leap days + +```go +fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0), calendar.Solar(1985, 1, 1, false, 7.0)) +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) // a Julian-only leap day +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JD()) +``` + +```text +1985-02-20 00:00:00 +0800 CST 1985-01-21 00:00:00 +0700 CST +0700-03-01 true 1.9767915e+06 +``` + +New moons and solar terms are computed in Beijing time by default, so under UTC+7 one lunisolar day maps to a different Gregorian date (here the first day of month 1 moves from 20 February to 21 January, because a shifted new moon or solstice reorders the months); the `timezone` of `Solar`/`Lunar` is in hours. 700-02-29 exists only in the Julian calendar: `Solar()` always reports the following day and `JD()` holds the exact date, see [Usage notes](#usage-notes). + +### Several candidates for a dual-numbering reform + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +for _, c := range res.SolarCandidates() { + fmt.Println(c.Format("2006-01-02")) +} +``` + +```text +0700-12-15 +0700-10-17 +``` + +The dual-numbering reforms (Wang Mang, Emperor Ming of Wei, Wu Zetian, Emperor Suzong of Tang) and the Taichu handoff give one lunisolar day two legal Gregorian days; the first line is the default candidate `Solar()` and `SolarCandidates()` returns them all, while a single-element slice is returned outside those windows, see [Usage notes](#usage-notes). + +### Ancient calendars and era-name conventions + +```go +zhuanxu, err := calendar.SolarToLunarByYMDWithCalendar(-300, 1, 5, calendar.AncientCalendarZhuanxu) +fmt.Println(zhuanxu.Lunar().CalendarSystem(), zhuanxu.Lunar().CalendarName(), err) +era, _ := calendar.LunarToSolarByYMD(1083, 10, 12, false) +fmt.Println(era.LunarDescWithEmperor()) +``` + +```text +zhuanxu 颛顼历 +[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] +``` + +The explicit-calendar entry points work only for years that have data, and `AncientCalendarDefault` is the zero value that falls back to routing by year; `CalendarSystem()`/`CalendarName()` are empty under default routing and in the modern range, and era names come from `Eras()`/`LunarDescWithEmperor()`, see [Calendar notes](#calendar-notes) and [Ancient Calendar Systems](#ancient-calendar-systems). + +## Calendar notes + +- **Default routing**: the package selects by year automatically. + + The pre-Qin range uses reconstructed Chunqiu and ancient-six-calendar systems, `-220..-104` uses the Qin/Han Zhuanxu calendar, `-103..1912` uses calendar tables, and `1913` onward uses the modern algorithm. +- **Explicit ancient calendars**: use APIs such as `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` when a specific ancient calendar system is required. +- **Data sources**: ancient-calendar support mainly references 《寿星天文历》; [Professor ytliu0's ChineseCalendar data](https://ytliu0.github.io/ChineseCalendar/index_simp.html) is used for validation. + + The modern range follows GB/T 33661-2017 and uses VSOP87/ELP computations for solar terms and new moons. +- **Solar terms**: `JieQi` returns modern astronomical solar-term instants; `CalendricalJieQi` returns calendar-compatible solar-term dates. + +## Usage notes + +### 1. One Gregorian date may map to several lunisolar dates +In periods when several regimes coexisted with different calendars (the Three Kingdoms, for example), one Gregorian date can map to several lunisolar dates. The package returns every conversion it can determine. + +### 2. One lunisolar date may map to several Gregorian dates +Rival calendars are not the only cause: one regime can produce the same effect during a calendar reform. After Wu Zetian's reform, for example, the third year of Shengli had two twelfth months. + +### 3. Gregorian calendar rules +All computation goes through Julian Days. The Gregorian side follows these rules: +- after `1582-10-15`: Gregorian calendar +- before `1582-10-04`: Julian calendar +- before year 8 CE: proleptic Julian calendar +- the day after `1582-10-04` is `1582-10-15` +- `1582-10-05` through `1582-10-14` do not exist and the corresponding entry points reject them +- year numbering: year `0` is 1 BCE, year `-1` is 2 BCE, and so on + +### 4. Time zone +The package targets the Chinese calendar, so solar terms and new moons are computed in Beijing time (UTC+8) by default. Applying Chinese lunisolar rules in another time zone can shift dates. + +For exploration and research, the lower-level `Solar` and `Lunar` methods convert between the Gregorian and lunisolar calendars under a **custom time zone**, following the **current Chinese calendar algorithm (GB/T 33661-2017)**. +For standard conversions in Beijing time, use the wrapped `SolarToLunar` and `LunarToSolar` methods. + +**Example**: the calendar rules require the winter solstice to fall in the eleventh lunisolar month. For the 1984 winter solstice: +```go +ws := calendar.JieQi(1984, 270) +fmt.Println(ws) +fmt.Println(moon.ClosestShuoYue(ws)) +``` + +| Event | UTC+8 | UTC+7 | +| --- | --- | --- | +| Winter solstice | 1984-12-22 | 1984-12-21 | +| New moon | 1984-12-22 | 1984-12-22 | + +In UTC+8 (China), 1984-12-22 is both the winter solstice and the new moon, so it is the first day of the eleventh lunisolar month; in UTC+7 the solstice moves up to 12-21, which makes 12-22 the first day of the twelfth month. + +Chinese New Year can differ by a month as well. Under this calendar convention, the first day of the first lunisolar month of 1985 maps to February 20 in UTC+8 and January 21 in UTC+7: +```go +fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0)) +fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) +``` + +### 5. Go-specific note + +⚠️ Go's standard-library `time.Time` differs from this package in calendar handling: + +- Before 1582-10-15 Go uses the proleptic Gregorian calendar rather than the Julian calendar. Without `Add`, it generally works as expected. +- So **before 1582-10-15, `time.Time.Weekday()` does not agree with this package**: + for 1582-10-04 this package reports Thursday, Go reports Monday. + +#### Weekdays and date arithmetic +To obtain the weekday this package uses: + +```go +// date should be the local midnight of the target day. +weekday := int(calendar.Date2JD(date)+1.5) % 7 +// 0 means Sunday, 1 means Monday, ..., 6 means Saturday. +``` +Using `Add` or `AddDate` on a `time.Time` before 1582 runs through the proleptic Gregorian calendar and lands one day away from the Julian calendar whenever it crosses a leap day that only the Julian calendar has. +For example, 700 is a leap year in the Julian calendar but not in Go's proleptic Gregorian calendar. + +### 6. Julian-only leap days (for example 700-02-29) +Before 1582, years divisible by 100 but not 400 (such as 100, 700 or 1500) have a 29 February in the Julian calendar, +while Go's `time.Time` uses the proleptic Gregorian calendar and has no such day (`time.Date(700, 2, 29, ...)` normalises to 700-03-01). The library accepts 700-02-29 as an existing day, with these constraints: + +- `Time.Solar()` / `LunarTime.SolarDate`: the library's canonical output, always the **following day** + (700-03-01 for 700-02-29), matching `basic.JD2DateByZone`; +- `Time.JulianOnly()`: whether the lunar date exists only in the Julian calendar (JSON field `julianOnly`); +- `Time.JD()`: the exact Julian day; for a Julian-only leap day it is one day earlier than `Solar()`, + otherwise the two agree (JSON field `jd`). + +```go +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JD(), julian.Lunar().MonthDay()) +// 0700-03-01 true 1.9767915e+06 二月初五 +``` + +> Two kinds of entry point keep this leap day: the integer year/month/day entry points `SolarToLunarByYMD` / `LunarToSolarByYMD`, and a direct +> `basic.JDCalc(700, 2, 29)` call returning the exact Julian day `1976791.5`; building a `time.Time` first is the step that loses it. + +### 7. Several Gregorian candidates for one lunar date + +The dual-numbering reforms (Wang Mang 9-23 CE, Emperor Ming of Wei 237-240 CE, Wu Zetian 689-700 CE, Emperor Suzong of Tang 761-762 CE) +and the Taichu calendar handoff (104 BCE) give one lunar date two legal Gregorian days. `Solar()` remains the library default, and +`SolarCandidates()` returns every candidate with the first element always equal to `Solar()`: + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02")) +fmt.Println(len(res.SolarCandidates())) +// 0700-12-15 +// 2 +``` + +Outside the reform windows there is a single candidate and `SolarCandidates()` returns a one-element +slice; a Julian-only leap day also reports only its canonical output, because its single legal day +cannot be expressed as a `time.Time` - use `JD()` for the exact date. + +## Calendar conversion + +### Gregorian to lunar + +- **Input**: a Gregorian date, as `time.Time` +- **Output**: a `calendar.Time` object, holding one or more matching lunisolar dates +- **The result carries**: + - a full lunisolar date description + - sexagenary (ganzhi) year, month, and day + - dynasty, emperor, and era name + - the complete structured lunisolar record + +### Lunar to Gregorian + +Two calling styles: + +#### Style 1: pass a lunisolar string +Formats: +1. `era name + year + month + day`, for example **`"元丰六年十月十二"`** (prefix a leap month with `闰`; day names read `初一`, `二十`, and so on) +2. `era name + year + month + ganzhi day`, for example **`"元嘉二十七年七月庚午"`** +3. `year + month + day`, for example **`"二零二五年正月初一"`** (prefix a leap month with `闰`; suits modern dates) +4. `year + month + ganzhi day`, for example **`"二零二五年正月戊戌日"`** +5. `Arabic digits + month + day`: Chinese numerals may be written as Arabic digits, for example **`"2025年1月1日"`**, which stands for `二零二五年正月初一` +6. Historical cases: month names could differ from modern usage (under Wu Zetian, `正月` and `一月` denoted different months); such month names are read as Chinese numerals + +> ⚠️ A lunisolar year and a Gregorian year do not line up exactly. 2025-01-28, Chinese New Year's Eve, is the 29th day of the 12th lunisolar month of 2024, so its string is `"二零二四年腊月廿九"`. + +#### Style 2: pass numeric fields +- **Parameters**: year (`int`), month (`int`), day (`int`), leap-month flag (`bool`) +- **Semantics**: locate the date by lunisolar year, month, day, and the leap-month flag; for modern conversions + +One lunisolar day can have several legal Gregorian candidates: `Solar()` takes the first, `SolarCandidates()` returns all of them (see note 7 above). + +### Code example + +```go +package main + +import ( + "b612.me/astro/calendar" + "encoding/json" + "fmt" + "time" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + + // Example 1: Gregorian to lunisolar. This date is in the Three Kingdoms period, so multiple parallel-calendar results are returned. + date := time.Date(240, 1, 1, 8, 8, 8, 8, cst) + lunar, _ := calendar.SolarToLunar(date) + fmt.Println(lunar.LunarDescWithEmperor()) + + // Structured lunisolar information for each matching historical result. + info := lunar.LunarInfo() + data, _ := json.MarshalIndent(info, "", " ") + fmt.Println(string(data)) + + // Example 2: lunisolar to Gregorian by string. This is the date from Su Shi's 《记承天寺夜游》. + solar, _ := calendar.LunarToSolar("元丰六年十月十二日") + for _, v := range solar { + fmt.Println(v.Time()) + fmt.Println(v.LunarDescWithEmperor()) + } + + // Example 3: lunisolar to Gregorian by numeric fields. 2026 month 1 day 1 is Chinese New Year. + modernDate, _ := calendar.LunarToSolarByYMD(2026, 1, 1, false) + fmt.Println(modernDate.Time()) +} +``` + +Output: + +```text +[魏明帝 景初三年腊月二十 蜀后主 延熙二年冬月十九 吴大帝 赤乌二年冬月二十] // one Gregorian instant maps to parallel Three Kingdoms lunisolar results +[ + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 12, + "lunarDay": 20, + "isLeap": false, + "lunarMonthDayDesc": "腊月二十", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "魏", + "emperor": "魏明帝", + "nianhao": "景初", + "yearOfNianhao": 3, + "eraDesc": "景初三年", + "lunarWithNianhaoDesc": "景初三年腊月二十", + "chineseZodiac": "羊" + }, + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 11, + "lunarDay": 19, + "isLeap": false, + "lunarMonthDayDesc": "冬月十九", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "蜀", + "emperor": "蜀后主", + "nianhao": "延熙", + "yearOfNianhao": 2, + "eraDesc": "延熙二年", + "lunarWithNianhaoDesc": "延熙二年冬月十九", + "chineseZodiac": "羊" + }, + { + "solarDate": "0240-01-01T08:08:08.000000008+08:00", + "lunarYear": 239, + "lunarYearChn": "二三九", + "lunarMonth": 11, + "lunarDay": 20, + "isLeap": false, + "lunarMonthDayDesc": "冬月二十", + "ganzhiYear": "己未", + "ganzhiMonth": "丙子", + "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jd": 1808717.8389814815, + "dynasty": "吴", + "emperor": "吴大帝", + "nianhao": "赤乌", + "yearOfNianhao": 2, + "eraDesc": "赤乌二年", + "lunarWithNianhaoDesc": "赤乌二年冬月二十", + "chineseZodiac": "羊" + } +] // structured lunisolar records, one object per matching historical result +1083-11-24 00:00:00 +0800 CST // Gregorian date corresponding to 元丰六年十月十二日 +[宋神宗 元丰六年十月十二 辽道宗 大康九年十月十二] // the same day also matches a Liao calendar result +2026-02-17 00:00:00 +0800 CST // Chinese New Year in 2026 +``` + +## Solar terms + +`JieQi(year, term)` returns the exact solar-term instant computed by the modern astronomical algorithm. + +`CalendricalJieQi(year, term)` returns the date on which the solar term falls under the default calendar, fixed at 00:00 Beijing time for that day. + +Use `CalendricalJieQiWithCalendar(year, term, system)` when a specific ancient calendar system is required. + +```go +package main + +import ( + "fmt" + + "b612.me/astro/calendar" +) + +func main() { + // Beginning of Spring in 2020. Solar-term constants correspond to apparent solar longitude. + fmt.Println(calendar.JieQi(2020, calendar.JQ_立春)) + // Winter Solstice in 2020. + fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至)) + // March Equinox in 2020. + fmt.Println(calendar.JieQi(2020, calendar.JQ_春分)) + // Direct longitude input is also supported; 0 degrees is the March Equinox. + fmt.Println(calendar.JieQi(2020, 0)) +} +``` + +Output: + +```text +2020-02-04 17:03:20.471614301 +0800 CST // Beginning of Spring +2020-12-21 18:02:20.648710727 +0800 CST // Winter Solstice +2020-03-20 11:49:37.149532735 +0800 CST // March Equinox +2020-03-20 11:49:37.149532735 +0800 CST // same result from direct longitude input +``` + +Calendrical solar-term example: + +```go +date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至) +fmt.Println(date, err) + +date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan) +fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err) +``` + +Output: + +```text +1582-12-22 00:00:00 +0800 CST +-202-12-25 +``` + +## Parameter and result conventions + +### Units and Parameter Conventions + +- Year numbering is astronomical: `0` is 1 BCE, `-1` is 2 BCE; Gregorian results are supported from 721 BCE through 3000 CE. +- The `timezone` argument of `Solar` and `Lunar` is in hours: `8.0` is UTC+8 and `7.0` is UTC+7. `JieQi`, `WuHou`, and `CalendricalJieQi*` take no time zone and always output Beijing time (UTC+8). +- The lunisolar month counts the distance from the first month: month 1 is `正月`, month 2 is `二月`, and so on. A leap month is marked by the separate `leap`/`IsLeap` flag and is never inferred from the month number. +- The lunisolar year argument uses the civil year as a proxy but starts at the lunisolar new year: the 30th day of the 12th month of the Ji-Hai year must be passed as `Solar(2019, 12, 30, false, 8)`, not `Solar(2020, 12, 30, false, 8)`. +- `Date2JD` reads only the year/month/day/hour/minute/second fields of a `time.Time` and performs no time-zone conversion, so Beijing midnight and UTC midnight of the same civil date give the same Julian day; `JD2Date` returns a time in the local zone of the host. + +### Time Scale + +- Every public instant is treated as a civil value, meaning the fields of the `time.Time` are read directly as a UTC label. + + When the library converts civil time to TT, values before 1972-01-01 are treated as UT1 and the exact window uses the built-in leap-second table; for explicit conversion use the root package's `astro.UT1FromUTC` / `astro.TTFromUTC` / `astro.DUT1`. +- `JieQi` and `WuHou` return civil instants in Beijing time with full fractional-second precision; `CalendricalJieQi*` always returns 00:00 Beijing time on the matching day. +- `JD`, `Date2JD`, and `NowJD` are all Julian-day floats on that same civil convention: the Beijing civil day `2026-02-17` corresponds to `2461088.5`. + +### Zero Values and Out-of-Range Input + +- The ten Gregorian days from 1582-10-05 through 1582-10-14 do not exist; the affected entry points report an error instead of silently normalising to a neighbouring date. +- `SolarToLunar` / `SolarToLunarByYMD` return an error outside `[-721,3000]`. `LunarToSolar*` is limited to the same Gregorian range, and the boundary lunisolar year `-722` is accepted while its result still falls inside that range. +- `CalendricalJieQiWithCalendar` returns an error for the Chunqiu calendar and for years without calendrical solar-term data; `LunarToSolarWithCalendar` accepts an era-name description only when the ancient era table matches and errors otherwise. +- `SolarCandidates()` returns a one-element slice holding `Solar()` when there is no second candidate, never an empty slice; `Lunar()` returns a zero-value `LunarTime` when there is no candidate, and its `JulianOnly()` then reports `false`. +- `LunarToSolarSingle` is deprecated, use `LunarToSolarByYMD`. `AncientCalendarDefault` is the zero value, and the explicit-calendar entry points fall back to the default implementation when they receive it. +- `LunarTime.CalendarSystem()` and `CalendarName()` return empty strings under default routing and in the modern range, which means "no explicit ancient calendar" rather than an error. + +### Accuracy and Applicability + +- The modern range follows the current Chinese calendar standard GB/T 33661-2017 and is recommended for `[1929,3000]`. + + `Solar`/`Lunar` use the same new-moon and solar-term rules and merely allow a custom time zone, so outside the recommended years the dates can differ from historical records. +- `[-103,1912]` uses the built-in calendar tables, `[-220,-104]` uses the reconstructed Qin/Han Zhuanxu calendar, and `[-721,-221]` uses the reconstructed default pre-Qin calendars. + + All three ranges are reconstructions and are not guaranteed to match every day actually promulgated at the time. +- New moons and solar terms are computed with modern astronomy, so for ancient dates their instants differ from contemporary observation and the resulting lunisolar dates can differ from the historical record. +- The ancient-calendar reconstruction stops at the level of whole days: `CalendricalJieQi*` returns the calendar-compatible date while `JieQi` returns the modern astronomical instant, and the two are not interchangeable. + +### Related Manuals + +The astronomical definition of a solar term (apparent solar longitude) and the determination of new moons are covered by [Sun and Moon](sun-moon.md#sun-and-moon-position) and [Sun and Moon](sun-moon.md#lunar-phases); those pages give modern astronomical quantities, while this manual gives the calendrical arrangement. diff --git a/doc/manual/en/coord.md b/doc/manual/en/coord.md new file mode 100644 index 0000000..603697d --- /dev/null +++ b/doc/manual/en/coord.md @@ -0,0 +1,424 @@ +# Coordinate Tools + +[中文](../coord.md) | [Back to README](../../../README.en.md) + +`coord` provides celestial coordinate conversion and observing helper calculations. Unless noted otherwise, angles are degrees, sidereal time is in hours, and `time.Time` is treated as an absolute instant and internally converted to UTC. + +Altitude, zenith distance, and horizon-visibility semantics follow [Observing-angle semantics](sun-moon.md#observing-angle-semantics); full topocentric usage appears in [Occultation · Local event chart](occultation.md#fixed-site-charts) and [Sun and Moon positions](sun-moon.md#sun-and-moon-position); + +topocentric solar-eclipse contacts appear in [Solar eclipses](eclipse.md#solar-eclipse). + +Time scales, observer height, and overall accuracy are documented in [Time scale conventions](timescale.md), [Observer height conventions](coord.md#observer-height), and [Applicability and accuracy](accuracy.md). + +## Contents + +- [Converting ecliptic coordinates to equatorial and horizontal](#converting-ecliptic-coordinates-to-equatorial-and-horizontal) +- [API Reference](#api-reference) + - [Ecliptic ↔ Equatorial](#ecliptic--equatorial) + - [Equatorial ↔ Horizontal](#equatorial--horizontal) + - [Topocentric Coordinates](#topocentric-coordinates) + - [Sidereal Time](#sidereal-time) + - [Precession and Nutation](#precession-and-nutation) + - [Galactic Coordinates](#galactic-coordinates) + - [Angular Separation](#angular-separation) + - [Atmospheric Refraction](#atmospheric-refraction) + - [Airmass](#airmass) + - [Parallactic Angle](#parallactic-angle) +- [Usage examples](#usage-examples) + - [Round-trip conversion between ecliptic and horizontal coordinates](#round-trip-conversion-between-ecliptic-and-horizontal-coordinates) + - [Topocentric correction and sidereal time](#topocentric-correction-and-sidereal-time) + - [Refraction, airmass and parallactic angle](#refraction-airmass-and-parallactic-angle) + - [Precession, nutation and obliquity conventions](#precession-nutation-and-obliquity-conventions) +- [Research APIs and Observing Helpers](#research-apis-and-observing-helpers) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Units](#units) + - [Time Scales](#time-scales) + - [Angle Quadrants and Normalization](#angle-quadrants-and-normalization) + - [Zero Values and Invalid Input](#zero-values-and-invalid-input) + - [Accuracy and Applicability](#accuracy-and-applicability) +- [Observer height](#observer-height) + +## Converting ecliptic coordinates to equatorial and horizontal + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/coord" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 4, 27, 10, 30, 45, 0, cst) + eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) + hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", eq.RA, eq.Dec) + fmt.Printf("azimuth=%.6f altitude=%.6f deg\n", hz.Azimuth, hz.Altitude) + fmt.Printf("GAST=%.6f h\n", coord.ApparentSiderealTime(date)) +} +``` + +Right ascension, declination and horizontal angles are in degrees; sidereal time is in hours. These equatorial coordinates belong to the observing date. Galactic conversion requires ICRS coordinates, so the two must not be mixed directly. + +## API Reference + +The tables group functions by the quantity being calculated. + +The snippets reuse `date`, `cst` and `eq` from the first example. The sidereal-time example also needs the standard `math` package. + +### Ecliptic ↔ Equatorial + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `EclipticToEquatorial` | ecliptic to equatorial | ecliptic longitude/latitude and right ascension/declination in degrees; uses the true obliquity at that instant, `RA ∈ [0,360)` | +| `EquatorialToEcliptic` | equatorial to ecliptic | inverse of the same obliquity; `Lon ∈ [0,360)`, `Lat ∈ [−90,90]` | +| `EclipticToEquatorialByObliquity` | ecliptic to equatorial with manual obliquity | all three parameters in degrees; for experiments with custom axial tilts, no date conversion | +| `EquatorialToEclipticByObliquity` | equatorial to ecliptic with manual obliquity | same; `Lon ∈ [0,360)`, `Lat ∈ [−90,90]` | +| `Ecliptic` | ecliptic result value | fields `Lon` and `Lat`, in degrees | +| `Equatorial` | equatorial result value | fields `RA` and `Dec`, in degrees | + +```go +// date is the shared preamble; take the true obliquity at that instant, then compare with the date-based path. +obliquity := coord.EclipticObliquity(date, true) +auto := coord.EclipticToEquatorial(date, 139.686111, 4.875278) +manual := coord.EclipticToEquatorialByObliquity(139.686111, 4.875278, obliquity) +back := coord.EquatorialToEclipticByObliquity(manual.RA, manual.Dec, obliquity) +fmt.Printf("auto=(%.9f, %.9f) manual=(%.9f, %.9f)\n", + auto.RA, auto.Dec, manual.RA, manual.Dec) +fmt.Printf("back=(%.9f, %.9f)\n", back.Lon, back.Lat) +``` + +### Equatorial ↔ Horizontal + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `EquatorialToHorizontal` | apparent equatorial to horizontal | longitude east-positive, latitude north-positive, degrees; uses apparent sidereal time (with nutation); `Azimuth ∈ [0,360)`, `Altitude ∈ [−90,90]`, `Zenith = 90 − Altitude` | +| `EquatorialToHorizontalByLocalSiderealTime` | equatorial to horizontal with manual sidereal time | sidereal time in hours, multiplied by 15 internally; no date lookup, suitable for manual experiments | +| `HourAngleDeclinationToHorizontal` | hour angle plus declination to horizontal | input hour angle normalized into `[0,360)`; `Azimuth ∈ [0,360)` | +| `HorizontalToHourAngleDeclination` | horizontal to hour angle plus declination | returned hour angle is normalized into `[0,360)` (not `[−180,180]`), declination `[−90,90]` | +| `HorizontalToEquatorialByLocalSiderealTime` | horizontal to equatorial with manual sidereal time | sidereal time in hours; `RA ∈ [0,360)` | +| `Horizontal` | horizontal result value | fields `Azimuth`, `Altitude`, `Zenith`, and `HourAngle`, all in degrees | + +```go +// Observer at 115°E, 40°N; eq comes from the shared preamble. +hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) +manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, eq.RA, eq.Dec, 40) +hz2 := coord.HourAngleDeclinationToHorizontal(hz.HourAngle, eq.Dec, 40) +ha, dec := coord.HorizontalToHourAngleDeclination(hz2.Azimuth, hz2.Altitude, 40) +eq2 := coord.HorizontalToEquatorialByLocalSiderealTime(10.5, hz2.Azimuth, hz2.Altitude, 40) +fmt.Printf("auto=(%.6f, %.6f, %.6f) manual=(%.6f, %.6f)\n", + hz.Azimuth, hz.Altitude, hz.Zenith, manual.Azimuth, manual.Altitude) +fmt.Printf("roundtrip ha=%.6f dec=%.6f ra=%.6f\n", ha, dec, eq2.RA) +``` + +### Topocentric Coordinates + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `TopocentricEquatorial` | geocentric to topocentric equatorial | `distanceAU` is the geocentric distance in AU; `height` is the ellipsoidal height in meters; `RA` only receives a small parallax correction and is not normalized into 360° | +| `TopocentricEcliptic` | geocentric to topocentric ecliptic | same; it solves the topocentric equatorial position first and converts to the ecliptic, so `Lon` is normalized into `[0,360)` and `Lat` lands in `[−90,90]` | +| `Ecliptic` / `Equatorial` | return value types | same field conventions as the ecliptic ↔ equatorial group | + +```go +// Target 0.00257 AU from the geocenter (roughly the Moon), observer at 115°E, 40°N, ellipsoidal height 53 m. +top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) +topEcl := coord.TopocentricEcliptic(date, 139.686111, 4.875278, 115, 40, 0.00257, 53) +fmt.Printf("top=(%.9f, %.9f) dRA=%.9f dDec=%.9f\n", + top.RA, top.Dec, top.RA-eq.RA, top.Dec-eq.Dec) +fmt.Printf("topEcl=(%.9f, %.9f)\n", topEcl.Lon, topEcl.Lat) +``` + +Topocentric geometry drives per-site occultation contact times, see [Local event chart](occultation.md#fixed-site-charts); topocentric quantities in `sun` and `moon` appear under [Sun and Moon positions](sun-moon.md#sun-and-moon-position). + +### Sidereal Time + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `MeanSiderealTime` | Greenwich mean sidereal time | hours; converts civil time to UT1 first, model is IAU 2006 (ERA route) | +| `ApparentSiderealTime` | Greenwich apparent sidereal time | hours; mean sidereal time plus the projection of the IAU 2000B longitude nutation | +| `HourAngle` | hour angle from apparent RA and site longitude | longitude east-positive, degrees; hour angle `[0,360)`; based on apparent sidereal time | + +```go +// Local sidereal time = Greenwich sidereal time + east longitude/15, folded back into [0,24) hours; math is only for this step. +gmst := coord.MeanSiderealTime(date) +gast := coord.ApparentSiderealTime(date) +lst := math.Mod(gmst+115.0/15, 24) +fmt.Printf("GMST=%.9f h GAST=%.9f h LST=%.9f h\n", gmst, gast, lst) +fmt.Printf("HA=%.6f deg\n", coord.HourAngle(date, eq.RA, 115)) +``` + +### Precession and Nutation + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `Precess` | precess equatorial coordinates from one date to another | RA/Dec in degrees; both dates are civil instants; pure precession rotation, no proper motion | +| `EclipticObliquity` | ecliptic obliquity | degrees; `nutation=false` gives the IAU 1980 mean obliquity, `true` adds the IAU 2000B obliquity nutation | +| `Nutation2000B` | IAU 2000B nutation | returns `(longitude nutation, obliquity nutation)` in degrees | +| `Nutation1980` | IAU 1980 nutation | returns `(longitude nutation, obliquity nutation)` in degrees | + +```go +// Precess J2000 equatorial coordinates to date and list both nutation series for comparison. +j2000 := time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC) +p := coord.Precess(j2000, date, 83.6331, 22.0145) +dLon2000B, dObl2000B := coord.Nutation2000B(date) +dLon1980, dObl1980 := coord.Nutation1980(date) +fmt.Printf("precessed=(%.9f, %.9f)\n", p.RA, p.Dec) +fmt.Printf("eps mean=%.9f true=%.9f\n", + coord.EclipticObliquity(date, false), coord.EclipticObliquity(date, true)) +fmt.Printf("nut 2000B=(%.9f, %.9f) 1980=(%.9f, %.9f)\n", + dLon2000B, dObl2000B, dLon1980, dObl1980) +``` + +### Galactic Coordinates + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `EquatorialToGalactic` | ICRS equatorial to Galactic | ICRS input in degrees; `Lon ∈ [0,360)`, `Lat ∈ [−90,90]`; fixed rotation matrix, no precession or proper motion | +| `GalacticToEquatorial` | Galactic to ICRS equatorial | transpose of the same matrix; `RA ∈ [0,360)` | +| `Galactic` | Galactic result value | fields `Lon` and `Lat`, in degrees | + +```go +// The Galactic-center direction in ICRS (266.4051, -28.936175) should come back near l=0, b=0. +gal := coord.EquatorialToGalactic(266.4051, -28.936175) +back := coord.GalacticToEquatorial(gal.Lon, gal.Lat) +fmt.Printf("gal=(%.6f, %.6f) back=(%.9f, %.9f)\n", gal.Lon, gal.Lat, back.RA, back.Dec) +``` + +### Angular Separation + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `AngularSeparation` | angular distance between two equatorial coordinates | inputs and output in degrees; great-circle angle, independent of input order | + +```go +// Angular distance between the Crab pulsar direction and the Galactic center. +sep := coord.AngularSeparation(83.6331, 22.0145, 266.4051, -28.936175) +fmt.Printf("sep=%.9f deg = %.6f arcsec\n", sep, sep*3600) +``` + +### Atmospheric Refraction + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `ApparentAltitude` | true altitude to apparent altitude | altitude and return value in degrees; pressure hPa, temperature °C | +| `TrueAltitude` | apparent altitude to true altitude | numerically inverts the Saemundsson model; returns NaN when there is no solution | +| `AtmosphericRefractionFromTrueAltitude` | refraction at a true altitude | degrees; add it to the true altitude to get the apparent altitude | +| `AtmosphericRefractionFromApparentAltitude` | refraction at an apparent altitude | degrees; subtract it from the apparent altitude to get the true altitude | +| `EquatorialToApparentHorizontal` | equatorial to apparent horizontal | applies refraction on top of `EquatorialToHorizontal`, changing only `Altitude`/`Zenith`, leaving `Azimuth`/`HourAngle` untouched | + +```go +// True altitude 10 deg, standard pressure 1010 hPa, temperature 0 C. +apparent := coord.ApparentAltitude(10, 1010, 0) +ref := coord.AtmosphericRefractionFromTrueAltitude(10, 1010, 0) +appHz := coord.EquatorialToApparentHorizontal(date, eq.RA, eq.Dec, 115, 40, 1010, 0) +fmt.Printf("true=10 apparent=%.9f ref=%.9f\n", apparent, ref) +fmt.Printf("back=%.9f appHz alt=%.6f zen=%.6f\n", + coord.TrueAltitude(apparent, 1010, 0), appHz.Altitude, appHz.Zenith) +``` + +### Airmass + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `AirmassPlaneParallelFromTrueAltitude` | plane-parallel model | true altitude in degrees; equivalent to `sec(z)`, good at moderate altitude, diverges near the horizon | +| `AirmassKastenYoungFromApparentAltitude` | Kasten-Young 1989 | apparent altitude in degrees; no automatic refraction correction | +| `AirmassPickeringFromApparentAltitude` | Pickering 2002 | apparent altitude in degrees; aimed at low-altitude observations | +| `AirmassKastenYoungFromTrueAltitude` | refraction first, then Kasten-Young | true altitude in degrees; pressure hPa, temperature °C | +| `AirmassPickeringFromTrueAltitude` | refraction first, then Pickering | same | + +```go +// True altitude 10 deg, standard pressure 1010 hPa, temperature 0 C; FromTrueAltitude applies refraction first. +apparent := coord.ApparentAltitude(10, 1010, 0) +fmt.Printf("plane=%.9f\n", coord.AirmassPlaneParallelFromTrueAltitude(10)) +fmt.Printf("ky true=%.9f apparent=%.9f\n", + coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0), + coord.AirmassKastenYoungFromApparentAltitude(apparent)) +fmt.Printf("pickering true=%.9f apparent=%.9f\n", + coord.AirmassPickeringFromTrueAltitude(10, 1010, 0), + coord.AirmassPickeringFromApparentAltitude(apparent)) +``` + +When only the raw formula is needed without the coordinate-layer refraction, use the three [airmass models](formula.md#airmass-models) in `formula`. + +### Parallactic Angle + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `ParallacticAngle` | parallactic angle from apparent RA/Dec | longitude/latitude in degrees; internally hour angle plus declination, returns `(−180,180]` | +| `ParallacticAngleByHourAngle` | parallactic angle from hour angle | hour angle, declination, latitude in degrees; returns `(−180,180]` | + +```go +// The parallactic angle drives camera rotation, spectrograph slit direction, and field orientation. +q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) +q2 := coord.ParallacticAngleByHourAngle(coord.HourAngle(date, eq.RA, 115), eq.Dec, 40) +fmt.Printf("q=%.9f qByHA=%.9f\n", q, q2) +``` + +The direction convention for the parallactic angle follows [Observing-angle semantics](sun-moon.md#observing-angle-semantics). + +## Usage examples + +### Round-trip conversion between ecliptic and horizontal coordinates + +```go +eq := coord.EclipticToEquatorial(date, 139.686111, 4.875278) +hz := coord.EquatorialToHorizontal(date, eq.RA, eq.Dec, 115, 40) +back := coord.EquatorialToEcliptic(date, eq.RA, eq.Dec) +fmt.Println(eq.RA, eq.Dec, hz.Altitude, back.Lon) +``` + +```text +143.72223158223719 19.53512536790277 -17.686511328302952 139.68611100000000 +``` + +The instant and the coordinates are enough; the library converts with the apparent obliquity of that day. + +Use the `EclipticToEquatorialByObliquity` / `EquatorialToEclipticByObliquity` research entry points to prescribe the obliquity yourself. + +Round trips are self-consistent: `EquatorialToEcliptic(EclipticToEquatorial(...))` returns the original values under one convention. + +### Topocentric correction and sidereal time + +```go +top := coord.TopocentricEquatorial(date, eq.RA, eq.Dec, 115, 40, 0.00257, 53) +fmt.Println(top.RA, top.Dec) +fmt.Println(coord.MeanSiderealTime(date), coord.ApparentSiderealTime(date)) +// With a local sidereal time in hand you can skip the library's own computation: +manual := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, 83.6331, 22.0145, 31.2) +fmt.Println(manual.Azimuth, manual.Altitude, manual.HourAngle) +``` + +```text +144.25512626944376 18.79025523004319 +16.852455700085 16.852556781127 +281.869347 24.489608 73.8669 +``` + +`distanceAU` must be a real geocentric distance (about `0.00257 AU` for the Moon); whether parallax can be neglected for a more distant target depends on the required accuracy. + +`height` is ellipsoidal height in metres and sidereal time is in hours. + +### Refraction, airmass and parallactic angle + +```go +fmt.Println(coord.ApparentAltitude(10, 1010, 0)) // true 10 -> apparent +fmt.Println(coord.TrueAltitude(10.093428, 1010, 0)) // apparent -> true (inverse) +fmt.Println(coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0)) // airmass at true altitude 10 +fmt.Println(coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40)) // camera/slit rotation angle +``` + +```text +10.093427592862 +10.000000410649 +5.537933369472 +-34.000957202636 +``` + +Refraction only applies for true altitudes inside `(-5, 90)` degrees and contributes nothing outside; the airmass family also offers simplified entry points that take only an apparent altitude (`...FromApparentAltitude`). + +### Precession, nutation and obliquity conventions + +```go +fmt.Println(coord.EclipticObliquity(date, true)) // true obliquity; false gives the mean one +lon, obl := coord.Nutation2000B(date) // IAU 2000B longitude/obliquity nutation +pre := coord.Precess(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), date, eq.RA, eq.Dec) +fmt.Println(lon, obl, pre.RA, pre.Dec) +``` + +```text +23.438261476424 +0.001652570533 0.002392788113 144.089973153762 19.416731576422 +``` + +Both `Nutation1980` and `Nutation2000B` can be called explicitly for term-by-term comparison; `Precess` applies precession only, without proper motion, nutation or aberration. + +Research entry points such as a manual sidereal time or a manual obliquity live under [Research APIs and observing helpers](#research-apis-and-observing-helpers). + +## Research APIs and Observing Helpers + +Research-style `coord` helpers do not automatically substitute the current obliquity or sidereal time. They are useful for experiments with custom axial tilts or manually specified hour angles. + +For ordinary observing calculations, use the `time.Time` based APIs such as `EclipticToEquatorial` and `EquatorialToHorizontal`. + +Observing helpers: + +- `ParallacticAngle` / `ParallacticAngleByHourAngle`: parallactic angle, or the direction angle of the zenith at the target +- `Airmass...FromApparentAltitude`: apply empirical airmass formula directly when apparent altitude is already known +- `Airmass...FromTrueAltitude`: estimate refraction from pressure/temperature, convert true altitude to apparent altitude, then compute airmass + +```go +// Parallactic angle of the target, useful for camera rotation, spectrograph slit direction, and field orientation. +q := coord.ParallacticAngle(date, eq.RA, eq.Dec, 115, 40) + +// With true altitude as input, estimate refraction first and then compute empirical airmass. +x := coord.AirmassKastenYoungFromTrueAltitude(10, 1010, 0) +fmt.Printf("q=%.6f airmass=%.6f\n", q, x) +``` + +The same observing helpers are also exposed in `sun`, `moon`, `star`, and the seven major-planet packages. If apparent altitude is already available and only the raw formula is needed, use `formula.Airmass...`. + +## Parameter and result conventions + +### Units + +- Angles are always degrees: ecliptic longitude/latitude, right ascension/declination, azimuth/altitude/zenith distance/hour angle, parallactic angle, and angular separation. + + Multiply by 3600 yourself when arcseconds are needed (for example the return value of `AngularSeparation`). +- Sidereal time is always hours: the return values of `MeanSiderealTime` and `ApparentSiderealTime`, and the `localSiderealTimeHours` argument of every `*ByLocalSiderealTime` entry point, are all hours; internally they are multiplied by 15 to become degrees, so do not confuse them with the hour notation of right ascension. +- `Equatorial.RA`, `Galactic.Lon`, `Horizontal.Azimuth`, and `Horizontal.HourAngle` are all **degrees**, not hours; the `Equatorial` type does not distinguish J2000, mean-of-date, or apparent coordinates, so the caller must keep the input convention consistent. +- Distances are AU (the `distanceAU` argument of `TopocentricEquatorial` and `TopocentricEcliptic`); observer height is the **ellipsoidal height (geodetic height)** in meters, with no geoid modeling, see the manual [Observer height conventions](coord.md#observer-height). +- Pressure is hPa and temperature is °C; refraction is calibrated at the standard state of `1010 hPa` and `10 °C`. +- This package produces no apparent diameter or apparent radius. The apparent-radius fields in the Sun/Moon and eclipse manuals are in arcseconds; do not interchange them with the degrees used here. + +### Time Scales + +- Every public entry point treats `time.Time` as an absolute (civil) instant and converts it with `date.UTC()` before computing the Julian date; the value itself is always interpreted as a UTC label, and equals UT1 before `1972-01-01`. + + The full convention is in the manual [Time scale conventions](timescale.md). +- The sidereal-time chain converts to UT1 explicitly: `MeanSiderealTime` / `ApparentSiderealTime` run the IAU 2006 model after `UTC2UT1`, and the apparent sidereal time inside `HourAngle`, `EquatorialToHorizontal`, and `TopocentricEquatorial` is UT1-based as well. +- Both `from` and `to` of `Precess(from, to)` are civil instants, each supplying its own precession epoch. +- Refraction and airmass do not involve time at all; they depend only on altitude and meteorological parameters. + +### Angle Quadrants and Normalization + +- The internal `normalize360` normalizes every angle that is meant to be displayed into `[0,360)`: right ascension, ecliptic longitude, Galactic longitude, azimuth, and hour angle all land in that interval. +- Declination, ecliptic latitude, Galactic latitude, and altitude pass through `Asin` (with an extra `[−1,1]` clamp on some paths) and land in `[−90,90]`. +- `ParallacticAngle` / `ParallacticAngleByHourAngle` use `Atan2` and return `(−180,180]`; this is the only signed angle in the package. +- The topocentric family is the exception: `TopocentricEquatorial.RA` is the input RA plus a small correction and is not normalized into 360°, so it may cross the boundary slightly when the target is near `0°/360°`; `TopocentricEcliptic` normalizes `Lon` into `[0,360)` and returns `Lat` through `Asin` inside `[−90,90]`, because it solves the topocentric equatorial position first and converts to the ecliptic afterwards. +- The hour angle returned by `HourAngleDeclinationToHorizontal` and `HorizontalToHourAngleDeclination` is normalized into `[0,360)`, so a negative hour angle west of the meridian appears here as `360−|HA|`; subtract 360 yourself when a signed hour angle is needed. +- `EquatorialToHorizontal` and `HourAngleDeclinationToHorizontal` both return a **geometric** altitude without refraction; use `EquatorialToApparentHorizontal` or `ApparentAltitude` for an apparent altitude. + +### Zero Values and Invalid Input + +- The refraction family requires `pressureHPa > 0` and `temperatureC > −273.15`; any NaN/Inf or out-of-range parameter returns NaN. +- At a true altitude `≤ −5°` or `≥ 90°` the refraction is treated as 0: `ApparentAltitude` returns the true altitude unchanged, and `TrueAltitude` and `AtmosphericRefractionFromApparentAltitude` return the input apparent altitude unchanged; `TrueAltitude` only solves numerically inside `(−5°, 90°)` and returns NaN when the inversion fails. +- The airmass family limits altitude (or zenith distance) to `[0,90]` and returns NaN outside or for non-finite input; the plane-parallel model returns `+Inf` exactly at altitude `0°` (zenith distance `90°`), while Kasten-Young and Pickering stay finite at `0°`. +- `EquatorialToApparentHorizontal` passes pressure and temperature straight to the refraction function: invalid meteorological parameters turn `Altitude` and therefore `Zenith` into NaN, while `Azimuth` and `HourAngle` remain geometric values. +- The topocentric family does not validate `distanceAU` or `height`; passing `0` or a negative `distanceAU` yields meaningless results, so pass a real geocentric distance (about `0.00257 AU` for the Moon). +- Purely geometric entry points such as the Galactic conversions and the angular separation do not validate parameters: non-finite input propagates to NaN, and an ecliptic latitude outside `[−90,90]` is not clamped but reinterpreted as a spherical direction. +- The four coordinate types have no `Valid` field: `Ecliptic{}`, `Equatorial{}`, `Horizontal{}`, and `Galactic{}` mean "all angles zero", not "unset", so zero values are not usable as an unset sentinel. + +### Accuracy and Applicability + +- Ecliptic obliquity: the mean term uses the IAU 1980 model, and `EclipticObliquity(date, true)` adds the IAU 2000B obliquity nutation on top; both `Nutation1980` and `Nutation2000B` are available and can be called explicitly for term-by-term comparison. +- Sidereal time: the IAU 2006 ERA route, with apparent sidereal time adding the IAU 2000B longitude nutation; apparent sidereal time for the same Julian date is memoized in a bounded table, and an injected ΔT override invalidates the memo by generation. +- Precession: `Precess` applies a long-term precession rotation to equatorial coordinates (its equatorial/ecliptic pole vectors include periodic terms), without proper motion, nutation, or aberration; it moves mean coordinates from one epoch to another and cannot replace a full "J2000 to apparent-of-date" chain. +- Galactic: a fixed ICRS ↔ Galactic rotation matrix, equivalent to SOFA `iauIcrs2g`/`iauG2icrs`, valid only for ICRS-convention input. +- Topocentric correction: the diurnal parallax scales inversely with `distanceAU` and is largest for the Moon (about 1°), and whether it can be neglected for a more distant body depends on the required accuracy; `height` only scales the parallax term, with meter-level elevation producing arcsecond-level differences. +- Topocentric ecliptic coordinates come from the topocentric equatorial position (`basic.TopocentricLoBo` returns both from a single evaluation): `TopocentricEcliptic.Lat` is guaranteed to land in `[−90,90]` and agrees with the independent `EquatorialToEcliptic(TopocentricEquatorial(...))` route. +- Refraction: the Saemundsson formula, valid for true altitudes in `(−5°, 90°)`, with no correction outside that window; refraction changes rapidly at low altitude (< 5°), where ±1 hPa or ±1 °C already produces a visible error. +- Airmass: the three empirical models agree at moderate altitude and differ most near the horizon; the plane-parallel model is a purely geometric approximation that diverges near the horizon, so use Kasten-Young or Pickering for careful low-altitude estimates, with the raw formulas in `formula` under [airmass models](formula.md#airmass-models). +- Parallactic angle: `ParallacticAngle` uses apparent RA/Dec and the site longitude/latitude, while `ParallacticAngleByHourAngle` uses the hour-angle form of the same geometry; both must agree for the same geometry. + +## Observer height + +Topocentric coordinates, rise/set, local eclipses and occultations interpret observer height as ellipsoidal height in metres. Convert orthometric height `H` using geoid undulation `N`: `height = H + N`. + +The library does not include a geoid model. `height=0` therefore denotes the reference ellipsoid, rather than local mean sea level. + +Obtain `N` from an external model when that difference matters, and match the observer-height convention when comparing external predictions. + +For scale, a height difference of 30 m projects to roughly 52 m on the ground along a ray at 30° altitude, using `|Δh|·cot(altitude)`. Its effect on a contact time depends on the local shadow velocity and contact geometry; it is not a fixed time correction. diff --git a/doc/manual/en/eclipse.md b/doc/manual/en/eclipse.md new file mode 100644 index 0000000..8fa8ce6 --- /dev/null +++ b/doc/manual/en/eclipse.md @@ -0,0 +1,992 @@ +# Solar and Lunar Eclipses + +[中文](../eclipse.md) | [Back to README](../../../README.en.md) + +> Full examples in this manual run from the repository root and write their figures to `doc/img/`. + +## Contents + +- [Simple example: the 2009 Great Yangtze Eclipse at a site in Shanghai](#simple-example-the-2009-great-yangtze-eclipse-at-a-site-in-shanghai) +- [API Reference](#api-reference) +- [Usage examples](#usage-examples) + - [Is there a solar or lunar eclipse at my site?](#is-there-a-solar-or-lunar-eclipse-at-my-site) + - [Global visibility maps and lunar charts](#global-visibility-maps-and-lunar-charts) + - [Central path and partial footprints](#central-path-and-partial-footprints) + - [Besselian elements and Saros](#besselian-elements-and-saros) +- [Solar eclipse](#solar-eclipse) + - [Timing checks against NASA material](#timing-checks-against-nasa-material) + - [2009 Yangtze River total eclipse near Yangshan](#2009-yangtze-river-total-eclipse-near-yangshan) + - [2012 Xiamen annular eclipse](#2012-xiamen-annular-eclipse) + - [Solar-eclipse SVG](#solar-eclipse-svg) +- [Lunar eclipse](#lunar-eclipse) + - [Code example](#code-example) + - [Checks against NASA data](#checks-against-nasa-data) + - [Lunar-eclipse SVG](#lunar-eclipse-svg) + - [References](#references) +- [Solar and Lunar Eclipse Charts](#solar-and-lunar-eclipse-charts) + - [Global visibility maps](#global-visibility-maps) + - [Lunar eclipse charts](#lunar-eclipse-charts) + - [Time scale and UT1](#time-scale-and-ut1) + +## Simple example: the 2009 Great Yangtze Eclipse at a site in Shanghai + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 0, 0, 0, 0, cst) + info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) + if !ok { + fmt.Println("no local solar eclipse") + return + } + fmt.Println(info.Type) + fmt.Printf("%+v\n", info) +} +``` + +When `ok=false`, no matching eclipse was found for that date or site; do not use the result fields. Separate APIs below calculate global events, local contacts and geographic paths. + +## API Reference + +SVG snippets use the import aliases `eclipsesvg "b612.me/astro/eclipse/svg"` and, for occultations, `moonsvg "b612.me/astro/moon/svg"`. Dates and time zones reuse the first example. + +| Name | Purpose | Notes | +| --- | --- | --- | +| `LocalSolarEclipseOnDate` / `LocalSolarEclipseOnDateNASABulletinSplitK` | Fixed-site solar eclipse query | Returns `(info, bool)`; NASA Split-K by default | +| `LunarEclipseOnDate` / `LunarEclipseOnDateDanjon` / `LunarEclipseOnDateChauvenet` | Lunar eclipse query | Same; the suffix forces a shadow-radius model | +| `SearchLocalCentralSolarEclipse` / `SolarEclipseCandidates` | Cross-year central-eclipse search / candidate list | The former carries `status.Exhausted` | +| `SolarEclipseCentralPath` / `SolarEclipsePartialFootprints` | Central path and partial-footprint geometry | Options are `SolarEclipsePathOptions` / `SolarEclipsePartialFootprintOptions` | +| `SolarEclipseBesselianElements` / `SolarEclipseBesselianMuForPublishedTable` | Besselian elements and the published-table conversion | For table comparison | +| `eclipsesvg.SolarEclipseMapSVG` / `LunarEclipseMapSVG` | Global visibility maps | Return `(string, bool)` | +| `eclipsesvg.LocalSolarEclipseSVG` / `LunarEclipseSVG` / `LunarEclipseDetailedSVG` | Fixed-site disk chart / shadow-path diagram / detailed layout | Same | +| `eclipsesvg.SolarEclipseMapSVGOptions` / `LunarEclipseDetailedSVGOptions` | Chart options (projection, layers, canvas) | Layer switches are documented in the charts section | +| `astro.TimeScaleUT1` | UT1 chart output | `Location` must be UTC in this mode | +| `...InUT1` converters (`SolarEclipseInfoInUT1`, `TimeLabelsInUT1`, ...) | rewrite the civil instants of a result as UT1 readings of the same physical instant | zero instants are kept as-is | + +## Usage examples + +### Is there a solar or lunar eclipse at my site? + +```go +solar, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) +fmt.Println(ok, solar.Type) +lunar, ok2 := eclipse.LunarEclipseOnDate(time.Date(2029, 1, 1, 0, 0, 0, 0, cst)) +fmt.Println(ok2, lunar.Type) +``` + +```text +true total +true total +``` + +- `LocalSolarEclipseOnDate` returns the type, all contact instants, magnitude, obscuration and the solar altitude at greatest eclipse in one call; when you only care whether something happens, read the second return value. +- To find "the next central eclipse" across years use `SearchLocalCentralSolarEclipse` (its `status.Exhausted` separates "nothing in the span" from "found"); for a bare candidate list use `SolarEclipseCandidates`. +- On the lunar side the counterparts are `LunarEclipseOnDate` plus the `Danjon` / `Chauvenet` suffixed entry points that force a shadow-radius model. + +### Global visibility maps and lunar charts + +```go +solar, ok := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}) +local, ok2 := eclipsesvg.LocalSolarEclipseSVG(date, 121.9850, 30.6167, 0, eclipsesvg.LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Step: 5 * time.Minute, Location: cst}) +lunar, ok3 := eclipsesvg.LunarEclipseSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), eclipsesvg.LunarEclipseSVGOptions{Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst}) +detailed, ok4 := eclipsesvg.LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}) +fmt.Println(ok, len(solar), ok2, len(local), ok3, len(lunar), ok4, len(detailed)) +``` + +```text +true 265827 true 13625 true 19831 true 319721 +``` + +Projection switching, layer switches (penumbral/umbral outlines, magnitude contours, isochrones) and canvas floors are all documented under [Global visibility maps and lunar eclipse charts](#solar-and-lunar-eclipse-charts); the trade-offs of the orthographic globe and polar layouts are under [Map Projections](map-geojson.md#map-projections). + +### Central path and partial footprints + +```go +partial, ok := eclipse.SolarEclipsePartialFootprints(date, + eclipse.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}) +central, hasCentral := eclipse.SolarEclipseCentralPath(date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}) +fmt.Println(ok, hasCentral, len(partial.CentralBandFootprints), len(central.CenterLine)) +``` + +```text +true true 42 1117 +``` + +- Use these two when you need geometry rather than a picture (your own data pipeline, or feeding `geojson`); `TargetSpacingKM` caps the center-line refinement spacing. +- Partial footprints are a union of instantaneous footprints, and a `Step` below two minutes is clamped to two minutes; the resulting `data-source` records the actual geometry source. + +### Besselian elements and Saros + +```go +elements, ok := eclipse.SolarEclipseBesselianElements(2460409.262835, + eclipse.SolarEclipseBesselianElementsOptions{DeltaTSeconds: 70.6, ReferenceJDE: 2460409.25}) +t := 0.5 +fmt.Printf("X=%.6f Y=%.6f D=%.6f L1=%.6f L2=%.6f\n", + elements.X.At(t), elements.Y.At(t), elements.D.At(t), elements.L1.At(t), elements.L2.At(t)) +fmt.Println(info.HasSaros, info.Saros) +``` + +```text +X=-0.062351 Y=0.355150 D=7.593613 L1=0.535842 L2=-0.010245 +true {136 37 71 true} +``` + +- With explicit `DeltaTSeconds` and `ReferenceJDE` you can compare term by term against a published Besselian table; published tables use the sidereal time of T0 itself, so convert with `SolarEclipseBesselianMuForPublishedTable` first. +- Before comparing UT instants against NASA catalogues or figure pages, subtract the ΔT convention they were published with, as described under [Time comparison against NASA data](#checks-against-nasa-data); the TD layer (without ΔT) is the one that tests ephemeris and geometry on its own. + +## Solar eclipse + +> Figure and footer time-scale declarations are documented in [Time Scale Declaration](map-geojson.md#time-scale-declaration). + +Solar-eclipse calculation lives in `eclipse`; SVG generation lives in `eclipse/svg`. The default lunar-radius convention follows NASA bulletin split-`k` (penumbral/partial `k = 0.2724880`, umbral and antumbral `k = 0.2722810`); IAU single-`k` (`0.2725076`) variants are available through same-named `...IAUSingleK` functions. + +There are two solar-radius conventions: the **standard** one (`959.639″` at 1 AU, matching published ephemerides and catalogues, the default) and the **measured** one (`959.95″` at 1 AU, the eclipse solar radius inferred from limb light curves), 0.31″ apart. + +For the 2024-04-08 total eclipse the measured convention narrows the path by about 0.6 km per side and shortens totality by about 1.5 s; for the 2023-10-14 annular eclipse it widens the path by about 1.3 km and lengthens the annular phase by about 2.1 s; partial magnitudes change by about `1e-4`. + +This lets you gauge how sensitive eclipse limits and central durations are to the solar radius. + +Either convention can be selected through `eclipse.SolarEclipseOptions{SunRadiusModel: ...}` with `SolarEclipseOnDateWithOptions`, `LocalSolarEclipseOnDateWithOptions`, and the search/panel variants `LastSolarEclipseWithOptions` / `NextSolarEclipseWithOptions` / `ClosestSolarEclipseWithOptions` / `SolarEclipseGeocentricPanelWithOptions`, or through `basic.SolarEclipseWithOptions` / `basic.LocalSolarEclipseWithOptions` and the `SunRadiusModel` field of the various `...Options` structs; the returned `SunRadiusModel` records the convention used, and `basic.SolarEclipseSunSemidiameter` together with the "S.D." rows of the panels use that same convention. + +Common entry points: + +- `SolarEclipseOnDate`: detect whether a global solar eclipse occurs near a local date +- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`: search global solar eclipses +- `LocalSolarEclipseOnDate`: detect whether a site can see a local solar eclipse on that date +- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`: search locally visible solar eclipses +- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`: search locally visible total solar eclipses, returning `(info, ok)` +- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`: search locally visible annular solar eclipses, returning `(info, ok)` +- `SolarEclipseCentralPath`: compute central line, northern/southern limits, and greatest-eclipse point +- `SolarEclipsePartialFootprints`: compute the partial-eclipse penumbral footprint on Earth, with optional sampled umbral/antumbral outlines +- `SolarEclipseBesselianElements`: return the polynomial Besselian elements of one solar eclipse (`X`/`Y`/`D`/`L1`/`L2`/`Mu` cubic coefficients plus `TanF1`/`TanF2`), or `(zero, false)` when the window holds no eclipse +- `eclipse/svg.LocalSolarEclipseSVG`: render a local solar-disk SVG + +`SolarEclipseBesselianElements` returns the same shape of table that published element tables carry: `T0JDE` is the reference instant, `t = (jde - T0JDE) * 24` is the number of TT hours from it, and `X`/`Y`/`D`/`L1`/`L2`/`Mu` are cubics in `t` (`D` and `Mu` in degrees, the rest in equatorial Earth radii), with `TanF1`/`TanF2` constant over the eclipse. + +`L1`/`L2` use the Explanatory Supplement form with its `1/cos f` factor, and a negative umbral `L2` means the Moon's centre has not yet passed the umbra's apex. + +By default `T0` is the whole TT hour below greatest eclipse, the window half-width is 3 hours, and five evenly spaced samples inside it are fitted by least squares, matching the fitting convention of published tables. + +The `Model`, `SunRadiusModel`, `PenumbralK`, `UmbralK` and `DeltaTSeconds` fields record the conventions that fix those numbers and are returned with them. + +**`Mu` uses a different time argument from published tables and must be shifted before comparison.** Published tables evaluate sidereal time at `T0` itself, while this library uses `UT = TT - ΔT`; the two differ by `DeltaT * 15.041067/3600` degrees. `Mu` stays continuous across the window and is not folded into `[0,360)`, and `SolarEclipseBesselianMuForPublishedTable` shifts only the constant term. + +The library reports the true Greenwich hour angle so that `Mu` stays consistent with its own greatest-eclipse longitude, centre line and contact times; pairing a published table's `Mu` with a correct sidereal time yields a longitude error of about 0.3 degrees (roughly 30 km at the greatest-eclipse latitude of 2024-04-08). + +```go +// A Besselian element table; an explicit T0 and DeltaT make it directly comparable. +elements, ok := eclipse.SolarEclipseBesselianElements( + 2460409.262835, // a TT Julian day near the total solar eclipse of 2024-04-08 + eclipse.SolarEclipseBesselianElementsOptions{DeltaTSeconds: 70.6, ReferenceJDE: 2460409.25}, +) +if ok { + t := 0.5 // 0.5 TT hours from T0 + fmt.Println(elements.X.At(t), elements.Y.At(t), elements.D.At(t)) // fundamental-plane coordinates and axis declination + fmt.Println(elements.L1.At(t), elements.L2.At(t)) // penumbral and umbral radii + // Published tables evaluate sidereal time at T0 itself; shift before comparing. + fmt.Println(eclipse.SolarEclipseBesselianMuForPublishedTable(elements.Mu, elements.DeltaTSeconds).At(t)) +} +``` + +`SolarEclipsePartialFootprintsInfo` also reports global shadow contacts. `P1/P4` are the external penumbral contacts and `P2/P3` are the internal penumbral contacts; `U1/U4` are the external umbral or antumbral contacts and `U2/U3` are the internal contacts. Contacts that do not occur remain zero `time.Time` values. + +`CentralBeginOnEarth` / `CentralEndOnEarth` retain their existing meaning of the shadow axis entering and leaving Earth; they are not aliases for `U1/U4`. + +Set `CentralShadowStep` in `SolarEclipsePartialFootprintOptions` when structured instantaneous central-shadow outlines are needed; samples are returned in `CentralShadowFootprints`. Zero disables this extra calculation in the data API; the SVG entry points follow the same rule and sample only for positive values (rounded up to one minute). + +The same options struct takes `GreatestTimeValues` or `GreatestTimeStep` when the isochrones are wanted straight from the data layer. + +`GreatestTimeValues []time.Time` holds the **greatest-eclipse time levels** as absolute instants (their `Location` does not affect the computation); at most 64 are kept, duplicates and levels outside the partial-eclipse window are skipped, the rest are sorted, and anything beyond the earliest 64 is dropped; a level with no usable branch produces no entry. + +When it is empty, `GreatestTimeStep` generates the levels instead; only a positive step applies, and the grid aligns to UTC ticks. A display-timezone grid has to be generated by the caller and passed through `GreatestTimeValues`. + +Contours come back in `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`. + +`JDE` is the matching TT Julian ephemeris day, `Time` is that level in the input timezone (an explicit level is echoed back unchanged, a step-derived one is converted from `JDE` and rounded to the millisecond so the round trip cannot truncate a whole minute into the previous one), and `Segments` are the isochrone branches at that instant. + +Isochrones exist only where the solar and lunar disks actually overlap and the Sun is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the partial-visibility boundary, nothing is continued beyond ±88° latitude, and one instant may carry several disconnected branches. + +When they are not requested, existing output is unchanged. + +`SolarEclipseInfo`, `LocalSolarEclipseInfo`, and the embedded `Eclipse` field in `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` include Saros metadata: + +- `HasSaros`: whether a Saros series was matched +- `Saros.Series`: NASA Saros series number when `Verified=true`, otherwise a provisional derived series number +- `Saros.Member`: 1-based member number within that series +- `Saros.Count`: total member count of that series +- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` + +Saros note: + +- One Saros is about `6585.321` days, that is `223` synodic months or about `18 years 11 days 8 hours`; series members are ordered by this period. +- A Saros series is a sequence of eclipses separated by one Saros period. `Series` identifies the sequence, while `Member` / `Count` describe the event's position in it. +- Saros metadata belongs to the eclipse event, not to the observing site. Global, local, path, and footprint results for the same eclipse should report the same Saros. +- Embedded NASA anchors take precedence. Unmatched events in astronomical years `-3000` through `+6000` use a precomputed extension table, including both end years; year `0` is 1 BCE. Only events outside that interval use live extrapolation. + + Precomputed and live results have `Verified=false` and are not official NASA assignments. +- Extended numbers follow NASA's [Saros/Inex numbering relations](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html). + + Members are computed with the Split-K model across the complete series, without clipping at the precomputed year limits. The computed result for `3288-11-15` is series `202`, member `1/71`. +- For example, the `2024-04-08` North American total solar eclipse is member `30/71` of Solar Saros `139`. + +### Timing checks against NASA material + +Solar-eclipse timing is checked in two forms: + +- **Global eclipses**: greatest-eclipse UT, magnitude, gamma, greatest-eclipse coordinates, and path width. The current comparison against NASA GSFC eclipse search / Besselian element material covers the `2023-04-20` hybrid, `2024-04-08` total, `2024-10-02` annular and `2025-03-29` partial eclipses. +- **Local eclipses**: local first contact, greatest eclipse, last contact, and totality/annularity duration. + + The current comparison against NASA GSFC local circumstances / Google map material covers a Chicago partial eclipse, the 2024 total-eclipse greatest point, and the 2024 annular-eclipse greatest point. + +**Separate the two layers first**, otherwise the numbers cannot be read: + +| Layer | What is compared | What it actually tests | Known magnitude | +| --- | --- | --- | --- | +| **TT / TD layer** (dynamical time, ΔT removed) | Geometry and ephemeris: Besselian elements, shadow-axis position, contact instants in TD | The ephemeris and shadow geometry on their own | about `0.2 s` median on the four regression samples; over 1901-2100 against NASA (195 paired eclipses) median `0.40 s`, p99 `2.37 s`, max `2.51 s` | +| **UT layer** (civil instants, including the ΔT convention) | The above plus one Earth-rotation conversion | That, plus whichever ΔT the publisher chose | systematic `4.8-5.9 s`, a convention offset rather than a geometry error | + +**The UT-layer difference comes from the ΔT convention, not from geometry**: NASA catalogue and diagram-page UT times are converted from TD with the ΔT adopted at publication (`74 s` for 2024, `75 s` for 2026), while the measured ΔT is about `69.1-69.2 s`. + +That `4.8-5.9 s` gap enters every UT-level comparison. Convert it to a ground quantity with `basic.DeltaTGroundShiftKM(deltaDeltaT, latitude)` (that is `0.4651*|deltaDeltaT|*cos(latitude)` km; ΔT only rotates the Earth, it does not move the TT geometry): measured `DeltaTGroundShiftKM(5, 36) = 1.881 km` and `DeltaTGroundShiftKM(5.9, 24) = 2.507 km`. + +**Read each threshold by layer**: + +| Check type | Sample | Time fields | Layer | Result | +| --- | --- | --- | --- | --- | +| Global solar eclipse | 4 modern eclipses | greatest-eclipse UT (including the publisher's ΔT convention) | UT layer; the `8 s` threshold **contains** the convention offset, see the next row for the cleaned value | second-level agreement within `8 s`, of which `4.8-5.9 s` is the ΔT convention | +| Global solar eclipse | same samples with the convention removed | greatest-eclipse TD (our TT against NASA TD) | TT layer | median difference about `0.2 s` | +| Local solar eclipse | 3 observing sites | greatest eclipse, first contact, last contact | UT layer, but public values are mostly whole minutes | matches the published minute values (a resolution limit, not a second-level claim) | +| Local central eclipse | 2 central-eclipse points | totality/annularity duration | **TT layer**: a difference of two instants, so ΔT cancels | second-level agreement, within `5 s` | + +- **Duration is the cleanest TT-layer metric**: it is the difference of two contact instants, so the ΔT convention cancels automatically; the `5 s` threshold therefore reflects geometry and ephemeris (most sensitive at the band edges) and nothing about ΔT. +- **The `8 s` threshold on absolute instants is mainly a convention metric**: it supports "the UT layer agrees" but not a geometry claim - compare the TD row for that. + + It is made up of `4.8-5.9 s` (ΔT convention) plus under `1 s` (TD-layer geometry residual) plus whole-second printing in the NASA catalogue, so it is a loose upper bound rather than an accuracy figure. +- **Wider agreement**: over 1901-2100 against NASA's five-millennium catalogue (195 paired eclipses; the NASA pages are missing 1986-2000 and 2088-2100), the type census `A145/T139/H13/P155` matches NASA entry by entry with zero type mismatches; greatest-eclipse TD differences have median `0.40 s`, p99 `2.37 s` and max `2.51 s`; + + gamma differences median `3.2e-5`, magnitude differences median `4.2e-5`, path-width differences median `0.5 km` (max `8.3 km`) and central-duration differences median `0.25 s` (max `0.59 s`). + + These are the figures that describe geometry and ephemeris accuracy. +- **To reproduce a publisher's UT values**: inject their adopted ΔT with `astro.SetDeltaT` (for example `74 s` for 2024) and read UT, which removes the convention offset from the comparison; the injection only affects the conversion and never the TT geometry. +- Global eclipse references often publish seconds, so second-level checks are meaningful there. + + Many local-circumstance pages publish contact times only to whole minutes, so minute-level agreement is the correct interpretation for those fields. + + The 2009 Yangshan and 2012 Xiamen examples below only demonstrate API calls and SVG output and claim no publication-grade accuracy for local contact times; check them item by item against NASA/IMCCE local circumstances when that matters. + +### 2009 Yangtze River total eclipse near Yangshan + +`2009-07-22` is the Great Yangtze Eclipse. The example below uses a site near Yangshan at the Yangtze River estuary southeast of Shanghai, close to the center line; totality lasts about 5 minutes 57 seconds. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) + + // Near Yangshan, Shanghai. East longitude and north latitude are positive; elevation is 0 m. + info, ok := eclipse.LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0) + fmt.Println(ok, info.Type) // whether a local eclipse is found; eclipse type + fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count + fmt.Println(info.PartialStart) // first contact + fmt.Println(info.CentralStart) // totality begins + fmt.Println(info.GreatestEclipse) // greatest eclipse + fmt.Println(info.CentralEnd) // totality ends + fmt.Println(info.PartialEnd) // last contact + fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // totality duration + fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // magnitude, obscuration, solar altitude at greatest eclipse + + // Central path for the same date, including greatest point, center line, and northern/southern limits. + path, _ := eclipse.SolarEclipseCentralPath( + date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 100}, + ) + fmt.Printf("greatest lon=%.4f lat=%.4f width=%.1fkm center=%d\n", + path.Greatest.Longitude, + path.Greatest.Latitude, + path.Greatest.WidthKM, + len(path.CenterLine), + ) +} +``` + +Output: + +```text +true total // Yangshan site has a local total solar eclipse +true {136 37 71 true} // Solar Saros 136, member 37/71, verified +2009-07-22 08:23:55.092397034 +0800 CST // first contact +2009-07-22 09:37:23.088684976 +0800 CST // totality begins +2009-07-22 09:40:20.87983489 +0800 CST // greatest eclipse +2009-07-22 09:43:19.723805487 +0800 CST // totality ends +2009-07-22 11:03:13.914820253 +0800 CST // last contact +5m56.635120511s // totality duration +magnitude=1.076997 obscuration=1.000000 altitude=57.293 // magnitude, obscuration, solar altitude at greatest eclipse +greatest lon=144.1167 lat=24.2193 width=258.3km center=289 // global greatest point, path width, center-line sample count +``` + +### 2012 Xiamen annular eclipse + +The `2012-05-21` annular eclipse was visible from the southeast coast of China. The Xiamen example has the Sun about 9.6 degrees above the horizon at greatest eclipse, and annularity lasts about 4 minutes 19 seconds. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/eclipse" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst) + + info, ok := eclipse.LocalSolarEclipseOnDate(date, 118.0894, 24.4798, 0) + fmt.Println(ok, info.Type) // whether a local eclipse is found; eclipse type + fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count + fmt.Println(info.PartialStart) // first contact + fmt.Println(info.CentralStart) // annularity begins + fmt.Println(info.GreatestEclipse) // greatest eclipse + fmt.Println(info.CentralEnd) // annularity ends + fmt.Println(info.PartialEnd) // last contact + fmt.Println(info.CentralEnd.Sub(info.CentralStart)) // annularity duration + fmt.Printf("magnitude=%.6f obscuration=%.6f altitude=%.3f\n", info.Magnitude, info.Obscuration, info.SunAltitude) // magnitude, obscuration, solar altitude at greatest eclipse +} +``` + +Output: + +```text +true annular // Xiamen site has a local annular solar eclipse +true {128 58 73 true} // Solar Saros 128, member 58/73, verified +2012-05-21 05:08:12.878718674 +0800 CST // first contact +2012-05-21 06:08:15.561088621 +0800 CST // annularity begins +2012-05-21 06:10:25.180663168 +0800 CST // greatest eclipse +2012-05-21 06:12:34.80941087 +0800 CST // annularity ends +2012-05-21 07:20:54.806806147 +0800 CST // last contact +4m19.248322249s // annularity duration +magnitude=0.933289 obscuration=0.872354 altitude=9.565 // magnitude, obscuration, solar altitude at greatest eclipse +``` + +### Solar-eclipse SVG + +The modern city example uses the `2035-09-02` total solar eclipse in Beijing. With approximate downtown coordinates (`116.4074E`, `39.9042N`), this event belongs to Solar Saros `145` as member `23/77`, and local totality lasts about `1m33s`. + +The default solar-eclipse SVG header includes Saros metadata and totality/annularity duration. `LocalSolarEclipseSVGOptions` can override: + +- `Title`: main title +- `SummaryText` / `GreatestText` / `MetaText`: three subtitle lines under the title +- `OverviewTitle` / `PhasePanelsTitle` / `ContactsTitle`: section titles +- `DirectionText` / `FooterNote`: footer direction note and extra note + +```go +package main + +import ( + "fmt" + "os" + "time" + + "b612.me/astro/eclipse" + eclipsesvg "b612.me/astro/eclipse/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + + // 2009 Yangshan total solar eclipse diagram. + totalSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + 121.9850, 30.6167, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + Language: "en", + }, + ) + fmt.Println(ok, len(totalSVG)) // whether SVG generation succeeded; SVG byte length + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-yangshan-2009-en.svg", []byte(totalSVG), 0o644) + } + + // 2012 Xiamen annular solar eclipse diagram. + annularSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + time.Date(2012, 5, 21, 12, 0, 0, 0, cst), + 118.0894, 24.4798, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + Language: "en", + }, + ) + fmt.Println(ok, len(annularSVG)) // whether SVG generation succeeded; SVG byte length + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-xiamen-2012-en.svg", []byte(annularSVG), 0o644) + } + + // 2035 Beijing total solar eclipse diagram, including Saros metadata and totality duration. + beijingDate := time.Date(2035, 9, 2, 12, 0, 0, 0, cst) + beijingInfo, ok := eclipse.LocalSolarEclipseOnDate(beijingDate, 116.4074, 39.9042, 0) + fmt.Println(ok, beijingInfo.Type) // whether a local eclipse is found; eclipse type + fmt.Println(beijingInfo.HasSaros, beijingInfo.Saros) // Saros match flag; series, member number, total count + fmt.Println(beijingInfo.CentralEnd.Sub(beijingInfo.CentralStart)) // totality duration + + beijingSVG, ok := eclipsesvg.LocalSolarEclipseSVG( + beijingDate, + 116.4074, 39.9042, 0, + eclipsesvg.LocalSolarEclipseSVGOptions{ + Width: 920, + Height: 720, + Step: 5 * time.Minute, + Location: cst, + Language: "en", + }, + ) + fmt.Println(ok, len(beijingSVG)) // whether SVG generation succeeded; SVG byte length + if ok { + _ = os.WriteFile("doc/img/solar-eclipse-beijing-2035-en.svg", []byte(beijingSVG), 0o644) + } +} +``` + +Output: + +```text +true 13587 // Yangshan total-eclipse SVG generated, 13587 bytes +true 13516 // Xiamen annular-eclipse SVG generated, 13516 bytes +true total // Beijing site has a local total solar eclipse +true {145 23 77 true} // Solar Saros 145, member 23/77, verified +1m33.329527974s // totality duration near downtown Beijing +true 13548 // Beijing total-eclipse SVG generated, 13548 bytes +``` + +Rendered examples: + +![2009 Yangshan total solar eclipse](../../img/solar-eclipse-yangshan-2009-en.svg) + +![2012 Xiamen annular solar eclipse](../../img/solar-eclipse-xiamen-2012-en.svg) + +![2035 Beijing total solar eclipse](../../img/solar-eclipse-beijing-2035-en.svg) + +## Lunar eclipse + +Lunar-eclipse detection and search live in `eclipse`; returned times preserve the input `time.Time` location. + +Common entry points: + +- `LunarEclipseOnDate`: detect whether a lunar eclipse occurs on a local date +- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`: search global lunar eclipses +- `LocalLunarEclipseOnDate`: detect whether a visible lunar eclipse is visible from a site on a local date +- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`: search visible local lunar eclipses +- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`: search visible local total lunar eclipses, returning `(info, ok)` +- `GeometricLocalLunarEclipseOnDate`: detect geometric lunar eclipse overlap without filtering by whether the Moon is above the horizon +- `eclipse/svg.LunarEclipseSVG`: render a lunar-eclipse shadow-path SVG + +`LocalLunarEclipseInfo.Visibility` classifies local visibility into eight states: `full`, `moonrise`, `moonset`, `rise-and-set`, `interrupted`, `penumbra-moonrise`, `penumbra-moonset`, and `invisible`. The two `penumbra-*` states require the Moon to remain below the horizon throughout the umbral phase; the check uses the altitude extremum over that interval to include grazing windows between contacts. A purely penumbral eclipse has no umbral contacts and never returns these states. `interrupted` means the Moon is visible at both penumbral contacts but drops below the horizon in between, even if the whole umbral phase is below it. Classification uses the site's local culmination, not the global greatest-eclipse instant. + +`MarshalLunarEclipse` exports the instantaneous hemispheres `visible-at-p1` and `visible-at-p4`, plus the time envelopes `visible-during-eclipse` and `visible-throughout-eclipse`. Their `aggregation` values are `union` and `intersection`; the latter is an empty `MultiPolygon` when no location remains visible for the whole interval. Envelopes use longitude columns derived from `boundary_points`, clamped to 360..720, and report the count as `longitude_points`. + +The same API exports `penumbra-moonset` and `penumbra-moonrise` bands for sites visible at P1 or P4 respectively, but below the horizon throughout the umbral phase. Each carries `phase=penumbral-only` and its own contact times. Purely penumbral eclipses export neither band. + +`MarshalLunarEclipseWithOptions` uses `LunarEclipseOptions` to control output and sampling. `SkipRoles` can omit envelopes and penumbra-only bands; skipping both envelopes also skips their sampling. `EnvelopeSweepSamples` defaults to 48 and is clamped to `[2, 192]`; `EnvelopeLongitudePoints` defaults to `max(360, boundaryPoints)` and is clamped to `[12, 720]`. + +`MoonHorizon` and `MoonStateAt` take a UTC Julian day. `HMoonHeight(jd, lon, lat, tz)` takes a local civil Julian day and a timezone offset in hours; only `tz=0` makes `jd` a UTC value. Time-scale conversion happens inside the library. + +`LunarEclipseInfo` includes: + +- eclipse type `Type` +- Saros metadata `HasSaros` / `Saros` +- penumbral magnitude `PenumbralMagnitude` +- umbral magnitude `UmbralMagnitude` +- P1, U1, U2, greatest eclipse, U3, U4, P4 contact times + +`Saros` has the same meaning as in the solar-eclipse section: + +- `Saros.Series`: NASA lunar Saros series number when `Verified=true`, otherwise a provisional derived series number +- `Saros.Member`: 1-based member number within that series +- `Saros.Count`: total member count of that series +- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` + +Lunar metadata also uses NASA anchors first, the extension table for astronomical years `-3000` through `+6000`, and live extrapolation outside that interval. + +Computed members include the union of events detected by Danjon and Chauvenet, so metadata is independent of the requested lunar model and observing site. Very shallow members may differ from the NASA catalog; `Verified` remains `false`. + +For example, the cross-year total lunar eclipse on `2028-12-31 / 2029-01-01` is member `49/72` of Lunar Saros `125`. + +Two shadow-radius conventions are retained: + +- **Danjon** (default): multiplies only the lunar horizontal-parallax term by `1.01`, then combines it with the solar semidiameter and solar parallax. + + NASA GSFC's current lunar-eclipse catalogs and diagram pages use the same route, as do the library defaults `LunarEclipseOnDate`, `LastLunarEclipse`, `NextLunarEclipse`, and `ClosestLunarEclipse`. +- **Chauvenet**, compatibility convention: starts with `0.99834 x Earth equatorial radius` and then multiplies the full shadow radii by `51/50`. This is closer to older traditional tables and is useful for compatibility checks. + +Differences: + +- `Chauvenet` gives larger penumbral and umbral shadows. Penumbral magnitude is usually about `0.025` larger, and umbral magnitude about `0.005` larger. +- For edge cases, `Chauvenet` can push an eclipse toward a deeper type. +- Against NASA catalogs, modern ephemeris software, or current mainstream lunar-eclipse material, the matching convention is the default `Danjon`. +- For compatibility with existing historical baselines, the matching convention is the explicitly-called `Chauvenet`. + +### Code example + +```go +package main + +import ( + "b612.me/astro/eclipse" + "fmt" + "time" +) + +func main() { + date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) + + // Default Danjon model, closer to NASA current material. + info := eclipse.ClosestLunarEclipse(date) + fmt.Println(info.Type) // eclipse type + fmt.Println(info.HasSaros, info.Saros) // Saros match flag; series, member number, total count + fmt.Println(info.Maximum) // greatest-eclipse time + fmt.Println(info.PenumbralMagnitude, info.UmbralMagnitude) // penumbral and umbral magnitudes + fmt.Println(info.PenumbralStart) // P1, penumbral eclipse begins + fmt.Println(info.PartialStart) // U1, partial eclipse begins + fmt.Println(info.TotalStart) // U2, totality begins + fmt.Println(info.TotalEnd) // U3, totality ends + fmt.Println(info.PartialEnd) // U4, partial eclipse ends + fmt.Println(info.PenumbralEnd) // P4, penumbral eclipse ends + + // Chauvenet model for compatibility with older conventions. + legacy := eclipse.ClosestLunarEclipseChauvenet(date) + fmt.Println(legacy.PenumbralMagnitude, legacy.UmbralMagnitude) // magnitudes under Chauvenet + + // Check a local civil date. Output time zone follows the input date. + local := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) + today, ok := eclipse.LunarEclipseOnDate(local) + fmt.Println(ok) // whether this local date overlaps a lunar eclipse + fmt.Println(today.Type) // eclipse type + fmt.Println(today.Maximum) // greatest-eclipse time in the input time zone +} +``` + +Output: + +```text +total // eclipse type +true {125 49 72 true} // Lunar Saros 125, member 49/72, verified +2028-12-31 16:52:05.603753328 +0000 UTC // greatest eclipse +2.2739938633996872 1.2461094682708755 // penumbral and umbral magnitudes +2028-12-31 14:03:54.239418804 +0000 UTC // P1 +2028-12-31 15:07:42.171904742 +0000 UTC // U1 +2028-12-31 16:16:27.306801974 +0000 UTC // U2 +2028-12-31 17:27:46.228030622 +0000 UTC // U3 +2028-12-31 18:36:32.270547151 +0000 UTC // U4 +2028-12-31 19:40:11.575520038 +0000 UTC // P4 +2.299608256177245 1.2511661731458574 // Chauvenet penumbral and umbral magnitudes +true // local date overlaps an eclipse +total // local eclipse type +2029-01-01 00:52:05.603753328 +0800 CST // greatest eclipse in UTC+8 +``` + +### Checks against NASA data + +The reference values come from NASA GSFC's lunar-eclipse catalog (greatest eclipse printed in **TD**, magnitudes as catalogued). + +**A UT-level comparison must first remove the time-scale convention**: the catalogue and the single-eclipse diagram pages convert TD to UT with the ΔT adopted at publication (`75 s` for 2026, `74 s` for 2024), while the measured ΔT is only about `69.1-69.2 s`. + +That is a `4.8-5.9 s` offset, so any UT-level contact comparison carries it wholesale; it is not a lunar-geometry error. The TD level (no ΔT) is the layer that tests the ephemeris and the shadow geometry on its own. + +| Sample | Model | Penumbral magnitude error | Umbral magnitude error | Greatest-eclipse TD difference (no ΔT) | Greatest-eclipse UT difference (with the ΔT convention) | +| --- | --- | --- | --- | --- | --- | +| 2026-03-03 total lunar eclipse | Danjon | -0.000067208 | -0.000069993 | +0.114 s | +5.99 s | +| 2026-03-03 total lunar eclipse | Chauvenet | +0.025599846 | +0.004935007 | +0.114 s | +5.99 s | +| 2026-08-28 partial lunar eclipse | Danjon | -0.000113694 | -0.000033624 | +0.090 s | +5.93 s | +| 2026-08-28 partial lunar eclipse | Chauvenet | +0.025567662 | +0.004957333 | +0.090 s | +5.93 s | +| 2024-03-25 penumbral lunar eclipse | Danjon | -0.000176555 | see note below | +1.012 s | +5.81 s | +| 2024-03-25 penumbral lunar eclipse | Chauvenet | +0.026044973 | see note below | +1.012 s | +5.81 s | + +For the `2026-03-03` total lunar eclipse, current default `Danjon` differences against NASA are: + +- type: both `total` +- greatest eclipse: our TD `11:34:52.113` vs NASA `11:34:52`, difference `+0.114 s`; in UT we are `5.99 s` later than the published diagram page, of which `5.88 s` is NASA's `ΔT = 75 s` against our measured `ΔT = 69.12 s` +- phase durations: penumbral `338.67 min` vs NASA `338.6`, umbral `207.17 min` vs `207.2`, total `58.31 min` vs `58.3`, all inside the catalogue's 0.1-minute printing resolution +- penumbral magnitude: `2.183732792` vs NASA `2.1838`, error `-0.000067208` +- umbral magnitude: `1.150630007` vs NASA `1.1507`, error `-0.000069993` + +For the same eclipse, `Chauvenet` gives: + +- type: both `total` +- penumbral magnitude: `2.209399846` vs NASA `2.1838`, error `+0.025599846` +- umbral magnitude: `1.155635007` vs NASA `1.1507`, error `+0.004935007` + +`Chauvenet` is the compatibility model kept for older almanac conventions: both shadows are larger than the default `Danjon`, so magnitudes and contacts shift at the minute / one-percent level against the current NASA catalogue. + +That is a model-convention difference and does not describe the timing accuracy of the default lunar-eclipse entry points. + +For pure penumbral eclipses, NASA may publish negative `umbral magnitude`, meaning the Moon's disk center remains outside the umbral boundary by that amount. + +This library preserves that negative value, so pure penumbral cases are compared in the same convention. + +> Note: the NASA catalogue prints TD to whole seconds, so anything within ±0.5 s is printing resolution; the `+1.012 s` for `2024-03-25` is slightly beyond that, a difference between the two chains in the very shallow penumbral geometry. + +### Lunar-eclipse SVG + +The default model and the suffixed entry-point conventions of `LunarEclipseSVG`, `LunarEclipseDetailedSVG` and `LunarEclipseMapSVG` are described under [Lunar eclipse charts](#lunar-eclipse-charts) below. + +The default lunar-eclipse SVG header includes Saros metadata. `LunarEclipseSVGOptions` can override: + +- `Title`: main title +- `SummaryText` / `MaximumText` / `CoordinatesText` / `DurationText` / `MetaText`: five information lines under the title +- `ContactsTitle`: contact-time section title +- `DirectionText` / `FooterNote`: footer direction note and extra note + +```go +package main + +import ( + "fmt" + "os" + "time" + + eclipsesvg "b612.me/astro/eclipse/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // Render the shadow-path diagram for the cross-year total lunar eclipse on 2029-01-01 UTC. + svg, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{ + Width: 960, + Height: 620, + Step: 10 * time.Minute, + Location: cst, + Language: "en", + }, + ) + fmt.Println(ok, len(svg)) // whether SVG generation succeeded; SVG byte length + if ok { + _ = os.WriteFile("doc/img/lunar-eclipse-2029-01-01-en.svg", []byte(svg), 0o644) + } +} +``` + +Output: + +```text +true 19816 // lunar-eclipse SVG generated, 19816 bytes +``` + +Rendered example: + +![2029 cross-year total lunar eclipse shadow path](../../img/lunar-eclipse-2029-01-01-en.svg) + +### References + +- NASA lunar eclipse decade catalog: +- NASA 2026-03-03 total lunar eclipse diagram: +- NASA 2026-08-28 partial lunar eclipse diagram: +- NASA 2024-03-25 penumbral lunar eclipse diagram: +- NASA lunar eclipse algorithm and history notes: + +## Solar and Lunar Eclipse Charts + +All five `eclipse/svg` entry points return `(string, bool)`. + +A `false` second value means the chart cannot be drawn with the current arguments (no such event on that date, canvas below the floor, or a UT1 scale paired with a non-UTC location) - it is not a rendering error. + +| Entry point | Chart | Suggested canvas | +| --- | --- | --- | +| `LocalSolarEclipseSVG` | Fixed-site solar disk chart (see "Solar-eclipse SVG" above) | 920x720 and up | +| `SolarEclipseMapSVG` | Global visibility map, four selectable projections | 1200x800; orthographic globe 1000x1414 | +| `LunarEclipseSVG` | Lunar shadow-path diagram | 960x620 and up | +| `LunarEclipseMapSVG` | Lunar world visibility map | 1200x800 | +| `LunarEclipseDetailedSVG` | Lunar detailed layout (diagram and base map on one page) | 1000x1414 or 1414x1000 | + +A solar global map draws the full partial-visibility region, the total/annular central band, the central line, global phase information and central-line time markers, together with the rise/set lines of first/greatest/last contact, the subsolar point, the shadow-axis entry and exit points, the `P1-P4/U1-U4` contacts, and the sampled penumbral and umbral/antumbral outlines that are off by default and requested on demand. + +A lunar map draws the `P1/P4` Moon-visible hemispheres, the moonrise/moonset transition zones and the all-visible region. It uses the three-shape approximation over the P1, greatest-eclipse and P4 horizons and does not carry the swept time envelopes exported on the GeoJSON side, so a very narrow unshaded seam can remain between the two horizon lines. + +### Global visibility maps + +#### Equirectangular + +The minimal call passes only the event date and the display time zone; everything else defaults (`TimeLabelStep` is 30 minutes): + +```go +solar, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1414, Height: 1000, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +fmt.Println(ok, len(solar)) +``` + +![2009 Yangtze total solar eclipse global visibility map](../../img/solar-eclipse-yangshan-2009-global-en.svg) + +The other two equirectangular examples are the `2012-05-21` Xiamen annular eclipse and the `2035-09-02` Beijing total eclipse. + +Both figures use the same recipe as the Yangtze map: no instantaneous penumbral/umbral outlines, 30-minute greatest-eclipse isochrones, and the magnitude contours disabled with an empty slice: + +```go +options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, +} +annular, ok := eclipsesvg.SolarEclipseMapSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), options) +total, ok := eclipsesvg.SolarEclipseMapSVG(time.Date(2035, 9, 2, 12, 0, 0, 0, cst), options) +fmt.Println(ok, len(annular), len(total)) +``` + +![2012 Xiamen annular solar eclipse global visibility map](../../img/solar-eclipse-xiamen-2012-global-en.svg) + +![2035 Beijing total solar eclipse global visibility map](../../img/solar-eclipse-beijing-2035-global-en.svg) + +#### Orthographic globe + +`EclipseMapProjectionOrthographic` gives the NASA-style **orthographic globe**: the view point is the greatest-eclipse point, only the hemisphere facing it is drawn, and the projection boundary is the great circle of the visible hemisphere. + +The orthographic projection uses the NASA-style centered-globe layout; a `1000x1414` canvas is recommended. It does not change the underlying geographic results. + +```go +globe, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1000, Height: 1414, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionOrthographic, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +``` + +![2009 Yangtze total solar eclipse orthographic globe](../../img/solar-eclipse-yangshan-2009-globe-en.svg) + +The orthographic globe is most comfortable in portrait (e.g. `1000x1414`); landscape (`1200x800`) also renders, with a visibly smaller globe. + +#### Polar azimuthal equidistant + +`EclipseMapProjectionNorthPolar` and `EclipseMapProjectionSouthPolar` place the pole at the centre of the canvas, which suits events whose band lies entirely at high latitude. + +`EclipseMapProjectionAuto` (the zero value) picks a polar map when that fits, so specify a projection explicitly only when the layout must be fixed; the projection only affects SVG presentation and never the underlying WGS84 geography. + +The partial-visibility region of the `2012-05-21` annular eclipse covers the north pole; forcing the north-polar projection makes the antimeridian-crossing extent easy to read: + +```go +arctic, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2012, 5, 21, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionNorthPolar, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, +) +``` + +![2012 annular solar eclipse north-polar global visibility map](../../img/solar-eclipse-arctic-2012-global-en.svg) + +The band of the `2021-12-04` total eclipse lies entirely over Antarctica, where the south-polar projection is the natural layout; the figure below uses the same recipe as the landscape global maps, with 30-minute greatest-eclipse isochrones only: + +```go +south, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2021, 12, 4, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + Projection: eclipsesvg.EclipseMapProjectionSouthPolar, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, + }, +) +``` + +![2021 Antarctic total solar eclipse south-polar global visibility map](../../img/solar-eclipse-southpolar-2021-12-04-en.svg) + +#### Four projections in one pass + +All four projections share one option set; only `Projection` differs, which makes it easy to generate a batch and pick a layout: + +```go +date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + } + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, options) + if !ok { + continue + } + _ = os.WriteFile("solar-eclipse-"+spec.name+".svg", []byte(svg), 0o644) +} +``` + +#### Layer and sampling switches + +| Option | Default | Meaning | +| --- | --- | --- | +| `TimeLabelStep` | 30 minutes | Central-line time-marker interval; a negative value disables them | +| `GreatestTimeStep` | off | Greatest-eclipse isochrones; a positive value must be requested explicitly, aligned to the display time zone, values below one minute become one minute, at most 64 per run | +| `MagnitudeValues` | `0.2/0.4/0.6/0.8` when nil | Magnitude-contour levels; an explicit empty slice disables them, a non-empty slice draws the given levels | +| `PenumbralOutlineStep` | off | Sampling interval of the instantaneous penumbral outline; zero or negative draws nothing, a positive value below one minute becomes one minute | +| `CentralShadowStep` | off | Sampling interval of the instantaneous umbral/antumbral outline; same rules | +| `PartialStep` | 2 minutes | Time step of partial footprints; non-positive values and positive values below two minutes are clamped to two minutes | +| `BoundaryPoints` | 180 | Angular sample count of each instantaneous partial footprint; non-positive uses 180, positive values are clamped to 12..1440 | +| `CentralStep` | 2 minutes | Central-path time step; non-positive uses two minutes, a positive value below one second becomes one second, and long events are widened automatically to keep the base path within 30000 samples | +| `TargetSpacingKM` | 150 km | Maximum center-line ground spacing; non-positive uses 150 km, NaN and +Inf disable refinement | + +The penumbral/umbral outlines are off by default. When `MagnitudeValues` is nil, the magnitude contours use the levels in the table; pass an explicit empty slice to disable them. + +The Xiamen, Beijing, north-polar and south-polar figures all use that minimum recipe: no instantaneous outlines, 30-minute greatest-eclipse isochrones, and the magnitude contours explicitly disabled: + +```go +options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, GreatestTimeStep: 30 * time.Minute, + MagnitudeValues: []float64{}, +} +``` + +Isochrones are not traced by sampling greatest-eclipse times on a grid: each fixed instant solves the zero set of `d(Sun-Moon center separation^2)/dt = 0` and continues it along the curve, so the cost is proportional to curve length. + +Every branch ends on the horizon or the partial-visibility boundary, propagation stops beyond latitude +/-88 degrees, and one instant can produce several disconnected branches. + +Degradable layers record their actual geometry source in `data-source`; the vocabulary is listed under [Map Projections](map-geojson.md#map-projections). + +#### Canvas floors and fallbacks + +- The solar-map floor is **800x560**: a width below 800 or a height below 560 falls back to 960x640. On narrower landscape canvases the map frame overlaps the right-hand data grid horizontally and the panel line spacing drops below 1 px. +- The partial-region fill is a union of instantaneous footprints, so `PartialStep` values below two minutes are clamped to two minutes; a denser request does not improve the result. +- The lunar detailed layout derives its arrangement from `Height`: `640x420` and `800x600` cannot hold the diagram and base-map floors and return `false`, while `1000x1414` and `1414x1000` render normally. + +### Lunar eclipse charts + +#### Three entry points and shadow models + +`LunarEclipseSVG` (shadow-path diagram), `LunarEclipseMapSVG` (world visibility) and `LunarEclipseDetailedSVG` (detailed layout) share one default model: Danjon first, falling back to Chauvenet for very shallow penumbral phases, matching `LunarEclipseOnDate`. + +Entries with a `Danjon` / `Chauvenet` suffix force the model; the `...Chauvenet` variants are the ones to compare against older tables that use the classical shadow radii. + +```go +diagram, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{ + Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst, + }, +) +fmt.Println(ok, len(diagram)) +``` + +![2029 New Year total lunar eclipse shadow-path diagram](../../img/lunar-eclipse-2029-01-01-en.svg) + +#### World visibility map + +The base map separates all-visible, moonrise-with-eclipse, moonset-with-eclipse and not-visible regions: all-visible requires the Moon above the horizon at `P1`, greatest eclipse and `P4` alike (visible at both contacts does not imply visible in between - at high latitudes a lower culmination can drop the Moon below the horizon around greatest, and that band is drawn as moonset-with-eclipse); moonset-with-eclipse covers places visible at `P1` but not at `P4`, plus the polar lens that is above the horizon only around greatest while below it at both contacts; moonrise-with-eclipse covers places visible at `P4` but not at `P1`; + +and not-visible means below the horizon at all three. `Projection` switches to polar and orthographic layouts; the map below is the default output, with the penumbral phases already folded into the partition: + +```go +visible, ok := eclipsesvg.LunarEclipseMapSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}, +) +``` + +![2029 New Year total lunar eclipse world visibility map](../../img/lunar-eclipse-2029-01-01-global-en.svg) + +The penumbral phase is folded into the partition by default, the way NASA's lunar eclipse world maps do it: the renderer draws the `U1`, `U2`, `U3` and `U4` horizon boundaries, shades the moonrise and moonset bands where only the penumbra is above the horizon, listed in the legend as "Penumbra moonrise" and "Penumbra moonset" (blue for moonrise, violet for moonset), and adds a row of umbral contact times under the summary line. The two bands exclude the **whole umbral interval**: a site that is above the horizon at any instant between U1 and U4 belongs to the umbral moonrise/moonset bands. The mask samples the full visible hemisphere every 15 minutes (disk resolution about 0.035 degrees), leaving a residual grazing window of about 0.05 degrees (roughly 0.15 pixel); shallower windows lasting only a few minutes are decided exactly by the site API and GeoJSON. + +A penumbral-only eclipse has no umbral contacts and renders identically either way. `DisablePenumbralPhase: true` falls back to the four-way partition of the three horizon instants and draws neither the `U1`-`U4` horizons nor the penumbra-only bands: + +```go +penumbral, ok := eclipsesvg.LunarEclipseMapSVG( + time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, DisablePenumbralPhase: true, + }, +) +``` + +`LunarEclipseDetailedSVGOptions` carries the same field for the base map of the detailed layout, which also draws the penumbral phase by default. + +#### Detailed layout + +The detailed layout combines both lunar charts on one page: a centred summary (greatest eclipse, penumbral/umbral magnitude, gamma, penumbral/umbral radii, Moon distance, Saros series), geocentric coordinate blocks for Sun and Moon on either side, the shadow-path diagram, three columns for duration, arc-minute scale and contact times, and the world visibility base map with its legend underneath. + +The shadow geometry comes from `basic.LunarEclipseShadowGeometryAt`, where **gamma uses the Earth equatorial radius while the penumbral/umbral radii are in degrees** - to convert them into Earth radii, multiply by the Earth parallax at the Moon. + +```go +detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, +) +``` + +![2029 New Year total lunar eclipse detailed layout](../../img/lunar-eclipse-2029-01-01-detailed-en.svg) + +The second event, the `2026-03-03` total lunar eclipse, uses the same entry points for a shadow-path diagram and a detailed layout: + +```go +diagram2026, ok := eclipsesvg.LunarEclipseSVG( + time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseSVGOptions{Width: 960, Height: 620, Step: 10 * time.Minute, Location: cst}, +) +detailed2026, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2026, 3, 3, 12, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, +) +``` + +![2026 total lunar eclipse shadow-path diagram](../../img/lunar-eclipse-2026-03-03-en.svg) + +![2026 total lunar eclipse detailed layout](../../img/lunar-eclipse-2026-03-03-detailed-en.svg) + +The layout follows `Height`: landscape puts the data blocks in two columns by three rows to the right of the map, portrait puts them in three columns by two rows below the globe. + +### Time scale and UT1 + +All four chart families are drawn in the UTC scale by default and state the scale inside the figure or in the footer. `TimeScale: astro.TimeScaleUT1` switches to UT1 readings and adds the `DUT1 = UT1-UTC` offset; in that mode `Location` must be UTC, otherwise the call returns `false`. Geometry is always computed on the civil instant and converted afterwards, so the conversion never shifts an isochrone. + +To read UT1 values in code, use the `...InUT1` converters of `eclipse` (`SolarEclipseInfoInUT1`, `LocalSolarEclipseInfoInUT1`, `LunarEclipseInfoInUT1`, `SolarEclipsePathInUT1`, `SolarEclipsePartialFootprintsInUT1`, `SolarEclipseGeocentricPanelInUT1`, `TimeLabelsInUT1`); they rewrite time fields only and keep zero instants as-is. See [Time Scale Declaration](map-geojson.md#time-scale-declaration) for the full convention. + +```go +ut1, ok := eclipsesvg.SolarEclipseMapSVG( + time.Date(2009, 7, 22, 12, 0, 0, 0, cst), + eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, + TimeScale: astro.TimeScaleUT1, TimeLabelStep: 30 * time.Minute, + }, +) +``` + +![2009 Yangtze total solar eclipse global visibility map (UT1 scale)](../../img/solar-eclipse-yangshan-2009-global-ut1-en.svg) diff --git a/doc/manual/en/formula.md b/doc/manual/en/formula.md new file mode 100644 index 0000000..99564e5 --- /dev/null +++ b/doc/manual/en/formula.md @@ -0,0 +1,341 @@ +# Formula Helpers + +[中文](../formula.md) | [Back to README](../../../README.en.md) + +`formula` holds the common formulas that are unrelated to a specific date or ephemeris; they suit popular-science estimates, novel settings, and teaching demonstrations. + +It performs no time-scale conversion and reads no ephemeris tables: the inputs are only instantaneous or constant parameters such as temperature, wavelength, distance, aperture, and altitude. + +Altitude and zenith-distance conventions follow [Observing-angle semantics](sun-moon.md#observing-angle-semantics); when coordinate-layer refraction is needed, use `coord`'s [airmass](coord.md#airmass); overall accuracy and applicability are in the manual [Scope And Accuracy](accuracy.md). + +## Contents + +- [Magnitude, synodic period and blackbody radiation](#magnitude-synodic-period-and-blackbody-radiation) +- [API Reference](#api-reference) + - [Blackbody and Radiation](#blackbody-and-radiation) + - [Synodic Period](#synodic-period) + - [Magnitude and Distance](#magnitude-and-distance) + - [Distance Units](#distance-units) + - [Telescope Metrics](#telescope-metrics) + - [Stellar Parameter Conversions](#stellar-parameter-conversions) + - [Airmass Models](#airmass-models) +- [Usage examples](#usage-examples) + - [Blackbody peak, total flux and stellar parameters](#blackbody-peak-total-flux-and-stellar-parameters) + - [Synodic period and magnitude/distance](#synodic-period-and-magnitudedistance) + - [Telescope limiting magnitude and resolution](#telescope-limiting-magnitude-and-resolution) + - [Comparing airmass models](#comparing-airmass-models) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Units](#units) + - [Time Scales](#time-scales) + - [Angle Quadrants and Degree/Radian Boundaries](#angle-quadrants-and-degreeradian-boundaries) + - [Zero Values and Invalid Input](#zero-values-and-invalid-input) + - [Accuracy and Applicability](#accuracy-and-applicability) + +## Magnitude, synodic period and blackbody radiation + +```go +package main + +import ( + "fmt" + + "b612.me/astro/formula" +) + +func main() { + // Empirical limiting magnitude for a 70 mm refractor at a site with naked-eye limit 6. + fmt.Printf("limiting=%.6f\n", formula.LimitingMagnitudeEmpirical(70, 6)) + + // Synodic period of Earth and Venus. Inputs and output are days. + fmt.Printf("synodic=%.6f\n", formula.SynodicPeriod(365.25636, 224.70069)) + + // Apparent magnitude of a Sun-like absolute-magnitude object at 100 pc. + fmt.Printf("apparent=%.6f\n", formula.ApparentMagnitudeFromAbsolute(4.83, 100)) + + // Treat the Sun as a 5772 K blackbody; compute peak wavelength and total radiant exitance. + fmt.Printf("peak=%.9em flux=%.6e\n", + formula.WienPeakWavelength(5772), + formula.StefanBoltzmannFlux(5772), + ) +} +``` + +Output: + +```text +limiting=11.000000 +synodic=583.920635 +apparent=9.830000 +peak=5.020394932e-07m flux=6.293859e+07 +``` + +## API Reference + +The interfaces, units, and return values are listed below by what is computed. + +The group snippets omit shared preamble: `fmt` is imported at the top of the file, and `formula` means `b612.me/astro/formula`. + +### Blackbody and Radiation + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `PlanckRadianceByWavelength` | Planck spectral radiance by wavelength | wavelength meters, temperature K; returns W·sr⁻¹·m⁻³; non-positive temperature or wavelength returns NaN | +| `WienPeakWavelength` | Wien displacement peak wavelength | temperature K; returns meters; temperature `≤ 0` or non-finite returns NaN | +| `StefanBoltzmannFlux` | total radiant exitance per unit area | temperature K; returns W/m²; `0 K` is valid and returns `0` | +| `SolarEffectiveTemperature` | built-in solar effective temperature constant | no arguments; returns K, currently `5772` | + +```go +// Treat the Sun as a 5772 K blackbody: peak wavelength, total exitance, and spectral radiance at 500 nm. +tSun := formula.SolarEffectiveTemperature() +fmt.Printf("peak=%.9e m flux=%.6e W/m^2\n", + formula.WienPeakWavelength(tSun), formula.StefanBoltzmannFlux(tSun)) +fmt.Printf("radiance@500nm=%.6e W·sr^-1·m^-3\n", + formula.PlanckRadianceByWavelength(500e-9, tSun)) +``` + +### Synodic Period + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `SynodicPeriod` | synodic period of two orbiting bodies | both inputs must share one unit and the output uses it; a period `≤ 0` or non-finite returns NaN, equal periods return `+Inf` | + +```go +// Synodic periods of Earth with other planets; inputs and outputs are days. +earth := 365.25636 +fmt.Printf("venus=%.6f mars=%.6f jupiter=%.6f\n", + formula.SynodicPeriod(earth, 224.70069), + formula.SynodicPeriod(earth, 686.980), + formula.SynodicPeriod(earth, 4332.589)) +``` + +### Magnitude and Distance + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `DistanceModulus` | distance modulus | distance pc; returns `m − M`; it is `0` at `10 pc`, and a distance `≤ 0` returns NaN | +| `ApparentMagnitudeFromAbsolute` | absolute magnitude plus distance to apparent magnitude | magnitude mag, distance pc; equals `M + distance modulus` | +| `AbsoluteMagnitudeFromApparent` | apparent magnitude plus distance to absolute magnitude | magnitude mag, distance pc; equals `m − distance modulus` | + +```go +// Sun-like absolute magnitude 4.83 placed at 10 pc / 100 pc / 1 kpc, and the inverse. +for _, d := range []float64{10, 100, 1000} { + m := formula.ApparentMagnitudeFromAbsolute(4.83, d) + fmt.Printf("d=%.0f pc m=%.6f M=%.6f mod=%.6f\n", + d, m, formula.AbsoluteMagnitudeFromApparent(m, d), formula.DistanceModulus(d)) +} +``` + +### Distance Units + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `Distance` | Converts parsecs, light-years or astronomical units to parsecs | A positive value plus a `DistanceUnit`; non-positive values, NaN and unknown units return NaN | + +| Constant | Meaning | +| --- | --- | +| `DistanceParsec` | Parsecs, an identity conversion | +| `DistanceLightYear` | Light-years | +| `DistanceAU` | Astronomical units | + +```go +// Sirius has a parallax of 0.375 arcseconds, i.e. 2.667 pc or 8.70 light-years. +pc := formula.Distance(1/0.375, formula.DistanceParsec) +fmt.Printf("%.3f pc = %.2f ly\n", pc, formula.Distance(pc, formula.DistanceParsec)/formula.Distance(1, formula.DistanceLightYear)) +``` + +Convention: the astronomical unit is `149597870.7` km, the light-year is the IAU defined `9460730472580.8` km, and the parsec follows from the exact relation `648000/π` astronomical units, giving `1 pc = 3.261563777 ly`. + +### Telescope Metrics + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `DawesLimitArcsec` | Dawes resolution limit | aperture mm; returns arcseconds from the empirical `116 / D` | +| `RayleighLimitArcsec` | Rayleigh resolution limit | aperture mm; returns arcseconds from the empirical `138.4 / D` | +| `LightGatheringPowerRatio` | light-gathering power ratio | two apertures in mm; returns `(D1 / D2)²`, dimensionless | +| `LimitingMagnitudeEmpirical` | empirical limiting magnitude | aperture mm, naked-eye limit mag; estimated as `naked-eye limit + 5·log10(D / 7)`, with 7 mm as the built-in dark-adapted pupil | + +```go +// 70 mm refractor: resolution limits, light grasp relative to a 7 mm dark-adapted pupil, and empirical limit at naked-eye limit 6. +fmt.Printf("dawes=%.6f rayleigh=%.6f\n", + formula.DawesLimitArcsec(70), formula.RayleighLimitArcsec(70)) +fmt.Printf("power=%.6f limiting=%.6f\n", + formula.LightGatheringPowerRatio(70, 7), + formula.LimitingMagnitudeEmpirical(70, 6)) +``` + +### Stellar Parameter Conversions + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `LuminosityFromRadiusTemperature` | radius plus temperature to luminosity | radius meters, temperature K; returns W via `4πR²σT⁴` | +| `LuminositySolarFromRadiusTemperature` | same, solar units | radius R☉, temperature K; returns L☉ | +| `RadiusFromLuminosityTemperature` | luminosity plus temperature to radius | luminosity W, temperature K; returns meters | +| `RadiusSolarFromLuminosityTemperature` | same, solar units | luminosity L☉, temperature K; returns R☉ | +| `EffectiveTemperatureFromLuminosityRadius` | luminosity plus radius to effective temperature | luminosity W, radius meters; returns K | +| `EffectiveTemperatureFromLuminositySolarRadius` | same, solar units | luminosity L☉, radius R☉; returns K | +| `SolarEffectiveTemperature` | built-in solar effective temperature | no arguments; returns K | + +```go +// A 2.5 R☉, 20 L☉ main-sequence star: solve for temperature, then recompute luminosity and radius. +t := formula.EffectiveTemperatureFromLuminositySolarRadius(20, 2.5) +fmt.Printf("Teff=%.6f K\n", t) +fmt.Printf("L=%.6f Lsun R=%.6f Rsun\n", + formula.LuminositySolarFromRadiusTemperature(2.5, t), + formula.RadiusSolarFromLuminosityTemperature(20, t)) +// The MKS version of the same quantities; the solar radius is the built-in constant 6.957e8 m. +rM := 2.5 * 6.957e8 +lW := formula.LuminosityFromRadiusTemperature(rM, t) +fmt.Printf("L=%.6e W R=%.6e m Teff=%.6f K\n", + lW, formula.RadiusFromLuminosityTemperature(lW, t), + formula.EffectiveTemperatureFromLuminosityRadius(lW, rM)) +``` + +### Airmass Models + +| Name | Purpose | Units and conventions | +| --- | --- | --- | +| `AirmassPlaneParallel` | plane-parallel model | true altitude in degrees; equivalent to `sec(z)`, returns `+Inf` at `0°` | +| `AirmassPlaneParallelByZenithDistance` | plane-parallel model by zenith distance | zenith distance in degrees; returns `+Inf` at `90°` | +| `AirmassKastenYoung` | Kasten-Young 1989 | apparent altitude in degrees; more robust than `sec(z)` at low altitude | +| `AirmassPickering` | Pickering 2002 | apparent altitude in degrees; intended for low-altitude correction | + +All four limit the input to `[0,90]` and return NaN outside that range or for non-finite input; altitude is measured from the horizon at `0°` to the zenith at `+90°`, zenith distance is its complement, and the convention is described under [Observing-angle semantics](sun-moon.md#observing-angle-semantics). + +```go +fmt.Println(formula.AirmassPlaneParallel(30)) +fmt.Println(formula.AirmassKastenYoung(5)) +fmt.Println(formula.AirmassPickering(5)) +fmt.Println(formula.AirmassPlaneParallelByZenithDistance(60)) +``` + +If coordinate-layer refraction correction is not needed, `formula` also provides the three airmass models directly, with more direct input semantics: + +- `AirmassPlaneParallel`: true altitude input, equivalent to the geometric `sec(z)` approximation +- `AirmassPlaneParallelByZenithDistance`: zenith-distance input +- `AirmassKastenYoung` / `AirmassPickering`: apparent-altitude input, no automatic refraction correction + +## Usage examples + +### Blackbody peak, total flux and stellar parameters + +```go +fmt.Println(formula.WienPeakWavelength(5772)) // peak wavelength (metres) +fmt.Println(formula.StefanBoltzmannFlux(5772)) // flux per unit area (W/m^2) +fmt.Println(formula.RadiusSolarFromLuminosityTemperature(1, 5772)) // radius from luminosity and temperature +fmt.Println(formula.EffectiveTemperatureFromLuminositySolarRadius(1, 1)) // temperature from luminosity and radius +``` + +```text +5.020394932432432e-07 +6.293859246828887e+07 +1.0000011882005775 +5772.003429145848 +``` + +The three conversions invert each other and return the input under one convention (the `1 -> 1.0000012` and `5772 -> 5772.0034` residuals come from both sides rounding to 5772 K); the `...Solar` variants use solar units and the plain ones use SI. + +### Synodic period and magnitude/distance + +```go +fmt.Println(formula.SynodicPeriod(365.25636, 224.70069)) // Earth and Venus (days) +fmt.Println(formula.DistanceModulus(10)) // distance modulus at 10 pc +fmt.Println(formula.ApparentMagnitudeFromAbsolute(4.83, 100)) // absolute magnitude 4.83 seen from 100 pc +fmt.Println(formula.AbsoluteMagnitudeFromApparent(4.83, 100)) // the inverse conversion +``` + +```text +583.9206352820089 +0 +9.83 +-0.16999999999999993 +``` + +`DistanceModulus(10)` is 0 because 10 pc is the defining distance of absolute magnitude; synodic periods take and return days, and the argument order does not matter. + +### Telescope limiting magnitude and resolution + +```go +fmt.Println(formula.DawesLimitArcsec(70), formula.RayleighLimitArcsec(70)) // both resolution limits at 70 mm +fmt.Println(formula.LightGatheringPowerRatio(200, 70)) // 200 mm against 70 mm +fmt.Println(formula.LimitingMagnitudeEmpirical(70, 6)) // limiting magnitude at 70 mm with a naked-eye limit of 6 +``` + +```text +1.6571428571428573 1.9771428571428573 +8.16326530612245 +11 +``` + +Dawes and Rayleigh differ by a coefficient (1.66" versus 1.98" at 70 mm), so state which one a report uses; the second argument of `LimitingMagnitudeEmpirical` is the naked-eye limit of the site and changes with it. + +### Comparing airmass models + +```go +for _, alt := range []float64{5, 30, 60, 90} { + fmt.Printf("alt=%.0f KY=%.6f Pickering=%.6f plane=%.6f\n", alt, + formula.AirmassKastenYoung(alt), formula.AirmassPickering(alt), formula.AirmassPlaneParallel(alt)) +} +``` + +```text +alt=5 KY=10.305791 Pickering=10.333706 plane=11.473713 +alt=30 KY=1.994293 Pickering=1.993154 plane=2.000000 +alt=60 KY=1.153992 Pickering=1.154058 plane=1.154701 +alt=90 KY=0.999712 Pickering=1.000000 plane=1.000000 +``` + +The three models nearly coincide at moderate and high altitude and differ most at 5 degrees (Kasten-Young against the plane-parallel model is about 1.2 airmasses); the plane-parallel model diverges at 0 degrees, so use Kasten-Young or Pickering for careful low-altitude work. + +Pressure- and temperature-corrected versions live in [Coordinate tools](coord.md#airmass) and this package keeps the raw formulas. + +## Parameter and result conventions + +### Units + +- Blackbody family: wavelength meters, temperature kelvin; `PlanckRadianceByWavelength` returns spectral radiance `W·sr⁻¹·m⁻³`, `StefanBoltzmannFlux` returns `W/m²`, and `WienPeakWavelength` returns meters. +- Stellar family: the MKS variants use radius meters, luminosity watts, and temperature kelvin; the Solar variants use solar radius R☉, solar luminosity L☉, and temperature kelvin, and both inputs and outputs are dimensionless solar multiples. +- Magnitude family: distance parsecs, magnitude mag; `DistanceModulus` returns `m − M`, also in mag. +- Distance units: `Distance` performs unit conversion only; the input unit comes from `DistanceUnit` and the return value is always parsecs. +- Telescope family: aperture millimeters; `DawesLimitArcsec` and `RayleighLimitArcsec` return **arcseconds**, not degrees; `LightGatheringPowerRatio` and `LimitingMagnitudeEmpirical` return a dimensionless ratio and a magnitude respectively. +- Airmass family: altitude (or zenith distance) in degrees; the return value is the dimensionless relative airmass with 1 at the zenith. +- Synodic period: the unit is chosen by the caller, both inputs must match, and the output matches them; the package does not assume days. +- This package produces no apparent diameter or apparent radius. Apparent-radius fields in the eclipse and occultation manuals are in arcseconds; here only the two Dawes/Rayleigh limits use arcseconds while all other angles are degrees, so do not interchange them. + +### Time Scales + +- No entry point accepts an instant or performs any time-scale conversion: the formulas depend only on parameters such as temperature, wavelength, distance, aperture, and altitude, and are independent of UTC, UT1, and TT. +- The synodic period is a length of time, not the instant of the next conjunction. + + Landing on a date requires the conjunction APIs in the planet packages plus civil time, whose convention is in the manual [Time Scale Conventions](timescale.md). +- The package has no ΔT, leap-second, or UT1 entry point; those only appear in instant-dependent chains such as `coord`, `eclipse`, and `moon`. + +### Angle Quadrants and Degree/Radian Boundaries + +- All angle parameters are degrees, are converted to radians internally, and come back as degrees or arcseconds; radians never leak to the caller. +- Altitude and zenith distance are both limited to `[0,90]`: the horizon is `0°`, the zenith is `+90°`, and zenith distance is the complement of altitude. + + The package performs no quadrant folding, so a negative altitude (below the horizon) is simply invalid and is never folded to the zenith or replaced by an absolute value. +- Zenith distance `z` and altitude `h` satisfy `z = 90° − h`; `AirmassPlaneParallel` takes `h` and `AirmassPlaneParallelByZenithDistance` takes `z`, and both must agree for the same geometry. +- Dawes/Rayleigh return arcseconds; divide by 3600 to compare with the degree-based angles in the other manuals. + +### Zero Values and Invalid Input + +- The blackbody family deliberately treats invalid input differently: `WienPeakWavelength` and `PlanckRadianceByWavelength` return NaN for temperature `≤ 0` or non-finite, while `StefanBoltzmannFlux` only returns NaN for temperature `< 0` or non-finite, so `0 K` is valid and returns `0` (a 0 K body has zero flux, while its peak wavelength is undefined). +- Synodic period: either period `≤ 0` or non-finite returns NaN; equal periods make the frequency difference zero and return `+Inf`. +- Magnitude family: `distanceParsec ≤ 0` or non-finite makes `DistanceModulus` return NaN, and the two conversion functions then return NaN as well; `DistanceModulus(10)` is always `0`. +- Telescope family: an aperture (or the second aperture) `≤ 0` or non-finite returns NaN; a zero second aperture in `LightGatheringPowerRatio` is rejected as invalid rather than dividing by zero; `LimitingMagnitudeEmpirical` takes no pupil argument, and 7 mm is a built-in constant. +- Stellar family: every input must be `> 0` or the function returns NaN; `EffectiveTemperatureFromLuminositySolarRadius`, `LuminositySolarFromRadiusTemperature`, and `RadiusSolarFromLuminosityTemperature` convert solar units to SI before computing. +- Airmass family: an altitude or zenith distance outside `[0,90]` (including negative values) or non-finite returns NaN; `AirmassPlaneParallel(0)` and `AirmassPlaneParallelByZenithDistance(90)` return `+Inf`, while `AirmassKastenYoung(0)` and `AirmassPickering(0)` stay finite. +- The package has no `(value, error)` or `(value, ok)` returns: invalid input is always expressed as NaN, which the caller checks with `math.IsNaN`. + +### Accuracy and Applicability + +- The blackbody family is an ideal-blackbody model with no absorption lines, interstellar extinction, or atmospheric extinction. `WienPeakWavelength` uses the Wien displacement constant `b = 2.897771955e-3 m·K`; because the per-frequency and per-wavelength peak conventions differ, `b/T` is strictly the peak in the wavelength convention. +- Constant conventions: `h = 6.62607015e-34 J·s`, `c = 299792458 m/s`, `k = 1.380649e-23 J/K`, `σ = 5.670374419e-8 W·m⁻²·K⁻⁴`; solar parameters `L☉ = 3.828e26 W`, `R☉ = 6.957e8 m`, `Teff = 5772 K`, the last exposed through `SolarEffectiveTemperature`. +- The magnitude family assumes no extinction, no K-correction, and no cosmological term; the two conversion functions merely add or subtract `DistanceModulus`, so their accuracy is entirely that of the externally supplied absolute magnitude and distance. +- The telescope family contains visible-light empirical values: Dawes and Rayleigh use fixed coefficients (`116` and `138.4`, aperture in mm) and do not vary with wavelength; `LightGatheringPowerRatio` compares aperture squares only and ignores central obstruction, transmission, and secondary-mirror losses; `LimitingMagnitudeEmpirical` ignores sky background, magnification, transmission, and observer skill. +- The stellar family solves `L = 4πR²σT⁴` in both directions, assuming spherical symmetry with no limb-darkening correction, rotation, or magnetic effects; the Solar and MKS variants share the same solar constants, so any difference between them comes only from that constant convention. +- Airmass family: the plane-parallel model is purely geometric `sec(z)`, usable only at moderate and high altitude, and it diverges near the horizon; Kasten-Young (1989) and Pickering (2002) are empirical fits that agree at moderate altitude and differ most near the horizon. With an apparent altitude already in hand, call `AirmassKastenYoung` / `AirmassPickering` directly; + + with only a true altitude plus refraction, use `coord`'s [airmass](coord.md#airmass). +- Every function in this package is a stateless pure function: it caches nothing and reads no global time-scale state, so it can be called in any order and, with caller-side synchronization, concurrently. diff --git a/doc/manual/en/map-geojson.md b/doc/manual/en/map-geojson.md new file mode 100644 index 0000000..1d0a377 --- /dev/null +++ b/doc/manual/en/map-geojson.md @@ -0,0 +1,533 @@ +# Event Maps, GeoJSON and KML + +[中文](../map-geojson.md) | [Back to README](../../../README.en.md) + +> Full examples in this manual run from the repository root and write their figures to `doc/img/`. + +## Contents + +- [Exporting solar-eclipse GeoJSON](#exporting-solar-eclipse-geojson) +- [API Reference](#api-reference) +- [Usage examples](#usage-examples) + - [Choosing a projection](#choosing-a-projection) + - [Exporting GeoJSON with time markers](#exporting-geojson-with-time-markers) + - [Declaring the scale and UT1](#declaring-the-scale-and-ut1) + - [Single instants and the layer vocabulary](#single-instants-and-the-layer-vocabulary) +- [Map Projections](#map-projections) +- [Time Scale Declaration](#time-scale-declaration) +- [GeoJSON](#geojson) + - [Filtering layers](#filtering-layers) + - [Coordinates, times and the antimeridian](#coordinates-times-and-the-antimeridian) + - [Solar shadows at a specified instant](#solar-shadows-at-a-specified-instant) + - [Horizon closure and interpolation](#horizon-closure-and-interpolation) + - [ΔT and ground position](#δt-and-ground-position) + - [Instant occultation footprints](#instant-occultation-footprints) + - [Finding events before calculating geometry](#finding-events-before-calculating-geometry) +- [KML](#kml) + - [Converting a file](#converting-a-file) + - [Options](#options) + - [Layers and styles](#layers-and-styles) + - [Time playback and static overlays](#time-playback-and-static-overlays) + - [File size and camera view](#file-size-and-camera-view) + - [Properties and input validation](#properties-and-input-validation) + +## Exporting solar-eclipse GeoJSON + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 0, 0, 0, 0, cst) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, + eclipse.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}) + if !ok { + log.Fatal("no solar eclipse") + } + path, ok := eclipse.SolarEclipseCentralPath(date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}) + var centralPath *eclipse.SolarEclipsePath + if ok { + centralPath = &path + } + data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, centralPath, + geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: cst}) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) +} +``` + +A partial eclipse may have no central path, so `centralPath` may be `nil`. This program writes GeoJSON to standard output for redirection to a file. SVG and KML examples follow. + +## API Reference + +SVG snippets use the import aliases `eclipsesvg "b612.me/astro/eclipse/svg"` and, for occultations, `moonsvg "b612.me/astro/moon/svg"`. Dates and time zones reuse the first example. + +| Name | Purpose | Notes | +| --- | --- | --- | +| `eclipsesvg.EclipseMapProjectionAuto` / `...Equirectangular` / `...NorthPolar` / `...SouthPolar` / `...Orthographic` | Solar/lunar map projections | The zero value is automatic | +| `moonsvg.MapProjectionAuto` / `...Equirectangular` / `...NorthPolar` / `...SouthPolar` / `...Orthographic` | Occultation map projections | Same convention | +| `SolarEclipseMapSVG` / `LunarEclipseMapSVG` | Solar and lunar global maps | Return `(string, bool)` | +| `StarOccultationPathSVG` / `PlanetOccultationPathSVG` | Occultation global path maps | Return `(string, error)` | +| `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` | Solar eclipse GeoJSON | Without and with time markers | +| `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` / `MarshalLunarEclipseWithOptions` | Lunar eclipse GeoJSON | Same three | +| `MarshalStarOccultation` / `MarshalPlanetOccultation` (and `...WithTimeMarkers`) | Occultation GeoJSON | Same pair | +| `NewSolarEclipseShadowSolver` / `MarshalSolarEclipseShadowInstant` | Single-instant footprint and its GeoJSON | For time-axis scrubbing | +| `TimeMarkerOptions` | Marker `Step`, `Location` and `TimeScale` | `Step` defaults to 30 minutes, at most 1440 markers | +| `astro.TimeScaleUT1` / `astro.DUT1` | UT1 scale and the DUT1 offset | UT1 requires a UTC `Location` | +| `kml.FromGeoJSON` | Convert GeoJSON into KML 2.2 | Standard library only; groups layers by `role` and supplies a default palette | + +## Usage examples + +### Choosing a projection + +```go +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + }) + fmt.Println(spec.name, ok, len(svg)) +} +``` + +```text +equirectangular true 265827 +north-polar true 181602 +south-polar true 91902 +orthographic true 537001 +``` + +The projection only affects SVG presentation and never the underlying WGS84 geography; `Auto` (the zero value) picks a polar map per event, so specify one explicitly only when the layout must be fixed. + +The orthographic globe is viewed from the greatest-eclipse point, draws only the facing hemisphere and switches to the NASA layout - its cost and the base-map inverse projection are described under [Map Projections](#map-projections). + +### Exporting GeoJSON with time markers + +```go +data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, centralPath, + geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: cst}) +fmt.Println(err, json.Valid(data), len(data)) +``` + +```text + true 426253 +``` + +- `WithTimeMarkers` appends Point features with `role=time-marker`: `label` is localised through `Location`, while `time` stays UTC RFC 3339. +- `Step` defaults to 30 minutes, positive values are at least one minute, and one export carries at most 1440 markers; use the unsuffixed `MarshalSolarEclipse` when no markers are wanted. +- Lunar eclipses and occultations have symmetric entry points (`MarshalLunarEclipse*`, `MarshalStarOccultation*`, `MarshalPlanetOccultation*`); GeoJSON carries no basemap, boundary, style or projection, leaving Web Mercator and tile choices to the application. + +### Declaring the scale and UT1 + +```go +fmt.Println(astro.DUT1(date)) // UT1−UTC in seconds +``` + +```text +0.23 +``` + +Charts are drawn in the UTC scale by default and state it inside the figure or its footer; `TimeScale: astro.TimeScaleUT1` switches to UT1 readings and prints `DUT1 = UT1−UTC = +0.23 s` in the figure, with `Location` required to be UTC. + +On the GeoJSON side the counterpart is the `time_scale` member (omitted for UTC, `"UT1"` for UT1); the full convention is under [Time Scale Declaration](#time-scale-declaration). + +### Single instants and the layer vocabulary + +```go +solver := eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{}) +instant, ok := solver.ShadowAt(date) +shadow, err := geojson.MarshalSolarEclipseShadowInstant(instant) +fmt.Println(ok, err, json.Valid(shadow), len(shadow)) +``` + +```text +true true 4217 +``` + +- The single-instant API computes only "this instant's umbral/penumbral footprint" and the station geometry; it produces no visibility bands, magnitude contours, rise/set boundaries or limits, and returns an empty value (not an error) when the umbra misses the Earth. +- Compare `interp_signature` before interpolating: only identical neighbouring instants are safe to interpolate vertex by vertex; a flipped `closed`, a changed segment or vertex count, or an empty/non-empty transition (near U1/U4) all require exact geometry instead. +- Degradable layers record their actual geometry source in `data-source`; the vocabulary is under [Map Projections](#map-projections). + +## Map Projections + +The solar, lunar and occultation SVGs share one set of projections: equirectangular, north-polar azimuthal equidistant, south-polar azimuthal equidistant, and orthographic globe. + +The projection only affects SVG presentation and never the underlying WGS84 geography. + +The automatic projection (the zero value `...Auto`) picks a polar map for the solar and occultation maps when that fits, while lunar maps default to equirectangular. + +| Projection | Constant (solar/lunar - occultation) | When to use | Suggested canvas | +| --- | --- | --- | --- | +| Automatic | `EclipseMapProjectionAuto` - `MapProjectionAuto` | Default; picks a polar map per event | 1200x800 | +| Equirectangular | `...Equirectangular` | Long bands crossing the antimeridian | 1200x800, 1414x1000 | +| North/south polar equidistant | `...NorthPolar` / `...SouthPolar` | Event lies entirely at high latitude | 1200x800, 1000x1414 | +| Orthographic globe | `...Orthographic` | NASA-style hemisphere view centred on the event | 1000x1414 | + +`EclipseMapProjectionOrthographic` / `MapProjectionOrthographic` produce the orthographic globe: the view point is the greatest-eclipse point (the event centre for an occultation), only the hemisphere facing it is drawn, and the projection boundary is the great circle of the visible hemisphere. + +The orthographic projection uses the NASA-style centered-globe layout; a `1000x1414` canvas is recommended. It does not change the underlying geographic results. + +Four projections of one event in a single pass: + +```go +date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + } + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, options) + if !ok { + continue + } + _ = os.WriteFile("solar-eclipse-"+spec.name+".svg", []byte(svg), 0o644) +} +``` + +Per-family options, layer switches and illustrated examples live in the [solar and lunar eclipse manual](eclipse.md#solar-and-lunar-eclipse-charts) and the [lunar occultation manual](occultation.md#lunar-occultation-charts); the GeoJSON side carries geographic results only and leaves the projection to the application. + +Degradable layers record their actual geometry source in `data-source`; the vocabulary is listed in the `eclipse/svg` package comment: `partial-band-union`, `sampled-footprint-sweep`, `partial-band-contours`, `rise-set-phase-lines`, `magnitude-contours`, `greatest-time-isochrones`, `besselian-critical-envelope`, `paired-limit-chords`, `sampled-open-sweep`, `central-path-limits`, `penumbral-outlines`, `central-shadow-outlines`, `p1-p4-visibility-regions`, `p1-p4-horizon-boundaries`. + +## Time Scale Declaration + +The `TimeScale` option fixes the scale of every instant printed in the figures. + +Solar eclipse maps, lunar eclipse maps, the lunar-eclipse shadow-path layout, and all three lunar-occultation layouts state it inside the figure or in the footer: + +- Default (`astro.TimeScaleUTC`, the zero value): `All times are UTC`, or `All times are UTC (shown in CST, UTC+08:00)` when the display zone is not UTC, so the scale and the display zone are declared separately. +- `astro.TimeScaleUT1`: `Times are UT1 (Universal Time 1); DUT1 = UT1−UTC = +0.05 s.` The offset varies with the event date. `Location` must be UTC in this mode, otherwise rendering returns `false` (the occultation side returns an error). + +The GeoJSON counterpart is the `time_scale` member: omitted for UTC, and `"UT1"` when every `time` string and `HH:MM` label is a UT1 reading (an RFC 3339 `Z` suffix is not strictly UT1). + +Switching the scale never changes the band, footprint, horizon or contour geometry; only the emitted time text becomes a UT1 reading. + +Time markers are placed at whole clock ticks of the output scale (a UT1 tick is a different physical instant), so their positions follow the instant they label. + +## GeoJSON + +`geojson` accepts an already computed solar-eclipse, lunar-eclipse, or lunar-occultation result and returns `[]byte`. + +Those bytes are one complete UTF-8 RFC 7946 `FeatureCollection`, not an image or compressed payload: they can be written to `.geojson`, passed to `encoding/json`, or served directly to a map client. + +```go +package main + +import ( + "encoding/json" + "fmt" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func main() { + date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints( + date, + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, + }, + ) + if !ok { + return + } + central, hasCentral := eclipse.SolarEclipseCentralPath( + date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}, + ) + var centralPath *eclipse.SolarEclipsePath + if hasCentral { + centralPath = ¢ral + } + + data, err := geojson.MarshalSolarEclipseWithTimeMarkers( + partial, centralPath, + geojson.TimeMarkerOptions{ + Step: 30 * time.Minute, + Location: time.FixedZone("CST", 8*3600), + }, + ) + fmt.Println(err, json.Valid(data)) +} +``` + +Each event type has plain and time-marker variants: + +- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` +- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` +- `MarshalLunarEclipseWithOptions`: one `LunarEclipseOptions` value carrying the time markers, `SkipRoles` and the time-envelope sampling. `SkipRoles` can omit envelopes and penumbra-only bands; skipping both envelopes also skips their sampling. `EnvelopeSweepSamples` defaults to 48 and is clamped to `[2, 192]`; `EnvelopeLongitudePoints` defaults to `max(360, boundaryPoints)` and is clamped to `[12, 720]`. +- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers` +- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers` +- `MarshalSolarEclipseWithOptions`: one `SolarEclipseOptions` value carrying both the time markers and `SkipRoles`, equivalent to the solar entry points above plus layer trimming. + + The usual entry in `SkipRoles` is `partial-footprint` (the instantaneous penumbral outlines); `partial-band` is the edge of the eclipse band (the magnitude-0 limit) and is normally kept. + +### Filtering layers + +`SolarEclipseOptions.SkipRoles` excludes features by `role`; its zero value retains every layer. `MarshalSolarEclipseWithOptions` accepts both filtering and time-marker options. + +Filtering occurs during encoding, after full geometry sampling. Omitting `partial-footprint` removes individual penumbral footprints without reducing the accuracy of `partial-band`, the central band, magnitude contours or rise/set edges. + +Removing every feature returns an error. + +### Coordinates, times and the antimeridian + +Coordinates are WGS84 `[longitude, latitude]` in degrees. Lines and polygons crossing the antimeridian are split, with matching intersection points on both sides. Polar rings represent a pole using the two coordinates `[±180, ±90]`. + +Closed rings include ±180° seam segments needed for filling. Omit those segments when drawing physical outlines. This also applies to closed line features such as `band-outline` and `total-band-outline`. + +On timed paths, `times` matches the coordinates point by point within each segment. `WithTimeMarkers` adds Point features with `role=time-marker`. `label` is for display; `time` defaults to UTC RFC 3339. + +Explicit UT1 output is identified by `time_scale`; see [Time scales](timescale.md). + +| `TimeMarkerOptions` field | Behavior | +| --- | --- | +| `Step` | Zero means 30 minutes; positive values must be at least one minute; at most 1440 markers per export | +| `Location` | Display zone for labels; `nil` means UTC | +| `TimeScale` | UTC by default; UT1 requires `Location` to be `nil` or `time.UTC` | + +GeoJSON contains no basemap, styling or projection. Applications can choose tiles, Web Mercator or a polar projection independently. + +### Solar shadows at a specified instant + +For an interactive time query, reuse the solver returned by `eclipse.NewSolarEclipseShadowSolver`: + +| Method | Input and result | +| --- | --- | +| `ShadowAt(date)` | Civil `time.Time`; instantaneous global shadow footprint | +| `ShadowAtJDE(jdeTT)` | TT Julian day; the same footprint type | +| `StationStateAt` / `StationStateAtJDE` | Site-specific magnitude, obscuration, separation, semidiameters, solar altitude/azimuth and total/annular state | +| `ShadowBetween(start, end, step)` | Footprint sequence; instants without a shadow retain empty entries | +| `StationStatesBetween` | Time-sampled site states | + +The default is umbra or antumbra. `Kind: SolarEclipseShadowPenumbra` selects the penumbra. Instant APIs do not calculate event-wide visibility bands, magnitude contours, limits or center lines. + +A shadow missing Earth produces an empty result. + +`geojson.MarshalSolarEclipseShadowInstant(instant)` encodes the result. Properties include `time`, `source_boundary_closed`, `geometry_role`, `closure`, `delta_t_seconds`, `model` and `interp_signature`. + +No shadow yields an empty FeatureCollection. + +| Shadow | Region role | Physical-boundary role | +| --- | --- | --- | +| Umbra/antumbra | `central-shadow-footprint` | `central-shadow-boundary` | +| Penumbra | `partial-footprint` | `partial-footprint-boundary` | + +Instant penumbral output and event-wide partial sampling use the same default boundary settings: 96 points and 200 km refinement. Compare results at the same instant. + +Prior single-machine measurements put a 96-point footprint at about 64 µs and an instantaneous site state at about 20 µs. These are cost estimates; first queries and batches depend on cache state. + +### Horizon closure and interpolation + +`central-shadow-footprint` is always a Polygon or MultiPolygon. If the horizon cuts it, the physical boundary extends to two horizon grazing points and closes along the horizon arc. + +`source_boundary_closed=false` and `closure` describes the arc using `kind`, `time` and `subsolar`. + +Draw `central-shadow-boundary` for the physical edge and fill the footprint for the region. At U1/U4, an empty footprint is omitted rather than degraded to a line. `source_boundary_closed=true` means the physical boundary closes by itself. + +Sampled penumbral footprints carry the same properties. A footprint describes one instant; a static band describes the event-wide envelope. They are not interchangeable. + +Compare adjacent `interp_signature` values, such as `umbra-closed-seg1-pt97`, before interpolating vertices. Signatures, segment counts and vertex counts must match. This condition is not a strict interpolation error bound. + +Query exact geometry if closure changes, antimeridian splitting changes, or a frame switches between empty and non-empty. In prior two-minute samples, footprint centroids moved 78–232 km mid-event and up to about 520 km near contacts. + +A fixed time step alone does not determine animation error. + +### ΔT and ground position + +The solver's `DeltaTSeconds > 0` sets ΔT for that handle; non-positive values use the process-wide model. Results report the value actually used. + +At a fixed TT, changing ΔT changes Earth's rotation phase without changing the relative Sun–Moon geometry. Approximate east–west displacement is `0.4651 × |ΔΔT| × cos(latitude)` km; use `basic.DeltaTGroundShiftKM` for this conversion. + +The library supplies no ΔT uncertainty model. Callers must provide the uncertainty being converted to a ground displacement. + +### Instant occultation footprints + +Pass results from `moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` to `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`. + +Properties include `delta_t_seconds`, `source_boundary_closed`, `geometry_role` and `interp_signature`. Lunar-horizon closure uses `closure.kind=target-horizon`, `body=moon` and the `sublunar` point. + +Occultations use process-wide ΔT and report its actual value. + +### Finding events before calculating geometry + +`eclipse.SolarEclipseCandidates(start, end, options)` returns greatest times, eclipse types, central types, magnitudes, gamma and optional Saros information. It does not calculate geographic geometry. + +For central eclipses at a fixed site, `SearchLocalCentralSolarEclipse` accepts `Kind`, `MaxYears`, `Backward`, `Geometric` and `Model` options and returns `(info, status)`. `status.Exhausted` reports that the search range was exhausted. + +`MaxYears<=0` uses the default budget of 6000 candidate steps, about 992 years. Calculate paths, footprints or SVG only after selecting an event when full maps are not needed for every candidate. + +## KML + +`kml.FromGeoJSON(data []byte, options kml.Options) ([]byte, error)` converts a GeoJSON FeatureCollection to KML 2.2. Input may come from this library or a third-party file. It converts existing geometry and time properties; it does not calculate ephemerides or generate animation frames. + +### Converting a file + +This program converts `eclipse.geojson` to `eclipse.kml` for Google Earth: + +```go +package main + +import ( + "log" + "os" + + "b612.me/astro/kml" +) + +func main() { + data, err := os.ReadFile("eclipse.geojson") + if err != nil { + log.Fatal(err) + } + result, err := kml.FromGeoJSON(data, kml.Options{ + Name: "2009-07-22 Solar eclipse", + }) + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile("eclipse.kml", result, 0644); err != nil { + log.Fatal(err) + } +} +``` + +### Options + +| Field | Zero value or default | Effect when set | +| --- | --- | --- | +| `Name` | Derived from event type and earliest time | Sets `Document/name` | +| `Language` | `"zh"` | `"en"` selects English layer names; unknown roles keep their identifiers | +| `Styles` | Built-in palette | Override by `role` or the more specific `event/role`, which takes precedence | +| `NoLookAt` | Automatic view | `true` omits `Document/LookAt` | +| `SkipRoles` | Keep every role | Remove entire roles, including geometry, styles and their contribution to the view | +| `FillContext` | Fill only highlighted central and occultation bands | `true` adds light-grey translucent fill to other polygons | +| `NoTimes` | Write available input times | `true` omits time primitives for a static overlay | + +### Layers and styles + +Features are grouped into Folders by `event` and `role`. Collections containing multiple event types add event prefixes to layer names. Third-party events and roles support the same style overrides. + +| Layer | role | Default style | +| --- | --- | --- | +| Solar central band and umbra | `central-band`, `central-shadow`, `central-shadow-footprint`, `central-shadow-sweep`, `total-footprint` | Red; polygon fill about 35% opaque | +| Center line | `center-line` | Black, 3 px | +| Solar greatest-time isochrones | `greatest-time-line` | Green | +| Magnitude contours | `magnitude-line`, `magnitude-one-envelope` | Yellow | +| Total and partial occultation bands | `total-band`, `partial-band`, `total-band-outline`, `occultation-band` | Yellow with translucent band fill | +| Rise/set visibility edges, lunar time envelopes and solar partial-band edge | `visibility-boundary`, `p1-horizon`, `p4-horizon`, `visible-at-p1`, `visible-at-p4`, `visible-during-eclipse`, `visible-throughout-eclipse`, solar `partial-band` | Orange | +| Penumbra-only moonrise/moonset bands | `penumbra-moonrise`, `penumbra-moonset` | Blue-violet for moonrise, violet for moonset, with a translucent fill | +| Other limits, outlines, footprints and time markers | Other roles | Grey; polygons are outline-only by default | + +Zero-valued `Style` fields retain palette defaults: + +| Field | Meaning | +| --- | --- | +| `LineColor` | Line color in KML `aabbggrr` hexadecimal | +| `FillColor` | Polygon fill; ignored by points and lines | +| `LineWidth` | Pixels; values at or below zero use the default | +| `NoFill` | Forces no fill, overriding `FillColor` and the palette | + +KML color order is alpha, blue, green, red. `ff0000ff` is opaque red; `590000ff` is about 35% opaque red. This differs from CSS `rrggbb`. + +```go +options := kml.Options{ + Language: "en", + Styles: map[string]kml.Style{ + "center-line": {LineColor: kml.ColorBlack, LineWidth: 4}, + "solar-eclipse/central-band": {FillColor: "590000ff"}, + "partial-footprint": {NoFill: true}, + }, +} +result, err := kml.FromGeoJSON(data, options) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result)) +``` + +Unfilled polygons become outline lines. Filled polygons and their outlines are represented separately; seams introduced by antimeridian splitting are omitted from the outlines. + +Polygons remain closed without drawing an artificial boundary along ±180°. + +### Time playback and static overlays + +| GeoJSON time data | KML output | +| --- | --- | +| Scalar `time` property | One `TimeStamp` on the Placemark containing all component geometries | +| MultiLineString `times` | Separate Placemarks per segment, each timestamped with its first time | +| At least one valid timestamp | Document `TimeSpan` spanning the earliest to latest timestamp | +| `time_scale: "UT1"` | Converted back to UTC under the active model before writing `` | +| `NoTimes: true` | No `TimeStamp` or `TimeSpan` | + +Timestamps preserve fractional seconds. A `label` supplies the feature name when present; generated names display whole seconds. The original UT1 `time_scale` remains in properties. + +Use the same ΔT model for GeoJSON generation and KML conversion. + +Animation frames must already exist in the input. `times` does not turn every line vertex into a separate frame. To animate umbral or penumbral footprints, generate timestamped features through the GeoJSON sampling options first. + +Google Earth hides timed features outside its selected time window. Use `NoTimes: true` to view a static full path. For playback, select the event date and adjust the visible time-window width. + +### File size and camera view + +Penumbral footprints can cover large areas. Dense sampling, many vertices and overlapping translucent polygons all increase rendering cost. Increase the sampling step or remove layers that the application does not need. + +To keep the overall partial-visibility edge and central path while dropping individual penumbral footprints: + +```go +result, err := kml.FromGeoJSON(data, kml.Options{ + SkipRoles: []string{"partial-footprint"}, + NoTimes: true, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result)) +``` + +When generating GeoJSON, `SkipRoles` in `geojson.MarshalSolarEclipseWithOptions` can exclude layers earlier and reduce intermediate data. KML's `SkipRoles` also works on existing files. + +Automatic framing prefers center lines, central bands, limits and umbral paths; it falls back to all features if those are absent. Bounds handle antimeridian crossings. `LookAt/range` is in metres, with a 200 km minimum. + +Set `NoLookAt: true` to let the client choose the view. + +### Properties and input validation + +After `SkipRoles` filtering, only properties present with identical values on every retained feature move to `Document/ExtendedData`. Other properties remain on their Placemarks, including nested objects. + +`times` is consumed as timestamps and is not repeated as a property. + +Coordinates need at least longitude and latitude. Longitudes outside ±180° wrap, while existing +180° and −180° values are preserved. Latitude must lie within ±90°. Additional altitude components are ignored; output uses `clampToGround`. + +Polygon rings are closed and oriented with counterclockwise outer rings and clockwise holes. Supported types are Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon and GeometryCollection. + +Errors include invalid JSON or geometry types, non-finite coordinates, invalid latitudes, incompatible time-array/segment structure, filtering away every feature, or having no renderable geometry. + +Two identical positions forming a single-point segment are skipped. An originally empty FeatureCollection produces a valid empty Document. diff --git a/doc/manual/en/occultation.md b/doc/manual/en/occultation.md new file mode 100644 index 0000000..810996e --- /dev/null +++ b/doc/manual/en/occultation.md @@ -0,0 +1,558 @@ +# Lunar Occultations + +[中文](../occultation.md) | [Back to README](../../../README.en.md) + +Lunar-occultation APIs live in `moon` and search only the target supplied by the caller; they never enumerate the star catalog (maintain your own table of frequently occulted stars if needed). Fixed-site APIs take `start`, `end`, longitude, latitude, and ellipsoidal height directly. + +Global-path results contain WGS84 samples suitable for `moon/svg`, `geojson`, or `kml`. + +Targets use two distinct contact models: + +- **Stars** are point sources. Results contain immersion, greatest occultation, and emersion. +- **Planets** are finite disks (pure circles). C1/C4 are external contacts; a fully covered disk also has C2/C3 internal contacts. Partial and grazing events have no C2/C3. +- The planetary model uses the equatorial body radius and excludes rings, atmospheric extensions, and oblateness. +- `FindBestStarOccultations` and `FindBestPlanetOccultations` return the global sea-level geometric greatest point. They do not score horizon visibility, lunar altitude, duration, or magnitude. +- `VisibleAtGreatest` only reports visibility at the selected point. +- The query window selects events by their greatest instant. Once selected, complete contacts or a complete global path are returned rather than clipped at the query endpoints. +- Contact times solve topocentric geometry between the target and lunar limb without atmospheric refraction. +- `MoonAltitudeAtGreatest` is the true altitude of the lunar center. `VisibleAtGreatest` reports whether it is at or above the geometric horizon. + +## Contents + +- [Searching for a stellar occultation at a fixed site](#searching-for-a-stellar-occultation-at-a-fixed-site) +- [API Reference](#api-reference) +- [Usage examples](#usage-examples) + - [Is there a lunar occultation at my site tonight?](#is-there-a-lunar-occultation-at-my-site-tonight) + - [Planetary occultations and finite disks](#planetary-occultations-and-finite-disks) + - [Global path maps and the detailed layout](#global-path-maps-and-the-detailed-layout) + - [Grazing events, isochrones and band widths](#grazing-events-isochrones-and-band-widths) +- [Stellar occultations](#stellar-occultations) + - [Search and path sampling options](#search-and-path-sampling-options) + - [Path algorithms and contours](#path-algorithms-and-contours) + - [Greatest-time contours](#greatest-time-contours) +- [Planetary occultations](#planetary-occultations) +- [Lunar Occultation Charts](#lunar-occultation-charts) + - [Global paths](#global-paths) + - [Detailed layout](#detailed-layout) + - [Fixed-site charts](#fixed-site-charts) + - [Time scale and UT1](#time-scale-and-ut1) + +## Searching for a stellar occultation at a fixed site + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) + end := start.AddDate(0, 0, 1) + target := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, + ProperMotionDecMasPerYear: -18, + // Optional distance enables 3D space motion; radial velocity is ignored without it. + } + events, err := moon.FindStarOccultations(start, end, target, + 121.56601, 6.80706, 0, moon.OccultationSearchOptions{}) + if err != nil { + log.Fatal(err) + } + if len(events) == 0 { + fmt.Println("no occultation in this window") + return + } + for _, event := range events { + fmt.Println(event.Type, event.Immersion, event.Greatest, event.Emersion) + } +} +``` + +An empty slice is a valid result: no event was found in the window. Events are selected by greatest-occultation time; immersion and emersion may lie outside the window. + +## API Reference + +The snippets use the target and search window from the first example. SVG calls use the import alias `moonsvg "b612.me/astro/moon/svg"`. + +| Name | Purpose | Notes | +| --- | --- | --- | +| `moon.FindStarOccultations` / `FindStarOccultationPaths` | Stellar occultation events / global paths | Targets are `moon.StarCoordinate` | +| `moon.FindPlanetOccultations` / `FindPlanetOccultationPaths` | Planetary events / global paths | Solved as finite disks | +| `moon.StarCoordinateFromStarData` | Build a target from the embedded catalog | The catalog must be loaded first | +| `moonsvg.FindStarOccultationSVGs` / `FindPlanetOccultationSVGs` | Search and render global charts | Return `([]string, error)` | +| `moonsvg.FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs` | Search and render fixed-site charts | Same | +| `moonsvg.StarOccultationPathSVG` / `PlanetOccultationPathSVG` | Render an existing global path | Return `(string, error)` | +| `moonsvg.StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG` | One-page detailed layout | Fixed orthographic globe | +| `moonsvg.StarOccultationSVGOptions` / `moonsvg.OccultationDetailedSVGOptions` | Chart options (canvas, projection, scale, marker step) | Projections via `MapProjection*`, scales via `astro.TimeScale*` | +| `moon.OccultationMercury` ... `moon.OccultationNeptune` | Planetary target constants | Passed to the planetary entry points | +| `moon.OccultationSearchOptions` / `moon.OccultationPathOptions` | Search and path options | Path options include `Step`, `TargetSpacingKM` and `GreatestTimeStep` | + +## Usage examples + +| Scenario | Entry point | Returns | +| --- | --- | --- | +| An occultation at a site on a given night | `moon.FindStarOccultations(start, end, star, lon, lat, height, searchOptions)` | `[]moon.StarOccultationInfo` | +| Global geometric greatest point | `moon.FindBestStarOccultations(start, end, star, searchOptions)` | `[]moon.StarOccultationInfo` | +| Global band geometry | `moon.FindStarOccultationPaths(start, end, star, pathOptions)` | `[]moon.StarOccultationPath` | +| Global visible footprint at one instant | `moon.StarOccultationFootprintAt(at, star)` | `moon.StarOccultationInstant` | +| Search and render a global band map in one step | `moonsvg.FindStarOccultationSVGs(start, end, star, pathOptions, svgOptions)` | `([]string, error)` | +| Render an existing path only | `moonsvg.StarOccultationPathSVG(path, svgOptions)` | `(string, error)` | +| One-page detailed layout | `moonsvg.StarOccultationDetailedSVG(path, star, detailedOptions)` | `(string, error)` | +| Search and render fixed-site charts for a given site | `moonsvg.FindLocalStarOccultationSVGs(start, end, star, lon, lat, height, searchOptions, localOptions)` | `([]string, error)` | +| Render an existing fixed-site event | `moonsvg.LocalStarOccultationSVG(info, star, localOptions)` | `(string, error)` | +| Topocentric diagram geometry only (no chart) | `moon.StarOccultationDiagram(info, star, diagramOptions)` | `moon.StarOccultationDiagramResult` | +| Hand off to GIS | `geojson.MarshalStarOccultation(path)` / `MarshalStarOccultationWithTimeMarkers(path, markerOptions)` / `MarshalStarOccultationFootprint(instant)` | `([]byte, error)` | + +### Is there a lunar occultation at my site tonight? + +```go +events, err := moon.FindStarOccultations(start, end, target, 121.56601, 6.80706, 0, moon.OccultationSearchOptions{}) +if err != nil { + panic(err) +} +for _, e := range events { + fmt.Println(e.Type, e.Immersion.Format("15:04:05.000"), + e.Greatest.Format("15:04:05.000"), e.Emersion.Format("15:04:05.000")) +} +``` + +```text +total 19:14:01.071 20:02:06.314 20:50:10.715 +``` + +- `FindStarOccultations` returns the fixed-site contacts: `Immersion`, `Greatest` and `Emersion`, with `Type` separating total from grazing events. +- A target can be given as raw RA/Dec or built from the embedded catalog with `moon.StarCoordinateFromStarData`; **searching does not load the catalog** - only the catalog entry points do. +- When you need global results rather than site contacts, `FindStarOccultationPaths` returns the path in one call. + +### Planetary occultations and finite disks + +```go +start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) +events, err := moon.FindPlanetOccultations(start, start.Add(24*time.Hour), moon.OccultationSaturn, + 104.52219613, 55.25401991, 0, moon.OccultationSearchOptions{}) +if err != nil { + panic(err) +} +for _, e := range events { + fmt.Println(e.TargetID, e.Type, e.HasInternalContacts) + fmt.Println(e.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST"), e.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) + fmt.Println(e.Greatest.Format("2006-01-02 15:04:05.000 MST"), e.InternalEmersion.Format("2006-01-02 15:04:05.000 MST"), e.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) +} +``` + +```text +Saturn total true +2025-02-01 11:29:09.710 CST 2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST 2025-02-01 12:32:46.415 CST 2025-02-01 12:33:18.312 CST +``` + +Planets are solved as **finite disks**: C2/C3 (internal contacts) exist only when `HasInternalContacts` is true, and `OccultationPlanet` sets the disk radius; Saturn's rings neither take part in the contact solution nor act as the disk boundary. + +### Global path maps and the detailed layout + +```go +paths, err := moon.FindStarOccultationPaths(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}) +if err != nil { + panic(err) +} +if len(paths) == 0 { + fmt.Println("no occultation path") + return +} +svg, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: moonsvg.MapProjectionSouthPolar, +}) +detailed, detailErr := moonsvg.StarOccultationDetailedSVG(paths[0], target, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst}) +fmt.Println(err, len(svg), detailErr, len(detailed)) +``` + +```text + 120119 633489 +``` + +- Path maps return `(string, error)` and accept the same four projections as the solar maps; the detailed layout is fixed to the orthographic globe, accepts no other projection, and derives its arrangement from the canvas aspect. +- Use `StarOccultationPathSVG` when the path already exists, and `FindStarOccultationSVGs` for search-plus-render in one call (see the two chains above). +- Canvas floors: `640x480` for a standalone path map. The detailed layout accepts a width of `480` but in practice needs about `670x595` to hold the map, data blocks and footer; smaller canvases return an error. + +### Grazing events, isochrones and band widths + +- **Grazing events have no center line**: when `HasTotalBand` is false the global map draws the northern and southern limits only, so never assume a center line exists. +- **Band-width conventions**: the "band width" printed on the map is the ground separation of the limits at greatest occultation, `GreatestLimitSeparationKM` (about `3666.6 km` for the HR 4799 sample), which is not interchangeable with the across-center-line width `Greatest.WidthKM` (about `3582.4 km`). +- **Isochrones**: request `moon.OccultationPathOptions.GreatestTimeStep` at the path layer; `moon/svg` only draws the `GreatestTimeContours` already present in the result, positioned by the core convention (aligned to whole UTC marks). +- **UT1 scale**: `TimeScale: astro.TimeScaleUT1` produces UT1 readings plus the DUT1 offset and requires a UTC `Location`, returning an error instead of drawing a wrong chart. + +## Stellar occultations + +> Figure and footer time-scale declarations are documented in [Time Scale Declaration](map-geojson.md#time-scale-declaration). + +Callers supply a `StarCoordinate`. `RA` and `Dec` are degrees; `Epoch` and `Frame` are required. Proper motions use `mas/year`; `ProperMotionRACosDecMasPerYear` follows the usual catalog convention `dRA*cos(Dec)`. + +| Field | Type | Zero value | Valid range and errors | Purpose | +| --- | --- | --- | --- | --- | +| `ID` | `string` | `""` | No restriction | Display label that appears in results and chart titles; never used in computation | +| `RA` | `float64` | — | Required, `[0, 360)`, otherwise `ErrInvalidOccultationInput` | Right ascension in **degrees** (not hours/minutes/seconds) | +| `Dec` | `float64` | — | Required, `[-90, 90]` | Declination in degrees, north positive and south negative | +| `Epoch` | `time.Time` | `time.Time{}` | Zero value is an error | Epoch of the two angles above, such as J2000.0 | +| `Frame` | `moon.CoordinateFrame` | `""` | Only `icrs` / `j2000` / `apparent_of_date`; empty is an error | Reference frame, see below | +| `ProperMotionRACosDecMasPerYear` | `float64` | `0` | Must be finite | Proper motion in right ascension, **mas/year**, in the `dRA·cos(Dec)` convention | +| `ProperMotionDecMasPerYear` | `float64` | `0` | Must be finite | Proper motion in declination, mas/year | +| `ParallaxMas` | `float64` | `0` | Must be finite and ≥ 0 | Annual parallax in mas; `0` means no distance was supplied, so proper motion falls back to two dimensions and the parallax correction is skipped | +| `DistanceLightYear` | `float64` | `0` | Must be finite and ≥ 0 | Distance in light-years; an alternative input to `ParallaxMas`, used only when the parallax is `0` | +| `RadialVelocityKmPerSecond` | `float64` | `0` | Must be finite and \|v\| ≤ 1000 | Radial velocity in km/s; `0` is valid and takes part only when a distance is known | + +The two distance inputs have one precedence rule: `ParallaxMas > 0` wins, otherwise the parallax is derived from `DistanceLightYear`. Supplying a distance enables the 3D space motion; without one, proper motion advances only the two angular components and `RadialVelocityKmPerSecond` takes no part. + +Additional notes on `Epoch` and `Frame`: + +- `j2000`: the coordinates are J2000.0 mean places and `Epoch` is `2000-01-01 12:00 UTC`. The precession origin is hard-coded to J2000.0 inside the library, so `Epoch` only decides from which year proper motion is extrapolated; passing a non-J2000 epoch makes the proper motion count one extra span. +- `icrs`: the coordinates are ICRS catalog positions that first pass through a ~17 mas frame-bias matrix; `Epoch` is the catalog epoch (Hipparcos `1991.25`, Gaia `2016.0`). +- `apparent_of_date`: the coordinates are the **apparent place at that instant** (precession, nutation and aberration already included) and `Epoch` must be that instant; the library solves back to the mean place at that instant and then propagates forward. Use this when taking the "current coordinates" shown by planetarium software such as Stellarium. + +A coordinate can be constructed directly: + +```go +target := moon.StarCoordinate{ + ID: "HR 4799", + RA: 189.1975, + Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, + ProperMotionDecMasPerYear: -18, +} +``` + +Alternatively, explicitly load the embedded 9100-star catalog and convert a `StarData` value with `StarCoordinateFromStarData`. + +The occultation search itself does not load the catalog; calls such as `star.InitStarDatabase`, `StarDataByName`, and `StarDataByHR` do. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/star" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) + end := start.Add(24 * time.Hour) + + if err := star.InitStarDatabase(); err != nil { + panic(err) + } + data, err := star.StarDataByName("进贤增九") + if err != nil { + panic(err) + } + target, err := moon.StarCoordinateFromStarData(data) + if err != nil { + panic(err) + } + + events, err := moon.FindStarOccultations( + start, end, target, + 121.56601, 6.80706, 0, + moon.OccultationSearchOptions{}, + ) + + if err != nil { + panic(err) + } + for _, event := range events { + fmt.Println(event.TargetID, event.Type) + fmt.Println( + event.Immersion.Format("2006-01-02 15:04:05.000 MST"), + event.Greatest.Format("2006-01-02 15:04:05.000 MST"), + event.Emersion.Format("2006-01-02 15:04:05.000 MST"), + ) + fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest) + } + + paths, err := moon.FindStarOccultationPaths( + start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + + if err != nil { + panic(err) + } + for _, path := range paths { + fmt.Println( + path.Start.Time.Format("2006-01-02 15:04:05.000 MST"), + path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"), + path.End.Time.Format("2006-01-02 15:04:05.000 MST"), + ) + fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n", + path.Greatest.Longitude, path.Greatest.Latitude, + path.Greatest.WidthKM, len(path.CenterLine)) + } +} +``` + +Output: + +```text +进贤增九 total +2025-06-05 19:14:01.076 CST 2025-06-05 20:02:06.311 CST 2025-06-05 20:50:10.721 CST +altitude=75.561 visible=true +2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST +greatest=121.566009 6.807079 width=3582.4km center=108 +``` + +### Search and path sampling options + +`OccultationSearchOptions` uses default step and safety margins when zero-valued; `MaxEvents > 0` limits the result count. `OccultationPathOptions` controls global-path sampling: + +| Field | Purpose | +| --- | --- | +| `Step` | Base time step | +| `TargetSpacingKM` | Refines the center line by ground distance; exceeding the sampling budget returns `ErrOccultationPathSamplingLimit` | +| `RiseSetStep` | Step for six immersion/greatest/emersion moonrise/moonset curves; zero means 5 minutes | +| `DisableRiseSet` | Skips those six phase curves | +| `DisableFootprints` | Omits dense instant footprints and forms a compact band from sparse support samples; keeps the center line, boundaries and rise/set curves | +| `IncludeFootprintTimeline` | Retains instant footprints alongside the compact band | +| `FootprintTimelineStep` | Sampling step for that timeline | + +The result's `GreatestLimitSeparationKM` is the ground separation between northern and southern limits at greatest occultation, used for the chart's band-width label. It differs from `Greatest.WidthKM` and is not interchangeable with it. + +### Path algorithms and contours + +`OccultationPathOptions.Algorithm` selects the stellar/planetary global-path ephemeris branch. + +Its zero value or `moon.OccultationPathAlgorithmOptimized` uses checked Cartesian interpolation at 30-minute nodes while retaining the station equations, continuous envelopes, and rise/set curves. + +`moon.OccultationPathAlgorithmExact` uses interpolated candidates with full-term ephemerides for final solving. Failed table checks fall back to full-term solving; evaluations outside the interpolation window use exact ephemerides. + +Checks are sampled safeguards, not a rigorous error bound at every instant. The branches share the geometric definition, but sample points and GeoJSON bytes need not be identical. + +Event-only searches, fixed-site contacts, independent instant-footprint APIs, and eclipses are unaffected. + +Both branches retain full-term evaluation of global start/end/greatest markers and center-line widths. Render the complete returned path, including visibility contours; discarding those contours invokes the sampled-footprint fallback, whose boundary is not interchangeable with the analytic visible set. + +In a returned path, `BandContours` are the static contact envelopes, `VisibilityContours` are the time envelope where the Moon is above the horizon, and `Footprints` are instantaneous samples for time-axis detail. + +They serve different geometry layers and should not be used as substitutes for one another. + +### Greatest-time contours + +`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` request **greatest-occultation time isolines**. Unlike the solar case, `GreatestTimeValues []float64` carries TT Julian ephemeris days; at most 64 are kept — deduplicated, sorted, and cut to the earliest 64 — and a level outside the visibility window or without a usable branch produces no entry. + +When it is empty, `GreatestTimeStep` takes over, again only for a positive value, aligned to UTC ticks. + +Contours land in `StarOccultationPath.GreatestTimeContours` (the planetary path has the same field) as `OccultationGreatestTimeContour` values whose `JDE`, `Time`, and `Segments` mean the same as in the solar case: `Time` keeps the original aligned instant for step-derived levels, while an explicit level is converted from `JDE` and rounded to the millisecond; both carry the UTC zone, whereas branch point times use the path timezone (the solar public layer instead reports `Time` in the input timezone). + +The boundary rules match as well: curves exist only where the target disk truly overlaps the lunar disk and the Moon is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the occultation-visibility boundary, nothing is continued beyond ±88° latitude, one instant may carry several disconnected branches, and output is unchanged when they are not requested. + +```go +options := moon.OccultationPathOptions{ + Algorithm: moon.OccultationPathAlgorithmExact, // Full-term ephemerides for the final solve. + DisableFootprints: true, +} +``` + +## Planetary occultations + +Planet targets use the constants from `OccultationMercury` through `OccultationNeptune`. This example solves C1-C4 for the `2025-02-01` occultation of Saturn at a site near the global geometric greatest point: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) + events, err := moon.FindPlanetOccultations( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + 104.52219613, 55.25401991, 0, + moon.OccultationSearchOptions{}, + ) + if err != nil { + panic(err) + } + for _, event := range events { + fmt.Println(event.TargetID, event.Type, event.HasInternalContacts) + fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1 + fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2 + fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST")) + fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3 + fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4 + } +} +``` + +Output: + +```text +Saturn total true +2025-02-01 11:29:09.710 CST +2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST +2025-02-01 12:32:46.415 CST +2025-02-01 12:33:18.312 CST +``` + +Global `FindPlanetOccultationPaths` results contain both the region where any part of the planetary disk overlaps the Moon and the region where the whole planet is hidden. + +`HasTotalBand` reports whether a total band exists, and `GreatestTotalWidthKM` is its width at greatest occultation; center lines, limits, and enabled instantaneous footprints all carry sample times. + +## Lunar Occultation Charts + +`moon/svg` offers two groups of entry points: "search and render" and "render an existing result". + +| Entry point | Purpose | +| --- | --- | +| `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs` | Search every occultation in the window and render one global path map per event | +| `StarOccultationPathSVG` / `PlanetOccultationPathSVG` | Render an existing global path (the value returned by `Find...Paths`) | +| `StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG` | One-page detailed layout | +| `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs` | Fixed-site topocentric lunar track, lunar path and contact-phase charts | +| `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG` | Render an existing fixed-site event | + +`eclipse/svg` entry points return `(string, bool)` while `moon/svg` entry points return `(string, error)` or `([]string, error)`: on the occultation side the second value is a real error (UT1 with a non-UTC location, canvas below the floor, invalid path), not a "cannot draw this chart" flag. + +Stars are treated as point sources and planets as finite disks: planetary contact times come from the disk-versus-limb geometry with the radius selected by `OccultationPlanet`, and Saturn's rings neither take part in the contact solution nor act as the disk boundary. + +A grazing occultation (`HasTotalBand` false) may have no center line at all, in which case the global map draws the northern and southern limits only. + +Greatest instants are measured from the Sun-Moon center separation for point stars and from external contact for finite planetary disks, matching `StarOccultationInfo.Greatest` and `PlanetOccultationInfo.Greatest` respectively. + +### Global paths + +`MapProjection` offers the same four projections as the solar maps. The projection only affects SVG presentation and never the underlying WGS84 geography: + +| Option | Meaning | +| --- | --- | +| `MapProjectionAuto` (zero value) | Chosen per event; high-latitude events may land on a polar map | +| `MapProjectionEquirectangular` | Equirectangular, clearest for long paths crossing the antimeridian | +| `MapProjectionNorthPolar` / `MapProjectionSouthPolar` | Azimuthal equidistant with the pole at the centre | +| `MapProjectionOrthographic` | Orthographic globe viewed from the event centre, drawing only the hemisphere facing it | + +The orthographic globe (`2025-06-05` occultation of HR 4799): + +```go +paths, err := moon.FindStarOccultationPaths(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}) +if err != nil || len(paths) == 0 { + return +} +svg, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, + Projection: moonsvg.MapProjectionOrthographic, +}) +if err != nil { + return +} +fmt.Println(len(svg)) +``` + +![2025 occultation of HR 4799 orthographic global path map](../../img/lunar-occultation-hr4799-2025-06-05-global-en.svg) + +The same path on the south-polar projection, which reads better than equirectangular when the path lies at high latitude: + +```go +south, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, + Projection: moonsvg.MapProjectionSouthPolar, +}) +if err != nil { + return +} +``` + +![2025 occultation of HR 4799 south-polar global path map](../../img/lunar-occultation-hr4799-2025-06-05-southpolar-en.svg) + +Use `Find...SVGs` to search and render in one step; the returned slice matches the matching events one for one: + +```go +svgs, err := moonsvg.FindStarOccultationSVGs(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + moonsvg.StarOccultationSVGOptions{Width: 1200, Height: 800, Location: cst}) +fmt.Println(err, len(svgs)) +``` + +`TimeLabelStep` defaults to 30 minutes and a negative value disables the time markers along the center line. A standalone global path map needs at least `640x480`; smaller canvases return `ErrInvalidStarOccultationSVGOptions` (`ErrInvalidPlanetOccultationSVGOptions` for the planetary entry points). + +The legend, footer and graticule all reserve space by canvas height, so shorter canvases press against the footer more easily; the south-polar example in this manual works fine at `1200x800`. + +### Detailed layout + +The detailed layout combines a whole occultation on one `1000x1414` page: a centred summary, geocentric/topocentric data blocks for Moon and target, an **orthographic globe** path map (northern and southern limits, visible/geometric center lines, the greatest point, immersion/greatest/emersion phase points and 30-minute time markers), and a footer note. + +The globe is viewed from the event centre and this layout is fixed to the orthographic projection, accepting no other; use the `StarOccultationPathSVG` / `FindStarOccultationSVGs` above when only a standalone path map is needed. + +```go +detailed, err := moonsvg.StarOccultationDetailedSVG(paths[0], target, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst, Language: "en"}) +if err == nil { + _ = os.WriteFile("doc/img/lunar-occultation-hr4799-2025-06-05-detailed-en.svg", []byte(detailed), 0o644) +} +``` + +![2025 occultation of HR 4799 detailed layout](../../img/lunar-occultation-hr4799-2025-06-05-detailed-en.svg) + +The page data falls into six blocks: geocentric Moon coordinates, target, path points, contact times, ephemeris and constants, and libration. + +A landscape canvas puts the data blocks in two columns by three rows to the right of the map, portrait puts them in three columns by two rows below the globe. + +The globe path map uses the Natural Earth `1:50m` coastline without administrative boundaries. + +The detailed layout derives its arrangement from the canvas and needs about `670x595` in practice (the `480` width floor is only an argument check), returning `ErrInvalidOccultationDetailedSVGOptions` when the map and data blocks do not fit (`800x600`, `1000x1414` and `1414x1000` all render; `660x600`, `800x590`, `640x420` and `900x400` are rejected). + +The "band width" printed on the map is the ground separation of the northern and southern limits at greatest occultation, `GreatestLimitSeparationKM` (about `3666.6 km` for the HR 4799 sample), which is a different convention from the across-center-line width `Greatest.WidthKM` (about `3582.4 km`); the two are not interchangeable. + +Greatest-time isochrones must be requested explicitly at the path layer through `moon.OccultationPathOptions.GreatestTimeStep`: `moon/svg` offers no switch of its own and only draws the `GreatestTimeContours` already present in the path result, so the request has to be made while computing the path and the line positions follow the core convention (`GreatestTimeStep` aligns to whole UTC marks; to align to the display time zone, convert the instants to dynamical-time Julian days first and pass them as `GreatestTimeValues`). + +A lunar occultation is visible worldwide for only a few hours (4 h 33 min for the HR 4799 sample), so the usual interval is denser than for solar eclipses, on the order of 15-30 minutes. + +A compact band using `DisableFootprints` merges once on the first render and then caches for the same path. + +### Fixed-site charts + +```go +localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( + start, end, target, + 121.56601, 6.80706, 0, + moon.OccultationSearchOptions{}, + moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 720, Location: cst}, +) +fmt.Println(err, len(localSVGs)) +``` + +The local chart is drawn from the topocentric geometry of the given observer; the figure below reuses the `2025-06-05` occultation of Xianxianzengjiu (HR 4799). + +The observer is at `121.56601 E, 6.80706 N`, close to the global geometric greatest point; the immersion, greatest and emersion instants in the figure are the topocentric contacts actually seen from that site, together with lunar orientation, the lunar path, Moon altitude, azimuth and horizon visibility. + +![2025 occultation of Xianxianzengjiu fixed-site chart](../../img/lunar-occultation-hr4799-2025-06-05-local-en.svg) + +### Time scale and UT1 + +Occultation charts are drawn in the UTC scale by default and state it in the figure. + +`TimeScale: astro.TimeScaleUT1` produces UT1 readings plus the `DUT1 = UT1-UTC` offset, and in that mode `Location` must be UTC: a non-UTC zone returns an error rather than drawing a wrong chart. + +GeoJSON export obeys the same contract through `TimeMarkerOptions.TimeScale` on `MarshalStarOccultation*` / `MarshalPlanetOccultation*`: the UT1 scale adds the `time_scale` member and rewrites every `time` property, leaving geometry untouched. + +See [Time Scale Declaration](map-geojson.md#time-scale-declaration) for the full convention. diff --git a/doc/manual/en/orbit.md b/doc/manual/en/orbit.md new file mode 100644 index 0000000..222d915 --- /dev/null +++ b/doc/manual/en/orbit.md @@ -0,0 +1,461 @@ +# Generic Small-Body Orbits + +[中文](../orbit.md) | [Back to README](../../../README.en.md) + +`orbit` propagates heliocentric two-body positions from orbital elements. It supports asteroids, comets, dwarf planets, and custom hypothetical orbits. + +The seven major planets are still computed by their own packages using built-in VSOP87 analytical terms. + +`orbit.Elements` supports two common forms: + +- classical elliptical elements: `A/E/I/Omega/W/M0` +- perihelion form: `Q/E/I/Omega/W/TpJD`, useful for comets and high-eccentricity orbits + +The reference frame of `orbit.Elements` is always the J2000 mean ecliptic and mean equinox. The examples below use one set of Ceres elements. + +Topocentric and rise/set helpers take east-positive longitude, north-positive latitude, and height in meters. + +The positional and topocentric quantities here also work together with the interfaces documented in the [star](star.md#stars) and [coordinate tools](coord.md#coordinate-tools) manuals. + +## Contents + +- [Calculating the position of Ceres from orbital elements](#calculating-the-position-of-ceres-from-orbital-elements) +- [API Reference](#api-reference) + - [Orbital Elements](#orbital-elements) + - [Positions](#positions) + - [Geometry](#geometry) + - [Rise, Set, and Culmination](#rise-set-and-culmination) + - [Photometry](#photometry) + - [Visual Binaries](#visual-binaries) +- [Usage examples](#usage-examples) + - [Picking a position layer](#picking-a-position-layer) + - [Will it rise tonight, and how high is it now?](#will-it-rise-tonight-and-how-high-is-it-now) + - [Distances, phase angle and H-G magnitude](#distances-phase-angle-and-h-g-magnitude) + - [Orbit types and position layers](#orbit-types-and-position-layers) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Position Layers](#position-layers) + - [Units and Frame Conventions](#units-and-frame-conventions) + - [Time Scale and Input Reading](#time-scale-and-input-reading) + - [Zero Values and Out-of-Range Input](#zero-values-and-out-of-range-input) + - [Accuracy and Applicability](#accuracy-and-applicability) + - [Common Pitfalls](#common-pitfalls) +- [Related Manuals](#related-manuals) + +## Calculating the position of Ceres from orbital elements + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/orbit" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + when := time.Date(2025, 11, 21, 20, 0, 0, 0, cst) + ceres := orbit.Elements{ + EpochJD: 2461000.5, A: 2.765615651508659, E: 0.07957631994408416, + I: 10.58788658206854, Omega: 80.24963090816965, + W: 73.29975464616518, M0: 231.5397330043706, + } + pos := orbit.ApparentGeocentricEquatorial(when, ceres) + fmt.Printf("RA=%.6f Dec=%.6f deg distance=%.6f AU\n", pos.RA, pos.Dec, pos.Distance) + rise, err := orbit.RiseTime(when, ceres, 121.4737, 31.2304, 20, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +Elements use the J2000 mean ecliptic frame and a TT/TDB Julian-day epoch. This is heliocentric two-body propagation; perturbation errors need separate evaluation over long spans or near planetary encounters. + +## API Reference + +### Orbital Elements + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `Elements` | Heliocentric two-body conic elements | `EpochJD`/`TpJD` are TT/TDB Julian days; `A`/`Q` in AU, `I`/`Omega`/`W`/`M0` in degrees, `E` dimensionless; `ADot`…`MDot` are per-day rates that apply to the classical elliptical form only | +| `MeanMotion` | Mean angular rate | degrees/day; `NaN` for parabolic and hyperbolic cases; a non-zero `MDot` is used directly | +| `MeanAnomaly` | Mean anomaly | degrees (`[0,360)`); `NaN` for parabolic and hyperbolic cases | +| `TrueAnomaly` | True anomaly | degrees (`[0,360)`); defined for elliptical, parabolic, and hyperbolic orbits, `NaN` for invalid elements | + +Mean anomaly and true anomaly are solved from the same element set, and `MDot` can replace the default mean motion: + +```go +fmt.Println(orbit.MeanMotion(ceres), orbit.MeanAnomaly(when, ceres), orbit.TrueAnomaly(when, ceres)) + +// Parabolic and hyperbolic orbits only accept the perihelion form; mean motion and mean anomaly are undefined. +parabolic := orbit.Elements{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +hyperbolic := orbit.Elements{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +fmt.Println(orbit.MeanMotion(parabolic), orbit.MeanAnomaly(when, parabolic)) +fmt.Println(orbit.TrueAnomaly(when, parabolic), orbit.TrueAnomaly(when, hyperbolic)) +``` + +The complete example exercises both the elliptical and the perihelion element form: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/orbit" +) + +func main() { + // Classical elliptical elements for 1 Ceres, referenced to J2000 mean ecliptic/equinox. + ceres := orbit.Elements{ + EpochJD: 2461000.5, + A: 2.765615651508659, + E: 0.07957631994408416, + I: 10.58788658206854, + Omega: 80.24963090816965, + W: 73.29975464616518, + M0: 231.5397330043706, + } + ceresPos := orbit.ApparentGeocentricEquatorial( + time.Date(2025, 11, 12, 0, 0, 0, 0, time.UTC), + ceres, + ) + fmt.Printf("ceres ra=%.6f dec=%.6f distance=%.6f\n", ceresPos.RA, ceresPos.Dec, ceresPos.Distance) + + // Halley's Comet example using perihelion distance Q and perihelion passage time TpJD. + halley := orbit.Elements{ + Q: 0.5870992, + E: 0.9671429, + I: 162.26269, + Omega: 58.42008, + W: 111.33249, + TpJD: 2446467.395, + } + halleyPos := orbit.ApparentGeocentricEquatorial( + time.Date(1986, 2, 9, 0, 0, 0, 0, time.UTC), + halley, + ) + fmt.Printf("halley ra=%.6f dec=%.6f distance=%.6f\n", halleyPos.RA, halleyPos.Dec, halleyPos.Distance) +} +``` + +Output: + +```text +ceres ra=7.739532 dec=-10.625981 distance=2.164391 +halley ra=312.112360 dec=-11.826451 distance=1.533936 +``` + +Orbital elements have epochs. The farther the target date is from the epoch, the more static-element error can grow. + +If the source provides long-term linear rates such as `ADot/EDot/IDot/OmegaDot/WDot/MDot`, they can be filled into `Elements` to reduce medium- and long-term drift. + +### Positions + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `EclipticPosition` | Ecliptic spherical return value | `Lon`/`Lat` in degrees, `Distance` in AU | +| `EquatorialPosition` | Equatorial spherical return value | `RA`/`Dec` in degrees, `Distance` in AU | +| `HeliocentricEclipticJ2000` | Heliocentric J2000 mean ecliptic | geometric, no light-time | +| `HeliocentricEcliptic` | Heliocentric ecliptic of date | geometric, referred to the mean equinox of date | +| `GeocentricEclipticJ2000` | Geocentric J2000 mean ecliptic | geometric, Earth and target at the same instant | +| `GeocentricEcliptic` | Geocentric ecliptic of date | geometric, referred to the mean equinox of date | +| `GeocentricEquatorialJ2000` | Geocentric J2000 mean equatorial | geometric, J2000 obliquity | +| `GeocentricEquatorial` | Geocentric mean equatorial of date | geometric, obliquity of date | +| `AstrometricGeocentricEquatorialJ2000` | Astrometric geocentric J2000 equatorial | geometric position plus light-time, directly comparable with J2000 catalogues | +| `ApparentGeocentricEcliptic` | Apparent geocentric ecliptic | light-time plus nutation, without a full aberration model | +| `ApparentGeocentricEquatorial` | Apparent geocentric equatorial | light-time plus nutation, without a full aberration model | +| `ApparentTopocentricEquatorial` | Apparent topocentric equatorial | apparent geocentric plus topocentric parallax | + +Each step down the chain adds one correction to the same instant — geometric, then light-time, then nutation, then topocentric: + +```go +h := orbit.HeliocentricEcliptic(when, ceres) // heliocentric ecliptic of date, geometric +g := orbit.GeocentricEquatorialJ2000(when, ceres) // geocentric J2000 mean equatorial +a := orbit.ApparentGeocentricEquatorial(when, ceres) // apparent geocentric equatorial +t := orbit.ApparentTopocentricEquatorial(when, ceres, 121.4737, 31.2304, 20) +e := orbit.ApparentGeocentricEcliptic(when, ceres) +fmt.Printf("h=%.6f %.6f %.6f\n", h.Lon, h.Lat, h.Distance) +fmt.Printf("g=%.6f %.6f %.6f\n", g.RA, g.Dec, g.Distance) +fmt.Printf("a=%.6f %.6f t=%.6f %.6f e=%.6f\n", a.RA, a.Dec, t.RA, t.Dec, e.Lon) +``` + +The `...J2000` variants and the of-date variants are two different frames: the former stay in the J2000 mean ecliptic/equinox, which suits catalogue comparison and long-term archiving; the latter use the mean ecliptic/equinox of date, which suits "today's sky" expressions. `Distance` is the instantaneous distance for the geometric interfaces and the light-time-converged distance for `Astrometric...`; the two differ by roughly the displacement during the light-time. + +### Geometry + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `SunDistance` | Heliocentric distance | AU, geometric | +| `EarthDistance` | Geocentric distance | AU, geometric | +| `Elongation` | Solar elongation | degrees, apparent geocentric angular separation | +| `PhaseAngle` | Phase angle | degrees, 0° means the fully lit face points at the observer | +| `IlluminatedFraction` | Illuminated fraction | dimensionless, typically within `[0,1]` | +| `Phase` | Alias of the illuminated fraction | identical to `IlluminatedFraction` | +| `ParallacticAngle` | Parallactic (zenith-direction) angle | degrees, hour angle and declination from one and the same topocentric solve | + +`orbit` also provides common observing geometry and lightweight photometry helpers: + +```go +r := orbit.SunDistance(when, ceres) // heliocentric distance +delta := orbit.EarthDistance(when, ceres) // geocentric distance +elong := orbit.Elongation(when, ceres) // solar elongation +phase := orbit.PhaseAngle(when, ceres) // phase angle +k := orbit.IlluminatedFraction(when, ceres) // illuminated fraction +mag := orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12) // H-G asteroid magnitude +q := orbit.ParallacticAngle(when, ceres, 121.4737, 31.2304, 20) // parallactic angle from a site + +fmt.Printf("r=%.6f delta=%.6f elong=%.6f phase=%.6f k=%.6f mag=%.3f q=%.6f\n", + r, delta, elong, phase, k, mag, q) +``` + +Near 180° elongation the phase angle approaches 0° and `IlluminatedFraction` approaches 1; all three quantities come from the same geocentric geometry. + +`ParallacticAngle` does not solve for declination separately: it reuses the same topocentric solve as `HourAngle`, so that hour angle and declination never come from two Julian-day paths about 1 ULP apart and introduce sub-nanodegree drift. + +All geometry helpers depend only on the absolute instant of `date`; `ParallacticAngle` is the only one that also takes observer parameters. + +### Rise, Set, and Culmination + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `Altitude` | Apparent altitude | degrees, apparent topocentric position on the observer's local civil day | +| `Zenith` | Zenith distance | degrees, equal to `90 - Altitude` | +| `Azimuth` | Apparent azimuth | degrees, north 0°, increasing toward east | +| `HourAngle` | Topocentric hour angle | degrees | +| `CulminationTime` | Culmination time | `time.Time`, keeps the location of the input `date` | +| `RiseTime` | Rise time | `(time.Time, error)`, second value is a sentinel error | +| `SetTime` | Set time | `(time.Time, error)`, second value is a sentinel error | +| `ERR_ORBIT_NEVER_RISE` | Sentinel: target never rises that day | returned by `RiseTime` | +| `ERR_ORBIT_NEVER_SET` | Sentinel: target never sets that day | returned by `SetTime` | + +```go +fmt.Println(orbit.Zenith(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.HourAngle(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.CulminationTime(when, ceres, 121.4737, 31.2304, 20).Format(time.RFC3339)) + +day := time.Date(2025, 11, 21, 0, 0, 0, 0, site) +set, err := orbit.SetTime(day, ceres, 121.4737, 31.2304, 20, true) +if errors.Is(err, orbit.ERR_ORBIT_NEVER_SET) { + fmt.Println("never sets today", set) +} +``` + +Treat an orbit as an observable target for topocentric pointing: + +```go +site := time.FixedZone("CST", 8*3600) +when := time.Date(2025, 11, 21, 20, 0, 0, 0, site) + +alt := orbit.Altitude(when, ceres, 121.4737, 31.2304, 20) // topocentric altitude +az := orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20) // topocentric azimuth +rise, _ := orbit.RiseTime(time.Date(2025, 11, 21, 0, 0, 0, 0, site), ceres, 121.4737, 31.2304, 20, true) // rise time + +fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) +``` + +These observing helpers work on topocentric apparent coordinates and suit rise/set and pointing support for asteroids, comets, or custom two-body targets. + +With `aero` false the criterion is the geometric horizon; with `aero` true the target altitude is taken as `-0.5667°` plus the horizon dip derived from ellipsoidal height and latitude. + +The second return value of `RiseTime`/`SetTime` is a real error: only a day without a rise/set is mapped to `ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET`, and any other failure passes through unchanged. + +### Photometry + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `AsteroidMagnitudeHG` | Asteroid apparent magnitude, H-G model | `absoluteMagnitude` is H and `slopeParameter` is G; both dimensionless | + +```go +fmt.Printf("H-G magnitude=%.3f\n", orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12)) +fmt.Printf("r=%.6f delta=%.6f elong=%.6f\n", + orbit.SunDistance(when, ceres), orbit.EarthDistance(when, ceres), orbit.Elongation(when, ceres)) +fmt.Printf("phase=%.6f k=%.6f k2=%.6f\n", + orbit.PhaseAngle(when, ceres), orbit.IlluminatedFraction(when, ceres), orbit.Phase(when, ceres)) +``` + +The H-G model uses only heliocentric distance, geocentric distance, and phase angle; it does not introduce the target radius, albedo, or rotation, and this package never fills in a default value for `G` — that comes from the external catalogue. + +### Visual Binaries + +| Name | Purpose | Units and convention | +| --- | --- | --- | +| `VisualBinaryElements` | Visual-binary orbital elements | `PeriodYears` in mean solar years, `PeriastronYear` as a decimal year, `SemiMajorAxis` in arcseconds, `Inclination`/`AscendingNode`/`PeriastronArgument` in degrees, `Eccentricity` dimensionless | +| `VisualBinaryPosition` | Computed visual-binary position | `MeanAnomaly`/`EccentricAnomaly`/`TrueAnomaly`/`PositionAngle` in degrees, `Radius`/`Separation` in arcseconds | +| `VisualBinary` | Visual-binary position at an instant | converts the instant to a UTC decimal year, then applies the classical apparent-orbit formula | +| `VisualBinaryByYear` | Visual-binary position by decimal year | takes the decimal year directly, skipping the instant conversion | + +```go +gammaVir := orbit.VisualBinaryElements{ + PeriodYears: 171.37, PeriastronYear: 1836.433, Eccentricity: 0.8808, + SemiMajorAxis: 3.746, Inclination: 146.05, AscendingNode: 31.78, PeriastronArgument: 252.88, +} +vb := orbit.VisualBinaryByYear(2026.0, gammaVir) +fmt.Printf("theta=%.6f rho=%.6f M=%.6f\n", vb.PositionAngle, vb.Separation, vb.MeanAnomaly) +``` + +`orbit` also includes a lightweight visual-binary solver using the classical apparent-orbit formula from chapter 55 of *Astronomical Algorithms*: + +```go +gammaVir := orbit.VisualBinaryElements{ + PeriodYears: 171.37, + PeriastronYear: 1836.433, + Eccentricity: 0.8808, + SemiMajorAxis: 3.746, + Inclination: 146.05, + AscendingNode: 31.78, + PeriastronArgument: 252.88, +} +vb := orbit.VisualBinary(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), gammaVir) +fmt.Printf("theta=%.6f rho=%.6f\n", vb.PositionAngle, vb.Separation) // position angle and separation +``` + +Position angle is measured with north at 0° and east at 90°, and both the separation and the radius vector `Radius` are in arcseconds. + +## Usage examples + +### Picking a position layer + +```go +helio := orbit.HeliocentricEcliptic(when, ceres) +geo := orbit.GeocentricEclipticJ2000(when, ceres) +ast := orbit.AstrometricGeocentricEquatorialJ2000(when, ceres) +app := orbit.ApparentGeocentricEquatorial(when, ceres) +fmt.Println(helio.Lon, helio.Lat, helio.Distance) +fmt.Println(geo.Lon, geo.Lat, geo.Distance) +fmt.Println(ast.RA, ast.Dec) +fmt.Println(app.RA, app.Dec, app.Distance) +``` + +```text +19.251489 -9.315340 2.912174 +2.147652 -12.026350 2.262445 +6.795211 -10.172420 +7.125008 -10.028726 2.262489 +``` + +The four layers mean different things: `Heliocentric*` is relative to the Sun (the first number is the heliocentric distance), `Geocentric*J2000` is the J2000 geocentric position, `Astrometric*J2000` removes light-time and suits catalog comparison, and `Apparent*` is the apparent position of the day used for observing and charts. + +For a topocentric apparent position use `ApparentTopocentricEquatorial(when, ceres, lon, lat, height)`. + +### Will it rise tonight, and how high is it now? + +```go +fmt.Println(orbit.RiseTime(when, ceres, 121.4737, 31.2304, 20, true)) +fmt.Println(orbit.CulminationTime(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.Altitude(when, ceres, 121.4737, 31.2304, 20), + orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20)) +``` + +```text +2025-11-21 14:41:48.913 CST +2025-11-21 20:19:34 CST +48.472628 172.699168 +``` + +- `aero = true` solves against the horizon corrected for refraction and apparent radius; `height` is ellipsoidal height in metres and longitude is east positive. +- Circumpolar or polar targets have no rise or set: `RiseTime`/`SetTime` return the `orbit.ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET` sentinels, so branch with `errors.Is`. + +### Distances, phase angle and H-G magnitude + +```go +fmt.Println(orbit.SunDistance(when, ceres), orbit.EarthDistance(when, ceres)) +fmt.Println(orbit.Elongation(when, ceres), orbit.PhaseAngle(when, ceres)) +fmt.Println(orbit.IlluminatedFraction(when, ceres), orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12)) +``` + +```text +2.912174 2.262445 +122.264630 16.670089 +0.978986 8.360 +``` + +- `PhaseAngle` is the Sun-target-Earth angle in degrees and `IlluminatedFraction` is the illuminated fraction; `Phase` is only an alias of `IlluminatedFraction`, so do not read it as a phase angle. +- H-G magnitudes take the absolute magnitude `H` and the slope parameter `G` (Ceres uses `3.34` and `0.12` in the example). + +### Orbit types and position layers + +```go +parabolic := orbit.Elements{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +hyperbolic := orbit.Elements{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +fmt.Println(orbit.MeanMotion(parabolic), orbit.MeanAnomaly(when, parabolic)) +fmt.Println(orbit.TrueAnomaly(when, parabolic), orbit.TrueAnomaly(when, hyperbolic)) +fmt.Println(orbit.MeanMotion(ceres)) +``` + +```text +NaN NaN +0.817533 0.537610 +0.21429712142765137 +``` + +- Parabolic and hyperbolic orbits only work in perihelion form (`Q` plus `TpJD`): mean motion and mean anomaly are undefined and return `NaN`, while the true anomaly solves for all three orbit types. +- When cross-checking against an external ephemeris, align the position layer and frame first (`Elements` is always J2000 mean ecliptic/equinox); `ADot…WDot` only apply to the classical ellipse form, and a non-zero `MDot` replaces the default mean motion. + +## Parameter and result conventions + +### Position Layers + +| Layer | Representative interfaces | Corrections included | +| --- | --- | --- | +| Heliocentric geometric | `HeliocentricEcliptic` / `HeliocentricEclipticJ2000` | none | +| Geocentric geometric | `GeocentricEcliptic` / `GeocentricEclipticJ2000` / `GeocentricEquatorial` / `GeocentricEquatorialJ2000` | minus the heliocentric Earth position | +| Geocentric astrometric | `AstrometricGeocentricEquatorialJ2000` | light-time | +| Geocentric apparent | `ApparentGeocentricEcliptic` / `ApparentGeocentricEquatorial` | light-time + nutation | +| Topocentric apparent | `ApparentTopocentricEquatorial` and all rise/set helpers | light-time + nutation + topocentric parallax | + +### Units and Frame Conventions + +- Angles are always in degrees, distances in AU, ellipsoidal heights for topocentric and rise/set helpers in meters, and time as `time.Time`. +- The frame of `Elements` is the J2000 mean ecliptic and mean equinox. Interfaces ending in `...J2000` keep that frame; the other `HeliocentricEcliptic`/`GeocentricEcliptic`/`GeocentricEquatorial` variants are of-date quantities, so do not mix the two frames. +- Positions come in three layers: `Heliocentric*`/`Geocentric*` are geometric, `AstrometricGeocentricEquatorialJ2000` adds light-time to the geometric position (solved iteratively in distance, at most 8 passes, converged at `1e-12` days), and `Apparent*` adds nutation on top of light-time. + + The planetary convention of this repository is exactly "light-time + nutation, without a full external aberration model", so `Apparent*` is at the same level as the planet packages and must not be treated as a full apparent place. +- `MeanMotion`/`MeanAnomaly`/`TrueAnomaly` return degrees; mean motion and mean anomaly are undefined for parabolic and hyperbolic orbits. + +### Time Scale and Input Reading + +- `EpochJD` and `TpJD` are TT/TDB Julian days. The coordinate, geometry, and photometry interfaces treat `date` as an absolute instant (`date.UTC()`, then `UTC2TT` to TT/TDB). + + `UTC2TT` treats civil values as UT1 before 1972-01-01 and uses the built-in leap-second table inside the exact window; that table can be overridden with `astro.SetTTMinusUTC`. +- Instantaneous topocentric quantities combine the local clock fields with `date.Zone()` to recover the absolute instant. UTC and local-zone representations of the same instant give the same position. Rise/set searches also use the local date, so choose the zone for the observing calendar. +- `CulminationTime`, `RiseTime`, and `SetTime` keep the zone of the input `date` and return civil instants. Internally they step back 12 hours when `date.Hour() > 12`, so that the search anchor stays within the selected date. +- Every public `time.Time` output is a civil value (UTC label); UT1 and TT only appear in internal conversions. For explicit conversion use the root package's `astro.UT1FromUTC` / `astro.TTFromUTC`. + +### Zero Values and Out-of-Range Input + +- The zero value of `Elements` is not a valid orbit. The classical elliptical form requires a finite positive `A`, `E` within `[0,1)`, and finite `EpochJD` and `M0`. + + Parabolic and hyperbolic orbits with `E >= 1` can only use the perihelion form, which requires a finite positive `Q`, a finite `TpJD`, `E >= 0`, and finite `I`/`Omega`/`W`. +- When `Q > 0` and `TpJD` is finite the perihelion form wins: `A`, `M0`, and `EpochJD` are ignored, `ADot`/`EDot`/`IDot`/`OmegaDot`/`WDot` have no effect, and only a non-zero `MDot` is used as the mean angular rate. +- With invalid elements `MeanMotion` and `MeanAnomaly` return `NaN` and the positional interfaces return three `NaN`s. + + `AsteroidMagnitudeHG` returns `NaN` when its inputs are non-finite or when heliocentric distance, geocentric distance, or phase angle is non-positive, and `+Inf` when the H-G phase blend is zero. +- `RiseTime` and `SetTime` return the sentinel errors `ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET` when no rise/set exists on the supplied local day, and pass any other failure through; on success the second return value is `nil`. +- `VisualBinary` and `VisualBinaryByYear` fill every numeric field of `VisualBinaryPosition` with `NaN` when `PeriodYears <= 0`, `SemiMajorAxis <= 0`, `Eccentricity` outside `[0,1)`, or any element is non-finite. + +### Accuracy and Applicability + +- `orbit` is two-body conic propagation: it contains only the supplied elements, with no planetary perturbations, non-gravitational terms, or relativistic corrections. The farther the date from the epoch, the larger the static-element error; `ADot`…`WDot` only mitigate linear drift and cannot replace a re-fit or a numerical integration. +- The difference between the `Apparent*` and topocentric quantities is purely geometric plus nutation, and neither includes atmospheric refraction; only `aero=true` in the rise/set helpers folds refraction into the criterion (target altitude `-0.5667°` plus the horizon dip derived from ellipsoidal height and latitude). +- `RiseTime`/`SetTime` iterate the rise/set geometry in the nominal zone `round(observerLon/15)`, so the site should sit near the center of its zone; only one root is solved, and boundary cases such as polar or circumpolar targets are reported through the sentinel errors. +- The visual-binary solver uses the classical apparent-orbit formula from chapter 55 of *Astronomical Algorithms* and does not apply when `Eccentricity >= 1`; it is a geometric projection only, with no mass, photometry, or perturbation information. + +### Common Pitfalls + +- Treating a `HeliocentricEcliptic` result as geocentric: heliocentric quantities are centered on the Sun, while geocentric ones have already subtracted the heliocentric Earth position. +- Treating `Apparent*` as a refraction-corrected apparent place: refraction appears only in the rise/set and observing helpers, not in the coordinate interfaces. +- Probing with a zero-value `Elements`: mean motion and the positional interfaces quietly return `NaN` instead of reporting an error. +- Expecting `ADot`…`WDot` to take effect in the perihelion form: those rates only propagate in the classical elliptical form. + +## Related Manuals + +- Stars and catalogues: [star](star.md#stars) +- Frame conversion and topocentric quantities: [coordinate tools](coord.md#coordinate-tools) +- The analogous rise/set interfaces: [Sun and Moon](sun-moon.md#sunrisesunset-and-moonrisemoonset) + +> To feed the ecliptic/equatorial coordinates here into a star catalogue or topocentric quantities, see [star](star.md#stars) and [coordinate tools](coord.md#coordinate-tools). diff --git a/doc/manual/en/planets.md b/doc/manual/en/planets.md new file mode 100644 index 0000000..7418c83 --- /dev/null +++ b/doc/manual/en/planets.md @@ -0,0 +1,927 @@ +# Planets + +[中文](../planets.md) | [Back to README](../../../README.en.md) + +The seven major planets each have a package of their own: `mercury`, `venus`, `mars`, `jupiter`, `saturn`, `uranus`, and `neptune`. + +They share the same API shape: a civil instant is passed as `time.Time`, angles and distances come back as `float64`, and event searches return `time.Time` or a struct. + +The inner planets (Mercury and Venus) add superior/inferior conjunction, greatest elongation, and geocentric transit; the outer planets (Mars through Neptune) add opposition and quadrature; Jupiter alone has the Galilean satellites and Saturn alone has ring parameters. The low-level VSOP87 series and the solar/lunar analytic series live in the `planet` package, shared by these seven packages and by `sun` / `moon`. + +- The function names of the common capabilities are identical across the seven packages (for example all of them provide `ApparentRa`, `ApparentDec`, and `ApparentRaDec`); only the extra families differ. + + Always qualify a call with its package name instead of mixing packages. +- Position functions return a **geocentric apparent place**. + + Topocentric quantities and horizontal coordinates are separate: `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle`, with formulas under [Coordinate Tools](coord.md). +- Event-search families come as `Last...` / `Next...` pairs (some also `Closest...`) and always return the nearest event at or before/after the input instant, endpoints included. +- The planets have no dedicated SVG chart entry point; occultation-related charts (including Saturn-ring occultations) are documented in [Lunar Occultations](occultation.md#lunar-occultation-charts). + +## Contents + +- [The position and rise time of Mars](#the-position-and-rise-time-of-mars) +- [API Reference](#api-reference) + - [Cross-planet comparison](#cross-planet-comparison) + - [Common capabilities and units](#common-capabilities-and-units) + - [The `...N` truncation family](#the-n-truncation-family) +- [Usage examples](#usage-examples) + - [Which planet can I see tonight?](#which-planet-can-i-see-tonight) + - [Oppositions, conjunctions, elongations, stations and retrogrades](#oppositions-conjunctions-elongations-stations-and-retrogrades) + - [Mercury and Venus transits](#mercury-and-venus-transits) + - [Phase, diameter, magnitude and nodes](#phase-diameter-magnitude-and-nodes) + - [Physical ephemerides and the Galilean satellites](#physical-ephemerides-and-the-galilean-satellites) + - [Comparison conventions](#comparison-conventions) +- [Basic examples](#basic-examples) + - [Inner planets](#inner-planets) + - [Outer planets](#outer-planets) +- [Topic examples](#topic-examples) + - [Position and coordinates](#position-and-coordinates) + - [Rise, set and culmination](#rise-set-and-culmination) + - [Conjunctions, oppositions, stations and quadratures](#conjunctions-oppositions-stations-and-quadratures) + - [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits) + - [Nodes, phase, magnitude, diameter and parallactic angle](#nodes-phase-magnitude-diameter-and-parallactic-angle) + - [Division of labour with other manuals](#division-of-labour-with-other-manuals) + - [Physical ephemerides](#physical-ephemerides) + - [Galilean satellites of Jupiter](#galilean-satellites-of-jupiter) + - [Shared types and constants from the `planet` package](#shared-types-and-constants-from-the-planet-package) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Time scale and civil time](#time-scale-and-civil-time) + - [Units and conventions](#units-and-conventions) + - [Zero values, out-of-range input and sentinel errors](#zero-values-out-of-range-input-and-sentinel-errors) + - [Topocentric versus geocentric](#topocentric-versus-geocentric) + - [Accuracy and scope](#accuracy-and-scope) + +## The position and rise time of Mars + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/mars" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 108.93, 34.27, 0.0 + ra, dec := mars.ApparentRaDec(date) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", ra, dec) + rise, err := mars.RiseTime(date, lon, lat, height, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +`ApparentRaDec` returns geocentric apparent right ascension and declination in degrees. Rise/set also needs a site and ellipsoidal height, and returns an error when no rise event exists. + +## API Reference + +### Cross-planet comparison + +Rows list capabilities and columns list planetary packages. Qualify each function with its package, for example `mars.NextOpposition`. A slash separates function names; `—` means the package has no corresponding API. + +| Capability | `mercury` | `venus` | `mars` | `jupiter` | `saturn` | `uranus` | `neptune` | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Apparent ecliptic longitude / latitude | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | +| Apparent right ascension / declination | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | +| Apparent magnitude | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | +| Geocentric / heliocentric distance | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | +| Orbital ascending / descending node | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | +| Apparent diameter / semidiameter | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | +| Phase angle / illuminated fraction / bright-limb position angle | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | +| Topocentric horizontal quantities | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | +| Rise / set / culmination | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | +| Physical ephemeris | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` / `CentralMeridians` / `CentralMeridiansN` | `Physical` / `PhysicalN` / `PhysicalSystemIII` / `Ring` | `Physical` / `PhysicalN` / `PhysicalSystemIII` | `Physical` / `PhysicalN` | +| Conjunction | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | +| Superior / inferior conjunction | `LastSuperiorConjunction` / `NextSuperiorConjunction` / `LastInferiorConjunction` / `NextInferiorConjunction` | `LastSuperiorConjunction` / `NextSuperiorConjunction` / `LastInferiorConjunction` / `NextInferiorConjunction` | — | — | — | — | — | +| Station | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde`, plus `LastRetrograde` / `NextRetrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde`, plus `LastRetrograde` / `NextRetrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | +| Opposition | — | — | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | +| Quadrature | — | — | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | +| Greatest elongation | `LastGreatestElongation` / `NextGreatestElongation` / `LastGreatestElongationEast` / `NextGreatestElongationEast` / `LastGreatestElongationWest` / `NextGreatestElongationWest` | `LastGreatestElongation` / `NextGreatestElongation` / `LastGreatestElongationEast` / `NextGreatestElongationEast` / `LastGreatestElongationWest` / `NextGreatestElongationWest` | — | — | — | — | — | +| Geocentric transit | `LastTransit` / `NextTransit` / `ClosestTransit` | `LastTransit` / `NextTransit` / `ClosestTransit` | — | — | — | — | — | +| Galilean satellites | — | — | — | `Satellites` / `SatellitePhenomena` / `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent` / `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent` | — | — | — | +| Result types | `PhysicalInfo` / `TransitInfo` | `PhysicalInfo` / `TransitInfo` | `PhysicalInfo` | `PhysicalInfo` / `CentralMeridianInfo` / `GalileanSatellitesInfo` / `GalileanPhenomenaInfo` / `GalileanSatellitePosition` / `GalileanSatellitePhenomenon` / `GalileanPhenomenonEvent` / `GalileanPhenomenonContactEvent` | `PhysicalInfo` / `RingInfo` | `PhysicalInfo` | `PhysicalInfo` | + +All seven packages spell the **truncation family** the same way: append `N` to any instantaneous evaluation function, where `n < 0` uses the full built-in series and `n >= 0` truncates it. + +See [the `...N` truncation family](#the-n-truncation-family) for details. Event-search families (`Last...` / `Next...` / `Closest...`) have no `N` variants. + +### Common capabilities and units + +| Capability | Purpose | Unit and convention | +| --- | --- | --- | +| `ApparentLo` / `ApparentBo` | Geocentric apparent ecliptic longitude and latitude | degrees; true equinox of date, including light time, aberration, and nutation | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | Geocentric apparent right ascension and declination | degrees; true equator of date; `ApparentRaDec` returns both at once | +| `ApparentMagnitude` | Apparent visual magnitude | magnitudes (dimensionless) | +| `Altitude` / `Azimuth` / `Zenith` / `HourAngle` | Topocentric horizontal coordinates | degrees; azimuth runs from north toward east, and `Zenith` equals `90 - Altitude` | +| `RiseTime` / `SetTime` / `DownTime` | Rise and set within the local civil day | `time.Time` keeping the input time zone; `DownTime` is a compatibility alias for `SetTime` | +| `CulminationTime` | Upper culmination instant | `time.Time` keeping the input time zone | +| `ParallacticAngle` | Parallactic angle (zenith direction angle) | degrees | +| `Diameter` / `Semidiameter` | Geocentric apparent diameter and semidiameter | arcseconds | +| `PhaseAngle` | Sun-planet-Earth angle | degrees | +| `IlluminatedFraction` / `Phase` | Illuminated fraction | `0-1`; `Phase` is an alias for `IlluminatedFraction` | +| `BrightLimbPositionAngle` | Position angle of the bright-limb center | degrees | +| `EarthDistance` / `SunDistance` | Earth distance and Sun distance | AU | +| `AscendingNode` / `DescendingNode` | Ecliptic longitude of the orbital plane's intersections with the ecliptic | degrees; the two differ by about `180°` at the same instant | +| `Physical` | Disk orientation, sub-Earth/sub-Solar coordinates, north-pole position angle | degrees; the positive longitude direction follows each body's IAU convention | +| `planet.WherePlanet` / `planet.WherePlanetN` | VSOP87 longitude, latitude, and heliocentric distance | degrees / AU; out-of-range input returns `NaN` instead of panicking | + +### The `...N` truncation family + +Every instantaneous evaluation function has an `...N` variant that trades accuracy for cost: `n < 0` uses the full built-in series and is equivalent to the plain function, while `n >= 0` keeps roughly `n` principal terms and scales the higher orders proportionally. + +Event-search families (`Last...` / `Next...` / `Closest...`) have no `N` variant. + +Functions with an `N` variant include `ApparentLoN`, `ApparentBoN`, `ApparentRaN`, `ApparentDecN`, `ApparentRaDecN`, `ApparentMagnitudeN`, `EarthDistanceN`, `SunDistanceN`, `AltitudeN`, `AzimuthN`, `ZenithN`, `HourAngleN`, `CulminationTimeN`, `RiseTimeN`, `SetTimeN`, `DownTimeN`, `ParallacticAngleN`, `DiameterN`, `SemidiameterN`, `PhaseAngleN`, `PhaseN`, `IlluminatedFractionN`, `BrightLimbPositionAngleN`, `AscendingNodeN`, `DescendingNodeN`, and `PhysicalN`, plus `CentralMeridiansN` for Jupiter, `RingN` and `PhysicalSystemIIIN` for Saturn, and `PhysicalSystemIIIN` for Uranus. + +```go +fmt.Println(mars.ApparentLo(date), mars.ApparentLoN(date, 8)) // ecliptic longitude: full series / truncated to about 8 terms +fmt.Println(mars.SunDistance(date), mars.SunDistanceN(date, 8)) // heliocentric distance, AU +``` + +## Usage examples + +### Which planet can I see tonight? + +```go +fmt.Println(venus.RiseTime(date, lon, lat, height, true)) // Venus rises today +fmt.Println(jupiter.CulminationTime(date, lon)) // Jupiter's upper culmination +fmt.Println(mars.Altitude(date, lon, lat), mars.Azimuth(date, lon, lat)) // Mars's altitude and azimuth now +``` + +```text +2020-01-01 10:02:34.350145161 +0800 CST +2020-01-01 12:32:17.585815787 +0800 CST +31.194578177219057 152.07031660415714 +``` + +Above the horizon means `Altitude` greater than `0`, and azimuth increases from due north towards the east; `aero = true` lowers the geometric horizon to about `-0.5667°` for standard refraction. + +Per-parameter conventions and the polar sentinel errors are in [Rise, set and culmination](#rise-set-and-culmination). + +### Oppositions, conjunctions, elongations, stations and retrogrades + +```go +fmt.Println(mars.NextOpposition(date)) // Mars's next opposition +fmt.Println(jupiter.NextConjunction(date)) // Jupiter's next conjunction +fmt.Println(saturn.NextProgradeToRetrograde(date)) // Saturn's next prograde-to-retrograde station +``` + +```text +2020-10-14 07:25:50.441412627 +0800 CST +2021-01-29 09:39:33.697994649 +0800 CST +2020-05-11 17:26:53.961271941 +0800 CST +``` + +Event searches return the nearest event at or after the input instant and keep its time zone; the paired `Last...` functions and the direction-agnostic `NextRetrograde` are in [Conjunctions, oppositions, stations and quadratures](#conjunctions-oppositions-stations-and-quadratures); + +Mercury's and Venus's `NextGreatestElongationEast` / `...West` are in [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits). + +### Mercury and Venus transits + +```go +transit := mercury.NextTransit(date) // next geocentric Mercury transit after 2020 +fmt.Println(transit.Valid, transit.Start, transit.Greatest) // whether one was found, first contact, greatest transit +fmt.Println(transit.Duration, transit.MinimumSeparationArcsec) // duration and minimum separation at greatest transit +``` + +```text +true 2032-11-13 14:41:13.161198198 +0800 CST 2032-11-13 16:54:12.821315824 +0800 CST +4h26m2.695272267s 572.0643215495325 +``` + +`TransitInfo.Valid` false means no transit inside the search window and every other field is the zero value; a transit is a geocentric geometry test and does not check whether the Sun is above the horizon at a given site. + +The four contacts, partial transits and the internal-contact fields are in [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits). + +### Phase, diameter, magnitude and nodes + +```go +fmt.Println(venus.PhaseAngle(date), venus.Phase(date)) // phase angle (degrees) and illuminated fraction +fmt.Println(venus.Diameter(date), venus.ApparentMagnitude(date)) // apparent diameter (arcseconds) and magnitude +fmt.Println(mars.AscendingNode(date), mars.DescendingNode(date)) // ascending/descending node longitudes (degrees) +``` + +```text +49.98145049145023 0.8215177914415865 +13.059409604614839 -4 +49.71479005849112 229.71479005849113 +``` + +`Phase` is an alias of `IlluminatedFraction` and runs from `0` to `1`; the two nodes are about `180°` apart at any instant. + +Definitions, aliases and the truncated versions are in [Nodes, phase, magnitude, diameter and parallactic angle](#nodes-phase-magnitude-diameter-and-parallactic-angle). + +### Physical ephemerides and the Galilean satellites + +```go +j := jupiter.Physical(date) // Jupiter's physical ephemeris +fmt.Println(j.DS, j.DE, j.CentralMeridianSystemIII) // sub-solar/sub-Earth latitude and System III central meridian +sats := jupiter.Satellites(date) // instantaneous positions of the four Galilean satellites +fmt.Println(sats.Io.OffsetXJupiterR, sats.Io.InFrontOfJupiter) // Io's offset and whether it is in front of the disk +``` + +```text +-56.55778470155335 -2.039966127259664 311.37430665615585 +3.8223302102343975 false +``` + +Saturn's rings have their own `saturn.Ring` (`EarthLatitude`, `MinorAxis`, and so on; angles in degrees and axes in arcseconds). Disk orientation and the central meridians are covered in [Physical ephemerides](#physical-ephemerides); the Galilean-satellite public entry points are in the `jupiter` package, while `JupiterGalilean*` in `basic` is the low-level entry that takes a Julian day, see [Galilean satellites of Jupiter](#galilean-satellites-of-jupiter). + +### Comparison conventions + +```go +fmt.Println(mars.ApparentLo(date), mars.ApparentLoN(date, 8)) // apparent longitude with every term / truncated to about 8 +_, err := mars.RiseTime(date, 0, 89, 0, true) // an observing site in the polar region +fmt.Println(errors.Is(err, mars.ERR_MARS_NEVER_RISE), errors.Is(err, mars.ERR_MARS_NEVER_SET)) +``` + +```text +238.38840227925655 238.39637464888327 +true false +``` + +`ApparentLo` and friends are geocentric apparent places referred to the **true equinox of date**; bring external J2000 or mean places to the same convention with `coord.Precess` before comparing. + +In the truncation family `n < 0` uses every built-in term and `n >= 0` truncates. + +The convention gap between the Galilean contact events and JPL Horizons / IMCCE tables is documented under [External baselines](#external-baselines), and the overall boundaries under [Parameter and result conventions](#parameter-and-result-conventions). + +## Basic examples + +The two examples below are the smallest runnable programs and use `date = 2020-01-01 08:08:08 CST` with the Xi'an coordinates. + +### Inner planets + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/mercury" + "b612.me/astro/venus" +) + +func main() { + // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // Instant of observation. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + + // Previous inferior conjunction of Mercury. + fmt.Println(mercury.LastInferiorConjunction(date)) + // Next superior conjunction of Venus. + fmt.Println(venus.NextSuperiorConjunction(date)) + // Previous Mercury station from prograde to retrograde. + fmt.Println(mercury.LastProgradeToRetrograde(date)) + // Next Venus station from retrograde to prograde. + fmt.Println(venus.NextRetrogradeToPrograde(date)) + // Previous greatest eastern elongation of Mercury. + fmt.Println(mercury.LastGreatestElongationEast(date)) + // Next greatest western elongation of Venus. + fmt.Println(venus.NextGreatestElongationWest(date)) + // Venus rise and set times in Xi'an. + fmt.Println(venus.RiseTime(date, lon, lat, height, true)) + fmt.Println(venus.SetTime(date, lon, lat, height, true)) + // Current apparent magnitude of Venus. + fmt.Println(venus.ApparentMagnitude(date)) + // Venus phase angle, illuminated fraction, and bright-limb position angle. + fmt.Println(venus.PhaseAngle(date)) + fmt.Println(venus.Phase(date)) + fmt.Println(venus.BrightLimbPositionAngle(date)) + // Earth-Venus distance. + fmt.Println(venus.EarthDistance(date)) + // Sun-Venus distance. + fmt.Println(venus.SunDistance(date)) +} +``` + +Output: + +```text +2019-11-11 23:21:41.971051096 +0800 CST // previous inferior conjunction of Mercury +2021-03-26 14:57:42.052354216 +0800 CST // next superior conjunction of Venus +2019-11-01 04:31:49.749019145 +0800 CST // previous Mercury station from prograde to retrograde +2020-06-25 02:07:41.599749326 +0800 CST // next Venus station from retrograde to prograde +2019-10-20 12:01:37.740152478 +0800 CST // previous greatest eastern elongation of Mercury +2020-08-13 08:14:46.304587125 +0800 CST // next greatest western elongation of Venus +2020-01-01 10:02:34.172435402 +0800 CST // Venus rise time in Xi'an; no error +2020-01-01 20:25:37.36411482 +0800 CST // Venus set time in Xi'an; no error +-4 // Venus apparent magnitude +49.98145049145023 // Venus phase angle, degrees +0.8215177914415865 // illuminated fraction of Venus +255.63802053541346 // bright-limb position angle of Venus, degrees +1.2778819631550336 // Earth-Venus distance, AU +0.7262651056423838 // Sun-Venus distance, AU +``` + +Inner and outer planets also expose `Diameter` / `Semidiameter` and `N` variants, returning geocentric apparent diameter/semidiameter in arcseconds. + +Planet apparent diameter and orbital nodes can be queried directly: + +```go +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // Martian apparent diameter and semidiameter, arcseconds +fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date)) // Venus ascending-node and descending-node ecliptic longitudes, degrees +``` + +Ascending node / descending node here means the two intersections of the body's orbital plane with the ecliptic: + +- `AscendingNode`: ecliptic longitude where the body crosses from south of the ecliptic to north of it +- `DescendingNode`: ecliptic longitude where the body crosses from north of the ecliptic to south of it +- return values are degrees; for the same instant, descending node is usually about `180°` from ascending node + +For `date := 2020-01-01 08:08:08 CST`, the output is: + +```text +4.287299886569956 2.143649943284978 // Mars apparent diameter and semidiameter, arcseconds +76.86008484515058 256.8600848451506 // Venus ascending-node and descending-node longitudes, degrees +``` + +Mercury and Venus also expose `NextTransit` / `LastTransit` / `ClosestTransit` for geocentric planetary transits. "Geocentric" means the planet disk crosses the solar disk as seen from Earth's center; it does not test whether the Sun is above the horizon at a particular observing site. For observing plans, combine this with local solar altitude and weather. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/mercury" + "b612.me/astro/venus" +) + +func main() { + // Next geocentric Mercury transit after the beginning of 2019. + mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)) + fmt.Println(mercuryTransit.Valid) + fmt.Println(mercuryTransit.Start) + fmt.Println(mercuryTransit.InternalStart) + fmt.Println(mercuryTransit.Greatest) + fmt.Println(mercuryTransit.InternalEnd) + fmt.Println(mercuryTransit.End) + fmt.Println(mercuryTransit.Duration) + fmt.Println(mercuryTransit.MinimumSeparationArcsec) + fmt.Println(mercuryTransit.SunSemidiameterArcsec) + fmt.Println(mercuryTransit.PlanetSemidiameterArcsec) + + // Next geocentric Venus transit after the beginning of 2012. + venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC)) + fmt.Println(venusTransit.Valid) + fmt.Println(venusTransit.Start) + fmt.Println(venusTransit.InternalStart) + fmt.Println(venusTransit.Greatest) + fmt.Println(venusTransit.InternalEnd) + fmt.Println(venusTransit.End) + fmt.Println(venusTransit.Duration) +} +``` + +Output: + +```text +true // a valid geocentric Mercury transit was found +2019-11-11 12:35:31.567597389 +0000 UTC // first contact: Mercury externally enters the solar disk +2019-11-11 12:37:12.817581295 +0000 UTC // second contact: Mercury is fully inside the solar disk +2019-11-11 15:19:48.36056292 +0000 UTC // greatest transit: Mercury center is closest to the Sun center +2019-11-11 18:02:29.176982045 +0000 UTC // third contact: Mercury starts leaving the solar disk +2019-11-11 18:04:10.637948513 +0000 UTC // fourth contact: Mercury externally leaves the solar disk +5h28m39.070351124s // geocentric transit duration from first to fourth contact +75.92400059923187 // minimum Mercury-Sun center separation at greatest transit, arcseconds +968.8881519533047 // solar semidiameter at greatest transit, arcseconds +4.978442871670873 // Mercury semidiameter at greatest transit, arcseconds +true // a valid geocentric Venus transit was found +2012-06-05 22:09:47.466886639 +0000 UTC // first contact: Venus externally enters the solar disk +2012-06-05 22:27:35.865356326 +0000 UTC // second contact: Venus is fully inside the solar disk +2012-06-06 01:29:35.572371482 +0000 UTC // greatest transit: Venus center is closest to the Sun center +2012-06-06 04:31:35.068444311 +0000 UTC // third contact: Venus starts leaving the solar disk +2012-06-06 04:49:23.25597167 +0000 UTC // fourth contact: Venus externally leaves the solar disk +6h39m35.789085031s // geocentric transit duration from first to fourth contact +``` + +### Outer planets + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/jupiter" + "b612.me/astro/mars" + "b612.me/astro/neptune" + "b612.me/astro/saturn" + "b612.me/astro/uranus" +) + +func main() { + // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // Instant of observation. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + + // Next opposition of Mars. + fmt.Println(mars.NextOpposition(date)) + // Next conjunction of Jupiter. + fmt.Println(jupiter.NextConjunction(date)) + // Previous Saturn station from prograde to retrograde. + fmt.Println(saturn.LastProgradeToRetrograde(date)) + // Saturn ring observing parameters. + ring := saturn.Ring(date) + fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f dU=%.6f major=%.6f minor=%.6f\n", + ring.EarthLatitude, + ring.SunLatitude, + ring.PositionAngle, + ring.DeltaU, + ring.MajorAxis, + ring.MinorAxis, + ) + // Next Uranus station from retrograde to prograde. + fmt.Println(uranus.NextRetrogradeToPrograde(date)) + // Previous eastern quadrature of Neptune. + fmt.Println(neptune.LastEasternQuadrature(date)) + // Next western quadrature of Mars. + fmt.Println(mars.NextWesternQuadrature(date)) + // Mars rise and set times in Xi'an. + fmt.Println(mars.RiseTime(date, lon, lat, height, true)) + fmt.Println(mars.SetTime(date, lon, lat, height, true)) + // Current apparent magnitude of Mars. + fmt.Println(mars.ApparentMagnitude(date)) + // Earth-Mars distance. + fmt.Println(mars.EarthDistance(date)) + // Sun-Mars distance. + fmt.Println(mars.SunDistance(date)) +} +``` + +Output: + +```text +2020-10-14 07:25:50.441412627 +0800 CST // next opposition of Mars +2021-01-29 09:39:33.697994649 +0800 CST // next conjunction of Jupiter +2019-04-30 10:28:00.187439918 +0800 CST // previous Saturn station from prograde to retrograde +saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // Saturn ring B, B', P, dU, major axis, minor axis +2020-01-11 15:23:23.360308706 +0800 CST // next Uranus station from retrograde to prograde +2019-12-08 17:00:15.517960488 +0800 CST // previous eastern quadrature of Neptune +2020-06-07 03:11:00.026179254 +0800 CST // next western quadrature of Mars +2020-01-01 04:41:29.621566236 +0800 CST // Mars rise time in Xi'an; no error +2020-01-01 14:55:32.963508367 +0800 CST // Mars set time in Xi'an; no error +1.57 // Mars apparent magnitude +2.1844284956325937 // Earth-Mars distance, AU +1.5897860004265403 // Sun-Mars distance, AU +``` + +`saturn.Ring` returns `RingInfo`: `EarthLatitude` is ring opening angle B, `SunLatitude` is B', `PositionAngle` is the position angle of the northern semiminor axis, `DeltaU` is the Saturnicentric longitude difference between the Sun and Earth in the ring plane, and `MajorAxis` / `MinorAxis` are the apparent outer major/minor axes in arcseconds. + +## Topic examples + +The snippets below are grouped by topic and all build on the shared variables `date`, `lon`, `lat`, and `height`; complete runnable versions are in the basic examples above. + +### Position and coordinates + +`ApparentLo` / `ApparentBo` / `ApparentRa` / `ApparentDec` / `ApparentRaDec` return a **geocentric apparent place**: the geometric geocentric position corrected for light time, aberration, and nutation, then converted to the true equator and equinox of date. + +These packages do not provide J2000 or mean-place output; for a J2000 frame, use `coord.Precess` for precession and `coord.Nutation2000B` for nutation, and use `coord.TopocentricEquatorial` and `coord.EquatorialToHorizontal` for topocentric quantities. See [Coordinate Tools](coord.md). + +```go +// Apparent place of date: ecliptic and equatorial coordinates, degrees. +lo, bo := venus.ApparentLo(date), venus.ApparentBo(date) +ra, dec := venus.ApparentRaDec(date) +fmt.Println(lo, bo, venus.ApparentRa(date), venus.ApparentDec(date), ra, dec) + +// Earth distance and Sun distance, AU. +fmt.Println(venus.EarthDistance(date), venus.SunDistance(date)) +``` + +### Rise, set and culmination + +`RiseTime` / `SetTime` / `DownTime` work on the **local civil day** only: `date` selects the local date and the output time zone (when the local hour is greater than 12 it is first shifted back by 12 hours), `height` is the ellipsoidal height in meters, and `aero == true` adds standard atmospheric refraction (lowering the geometric horizon to about `-0.5667°`). + +Polar day, polar night, or a day with no crossing returns a [sentinel error](#parameter-and-result-conventions) instead of an instant. `CulminationTime` gives upper culmination; `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` form the topocentric family, with geometry under [Coordinate Tools](coord.md). + +```go +// Rise, set, and culmination on the local civil day in Xi'an; aero=true adds refraction. +rise, err := mars.RiseTime(date, lon, lat, height, true) +set, err := mars.SetTime(date, lon, lat, height, true) +fmt.Println(rise, set, err) +fmt.Println(mars.CulminationTime(date, lon)) +``` + +```go +// Topocentric horizontal quantities: east-positive longitude, north-positive latitude, degrees. +fmt.Println(mars.Altitude(date, lon, lat), mars.Azimuth(date, lon, lat)) +fmt.Println(mars.Zenith(date, lon, lat), mars.HourAngle(date, lon)) +fmt.Println(mars.ParallacticAngle(date, lon, lat)) +``` + +```go +// DownTime is a compatibility alias for SetTime; the N variant evaluates a truncated series. +set, err := mars.DownTime(date, lon, lat, height, true) +fmt.Println(set, err) +fmt.Println(mars.CulminationTimeN(date, lon, 12)) +``` + +### Conjunctions, oppositions, stations and quadratures + +Event searches always return the nearest event at or before/after the input instant (endpoints included), in the input time zone. The inner planets use `LastConjunction` / `NextConjunction` plus the superior/inferior conjunction family; the outer planets use `LastConjunction` / `NextConjunction`, `LastOpposition` / `NextOpposition`, and the eastern/western quadrature family. + +Stations come in two directions: `LastProgradeToRetrograde` / `NextProgradeToRetrograde` (prograde to retrograde) and `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` (retrograde to prograde); Mercury and Venus additionally have `LastRetrograde` / `NextRetrograde`, which ignore the direction, and the outer planets do not have those two names. + +For the inner planets a conjunction is simply "on the same side as the Sun"; the outer planets additionally split opposition and quadrature: only the five planets outside Earth's orbit can reach opposition (Sun-Earth-planet in a line) or eastern/western quadrature (about 90° of ecliptic longitude from the Sun). + +Mercury and Venus stay inside Earth's orbit, so those two families exist only in the outer-planet packages. + +```go +// Inner planets: superior/inferior conjunction, and direction-agnostic stations. +fmt.Println(mercury.LastSuperiorConjunction(date), mercury.NextInferiorConjunction(date)) +fmt.Println(mercury.LastRetrograde(date), mercury.NextRetrograde(date)) +``` + +```go +// Outer planets: conjunction, opposition, and eastern/western quadrature. +fmt.Println(jupiter.NextConjunction(date), mars.NextOpposition(date)) +fmt.Println(mars.NextEasternQuadrature(date), neptune.LastWesternQuadrature(date)) +``` + +```go +// Both station directions have Last/Next forms. +fmt.Println(saturn.LastProgradeToRetrograde(date), saturn.NextProgradeToRetrograde(date)) +fmt.Println(saturn.LastRetrogradeToPrograde(date), saturn.NextRetrogradeToPrograde(date)) +``` + +### Greatest elongation and geocentric transits + +Greatest elongation only makes sense for Mercury and Venus: `LastGreatestElongation` / `NextGreatestElongation` ignore the side, while `LastGreatestElongationEast` / `NextGreatestElongationEast` and the `...West` forms distinguish it. + +The public entry points for geocentric transits are likewise only in `mercury` / `venus`: `LastTransit` / `NextTransit` / `ClosestTransit` return a `TransitInfo`. + +`Valid == false` means no transit inside the search window and every other field is a zero value; a partial transit has no internal contacts, so `HasInternal` is false and `InternalStart` / `InternalEnd` are zero. + +A transit is a purely geocentric geometry test and does not check whether the Sun is above the horizon at a given site. + +```go +// Greatest elongation: use ...East / ...West to pick a side, or the plain form for either. +fmt.Println(mercury.NextGreatestElongationEast(date), venus.LastGreatestElongationWest(date)) +fmt.Println(venus.NextGreatestElongation(date)) +``` + +```go +// Geocentric transit: when Valid is false every other field is a zero value. +transit := mercury.NextTransit(date) +if transit.Valid { + fmt.Println(transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End) + fmt.Println(transit.Duration, transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec) +} +``` + +```go +// Remaining transit fields; InternalDuration is 0 when there are no internal contacts. +transit := venus.ClosestTransit(date) +fmt.Println(transit.HasInternal, transit.InternalDuration, transit.MinimumSeparationArcsec) +fmt.Println(transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec) +``` + +### Nodes, phase, magnitude, diameter and parallactic angle + +`AscendingNode` / `DescendingNode` are the ecliptic longitudes of the two intersections of the planet's orbital plane with the ecliptic, in degrees, about `180°` apart at the same instant. + +`PhaseAngle` is the Sun-planet-Earth angle in degrees; `IlluminatedFraction` (alias `Phase`) is the illuminated fraction from `0` to `1`; `BrightLimbPositionAngle` is the position angle of the bright-limb center in degrees. + +`Diameter` / `Semidiameter` return the geocentric apparent diameter and semidiameter in arcseconds. `ParallacticAngle` returns the topocentric parallactic angle (zenith direction angle) in degrees; topocentric coordinates are under [Coordinate Tools](coord.md). + +```go +fmt.Println(mars.AscendingNode(date), mars.DescendingNode(date)) // node longitudes, degrees +fmt.Println(mars.PhaseAngle(date), mars.IlluminatedFraction(date), mars.Phase(date)) // phase angle in degrees; illuminated fraction +fmt.Println(mars.ApparentMagnitude(date), mars.BrightLimbPositionAngle(date)) // apparent magnitude; bright-limb position angle +``` + +```go +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // apparent diameter and semidiameter, arcseconds +fmt.Println(mars.ParallacticAngle(date, lon, lat)) // parallactic angle, degrees +``` + +```go +// Nodes and apparent diameter also have truncated variants. +fmt.Println(mars.AscendingNodeN(date, 12), mars.DescendingNodeN(date, 12)) +fmt.Println(mars.DiameterN(date, 12), mars.SemidiameterN(date, 12)) +``` + +### Division of labour with other manuals + +- Sidereal time, precession and nutation, and topocentric/horizontal conversion are in [Coordinate Tools](coord.md). +- Sun and Moon positions, phases, rise/set, and syzygies are in the [Sun and Moon manual](sun-moon.md). +- Planetary occultations, occultation bands, and lunar-disk charts are in [Lunar Occultations](occultation.md#lunar-occultation-charts). +- Asteroids, comets, and other bodies computed from orbital elements are in [Small-body orbits](orbit.md). +- Time-scale declarations, UT1 conventions, and GeoJSON output are in [Event Maps and GeoJSON](map-geojson.md); the scale convention itself is in [Time Scale Declaration](map-geojson.md#time-scale-declaration). +- The authoritative capability list, dependency matrix, and accuracy summary are in the root [README](../../../README.en.md). + +### Physical ephemerides + +All seven major planets provide `Physical` / `PhysicalN` for disk orientation, sub-Earth/sub-Sun coordinates, and north-pole position angle. Jupiter additionally exposes System I/II/III central meridians, and Saturn exposes ring parameters. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/jupiter" + "b612.me/astro/saturn" +) + +func main() { + date := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) + + // Jupiter: DS and DE are planetocentric declinations of the Sun and Earth relative to Jupiter's equator. + // CMI/CMII/CMIII are Jupiter System I/II/III central meridians, degrees. + j := jupiter.Physical(date) + fmt.Printf("jupiter DS=%.6f DE=%.6f CMI=%.6f CMII=%.6f CMIII=%.6f\n", + j.DS, + j.DE, + j.CentralMeridianSystemI, + j.CentralMeridianSystemII, + j.CentralMeridianSystemIII, + ) + + // Saturn ring: B/B' are ring-plane latitudes seen from Earth and Sun; P is the position angle of the ring minor axis. + ring := saturn.Ring(date) + fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f major=%.6f minor=%.6f\n", + ring.EarthLatitude, + ring.SunLatitude, + ring.PositionAngle, + ring.MajorAxis, + ring.MinorAxis, + ) +} +``` + +Output: + +```text +jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // Jupiter DS/DE and System I/II/III central meridians, degrees +saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // Saturn ring B, B', minor-axis position angle, outer major/minor axes +``` + +If only Jupiter central meridians are needed: + +```go +cm := jupiter.CentralMeridians(date) +fmt.Printf("CMI=%.6f CMII=%.6f CMIII=%.6f\n", cm.SystemI, cm.SystemII, cm.SystemIII) // Jupiter System I/II/III central meridians +``` + +Saturn and Uranus also retain explicit `System III` semantic aliases: + +```go +sat3 := saturn.PhysicalSystemIII(date) +ura3 := uranus.PhysicalSystemIII(date) +fmt.Printf("saturn systemIII lon=%.6f lat=%.6f P=%.6f\n", sat3.SubEarthLongitude, sat3.SubEarthLatitude, sat3.NorthPolePositionAngle) // Saturn sub-Earth longitude/latitude and north-pole position angle +fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude, ura3.SubEarthLatitude, ura3.NorthPolePositionAngle) // Uranus sub-Earth longitude/latitude and north-pole position angle +``` + +All seven packages return their own `PhysicalInfo`, whose fields mirror `basic.PlanetPhysicalInfo` one for one. + +The positive direction of `SubEarthLongitude` / `SubSolarLongitude` follows each body's current IAU/Horizons cartographic convention: Mercury, Mars, Jupiter, Saturn, and Neptune use west-positive longitudes, while Venus and Uranus use east-positive longitudes. + +Saturn-ring parameters describe only the disk and band; they take no part in occultation contact geometry, and charts are in [Lunar Occultations](occultation.md#lunar-occultation-charts). + +```go +p := uranus.Physical(date) // Uranus sub-Earth/sub-Solar coordinates and north-pole position angle, degrees +fmt.Println(p.SubEarthLongitude, p.SubEarthLatitude, p.SubSolarLongitude, p.SubSolarLatitude, p.NorthPolePositionAngle) +``` + +```go +// Truncated variants of the Saturn ring and the Jupiter central meridians. +fmt.Println(saturn.RingN(date, 12).MinorAxis, jupiter.CentralMeridiansN(date, 12).SystemIII) +``` + +### Galilean satellites of Jupiter + +The public entry points are in the **`jupiter` package** (`jupiter.Satellites`, `jupiter.SatellitePhenomena`, `jupiter.NextGalileanPhenomenonEvent`, and so on); they take a `time.Time` and return Jupiter's own types. + +The `JupiterGalilean*` functions in `basic` (such as `basic.JupiterGalileanSatelliteObservations` and `basic.NextJupiterGalileanPhenomenonEvent`) are the low-level entries of the same implementation: they take a Julian day and return `basic` types. + +Applications should use the `jupiter` layer. + +```go +// Entry point is in the jupiter package; satellite numbers use jupiter.GalileanSatelliteIo and friends. +sats := jupiter.Satellites(date) +fmt.Println(sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) +``` + +Satellite numbers and phenomenon types have named constants, so there is no need to write raw numbers or strings: + +- Satellite numbers: `GalileanSatelliteIo`, `GalileanSatelliteEuropa`, `GalileanSatelliteGanymede`, `GalileanSatelliteCallisto` +- Phenomenon types (`GalileanPhenomenonType`): `GalileanPhenomenonTransit`, `GalileanPhenomenonOccultation`, `GalileanPhenomenonEclipse`, `GalileanPhenomenonShadowTransit` +- Contact phases (`GalileanPhenomenonContactPhase`): `GalileanPhenomenonContactDisappearance`, `GalileanPhenomenonContactReappearance` + +```go +// Satellite numbers and phenomenon types are constants, so no raw numbers or strings are needed. +event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteCallisto, jupiter.GalileanPhenomenonShadowTransit) +fmt.Println(event.Type == jupiter.GalileanPhenomenonShadowTransit) +``` + +The `jupiter` package provides apparent positions, instantaneous phenomena, and event searches for the four Galilean satellites. + +Common entry points: + +- `Satellites`: instantaneous apparent positions relative to Jupiter's disk +- `SatellitePhenomena`: instantaneous transit, occultation, eclipse, and shadow-transit flags +- `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`: search whole phenomenon intervals +- `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`: search IMCCE-style D/F contact events + +Two conventions matter: + +- `GalileanPhenomenonEvent` treats the satellite as a point and checks when its center enters or leaves Jupiter's disk. It is suitable for fast phenomenon search and internal state checks. +- `GalileanPhenomenonContactEvent` includes the finite disk of the satellite and splits disappearance and reappearance contact windows. It is the better match for IMCCE tables such as `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F`. + +The two conventions may differ by up to about 7 minutes in duration. This is a definition difference, not a timing-accuracy failure. Use `GalileanPhenomenonContactEvent` for observing predictions and direct comparison with public almanacs. + +#### Code example + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/jupiter" +) + +func main() { + date := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + + // Instantaneous positions of the four satellites relative to Jupiter's center. + sats := jupiter.Satellites(date) + fmt.Printf("io x=%.6f y=%.6f front=%v\n", sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) + fmt.Printf("europa ra=%.6f dec=%.6f\n", sats.Europa.ApparentRA, sats.Europa.ApparentDec) + + // Instantaneous phenomenon flags. + ph := jupiter.SatellitePhenomena(date) + fmt.Printf("io transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Io.Transit, ph.Io.Occultation, ph.Io.Eclipse, ph.Io.ShadowTransit) + fmt.Printf("europa transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Europa.Transit, ph.Europa.Occultation, ph.Europa.Eclipse, ph.Europa.ShadowTransit) + + // Next full Io transit event. + event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteIo, jupiter.GalileanPhenomenonTransit) + fmt.Printf("event valid=%v sat=%d type=%s\n", event.Valid, event.Satellite, event.Type) + fmt.Println(event.Start) + fmt.Println(event.Greatest) + fmt.Println(event.End) + fmt.Println(event.Duration) + + // Next IMCCE-style contact window for a Europa occultation. + contact := jupiter.NextGalileanPhenomenonContactEvent(date, jupiter.GalileanSatelliteEuropa, jupiter.GalileanPhenomenonOccultation) + fmt.Printf("contact valid=%v sat=%d type=%s\n", contact.Valid, contact.Satellite, contact.Type) + fmt.Println(contact.Disappearance.Start) + fmt.Println(contact.Disappearance.ModelCrossing) + fmt.Println(contact.Disappearance.End) + fmt.Println(contact.Greatest) + fmt.Println(contact.Reappearance.Start) + fmt.Println(contact.Reappearance.ModelCrossing) + fmt.Println(contact.Reappearance.End) +} +``` + +Output: + +```text +io x=-0.675026 y=-0.032798 front=true // Io X/Y offset from Jupiter center, in Jupiter radii; in front of Jupiter +europa ra=110.769133 dec=22.335828 // Europa apparent RA and Dec, degrees +io transit=true occultation=false eclipse=false shadow=true // Io is transiting, and its shadow is also transiting +europa transit=false occultation=false eclipse=false shadow=false // Europa has no transit, occultation, eclipse, or shadow transit at this instant +event valid=true sat=1 type=transit // next valid event is an Io transit +2026-01-16 16:32:47.552742362 +0000 UTC // Io transit begins +2026-01-16 17:40:44.189371168 +0000 UTC // midpoint of the Io transit +2026-01-16 18:48:40.287077128 +0000 UTC // Io transit ends +2h15m52.734334766s // Io transit duration +contact valid=true sat=2 type=occultation // next valid contact event is a Europa occultation +2026-01-17 01:00:34.99533087 +0000 UTC // Europa occultation disappearance starts +2026-01-17 01:02:31.714070141 +0000 UTC // model center crossing during disappearance +2026-01-17 01:04:28.432809412 +0000 UTC // disappearance ends +2026-01-17 02:27:37.807798683 +0000 UTC // deepest occultation +2026-01-17 03:50:48.120300471 +0000 UTC // reappearance starts +2026-01-17 03:52:43.901527225 +0000 UTC // model center crossing during reappearance +2026-01-17 03:54:39.68275398 +0000 UTC // reappearance ends +``` + +#### External baselines + +The Galilean-satellite implementation was checked against two external baselines: + +- **JPL Horizons**: apparent positions of the four satellites relative to Jupiter's center, and shadow-center offsets from Jupiter's disk during shadow transits. +- **IMCCE 2026 tables**: transits, occultations, Jupiter eclipses, shadow transits, and D/F contact windows. + +Comparison results: + +- `Satellites` positions relative to Jupiter center: maximum sample difference against JPL Horizons about `X=0.054"`, `Y=0.048"`. +- `SatellitePhenomena` shadow-transit shadow-center offsets: maximum sample difference against JPL Horizons about `X=0.051"`, `Y=0.016"`; boolean phenomenon flags match in the samples. +- `GalileanPhenomenonContactEvent` differs from IMCCE 2026 D/F contact times by at most about `79 s`, and contact durations by about `17 s` in the compared cases. +- `GalileanPhenomenonEvent` uses a different definition from IMCCE D/F contacts, so start and end times can differ by about `7 min`. + +### Shared types and constants from the `planet` package + +The `planet` package is the low-level analytic-series entry point, shared by the seven planet packages and by `sun` / `moon`. + +It exports functions only and **no types**, so what the planet packages really share is one set of numerical conventions and truncation semantics rather than shared types: each package's `PhysicalInfo` mirrors `basic.PlanetPhysicalInfo` and `TransitInfo` mirrors `basic.PlanetTransitResult`, field for field, while the types themselves still belong to their own packages. + +| Entry point | Purpose | Unit | +| --- | --- | --- | +| `planet.WherePlanet` / `planet.WherePlanetN` | VSOP87 result: `xt` is `1..7` for Mercury through Neptune and `-1` or `0` for Earth, `zn` is `0` longitude, `1` latitude, `2` heliocentric distance; an out-of-range `xt` / `zn` returns `NaN` instead of panicking | degrees / AU | +| `planet.Distance` | Sun-Earth distance | AU | +| `planet.SunLo` / `planet.SunM` / `planet.SunMidFun` / `planet.SunTrueLo` / `planet.SunApparentLo` | Solar geometric longitude, mean anomaly, equation of center, true longitude, apparent longitude | degrees | +| `planet.Earthe` / `planet.EarthPI` | Earth's orbital eccentricity and longitude of perihelion | dimensionless / degrees | +| `planet.MoonLo` / `planet.MoonM` / `planet.MoonLonX` / `planet.SunMoonAngle` | Lunar mean longitude, mean anomaly, mean argument of latitude, and mean elongation | degrees | +| `planet.MoonI` / `planet.MoonB` / `planet.MoonR` | Periodic longitude, latitude, and distance terms (truncated ELP2000/82-style series) | `10⁻⁶` degrees / `10⁻⁶` degrees / `10⁻³ km` | +| `planet.MoonTrueLo` / `planet.MoonTrueBo` / `planet.MoonAway` | Lunar true longitude, true latitude, and Earth distance | degrees / degrees / km | + +```go +// xt=1..7 is Mercury..Neptune; zn=0 longitude, 1 latitude, 2 heliocentric distance (AU). +fmt.Println(planet.WherePlanet(4, 2, 2460000.5)) // Jupiter heliocentric distance, AU +fmt.Println(planet.WherePlanetN(4, 2, 2460000.5, 12)) // truncated form, about 12 principal terms +fmt.Println(planet.WherePlanet(8, 0, 2460000.5)) // xt out of range: NaN +``` + +```go +// Earth's heliocentric longitude (xt=-1) and a planet's can be compared on the same convention. +fmt.Println(planet.WherePlanet(-1, 0, 2460000.5), planet.WherePlanet(4, 0, 2460000.5)) +``` + +## Parameter and result conventions + +The conventions below are shared by all seven packages; per-capability return units are summarised under [Common capabilities and units](#common-capabilities-and-units). + +### Time scale and civil time + +Every public API treats its `time.Time` argument as a **civil instant** (a UTC label): position and physical functions take `date.UTC()` and convert to TT internally before evaluating the ephemeris, while rise/set, culmination, and the topocentric horizontal family additionally read `date.Zone()` for local-time computation. + +UTC had no leap seconds before 1972-01-01; the library treats that span as UT1, so a civil-time label there is equal to UT1. + +From 1972 on the built-in leap-second table is used, and beyond the exact window the active UTC tracking policy applies. For explicit conversion use `astro.UT1FromUTC`, `astro.TTFromUTC`, and `astro.DUT1` from the root package; time-scale declarations inside figures and captions and the UT1 convention are in [Time Scale Declaration](map-geojson.md#time-scale-declaration). + +### Units and conventions + +Angles are always in degrees; apparent diameter and semidiameter are in arcseconds; distances follow the function name -- `EarthDistance` / `SunDistance` and `planet.WherePlanet` with `zn=2` are in AU, while `planet.MoonAway` is in km. + +`PhaseAngle` is in degrees, `IlluminatedFraction` and its alias `Phase` are `0-1`, and `ApparentMagnitude` is a magnitude (dimensionless); rise/set, culmination, and every event search return `time.Time` in the input time zone. + +Duration fields inside event structs (`TransitInfo.Duration`, `TransitInfo.InternalDuration`, `GalileanPhenomenonEvent.Duration`, `GalileanPhenomenonContact.Duration`) are Go `time.Duration` values, not Julian days or day counts; the `Start` / `Greatest` / `End` / `InternalStart` / `InternalEnd` fields of `TransitInfo` keep the caller's time zone. + +Geocentric, topocentric, and distance are three different conventions: `ApparentLo` / `ApparentBo` / `ApparentRa` / `ApparentDec` / `ApparentRaDec` are geocentric apparent places, `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` are topocentric quantities, and `EarthDistance` / `SunDistance` are geometric geocentric distances. + +### Zero values, out-of-range input and sentinel errors + +When an event search finds nothing inside its window it returns zero values or a struct with `Valid == false` (`TransitInfo.Valid`, `GalileanPhenomenonEvent.Valid`, `GalileanPhenomenonContactEvent.Valid`) instead of an error; when `TransitInfo.HasInternal` is false, `InternalStart` / `InternalEnd` are zero values because a partial transit has no internal contacts. + +`planet.WherePlanet` / `planet.WherePlanetN` return `NaN` instead of panicking when `xt` / `zn` is out of range; in the `...N` truncation family, `n < 0` uses the full built-in series and `n >= 0` truncates it. + +`RiseTime` / `SetTime` / `DownTime` (including their `N` forms) are the only family that returns an `error`: when the body has no geometric rise or set on that day they return the sentinel error below with a zero instant. + +Each planet package has its own polar-day/polar-night errors. + +`NEVER_RISE` in the name means "never rises that day" (polar night), `NEVER_SET` means "never sets that day" (polar day), and `NEVER_DOWN` is a compatibility alias for `NEVER_SET`. + +| Package | Never rises | Never sets | Set alias | +| --- | --- | --- | --- | +| `mercury` | `ERR_MERCURY_NEVER_RISE` | `ERR_MERCURY_NEVER_SET` | `ERR_MERCURY_NEVER_DOWN` | +| `venus` | `ERR_VENUS_NEVER_RISE` | `ERR_VENUS_NEVER_SET` | `ERR_VENUS_NEVER_DOWN` | +| `mars` | `ERR_MARS_NEVER_RISE` | `ERR_MARS_NEVER_SET` | `ERR_MARS_NEVER_DOWN` | +| `jupiter` | `ERR_JUPITER_NEVER_RISE` | `ERR_JUPITER_NEVER_SET` | `ERR_JUPITER_NEVER_DOWN` | +| `saturn` | `ERR_SATURN_NEVER_RISE` | `ERR_SATURN_NEVER_SET` | `ERR_SATURN_NEVER_DOWN` | +| `uranus` | `ERR_URANUS_NEVER_RISE` | `ERR_URANUS_NEVER_SET` | `ERR_URANUS_NEVER_DOWN` | +| `neptune` | `ERR_NEPTUNE_NEVER_RISE` | `ERR_NEPTUNE_NEVER_SET` | `ERR_NEPTUNE_NEVER_DOWN` | + +```go +// Polar day/night: the rise/set functions return a sentinel error and a zero time.Time. +rise, err := mercury.RiseTime(date, lon, lat, height, true) +switch { +case errors.Is(err, mercury.ERR_MERCURY_NEVER_RISE): + fmt.Println("polar night: never rises that day", rise.IsZero()) +case errors.Is(err, mercury.ERR_MERCURY_NEVER_SET): + fmt.Println("polar day: never sets that day", rise.IsZero()) +} +``` + +### Topocentric versus geocentric + +`Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` use `date`'s time zone for local-time computation, with east-positive longitude, north-positive latitude, and `height` as the ellipsoidal height in meters (not orthometric elevation). + +They are a different convention from the geocentric apparent place of `ApparentRa` / `ApparentDec`; convert through [Coordinate Tools](coord.md) before mixing them. + +### Accuracy and scope + +The planet packages use the built-in truncated VSOP87 series, covering about 4000 years around J2000; the truncation magnitudes relative to full VSOP87 are in [Sun and planets](accuracy.md#sun-and-planets) and the overall scope is in [Scope And Accuracy](accuracy.md). + +The `...N` forms relax that baseline further and suit batch scans or live front-end refreshes. + +`saturn.Ring` and the physical ephemerides affect only the disk and band rendering: the Saturn ring takes no part in occultation contact geometry and is not drawn as a disk boundary. The planets have no dedicated chart entry point; occultation and Saturn-ring charts are in [Lunar Occultations](occultation.md#lunar-occultation-charts). diff --git a/doc/manual/en/star.md b/doc/manual/en/star.md new file mode 100644 index 0000000..604680e --- /dev/null +++ b/doc/manual/en/star.md @@ -0,0 +1,325 @@ +# Stars + +[中文](../star.md) | [Back to README](../../../README.en.md) + +> Full examples in this manual run from the repository root. + +The library ships a catalog of 9100 stars (BSC / HR numbers `1-9110`, apparent magnitudes `-1.46` to `7.96`) and propagates proper motion automatically. The catalog stores **J2000** right ascension and declination (`InnerStarData.Ra`/`Dec`); to get the position at a given instant you must apply proper motion, precession and nutation through `StarData.RaDecByDate(date)`. Rise/set, topocentric quantities and constellation lookup all expect the corrected coordinates. + +## Contents + +- [Finding Sirius and its rise time](#finding-sirius-and-its-rise-time) +- [API Reference](#api-reference) + - [Constellation lookup](#constellation-lookup) + - [Star catalog](#star-catalog) + - [Rise, set and culmination](#rise-set-and-culmination) + - [Topocentric quantities](#topocentric-quantities) + - [Sidereal time](#sidereal-time) + - [Calculating observing quantities for bright stars](#calculating-observing-quantities-for-bright-stars) + - [Complete example](#complete-example) + - [Bulk lookups and caching](#bulk-lookups-and-caching) +- [Usage examples](#usage-examples) + - [Can I see this star tonight?](#can-i-see-this-star-tonight) + - [Using a J2000 position at a given instant](#using-a-j2000-position-at-a-given-instant) + - [Constellation lookup and the bright-star catalog](#constellation-lookup-and-the-bright-star-catalog) + - [Polar edges and catalog conventions](#polar-edges-and-catalog-conventions) +- [Parameter and result conventions](#parameter-and-result-conventions) +- [Related manuals](#related-manuals) + +## Finding Sirius and its rise time + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/star" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 115.0, 40.0, 0.0 + sirius, err := star.StarDataByName("天狼") + if err != nil { + log.Fatal(err) + } + ra, dec := sirius.RaDecByDate(date) + rise, err := star.RiseTime(date, ra, dec, lon, lat, height, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(star.Constellation(ra, dec, date)) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", ra, dec) + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +Catalog coordinates have epoch J2000. `RaDecByDate` applies proper motion, precession and nutation before date-specific rise/set, altitude or constellation calculations. + +Proper motion is propagated in **Julian years** (365.25 days), and `RaDecByDate` converts the civil instant to TT before taking the epoch difference. Whenever a record carries a distance (`Pc > 0`), `RaDecByJde` advances a full **three-dimensional space motion**: proper motion supplies the tangential velocity and `RadVel` the line-of-sight component, the position vector is extrapolated linearly and its direction is taken. Records without a distance fall back to two dimensions, advancing only the two angular components, which is equivalent to treating the star as infinitely distant. The two differ by second-order terms only — the curvature of the great-circle path and the change in angular scale as radial motion alters the distance; even the fastest bright star moves less than about 0.09 arcseconds from it within 26 years. + +## API Reference + +| Group | Entry points | Purpose | Units and convention | +| --- | --- | --- | --- | +| Constellation | `Constellation` / `ConstellationEN` / `ConstellationCode` | Chinese name / English name / IAU three-letter code | Corrected RA/Dec in degrees plus the instant | +| Star catalog | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `TopBrightStars` | Initialise the embedded catalog, look up by HR number or Chinese name, fetch the bright-star sample | HR `1-9110`; `Mag` is apparent magnitude | +| Coordinate correction | `StarData.RaDecByDate` (`RaDecByJde` on the `basic` side) | Correct a J2000 position to the given instant | Returns RA/Dec in degrees | +| Rise, set, culmination | `RiseTime` / `SetTime` / `CulminationTime` (`DownTime` is a deprecated alias of `SetTime`) | Rise, set and culmination instants | Civil instants; polar cases return sentinel errors | +| Topocentric quantities | `Altitude` / `ApparentAltitude` / `Azimuth` / `Zenith` / `ApparentZenith` | (Apparent) altitude, azimuth, (apparent) zenith distance | Degrees | +| Hour angle and parallactic angle | `HourAngle` / `ParallacticAngle` | Stellar hour angle, zenith-direction parallactic angle | Degrees | +| Sidereal time | `MeanSiderealTime` / `ApparentSiderealTime` | Mean and apparent sidereal time | Hours | + +The `star` package has no `...N` truncated entry points; truncated analytical series live on the matching functions of `sun`, `moon`, the planets and `coord`, where `n < 0` uses every built-in term and `n >= 0` truncates the series (see those manuals). + +The snippets below omit shared preamble variables: `date` (observation instant, civil time scale), `lon`/`lat` (observer longitude/latitude, east/north positive, degrees), `height` (observer height, **ellipsoidal**, metres), `aero` (whether refraction and apparent-radius corrections are applied). + +### Constellation lookup + +```go +sirius, _ := star.StarDataByName("天狼") +ra, dec := sirius.RaDecByDate(date) +fmt.Println(star.Constellation(ra, dec, date)) // 大犬座 +fmt.Println(star.ConstellationEN(ra, dec, date)) // Canis Major +fmt.Println(star.ConstellationCode(ra, dec, date)) // CMA +``` + +All three share one boundary table and differ only in output convention. The lookup uses the **apparent position of the day**, so pass the result of `RaDecByDate`, never the J2000 coordinates from the catalog. + +### Star catalog + +```go +_ = star.InitStarDatabase() +s, _ := star.StarDataByHR(2491) +fmt.Println(s.HR, s.ChineseName, s.CommonName, s.Mag) // 2491 天狼 Sirius -1.46 +bright, _ := star.TopBrightStars() +fmt.Println(len(bright), bright[0].HR) // size of the bright-star sample and the leading HR number +``` + +The catalog loads lazily behind `sync.Once`; `InitStarDatabase()` only warms it up. It is idempotent and surfaces the load error explicitly, and skipping it still works because the first lookup loads the catalog automatically. + +`TopBrightStars()` returns 169 built-in entries around magnitude 3 or brighter, roughly ordered from brighter to dimmer. + +`basic.StarData` adds naming fields on top of `InnerStarData`, whose conventions are: + +| Field | Meaning | +| --- | --- | +| `HR` / `HD` / `HIP` | Bright Star number (`1-9110`) / Henry Draper number / Hipparcos number | +| `Ra` / `Dec` | **J2000** right ascension and declination in degrees (run `RaDecByDate` for the apparent position) | +| `Mag` | Apparent magnitude | +| `PmRA` / `PmDec` | Annual proper motion `cos(dec)*dRA/dt` and in declination, arcseconds per year | +| `RadVel` / `RotVel` | Radial and proper-motion velocity, km/s | +| `Pc` | Distance in parsecs; `> 0` propagates proper motion as a 3D space motion | +| `ChineseName` / `ChineseAlias` / `ChineseBayerName` | Chinese name, alias and Bayer designation | +| `CommonName` / `CommonAliasName` | Common English name and alias | +| `Cst` / `CstChinese` | Constellation name in English and Chinese | + +Lookup by name matches the catalog's Chinese names only (`天狼` for Sirius, `织女一` for Vega); English names are not matched. Use `StarDataByHR` to look up by number. + +### Rise, set and culmination + +```go +ra, dec := 101.28715533, -16.71611586 // Sirius-like sample coordinates near J2000 +rise, _ := star.RiseTime(date, ra, dec, lon, lat, height, true) +set, _ := star.SetTime(date, ra, dec, lon, lat, height, true) +fmt.Println(rise, set) +fmt.Println(star.CulminationTime(date, ra, lon)) +``` + +With `aero` true the rise/set solution uses the horizon corrected by refraction and apparent radius; false uses the geometric horizon. `CulminationTime` needs only right ascension and longitude. + +Under a polar night or midnight sun, `RiseTime`/`SetTime` return sentinel errors instead of an instant, see below. + +### Topocentric quantities + +```go +fmt.Println(star.Altitude(date, ra, dec, lon, lat)) +fmt.Println(star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10)) +fmt.Println(star.Azimuth(date, ra, dec, lon, lat), star.Zenith(date, ra, dec, lon, lat)) +fmt.Println(star.HourAngle(date, ra, lon), star.ParallacticAngle(date, ra, dec, lon, lat)) +``` + +`ApparentAltitude`/`ApparentZenith` take two extra arguments, pressure (hPa) and temperature (C), for the refraction correction. `ParallacticAngle` is the usual input for rotating a camera or a spectrograph slit. + +### Sidereal time + +```go +fmt.Println(star.MeanSiderealTime(date), star.ApparentSiderealTime(date)) +``` + +Both return hours; the relation between sidereal time, hour angle and horizontal transforms is documented in the `coord` manual. + +### Calculating observing quantities for bright stars + +```go +bright, _ := star.TopBrightStars() +for _, s := range bright[:3] { + ra, dec := s.RaDecByDate(date) + alt := star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10) + fmt.Printf("%-6s %-10s mag=%.2f alt=%.3f\n", s.ChineseName, star.ConstellationEN(ra, dec, date), s.Mag, alt) +} +``` + +Run at `date = 2020-01-01 08:08:08 CST` from `115 E, 40 N` with pressure `1010 hPa` and temperature `10 C`, this prints: + +```text +天狼 Canis Major mag=-1.46 alt=-30.180 +老人 Carina mag=-0.72 alt=-48.661 +大角 Bootes mag=-0.04 alt=68.926 +``` + +### Complete example + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/star" + "b612.me/astro/tools" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // Observation instant. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + + // Initialise the star catalog. + _ = star.InitStarDatabase() + sirius, _ := star.StarDataByName("天狼") + ra, dec := sirius.RaDecByDate(date) + // Sirius rising. + riseDate, _ := star.RiseTime(date, ra, dec, 115, 40, 0, true) + fmt.Println(riseDate) + // Sirius setting. + setDate, _ := star.SetTime(date, ra, dec, 115, 40, 0, true) + fmt.Println(setDate) + fmt.Println(star.Constellation(ra, dec, date)) + + // Vega. + vega, _ := star.StarDataByName("织女一") + ra, dec = vega.RaDecByDate(time.Date(13600, 1, 1, 0, 0, 0, 0, time.Local)) + // Vega right ascension in 13600 CE. + fmt.Println(tools.Format(ra/15, 1)) + // Vega declination in 13600 CE. + fmt.Println(tools.Format(dec, 0)) + + bright, _ := star.TopBrightStars() + fmt.Println(bright[0].ChineseName, bright[0].CommonName, bright[0].Mag) +} +``` + +Output: + +```text +2019-12-31 19:22:56.144202053 +0800 CST // Sirius rising +2020-01-01 05:30:39.802506566 +0800 CST // Sirius setting +大犬座 // constellation of Sirius +6h3m46.61s // Vega right ascension in 13600 CE +84°18′27.15″ // Vega declination in 13600 CE +天狼 Sirius -1.46 // first bright-star entry: Chinese name, common name, magnitude +``` + +The printed constellation name is the Chinese one because `Constellation` is the Chinese-name entry point; use `ConstellationEN` for `Canis Major` and `ConstellationCode` for `CMA`. + +### Bulk lookups and caching + +```go +for _, hr := range []int{2491, 2326, 5340} { + s, err := star.StarDataByHR(hr) + if err != nil { + continue + } + ra, dec := s.RaDecByDate(date) + fmt.Println(s.ChineseName, star.ConstellationCode(ra, dec, date), s.Mag) +} +``` + +The catalog loads once on first access and is then a read-only cache, so share it across calls and goroutine-free request paths instead of caching it yourself. Unknown numbers or names return an error; in bulk code it is enough to skip on `err != nil` without classifying the error. + +## Usage examples + +### Can I see this star tonight? + +```go +ra, dec := sirius.RaDecByDate(date) +fmt.Println(star.RiseTime(date, ra, dec, lon, lat, height, true)) // rise +fmt.Println(star.SetTime(date, ra, dec, lon, lat, height, true)) // set +fmt.Println(star.CulminationTime(date, ra, lon)) // culmination +fmt.Println(star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10), star.Azimuth(date, ra, dec, lon, lat)) +``` + +`aero = true` solves rise/set against the horizon corrected for refraction and apparent radius, which sits closer to the visual "just above the horizon" moment; `false` uses the geometric horizon, and the two differ on the order of 2-3 minutes. + +Use the apparent altitude to decide whether the star is visible now, with `1010 hPa / 10 C` as the usual default weather values. `height` is **ellipsoidal height** in metres; when you only have orthometric height, add the geoid undulation first, see [Observer height conventions](coord.md#observer-height) in the README. + +### Using a J2000 position at a given instant + +```go +ra, dec := sirius.RaDecByDate(date) // J2000 -> instant (proper motion + precession + nutation) +fmt.Println(star.ApparentSiderealTime(date)) // apparent sidereal time, hours +fmt.Println(star.HourAngle(date, ra, lon)) // hour angle, degrees +``` + +The catalog stores J2000 positions, so skipping `RaDecByDate` puts rise/set and constellation lookups off by degrees. Sidereal time, horizontal transforms, precession and nutation are covered in [Coordinate tools](coord.md); every time argument is a civil instant (a UTC label, equal to UT1 before 1972-01-01), see [Time Scale Declaration](map-geojson.md#time-scale-declaration). + +### Constellation lookup and the bright-star catalog + +```go +bright, _ := star.TopBrightStars() +for _, s := range bright[:3] { + ra, dec := s.RaDecByDate(date) + fmt.Println(s.ChineseName, star.ConstellationCode(ra, dec, date), s.Mag, s.CommonName) +} +``` + +```text +天狼 CMA -1.46 Sirius +老人 CAR -0.72 Canopus +大角 BOO -0.04 Arcturus +``` + +All three constellation entry points share one boundary table and differ only in output: `Constellation` gives the Chinese name, `ConstellationEN` the English name and `ConstellationCode` the IAU three-letter code; the lookup uses the apparent position of the day. Catalog fields (`HR`/`HD`/`HIP`, J2000 RA/Dec, magnitude, proper motion, parallax distance and the naming fields) are listed as a table under [API Reference](#star-catalog). + +### Polar edges and catalog conventions + +```go +_, err := star.RiseTime(date, ra, dec, 0, 89, 0, true) +if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVER_SET) { + fmt.Println("该日无升落:", err) +} +``` + +- **Polar sentinel errors**: `RiseTime` returns `star.ERR_STAR_NEVER_RISE` during a polar night (below the horizon all day) and `SetTime` returns `star.ERR_STAR_NEVER_SET` during a midnight sun (above it all day); branch with `errors.Is`. + + `ERR_STAR_NEVER_DOWN` is a deprecated alias of `ERR_STAR_NEVER_SET`, and `DownTime` is a deprecated alias of `SetTime`. +- **Refraction range**: the Saemundsson approximation only applies for true altitudes inside `(-5, 90)` degrees and contributes zero outside, so well below the horizon the apparent altitude equals the geometric one. +- **Catalog conventions**: BSC / HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; name lookup matches the built-in Chinese names only (`天狼`, `织女一`), never English names. +- **Cross-checking against external catalogs**: positions here are J2000 mean places with proper motion plus precession and nutation; when comparing term by term, bring both sides to the same instant with `RaDecByDate` first. + +## Parameter and result conventions + +- **Units**: right ascension, declination, altitude, zenith distance, azimuth, hour angle, parallactic angle are in **degrees**; sidereal time is in **hours**; `tools.Format` renders them as degrees-minutes-seconds or hours-minutes-seconds. + + In the catalog `PmRA`/`PmDec` are arcseconds per year, `RadVel`/`RotVel` are km/s and `Pc` is parsecs. +- **Time scale**: every public time argument and return value is a civil instant (a UTC label, equal to UT1 before 1972-01-01); see [Time Scale Declaration](map-geojson.md#time-scale-declaration). +- **Coordinate convention**: the catalog is J2000; `RaDecByDate` adds proper motion, precession and nutation. Skipping it puts rise/set and constellation lookups off by a large margin (a century of precession is a degree-level shift). +- **Polar sentinel errors**: `RiseTime` returns `star.ERR_STAR_NEVER_RISE` during a polar night (the star stays below the horizon all day) and `SetTime` returns `star.ERR_STAR_NEVER_SET` during a midnight sun (it stays above it); test with `errors.Is`. `ERR_STAR_NEVER_DOWN` is a deprecated alias of `ERR_STAR_NEVER_SET`. +- **Observer height**: `height` is ellipsoidal height in metres; when you only have orthometric height, add the geoid undulation first, as described under "Observer Height Convention" in the README. +- **Refraction range**: the Saemundsson approximation behind `ApparentAltitude`/`ApparentZenith` only applies for true altitudes inside `(-5, 90)` degrees and contributes zero outside that range, so well below the horizon the apparent altitude equals the geometric one (the `alt=-30.180` above is the same value `Altitude` returns). + + Pressure must be positive and temperature above absolute zero, otherwise the result is `NaN`. +- **`aero` semantics**: a true value solves rise/set against the horizon corrected by standard refraction, a false value uses the geometric horizon; the difference is about 2-3 minutes at low latitudes and grows noticeably at high latitudes. +- **Catalog range**: HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; name lookup matches the built-in Chinese names only. + +## Related manuals + +- Sidereal time, horizontal transforms, precession/nutation and parallactic angle: [Coordinate tools](coord.md) +- Rise/set semantics next to the Sun and Moon: [Sun and Moon](sun-moon.md) +- Time scale in figures: [Time Scale Declaration](map-geojson.md#time-scale-declaration) diff --git a/doc/manual/en/sun-moon.md b/doc/manual/en/sun-moon.md new file mode 100644 index 0000000..4f2bf66 --- /dev/null +++ b/doc/manual/en/sun-moon.md @@ -0,0 +1,1057 @@ +# Sun and Moon + +[中文](../sun-moon.md) | [Back to README](../../../README.en.md) + +`sun` and `moon` are the main chains; `lite/sun` and `lite/moon` are independent approximation implementations. + +Unless stated otherwise, angles are in degrees, apparent diameters and semidiameters are in arcseconds, `sun.EarthDistance` is in AU and `moon.EarthDistance` is in kilometers. Observing APIs generally use civil instants. + +Apparent-solar-time results instead represent local solar readings; see [Time scales](timescale.md). + +This manual covers the Sun and the Moon themselves: position, rise/set and culmination, topocentric quantities, phases and syzygies, perigee/apogee and nodes, maximum declinations, libration, apparent size, and physical ephemeris. + +Eclipse geometry lives in [Solar and Lunar Eclipse Charts](eclipse.md#solar-and-lunar-eclipse-charts), occultations in [Lunar Occultation Charts](occultation.md#lunar-occultation-charts), and general topocentric and refraction conversions in [Coordinate Tools](coord.md). + +## Contents + +- [Sunrise, sunset and lunar phase](#sunrise-sunset-and-lunar-phase) +- [API Reference](#api-reference) + - [sun](#sun) + - [moon](#moon) + - [lite/sun](#litesun) + - [lite/moon](#litemoon) + - [Truncated ...N family](#truncated-n-family) +- [Usage examples](#usage-examples) + - [Today's sunrise, sunset and twilight](#todays-sunrise-sunset-and-twilight) + - [Moonrise, moonset and the Moon's altitude now](#moonrise-moonset-and-the-moons-altitude-now) + - [Lunar phase and the next new / full moon](#lunar-phase-and-the-next-new--full-moon) + - [Apparent size, Earth-Moon distance and libration](#apparent-size-earth-moon-distance-and-libration) + - [Geocentric vs topocentric (getting the Moon position right)](#geocentric-vs-topocentric-getting-the-moon-position-right) + - [Main chain vs lite](#main-chain-vs-lite) +- [Observing-angle semantics](#observing-angle-semantics) +- [Combined example: rise, set, and position](#combined-example-rise-set-and-position) + - [Sunrise/sunset and moonrise/moonset](#sunrisesunset-and-moonrisemoonset) + - [Sun and Moon position](#sun-and-moon-position) +- [The Sun](#the-sun) + - [Position](#position) + - [Rise, set and culmination](#rise-set-and-culmination) + - [Topocentric quantities and parallactic angle](#topocentric-quantities-and-parallactic-angle) + - [Apparent solar time and equation of time](#apparent-solar-time-and-equation-of-time) + - [Physical ephemeris and apparent size](#physical-ephemeris-and-apparent-size) + - [Earth orbit extrema](#earth-orbit-extrema) +- [The Moon](#the-moon) + - [Position](#position-1) + - [Rise, set and culmination](#rise-set-and-culmination-1) + - [Lunar phases](#lunar-phases) + - [Perigee and apogee](#perigee-and-apogee) + - [Nodes](#nodes) + - [Maximum declinations](#maximum-declinations) + - [Libration and bright-limb position angle](#libration-and-bright-limb-position-angle) + - [Apparent size and Earth-Moon distance](#apparent-size-and-earth-moon-distance) +- [Lite chains](#lite-chains) + - [lite/sun](#litesun-1) + - [lite/moon](#litemoon-1) + - [Differences from the main chain and error levels](#differences-from-the-main-chain-and-error-levels) +- [Parameter and result conventions](#parameter-and-result-conventions) + - [Units and angle conventions](#units-and-angle-conventions) + - [Time scale](#time-scale) + - [Height and aero](#height-and-aero) + - [Zero values and out-of-range](#zero-values-and-out-of-range) + - [Accuracy and scope](#accuracy-and-scope) + +## Sunrise, sunset and lunar phase + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 108.93, 34.27, 0.0 + rise, err := sun.RiseTime(date, lon, lat, height, true) // sunrise time + if err != nil { + log.Fatal(err) + } + set, err := sun.SetTime(date, lon, lat, height, true) // sunset time + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339), set.Format(time.RFC3339)) + fmt.Println(moon.Phase(date), moon.PhaseDesc(date)) // lunar phase (illuminated fraction) and phase description +} +``` + +`aero=true` uses a rise/set criterion with refraction. Rise/set functions return an error for polar conditions or a missing event on that date. `moon.Phase` is the illuminated fraction, not the lunar age. + +## API Reference + +The tables below group the exported entry points of all four packages. Most evaluation entry points also have a `...N` truncated variant, described together under [Truncated ...N family](#truncated-n-family). + +Every `time.Time` parameter is an absolute instant, and `lon`/`lat` are east-positive and north-positive. + +### sun + +| Name | Purpose | Unit and convention | +| --- | --- | --- | +| `TrueLo` / `ApparentLo` | True / apparent solar longitude | degrees, geocentric | +| `TrueBo` | True solar latitude | degrees, geocentric; no separate apparent latitude | +| `GeometricLo` / `MidFunc` | Geometric solar longitude / equation of center | degrees | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | Apparent right ascension / declination | degrees, geocentric | +| `EclipticObliquity` | Obliquity of the ecliptic | degrees; the second argument adds nutation in obliquity when `true` | +| `EclipticNutation` / `EclipticNutation1980` | Nutation in longitude | degrees, IAU 2000B / IAU 1980 | +| `AxialtiltNutation` / `AxialtiltNutation1980` | Nutation in obliquity | degrees, IAU 2000B / IAU 1980 | +| `RiseTime` / `SetTime` | Sunrise / sunset | `(time.Time, error)`; `aero` and `height` are described under Parameter and result conventions | +| `DownTime` | Sunset alias | deprecated; calls `SetTime` internally | +| `CulminationTime` | Upper culmination | `time.Time` | +| `MorningTwilight` / `EveningTwilight` | Morning / evening twilight | `(time.Time, error)`; the angle is usually -6 / -12 / -18 degrees | +| `Altitude` / `Zenith` / `Azimuth` / `HourAngle` | Geometric altitude / zenith distance / azimuth / hour angle | degrees, topocentric | +| `ApparentAltitude` / `ApparentZenith` | Apparent altitude / apparent zenith distance | degrees; requires pressure in hPa and temperature in degrees Celsius | +| `ParallacticAngle` | Parallactic angle (zenith direction angle) | degrees, signed | +| `ApparentSolarTime` | Apparent solar time | `time.Time`, with the zone derived from longitude | +| `EquationTime` | Equation of time | hours | +| `Diameter` / `Semidiameter` | Apparent diameter / semidiameter | arcseconds | +| `EarthDistance` | Earth-Sun distance | AU | +| `Physical` | Solar disk physical quantities | returns `PhysicalInfo`, fields in degrees | + +### moon + +| Name | Purpose | Unit and convention | +| --- | --- | --- | +| `TrueLo` / `TrueBo` / `ApparentLo` | Geocentric true longitude / true latitude / apparent longitude | degrees | +| `TrueRa` / `TrueDec` / `TrueRaDec` | Geocentric true equatorial coordinates | degrees | +| `GeocentricApparentRa` / `GeocentricApparentDec` / `GeocentricApparentRaDec` | Geocentric apparent equatorial coordinates | degrees | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | Topocentric apparent equatorial coordinates | degrees; requires observer longitude and latitude | +| `Altitude` / `Zenith` / `Azimuth` / `HourAngle` | Topocentric altitude / zenith distance / azimuth / hour angle | degrees | +| `ApparentAltitude` / `ApparentZenith` | Apparent altitude / apparent zenith distance | degrees; requires pressure in hPa and temperature in degrees Celsius | +| `ParallacticAngle` | Parallactic angle | degrees, signed, explicitly depends on observer longitude and latitude | +| `RiseTime` / `SetTime` | Moonrise / moonset | `(time.Time, error)` | +| `DownTime` | Moonset alias | deprecated; calls `SetTime` internally | +| `CulminationTime` | Upper culmination | `time.Time`; requires longitude and latitude | +| `Phase` / `PhaseDesc` | Illuminated fraction / Chinese textual phase | fraction `[0,1]` / string | +| `SunMoonLoDiff` | Apparent Moon-Sun longitude difference | degrees, `[0,360)` | +| `ShuoYue` / `ShangXianYue` / `WangYue` / `XiaXianYue` | New / first-quarter / full / last-quarter moon solved near a decimal-year anchor | `time.Time`, UTC | +| `NewMoon` / `FullMoon` / `FirstQuarter` / `LastQuarter` | English aliases for the four phases above | `time.Time`, UTC | +| `Next*` / `Last*` / `Closest*` | Next / previous / closest phase and maximum-declination events | `time.Time`; results keep the input time zone | +| `NextConjunctionWithPlanet` / `LastConjunctionWithPlanet` / `ClosestConjunctionWithPlanet` | Moon-planet conjunction (in right ascension) | `time.Time`; the target is a `ConjunctionPlanet` constant | +| `PerigeesInMonth` / `ApogeesInMonth` | All perigees / apogees in a Gregorian month | `[]ApsisInfo`, distance in km | +| `MaximumNorthDeclinationsInMonth` / `MaximumSouthDeclinationsInMonth` | All maximum northern / southern declination events in a month | `[]MaximumDeclinationInfo`, declination in degrees | +| `AscendingNode` / `DescendingNode` | Ascending / descending node longitude | degrees | +| `Physical` / `TopocentricPhysical` | Geocentric / topocentric libration and rotation-axis position angle | returns `PhysicalInfo`, fields in degrees | +| `BrightLimbPositionAngle` / `TopocentricBrightLimbPositionAngle` | Geocentric / topocentric bright-limb position angle | degrees | +| `Diameter` / `Semidiameter` | Apparent diameter / semidiameter | arcseconds | +| `EarthDistance` | Earth-Moon distance | kilometers | + +The `moon` package also exposes occultation APIs. Their parameters, results and examples are documented under [Lunar occultations](occultation.md). + +| Group | Exports | +| --- | --- | +| Events and paths | `FindStarOccultations`, `FindPlanetOccultations`, `FindBestStarOccultations`, `FindBestPlanetOccultations`, `FindStarOccultationPaths`, `FindPlanetOccultationPaths` | +| Instant footprints and disk geometry | `StarOccultationFootprintAt`, `PlanetOccultationFootprintsAt`, `StarOccultationDiagram`, `PlanetOccultationDiagram` | +| UT1 label conversion | `StarOccultationInfoInUT1`, `StarOccultationPathInUT1`, `PlanetOccultationInfoInUT1`, `PlanetOccultationPathInUT1` | +| Stellar results | `StarOccultationInfo`, `StarOccultationPath`, `StarOccultationInstant` | +| Planetary results | `PlanetOccultationInfo`, `PlanetOccultationPath`, `PlanetOccultationInstant`, `PlanetOccultationFootprint` | +| Path data | `OccultationFootprint`, `OccultationPathPoint`, `OccultationGreatestTimeContour`, `OccultationRiseSetCurve` | +| Search and path options | `OccultationSearchOptions`, `OccultationPathOptions`, `OccultationPathAlgorithm` | +| Disk diagram data | `StarOccultationDiagramFrame`, `StarOccultationDiagramOptions`, `StarOccultationDiagramResult`, `PlanetOccultationDiagramFrame`, `PlanetOccultationDiagramOptions`, `PlanetOccultationDiagramResult` | +| Targets and coordinates | `StarData`, `StarCoordinate`, `StarCoordinateFromStarData`, `Observer`, `CoordinateFrame`, `CoordinateFrameICRS`, `CoordinateFrameJ2000`, `CoordinateFrameApparentOfDate` | +| Event types | `OccultationPlanet`, `OccultationType`, `OccultationTotal`, `OccultationPartial`, `OccultationGrazing` | +| Path algorithms | `OccultationPathAlgorithmOptimized`, `OccultationPathAlgorithmExact` | +| Rise/set phases | `RiseSetPhase`, `RiseSetDirection`, `RiseSetPhaseStart`, `RiseSetPhaseGreatest`, `RiseSetPhaseEnd`, `RiseSetDirectionRise`, `RiseSetDirectionSet` | +| Errors | `ErrInvalidOccultationInput`, `ErrOccultationPathSamplingLimit` | + +Planet targets use constants `OccultationMercury` through `OccultationNeptune`. + +### lite/sun + +| Name | Purpose | Unit and convention | +| --- | --- | --- | +| `TrueLo` / `ApparentLo` | Lightweight true / apparent longitude | degrees, geocentric | +| `TrueRa` / `TrueDec` / `TrueRaDec` | Lightweight true equatorial coordinates | degrees, geocentric | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | Lightweight apparent equatorial coordinates | degrees, geocentric | +| `Distance` | Lightweight Earth-Sun distance | AU | +| `HourAngle` / `Azimuth` / `Altitude` / `Zenith` | Lightweight hour angle / azimuth / altitude / zenith distance | degrees, topocentric | +| `RiseTime` / `SetTime` | Lightweight sunrise / sunset | `(time.Time, error)` | +| `ERR_SUN_NEVER_RISE` / `ERR_SUN_NEVER_SET` | Polar night / polar day | error values | + +### lite/moon + +| Name | Purpose | Unit and convention | +| --- | --- | --- | +| `TrueLo` / `TrueBo` | Lightweight geocentric true longitude / latitude | degrees | +| `TrueRa` / `TrueDec` / `TrueRaDec` | Lightweight geocentric true equatorial coordinates | degrees | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | Lightweight topocentric apparent equatorial coordinates | degrees; requires observer longitude and latitude | +| `HourAngle` / `Azimuth` / `Altitude` / `Zenith` | Lightweight hour angle / azimuth / altitude / zenith distance | degrees, topocentric | +| `SunMoonLoDiff` / `Phase` / `PhaseAge` | Lightweight Moon-Sun longitude difference / illuminated fraction / lunar age | degrees / `[0,1]` / days | +| `RiseTime` / `SetTime` | Lightweight moonrise / moonset | `(time.Time, error)` | +| `ERR_MOON_NEVER_RISE` / `ERR_MOON_NEVER_SET` / `ERR_NOT_TODAY` | Polar night / polar day / event not on the queried date | error values | + +### Truncated ...N family + +Most evaluation entry points of `sun` and `moon` have a `...N` variant: the name is the base name plus `N`, one extra `n int` parameter is appended, and the return shape is unchanged. + +`n < 0` keeps every analytical term embedded in this repository and matches the non-`N` version; `n >= 0` truncates the series to `n` terms, which is useful for performance comparison, bulk coarse evaluation, and error-sensitivity experiments. + +- sun: `TrueLoN`, `TrueBoN`, `AltitudeN`, `ZenithN`, `AzimuthN`, `HourAngleN`, `ParallacticAngleN`, `ApparentAltitudeN`, `ApparentZenithN`, `DiameterN`, `SemidiameterN`, `PhysicalN`, `RiseTimeN`, `SetTimeN`, `DownTimeN`, `CulminationTimeN`, `MorningTwilightN`, `EveningTwilightN`, `ApparentSolarTimeN` +- moon: `TrueLoN`, `TrueBoN`, `AscendingNodeN`, `DescendingNodeN`, `DiameterN`, `SemidiameterN`, `PhysicalN`, `TopocentricPhysicalN`, `BrightLimbPositionAngleN`, `TopocentricBrightLimbPositionAngleN` + +The topocentric equatorial coordinates (`ApparentRa` / `ApparentDec` / `ApparentRaDec`), the lunar rise/set entry points (`RiseTime` / `SetTime`), and the phase family have no `N` variant and are not controlled by the truncation switch. + +## Usage examples + +### Today's sunrise, sunset and twilight + +```go +fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) // civil dawn +fmt.Println(sun.RiseTime(date, lon, lat, height, true)) +fmt.Println(sun.SetTime(date, lon, lat, height, true)) +fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) // civil dusk +``` + +```text +2020-01-01 07:22:28.138198256 +0800 CST +2020-01-01 07:49:52.591398954 +0800 CST +2020-01-01 17:45:09.366609156 +0800 CST +2020-01-01 18:12:33.801986575 +0800 CST +``` + +Passing `-12` / `-18` instead selects nautical and astronomical twilight. With `aero = true` the rise/set solution uses the horizon corrected for refraction and apparent radius; the difference from the geometric horizon is described under [Rise, set and culmination](#rise-set-and-culmination). + +### Moonrise, moonset and the Moon's altitude now + +```go +rise, _ := moon.RiseTime(date, lon, lat, height, true) +set, _ := moon.SetTime(date, lon, lat, height, true) +fmt.Println(rise) +fmt.Println(set) +fmt.Println(moon.Altitude(date, lon, lat), moon.Azimuth(date, lon, lat)) +``` + +```text +2020-01-01 11:52:50.042243599 +0800 CST +2020-01-01 23:26:49.498263895 +0800 CST +-45.349728852972675 67.63824603392399 +``` + +Lunar rise and set are computed for the local civil day and the pair is not necessarily continuous, so the full cycle after `date` follows from the order of the rise and set instants; see [Sunrise/sunset and moonrise/moonset](#sunrisesunset-and-moonrisemoonset) for the details. A negative altitude means the Moon is below the horizon, so `-45.35` degrees here means it is not visible. + +### Lunar phase and the next new / full moon + +```go +fmt.Println(moon.Phase(date), moon.PhaseDesc(date)) // illuminated fraction and phase name +fmt.Println(moon.NextShuoYue(date)) // next new moon +fmt.Println(moon.NextWangYue(date)) // next full moon +``` + +```text +0.30004130960877884 上峨眉月 +2020-01-25 05:41:58.271192908 +0800 CST +2020-01-11 03:21:17.159625291 +0800 CST +``` + +`Next*` / `Last*` / `Closest*` are the next / previous / closest search conventions; first and last quarter are `moon.NextShangXianYue` / `moon.NextXiaXianYue`. Results keep the input time zone; the full conventions for the four phases and the synodic month are under [Lunar phases](#lunar-phases). + +### Apparent size, Earth-Moon distance and libration + +```go +fmt.Println(moon.Diameter(date), moon.EarthDistance(date)) +p := moon.Physical(date) +fmt.Println(p.LibrationLongitude, p.LibrationLatitude, p.PositionAngle) +``` + +```text +1774.6658461637385 404238.6096080479 +0.7655535663486027 6.382898400777244 -23.672356410246774 +``` + +`Diameter` is in arcseconds and `EarthDistance` in kilometers; `1774.67` arcseconds (about 29.6 arcminutes) corresponds to roughly 404,000 km near apogee, about 5% smaller than the mean apparent diameter. + +`Physical` gives the geocentric libration; the topocentric counterpart `TopocentricPhysical` and the field conventions are under [Libration and bright-limb position angle](#libration-and-bright-limb-position-angle). + +### Geocentric vs topocentric (getting the Moon position right) + +```go +geoRa, geoDec := moon.GeocentricApparentRaDec(date) // geocentric apparent position +topRa, topDec := moon.ApparentRaDec(date, lon, lat) // topocentric apparent position +fmt.Printf("geocentric %.4f %.4f\n", geoRa, geoDec) +fmt.Printf("topocentric %.4f %.4f\n", topRa, topDec) +fmt.Printf("delta dRA=%.4f dDec=%.4f\n", topRa-geoRa, topDec-geoDec) +fmt.Println(sun.ApparentRaDec(date)) // the Sun only exposes geocentric coordinates +``` + +```text +geocentric 349.2322 -9.9506 +topocentric 349.7343 -10.3485 +delta dRA=0.5021 dDec=-0.3978 +280.8950939694744 -23.05840775453492 +``` + +The Moon is close, so geocentric and topocentric positions differ by half a degree (here `0.50` degrees in right ascension and `0.40` in declination), together more than one lunar apparent diameter; anything an observer sees must go through `moon.ApparentRaDec`. The Sun, 1 AU away, has negligible parallax and is exposed geocentrically only (of course, you can also call the topocentric coordinate conversion entry points to get a topocentric position =-=). The conventions are in [Observing-angle semantics](#observing-angle-semantics). + +### Main chain vs lite + +```go +fmt.Println(sun.Altitude(date, lon, lat), litesun.Altitude(date, lon, lat)) // main / lightweight geometric altitude +fmt.Println(moon.Phase(date), litemoon.Phase(date)) // illuminated fraction +fmt.Println(litemoon.PhaseAge(date)) // lightweight lunar age (days) +fmt.Println(litesun.RiseTime(date, lon, lat, height, true)) // lightweight sunrise +``` + +```text +2.40091496867759 2.403576774819768 +0.30004130960877884 0.2978124633132848 +5.42608394367707 +2020-01-01 07:49:51.69717729 +0800 CST +``` + +The lightweight chains mirror the main-chain shapes but not its accuracy: at this instant the altitude differs by about `0.0027` degrees and the phase by `0.0022`. + +Over 2026 at 8 sites the mean absolute error of `lite/sun` sunrise is `0.02 min` (P95 `0.04 min`, max `0.31 min`), of `lite/moon` moonrise `0.28 min` (P95 `0.57 min`, max `1.44 min`), and `lite/moon` `Phase()` peaks at `0.00243`. + +The full comparison and the truncation errors are under [Differences from the main chain and error levels](#differences-from-the-main-chain-and-error-levels) and in [Lite chains](accuracy.md#lite-lightweight-chains). + +## Observing-angle semantics + +- `Altitude`: altitude angle; horizon is `0°`, zenith is `+90°` +- `Zenith`: zenith distance; zenith is `0°`, horizon is `90°` +- `Zenith` and `Altitude` are complements; the two add up to `90°` +- `Azimuth`: azimuth, measured from north (`0°`) toward east, in `[0°, 360°)` +- `HourAngle`: hour angle, `0°` at upper culmination and increasing westward (afternoon), normalized to `[0°, 360°)` +- `ParallacticAngle`: parallactic angle (zenith direction angle), signed, in degrees; the shared sign convention and topocentric geometry live in [Coordinate Tools](coord.md) +- `Altitude` / `Azimuth` are geometric center altitude and azimuth without refraction or semidiameter correction; `ApparentAltitude` / `ApparentZenith` add atmospheric refraction and need pressure (hPa) and temperature (degrees Celsius) +- Solar equatorial coordinates are geocentric; for the Moon, `TrueRaDec` and `GeocentricApparentRaDec` are geocentric while `ApparentRa` / `ApparentDec` / `ApparentRaDec` are topocentric and require observer longitude and latitude + +## Combined example: rise, set, and position + +The two complete examples below cover the most common entry points. Their shared setup is Xi'an (`108.93°E, 34.27°N`) and `2020-01-01 08:08:08 CST`. + +The later snippets omit shared variables and keep only the statements relevant to their capability. + +### Sunrise/sunset and moonrise/moonset + +> ⚠️ Moon rise/set times are computed for the queried civil date, so the rise and set instants need not be continuous. +> +> For example, the Moon may set at 01:00 and rise again at noon, in which case the rise time is later than the set time; the evening moonset in that scenario corresponds to the next day's date. +> +> The full rise/set cycle follows from the order of the two instants: check whether the rise time falls after the set time to pick the correct subsequent instants. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + // Xi'an, China. Longitude east and latitude north are positive; elevation is 0 m. + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // All "today" semantics are based on this local civil date. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + // Civil morning twilight begins when the Sun is 6 degrees below the horizon. + // Civil twilight is 6 degrees below the horizon, nautical 12, astronomical 18. + fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) + // Sunrise: dynamic standard refraction and instantaneous solar semidiameter, upper limb. + fmt.Println(sun.RiseTime(date, lon, lat, height, true)) + // Upper culmination of the Sun in Xi'an. + fmt.Println(sun.CulminationTime(date, lon)) + // Sunset: dynamic standard refraction and instantaneous solar semidiameter, upper limb. + fmt.Println(sun.SetTime(date, lon, lat, height, true)) + // Civil evening twilight ends when the Sun is 6 degrees below the horizon. + fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) + + // Moonrise: dynamic standard refraction and instantaneous lunar semidiameter, upper limb. + fmt.Println(moon.RiseTime(date, lon, lat, height, true)) + // Upper culmination of the Moon in Xi'an. + fmt.Println(moon.CulminationTime(date, lon, lat)) + // Moonset: dynamic standard refraction and instantaneous lunar semidiameter, upper limb. + fmt.Println(moon.SetTime(date, lon, lat, height, true)) +} +``` + +Output: + +```text +2020-01-01 07:22:27.960488498 +0800 CST +2020-01-01 07:49:52.413689196 +0800 CST +2020-01-01 12:47:35.933117866 +0800 CST +2020-01-01 17:45:09.188657999 +0800 CST +2020-01-01 18:12:33.624035418 +0800 CST +2020-01-01 11:52:49.860912859 +0800 CST +2020-01-01 17:36:48.811488747 +0800 CST +2020-01-01 23:26:49.313553571 +0800 CST +``` + +### Sun and Moon position + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/star" + "b612.me/astro/sun" + "b612.me/astro/tools" +) + +func main() { + // Xi'an, China. + var lon, lat float64 = 108.93, 34.27 + cst := time.FixedZone("CST", 8*3600) + // Instant of observation. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + // Apparent ecliptic longitude of the Sun, in degrees. + fmt.Println(sun.ApparentLo(date)) + // True obliquity of the ecliptic at this instant. + fmt.Println(sun.EclipticObliquity(date, true)) + // Apparent right ascension and declination of the Sun. + ra, dec := sun.ApparentRaDec(date) + fmt.Println("RA:", tools.Format(ra/15, 1), "Dec:", tools.Format(dec, 0)) + // English constellation containing the Sun. + fmt.Println(star.ConstellationEN(ra, dec, date)) + // Solar azimuth, altitude, and zenith distance at Xi'an. + fmt.Println("Azimuth:", sun.Azimuth(date, lon, lat), "Altitude:", sun.Altitude(date, lon, lat), "Zenith:", sun.Zenith(date, lon, lat)) + // Sun-Earth distance, in AU. + fmt.Println(sun.EarthDistance(date)) + + // Topocentric apparent right ascension and declination of the Moon. + ra, dec = moon.ApparentRaDec(date, lon, lat) + fmt.Println("RA:", tools.Format(ra/15, 1), "Dec:", tools.Format(dec, 0)) + // English constellation containing the Moon. + fmt.Println(star.ConstellationEN(ra, dec, date)) + // Lunar azimuth, altitude, and zenith distance at Xi'an. + fmt.Println("Azimuth:", moon.Azimuth(date, lon, lat), "Altitude:", moon.Altitude(date, lon, lat), "Zenith:", moon.Zenith(date, lon, lat)) + // Earth-Moon distance, in km. + fmt.Println(moon.EarthDistance(date)) +} +``` + +Output: + +```text +280.01526210031136 +23.4362178391013 +RA: 18h43m34.82s Dec: -23°3′30.27″ +Sagittarius +Azimuth: 120.19477090015224 Altitude: 2.4014437419430097 Zenith: 87.59855625805699 +0.983292937163176 +RA: 23h18m56.24s Dec: -10°20′54.42″ +Aquarius +Azimuth: 67.63889332004852 Altitude: -45.34916937173283 Zenith: 135.34916937173284 +404238.6096080479 +``` + +## The Sun + +These snippets omit the shared setup: `cst := time.FixedZone("CST", 8*3600)`, `date := time.Date(2026, 1, 1, 12, 0, 0, 0, cst)`, and `var lon, lat float64 = 108.93, 34.27` (Xi'an); `fmt`, `time`, `sun`, and `moon` are assumed to be imported. + +### Position + +Solar position entry points depend only on the absolute instant, not on the observer. True ecliptic latitude comes from `TrueBo`; there is no separate apparent latitude. + +The second argument of `EclipticObliquity` decides whether nutation in obliquity is added. + +```go +// True and apparent solar longitude, and true latitude, in degrees. +fmt.Println(sun.TrueLo(date), sun.ApparentLo(date), sun.TrueBo(date)) +// Apparent right ascension and declination of the Sun. +ra, dec := sun.ApparentRaDec(date) +fmt.Println("RA:", ra, "Dec:", dec) +fmt.Println(sun.ApparentRa(date), sun.ApparentDec(date)) +// Obliquity of the ecliptic, geometric longitude, and equation of center. +fmt.Println(sun.EclipticObliquity(date, true), sun.GeometricLo(date), sun.MidFunc(date)) +``` + +Output: + +```text +280.742671383543 280.7383965677222 0.00018280886212040676 +RA: 281.6786097810291 Dec: -23.00369182413533 +281.6786097810291 -23.00387403948847 +23.438148552330773 280.83169623098 -0.08703499790144194 +``` + +Every entry point in this family also has a `...N` truncated variant. The comparison below truncates at `n = 8`; with `n < 0` the result matches the non-`N` version exactly: + +```go +// n<0 keeps every embedded VSOP term; n>=0 truncates the series. +fmt.Println(sun.TrueLo(date), sun.TrueLoN(date, 8)) +fmt.Println(sun.Altitude(date, lon, lat), sun.AltitudeN(date, lon, lat, 8)) +fmt.Println(sun.Diameter(date), sun.DiameterN(date, 8)) +``` + +Output: + +```text +280.742671383543 280.7439900413756 +31.61569462953789 31.615524473491835 +1950.9979407481142 1950.9994358395434 +``` + +### Rise, set and culmination + +`RiseTime` / `SetTime` anchor on the local civil day of the time zone carried by `date` and keep the same time zone in the result. `height` is the observer elevation interpreted as ellipsoidal (geodetic) height in meters. + +With `aero = true`, the **upper limb** crossing is computed with dynamic standard refraction and the instantaneous semidiameter; with `aero = false`, only the geometric center crossing is tested. + +Twilight entry points take the target altitude as a parameter: civil twilight `-6°`, nautical `-12°`, astronomical `-18°`, with `MorningTwilight` and `EveningTwilight` for the two sides. + +```go +// Target altitudes for civil, nautical, and astronomical morning twilight. +for _, angle := range []float64{-6, -12, -18} { + t, err := sun.MorningTwilight(date, lon, lat, angle) + fmt.Println(angle, t.Format("15:04:05"), err) +} +// Upper culmination and sunrise; err is non-nil when no event exists. +fmt.Println(sun.CulminationTime(date, lon).Format("15:04:05")) +t, err := sun.RiseTime(date, lon, lat, 0, true) +fmt.Println(t.Format("15:04:05"), err) +``` + +Output: + +```text +-6 07:22:47 +-12 06:51:31 +-18 06:21:00 +12:47:50 +07:50:10 +``` + +### Topocentric quantities and parallactic angle + +`Altitude` / `Zenith` / `Azimuth` / `HourAngle` follow the geometric chain without refraction; `ApparentAltitude` / `ApparentZenith` add atmospheric refraction and need pressure and temperature; `ParallacticAngle` is the signed parallactic angle. General topocentric and refraction conversions (including apparent altitude and topocentric equatorial coordinates) live in [Coordinate Tools](coord.md). + +```go +fmt.Println(sun.Azimuth(date, lon, lat), sun.Altitude(date, lon, lat), sun.Zenith(date, lon, lat)) +fmt.Println(sun.ApparentAltitude(date, lon, lat, 1010, 10), sun.ApparentZenith(date, lon, lat, 1010, 10)) +// Hour angle and signed parallactic angle. +fmt.Println(sun.HourAngle(date, lon, lat), sun.ParallacticAngle(date, lon, lat)) +``` + +Output: + +```text +167.09774780715728 31.61569462953789 58.38430537046211 +31.643010360459822 58.356989639540174 +348.07820699607544 -11.564174033740159 +``` + +### Apparent solar time and equation of time + +`ApparentSolarTime` returns the apparent solar time at a longitude; the result uses a fixed-offset time zone derived from that longitude, not the zone passed by the caller. + +`EquationTime` returns the equation of time at the same instant, in hours. `sundial.TrueSolarTime` uses the same convention, and `sundial` additionally provides local mean solar time and sundial geometry; see [Sundial and Apparent Solar Time](sundial.md). + +```go +// Apparent solar time; the result time zone is derived from longitude. +fmt.Println(sun.ApparentSolarTime(date, lon).Format("2006-01-02 15:04:05 -0700")) +// Equation of time, in hours. +fmt.Println(sun.EquationTime(date)) +// sundial.TrueSolarTime uses the same convention. +fmt.Println(sundial.TrueSolarTime(date, lon).Format("2006-01-02 15:04:05 -0700")) +``` + +Output: + +```text +2026-01-01 11:12:18 +0715 +-0.05674946079069686 +2026-01-01 11:12:18 +0715 +``` + +### Physical ephemeris and apparent size + +`sun.Physical` returns `PhysicalInfo`: `P` is the position angle of the solar north pole, `B0` is the heliographic latitude of the disk center, and `L0` is the Carrington heliographic longitude of the disk center, all in degrees. + +`Diameter` / `Semidiameter` give the apparent diameter and semidiameter in arcseconds; `EarthDistance` gives the Earth-Sun distance in AU. + +```go +// Apparent diameter and semidiameter (arcseconds), and Earth-Sun distance (AU). +fmt.Println(sun.Diameter(date), sun.Semidiameter(date), sun.EarthDistance(date)) +// Solar physical quantities P/B0/L0, in degrees. +p := sun.Physical(date) +fmt.Println(p.P, p.B0, p.L0) +``` + +Output: + +```text +1950.9979407481142 975.4989703740571 0.9833237486528845 +1.979086377118846 -3.0131029209723916 296.9595333604375 +``` + +The Sun, Moon, and seven major planets share the same interface shapes, so they can be compared side by side: + +```go +fmt.Println(sun.Diameter(date), sun.Semidiameter(date)) +fmt.Println(sun.Physical(date)) +fmt.Println(moon.Diameter(date), moon.Semidiameter(date)) +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) +``` + +### Earth orbit extrema + +The extrema of the Earth-Sun distance come from the `earth` package, with UTC times and AU distances; for the orbital eccentricity at one instant, call `EarthEccentricity` directly: + +```go +// Earth perihelion and aphelion in 2026; time is UTC, distance is AU. +peri := earth.Perihelion(2026) +aphe := earth.Aphelion(2026) +fmt.Printf("earth perihelion=%s distance=%.9fAU\n", peri.Time.Format(time.RFC3339), peri.Distance) +fmt.Printf("earth aphelion=%s distance=%.9fAU\n", aphe.Time.Format(time.RFC3339), aphe.Distance) +``` + +Output: + +```text +earth perihelion=2026-01-03T17:15:35Z distance=0.983302050AU +earth aphelion=2026-07-06T17:31:24Z distance=1.016643936AU +``` + +```go +fmt.Printf("earth e=%.9f\n", earth.EarthEccentricity(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) +``` + +## The Moon + +These snippets reuse the shared setup of the solar section: `date` is still `2026-01-01 12:00:00 CST` and the observer is still in Xi'an. + +### Position + +Lunar equatorial coordinates come in three layers: `TrueRaDec` is the geocentric true place, `GeocentricApparentRaDec` is the geocentric apparent place, and `ApparentRaDec` is the topocentric apparent place. + +On the ecliptic side only geocentric quantities exist: `TrueLo` true longitude, `TrueBo` true latitude, and `ApparentLo` apparent longitude. + +```go +// Geocentric true equatorial coordinates. +fmt.Println(moon.TrueRaDec(date)) +// Geocentric apparent equatorial coordinates. +fmt.Println(moon.GeocentricApparentRaDec(date)) +// Topocentric apparent equatorial coordinates. +fmt.Println(moon.ApparentRaDec(date, lon, lat)) +// True longitude, true latitude, and apparent longitude. +fmt.Println(moon.TrueLo(date), moon.TrueBo(date), moon.ApparentLo(date)) +``` + +Output: + +```text +66.66309709020791 26.830370234236764 +66.66476688311091 26.830608930005567 +67.0275694157259 25.982665403390747 +69.21422925147913 5.060516750865828 69.21574418700706 +``` + +### Rise, set and culmination + +Lunar rise/set, like the solar one, anchors on the local civil day: `RiseTime` / `SetTime` return that day's event instants and `CulminationTime` returns that day's upper culmination. + +Successive lunar rises and sets need not be continuous, and the full cycle after `date` follows from the order of the two instants. When an event falls outside the queried date, the rise/set entry points return `ERR_NOT_TODAY`. + +```go +// Moonrise, upper culmination, and moonset on the local civil day. +rise, err := moon.RiseTime(date, lon, lat, 0, true) +fmt.Println(rise.Format("15:04:05"), err) +fmt.Println(moon.CulminationTime(date, lon, lat).Format("15:04:05")) +set, err := moon.SetTime(date, lon, lat, 0, true) +fmt.Println(set.Format("15:04:05"), err) +``` + +Output: + +```text +16:17:11 +00:03:16 +06:41:37 +``` + +### Lunar phases + +`Phase` returns the illuminated fraction in `[0,1]`, `PhaseDesc` returns a Chinese phase name, and `SunMoonLoDiff` returns the apparent Moon-Sun longitude difference normalized to `[0,360)` (near `0°` at new moon and near `180°` at full moon). + +`Next*` / `Last*` / `Closest*` are the three search conventions, and results keep the input time zone. + +Each of the four phases has both a pinyin name and an English alias, for example `ShuoYue` / `NewMoon`, `WangYue` / `FullMoon`, `ShangXianYue` / `FirstQuarter`, and `XiaXianYue` / `LastQuarter`. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // Instant of observation. + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + // Illuminated fraction of the lunar disk. + fmt.Println(moon.Phase(date)) + // Chinese textual phase description. + fmt.Println(moon.PhaseDesc(date)) + // Next new moon; moon.NextNewMoon(date) is the English alias. + fmt.Println(moon.NextShuoYue(date)) + // Next first quarter; moon.NextFirstQuarter(date) is the English alias. + fmt.Println(moon.NextShangXianYue(date)) + // Next full moon; moon.NextFullMoon(date) is the English alias. + fmt.Println(moon.NextWangYue(date)) + // Next last quarter; moon.NextLastQuarter(date) is the English alias. + fmt.Println(moon.NextXiaXianYue(date)) +} +``` + +Output: + +```text +0.30004130960877884 // about 30% of the lunar disk is illuminated +上峨眉月 // Chinese phase description +2020-01-25 05:41:58.271192908 +0800 CST // next new moon +2020-01-03 12:45:23.229190707 +0800 CST // next first quarter +2020-01-11 03:21:17.159625291 +0800 CST // next full moon +2020-01-17 20:58:23.396406769 +0800 CST // next last quarter +``` + +`Last*`, `Closest*`, and the decimal-year anchors `ShuoYue` / `FullMoon` return UTC. `ClosestConjunctionWithPlanet` finds the closest Moon-planet conjunction, with the target given by a `ConjunctionPlanet` constant: + +```go +// Previous and closest new moon and full moon. +fmt.Println(moon.LastShuoYue(date), moon.ClosestShuoYue(date)) +fmt.Println(moon.LastWangYue(date), moon.ClosestWangYue(date)) +// First and last quarter. +fmt.Println(moon.LastFirstQuarter(date), moon.ClosestLastQuarter(date)) +// Decimal-year anchors; the results are UTC. +fmt.Println(moon.ShuoYue(2025.5).Format(time.RFC3339), moon.FullMoon(2025.5).Format(time.RFC3339)) +// Closest Moon-planet conjunction in right ascension. +fmt.Println(moon.ClosestConjunctionWithPlanet(date, moon.ConjunctionJupiter)) +``` + +Output: + +```text +2025-12-20 09:43:19.074603617 +0800 CST 2025-12-20 09:43:19.074603617 +0800 CST +2025-12-05 07:14:03.670351803 +0800 CST 2026-01-03 18:02:53.55531156 +0800 CST +2025-12-28 03:09:50.141303837 +0800 CST 2026-01-10 23:48:22.03346461 +0800 CST +2025-06-25T10:31:35Z 2025-07-10T20:36:46Z +2026-01-04 05:59:19.9425897 +0800 CST +``` + +### Perigee and apogee + +`PerigeesInMonth` / `ApogeesInMonth` return every perigee and apogee event in a Gregorian month. Each element is an `ApsisInfo` with `Time` (UTC) and `Distance` (km); a month may contain zero, one, or several events. + +```go +// Lunar perigee and apogee in January 2026; distance is in km. +perigees := moon.PerigeesInMonth(2026, time.January) +apogees := moon.ApogeesInMonth(2026, time.January) +fmt.Printf("moon perigee=%s distance=%.1fkm count=%d\n", perigees[0].Time.Format(time.RFC3339), perigees[0].Distance, len(perigees)) +fmt.Printf("moon apogee=%s distance=%.1fkm count=%d\n", apogees[0].Time.Format(time.RFC3339), apogees[0].Distance, len(apogees)) +``` + +Output: + +```text +moon perigee=2026-01-01T21:44:24Z distance=360348.1km count=2 +moon apogee=2026-01-13T20:47:13Z distance=405437.9km count=1 +``` + +### Nodes + +The Moon also exposes ascending-node and descending-node longitudes, which are useful for eclipse seasons, orbital geometry, and lunar-orbit studies: + +```go +nodeDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) +fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate)) +``` + +The ascending / descending node definitions here are the same as in the planets chapter: + +- `AscendingNode`: ecliptic longitude where the Moon crosses from south of the ecliptic to north of it +- `DescendingNode`: ecliptic longitude where the Moon crosses from north of the ecliptic to south of it +- both values are degrees, and are usually about `180°` apart at the same instant + +For the `nodeDate := 2026-01-01 00:00:00 UTC` example above, the output is: + +```text +340.95708624505863 160.9570862450587 +``` + +### Maximum declinations + +Lunar declination reaches northern and southern extrema within one nodal month. `MaximumDeclinationInfo` carries `Time` (the event instant) and `Declination` (the geocentric declination at that instant, in degrees). + +The monthly entry points return every event in the month, while `Next*` / `Last*` / `Closest*` search by instant: + +```go +// Closest maximum northern and previous maximum southern declination. +north := moon.ClosestMaximumNorthDeclination(date) +south := moon.LastMaximumSouthDeclination(date) +fmt.Println(north.Time.Format(time.RFC3339), north.Declination) +fmt.Println(south.Time.Format(time.RFC3339), south.Declination) +// Next maximum northern declination and all events in the month. +fmt.Println(moon.NextMaximumNorthDeclination(date).Time.Format(time.RFC3339)) +events := moon.MaximumNorthDeclinationsInMonth(2026, time.January) +fmt.Println(len(events)) +for _, event := range events { + fmt.Println(event.Time.Format(time.RFC3339), event.Declination) +} +``` + +Output: + +```text +2026-01-02T16:10:49+08:00 28.266373428242343 +2025-12-20T07:06:57+08:00 -28.23514705130737 +2026-01-02T16:10:49+08:00 +2 2026-01-02T08:10:49Z 28.266373428242343 +``` + +The monthly-list convention for the same events looks like this (input `2026-01-01 00:00:00 UTC`): + +```go +// Maximum northern and southern lunar declinations in January 2026. +north := moon.MaximumNorthDeclinationsInMonth(2026, time.January) +south := moon.MaximumSouthDeclinationsInMonth(2026, time.January) +fmt.Printf("north=%s dec=%.6f\n", north[0].Time.Format(time.RFC3339), north[0].Declination) +fmt.Printf("south=%s dec=%.6f\n", south[0].Time.Format(time.RFC3339), south[0].Declination) +``` + +Output: + +```text +north=2026-01-02T08:10:49Z dec=28.266373 +south=2026-01-16T05:15:14Z dec=-28.304184 +``` + +### Libration and bright-limb position angle + +`Physical` returns the geocentric libration and `TopocentricPhysical` the topocentric libration. + +Both are `PhysicalInfo`, carrying the optical, physical, and total libration components plus the rotation-axis position angle `PositionAngle`, in degrees. + +The bright-limb position angle starts at `0°` at the lunar north point and increases eastward. + +```go +// Geocentric libration and rotation-axis position angle. +p := moon.Physical(date) +fmt.Println(p.LibrationLongitude, p.LibrationLatitude, p.PositionAngle) +// Topocentric libration and rotation-axis position angle. +topo := moon.TopocentricPhysical(date, lon, lat, 0) +fmt.Println(topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) +// Geocentric and topocentric bright-limb position angles. +fmt.Println(moon.BrightLimbPositionAngle(date), moon.TopocentricBrightLimbPositionAngle(date, lon, lat, 0)) +``` + +Output: + +```text +-0.9680924808747591 -6.547834757841939 -9.025022841390472 +-0.7780085060449551 -5.659649431558411 -8.883877708625544 +269.08333384819935 267.8559531949645 +``` + +A complete topocentric example (Shanghai, `height = 4 m`): + +```go +// Lunar libration and rotation-axis position angle. +physical := moon.Physical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) +fmt.Printf("libration lon=%.6f lat=%.6f pa=%.6f\n", physical.LibrationLongitude, physical.LibrationLatitude, physical.PositionAngle) +// Bright-limb position angle; 0 degrees starts at the lunar north point and increases eastward. +fmt.Printf("bright limb=%.6f\n", moon.BrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) +// Topocentric libration, rotation-axis position angle, and bright-limb angle from Shanghai. +topo := moon.TopocentricPhysical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4) +fmt.Printf("topo libration lon=%.6f lat=%.6f pa=%.6f\n", topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) +fmt.Printf("topo bright limb=%.6f\n", moon.TopocentricBrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4)) +``` + +Output: + +```text +libration lon=-1.278902 lat=-6.531444 pa=-9.967050 +bright limb=267.364849 +topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 +topo bright limb=266.038258 +``` + +### Apparent size and Earth-Moon distance + +`Diameter` / `Semidiameter` give the apparent diameter and semidiameter in arcseconds, and `EarthDistance` gives the Earth-Moon distance in kilometers. All three depend only on the absolute instant: + +```go +// Apparent diameter and semidiameter (arcseconds), and Earth-Moon distance (km). +fmt.Println(moon.Diameter(date), moon.Semidiameter(date), moon.EarthDistance(date)) +``` + +Output: + +```text +1986.4975069969655 993.2487534984828 360488.4234539985 +``` + +## Lite chains + +`lite/sun` and `lite/moon` are independent approximation implementations: they do not depend on VSOP87 or the main chain's ELP2000/82 series, they target CPU- and memory-constrained environments, and their calling style matches the main chain. + +Rise/set search uses fixed-step scanning plus bisection, without the main chain's high-precision nutation iteration. + +```go +package main + +import ( + "fmt" + "time" + + litemoon "b612.me/astro/lite/moon" + litesun "b612.me/astro/lite/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 1, 1, 20, 0, 0, 0, cst) + + fmt.Println(litesun.Altitude(date, 121.4737, 31.2304)) + fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) + + fmt.Println(litemoon.Phase(date)) + fmt.Println(litemoon.PhaseAge(date)) + fmt.Println(litemoon.RiseTime(date, 121.4737, 31.2304, 0, true)) +} +``` + +The snippets below reuse this section's shared setup: Shanghai (`121.4737°E, 31.2304°N`) and `2026-01-01 20:00:00 CST`. + +### lite/sun + +The lightweight solar chain provides longitude, equatorial coordinates, distance, horizontal coordinates, and rise/set, but no apparent size, solar disk physical quantities, or twilight entry points. + +`Distance` is the Earth-Sun distance in AU; note that the main chain names this capability `EarthDistance` while the lite chain names it `Distance`. + +```go +// Lightweight true and apparent solar longitude, and Earth-Sun distance (AU). +fmt.Println(litesun.TrueLo(date), litesun.ApparentLo(date), litesun.Distance(date)) +// Lightweight apparent right ascension and declination. +ra, dec := litesun.ApparentRaDec(date) +fmt.Println(ra, dec) +// Lightweight hour angle, azimuth, altitude, and zenith distance. +fmt.Println(litesun.HourAngle(date, 121.4737, 31.2304), litesun.Azimuth(date, 121.4737, 31.2304), litesun.Altitude(date, 121.4737, 31.2304), litesun.Zenith(date, 121.4737, 31.2304)) +// Lightweight rise and set. +fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) +fmt.Println(litesun.SetTime(date, 121.4737, 31.2304, 0, true)) +``` + +Output: + +```text +281.0835889076667 281.0793650422643 0.9833163427233701 +282.0475744673639 -22.973803828458102 +120.58011725187828 263.4579582386061 -37.07676039520773 127.07676039520773 +2026-01-01 06:52:24.6475178 +0800 CST +2026-01-01 17:02:44.014452695 +0800 CST +``` + +### lite/moon + +The lightweight lunar chain provides a few-perturbation-term lunar position, light topocentric correction, phase and age, and rise/set, but no libration, apparent size, Earth-Moon distance, or nodes. + +`PhaseAge` returns the lunar age in days and exists only in the lite chain: + +```go +// Lightweight true longitude and latitude. +fmt.Println(litemoon.TrueLo(date), litemoon.TrueBo(date)) +// Lightweight geocentric true equatorial coordinates. +ra, dec := litemoon.TrueRaDec(date) +fmt.Println(ra, dec) +// Lightweight topocentric apparent equatorial coordinates. +fmt.Println(litemoon.ApparentRaDec(date, 121.4737, 31.2304)) +// Moon-Sun longitude difference, illuminated fraction, and lunar age. +fmt.Println(litemoon.SunMoonLoDiff(date), litemoon.Phase(date), litemoon.PhaseAge(date)) +// Lightweight horizontal coordinates. +fmt.Println(litemoon.Altitude(date, 121.4737, 31.2304), litemoon.Azimuth(date, 121.4737, 31.2304), litemoon.Zenith(date, 121.4737, 31.2304)) +``` + +Output: + +```text +74.25630740893 5.078407838127742 +72.2518040645064 27.549605139478064 +72.74256784654426 27.432483413326057 +153.17694236666568 0.9462021494002484 12.565014741082448 +63.55513206820331 90.53047230027812 26.444867931796693 +``` + +### Differences from the main chain and error levels + +The lite chains differ from the main chain in implementation and accuracy while keeping nearly the same interface shapes. + +Pure evaluation entry points such as position and phase run about `8.3-27.3x` faster than the main chain, and rise/set entry points about `1.0-3.7x`, with zero heap allocation in the computation path; the rise/set scan step is `30` minutes for `lite/sun` and `15` minutes for `lite/moon`. + +Errors against `sun` / `moon` (year 2026, 8 sites) are listed below, with data from [Lite lightweight chains](accuracy.md#lite-lightweight-chains): + +| Capability | Mean absolute error | P95 | Max absolute error | +| --- | --- | --- | --- | +| `lite/sun` sunrise | `0.02 min` | `0.04 min` | `0.31 min` | +| `lite/sun` sunset | `0.02 min` | `0.06 min` | `0.35 min` | +| `lite/moon` moonrise | `0.28 min` | `0.57 min` | `1.44 min` | +| `lite/moon` moonset | `0.36 min` | `0.86 min` | `1.24 min` | +| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | +| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | +| `lite/moon` geocentric longitude | `2.41'` | `6.82'` | `9.91'` | +| `lite/moon` geocentric latitude | `0.87'` | `1.83'` | `2.92'` | + +Neither package provides a `...N` truncated family. On the solar side the polar-night / polar-day errors are `ERR_SUN_NEVER_RISE` / `ERR_SUN_NEVER_SET`; on the lunar side, besides `ERR_MOON_NEVER_RISE` / `ERR_MOON_NEVER_SET`, there is `ERR_NOT_TODAY`, with the same semantics as the main chain. + +## Parameter and result conventions + +### Units and angle conventions + +- Angles are always in degrees; `RA`, `Lon`, and `Azimuth` are normalized to `[0°, 360°)`, while declination and ecliptic latitude lie in `[−90°, 90°]` +- Apparent diameters and semidiameters are in arcseconds; `sun.EarthDistance` is AU and `moon.EarthDistance` is kilometers +- `Phase` is the illuminated fraction in `[0,1]`, `PhaseAge` (lite chain) is in days, and `EquationTime` is in hours +- Distance extrema: `earth.Perihelion` / `earth.Aphelion` are AU, and `ApsisInfo.Distance` is kilometers + +### Time scale + +Observing inputs and event times use the civil-time convention. Before `1972-01-01`, the library treats those readings as UT1. `ApparentSolarTime` returns a local solar clock reading rather than another civil event time. + +The time zone carried by a `time.Time` only affects the division of local civil days and the zone of the returned value, never the absolute instant. + +The full UT1 and chart-label declaration is in the [Time Scale Declaration](map-geojson.md#time-scale-declaration). + +### Height and aero + +- The `height` of the rise/set entry points is the observer elevation interpreted as ellipsoidal (geodetic) height in meters, not orthometric height; see [Observer Height Convention](coord.md#observer-height) +- `aero = true`: upper-limb crossing with dynamic standard refraction and the instantaneous semidiameter; `aero = false`: only the geometric center crossing +- `pressureHPa` and `temperatureC` of `ApparentAltitude` / `ApparentZenith` are the observed pressure (hPa) and temperature (degrees Celsius) + +### Zero values and out-of-range + +- During polar night the rise/set entry points return `ERR_SUN_NEVER_RISE` / `ERR_MOON_NEVER_RISE`; during polar day they return `ERR_SUN_NEVER_SET` / `ERR_MOON_NEVER_SET`, with a zero `time.Time` as the first return value +- When no twilight exists, they return `ERR_TWILIGHT_NOT_EXISTS` +- When a lunar rise/set event falls outside the queried date, they return `ERR_NOT_TODAY`, matching the "moonrise/moonset are computed for the queried civil date" semantics above +- `DownTime` / `DownTimeN` are deprecated aliases of `SetTime`, and `ERR_SUN_NEVER_DOWN` / `ERR_MOON_NEVER_DOWN` are deprecated aliases of the polar-day errors; new code should not use them +- In `...N`, `n < 0` uses every embedded term and `n >= 0` truncates; `n` only changes the series length, never the returned unit or time zone + +### Accuracy and scope + +The Sun and planets use embedded VSOP87 analytical terms and the Moon uses an embedded ELP2000/82-style truncated series, covering roughly 4000 years around J2000 with no external ephemeris files. + +Truncation errors, the lunar chain's capability boundaries, and the lite chains' quantified errors are documented in [Scope And Accuracy](accuracy.md). + +The solar and lunar entry points in this manual suit calendars, observing aids, outreach, and amateur prediction; spacecraft navigation, precise occultation prediction, and rigorous dynamical integration require a professional ephemeris such as JPL DE. diff --git a/doc/manual/en/sundial.md b/doc/manual/en/sundial.md new file mode 100644 index 0000000..1b5957a --- /dev/null +++ b/doc/manual/en/sundial.md @@ -0,0 +1,351 @@ +# Sundials and Apparent Solar Time + +[中文](../sundial.md) | [Back to README](../../../README.en.md) + +> Full examples in this manual run from the repository root. + +`sundial` gathers the apparent solar time, solar hour angle and dial geometry of the `sun` package in one place and does not introduce a second algorithm. + +The dial side follows the classical planar-dial model: a polar-axis stylus whose shadow falls on an arbitrary plane, with the coordinate convention given by the constructor - a horizontal dial has **x pointing east and y pointing north**. + +## Contents + +- [Calculating a shadow on a horizontal sundial](#calculating-a-shadow-on-a-horizontal-sundial) +- [API Reference](#api-reference) + - [True, mean solar time and the equation of time](#true-mean-solar-time-and-the-equation-of-time) + - [Hour angle](#hour-angle) + - [Horizontal hour-line angle](#horizontal-hour-line-angle) + - [Planar dial core](#planar-dial-core) + - [Plate illumination intervals](#plate-illumination-intervals) + - [Time lines and declination curves](#time-lines-and-declination-curves) + - [Equatorial, horizontal and vertical dials](#equatorial-horizontal-and-vertical-dials) + - [Returned structures](#returned-structures) + - [Complete example](#complete-example) + - [Combined example: usable hour angles and a time line](#combined-example-usable-hour-angles-and-a-time-line) + - [Common pitfalls](#common-pitfalls) +- [Usage examples](#usage-examples) + - [Apparent solar time versus clock time](#apparent-solar-time-versus-clock-time) + - [Horizontal hour-line angles and the shadow point](#horizontal-hour-line-angles-and-the-shadow-point) + - [Plate illumination intervals and time lines](#plate-illumination-intervals-and-time-lines) + - [Degenerate geometry and per-face dial conventions](#degenerate-geometry-and-per-face-dial-conventions) +- [Parameter and result conventions](#parameter-and-result-conventions) +- [Related manuals](#related-manuals) + +## Calculating a shadow on a horizontal sundial + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/sundial" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 6, 21, 9, 30, 0, 0, cst) + lon, lat := 121.4737, 31.2304 + fmt.Println(sundial.TrueSolarTime(date, lon)) + fmt.Println(sundial.HourAngle(date, lon)) + dial := sundial.HorizontalDial(lat, 10) + shadow := dial.ShadowPointAt(date, lon) + if !shadow.Illuminated { + fmt.Println("no illuminated shadow") + return + } + fmt.Printf("x=%.6f y=%.6f\n", shadow.X, shadow.Y) +} +``` + +The dial length and returned coordinates use the same unit: a stylus length in centimetres gives shadow coordinates in centimetres. x points east and y north. Use the shadow only when `Illuminated` is true. + +## API Reference + +| Group | Entry points | Purpose | Units and convention | +| --- | --- | --- | --- | +| True/mean solar time | `TrueSolarTime` / `MeanSolarTime` | Local apparent/mean solar time at a longitude for an absolute instant | Local solar clock readings; longitude east positive (degrees) | +| Solar hour angle | `HourAngle` | Apparent solar hour angle | Degrees, negative in the morning and positive in the afternoon | +| Hour-angle helpers | `MeanSolarHourAngle` / `ZoneTimeHourAngle` | Turn a local mean-solar or zone clock reading into an apparent solar hour angle | Hours and longitude in degrees | +| Horizontal hour line | `HorizontalHourLineAngle` / `HorizontalHourLineAngleAt` | Hour-line angle of a horizontal dial relative to the noon line | Degrees | +| Planar dial core | `PlanarDial` (fields) + `Geometry` / `ShadowPointByHourAngleDeclination` / `ShadowPointAt` | Geometry and shadow point of an arbitrary plane | Coordinates share the stylus-length unit | +| Plate illumination | `PlaneIlluminatedHourAngleIntervals` / `IlluminatedHourAngleIntervals` | Plate-lit hour-angle intervals and the final usable intervals | Degrees, `[-180, 180]` | +| Time lines | `MeanSolarTimePoint` / `ZoneTimePoint` / `MeanSolarTimeLine` / `ZoneTimeLine` | Attach mean-solar or zone time lines directly to the dial geometry | Returns `PlanarShadowPoint` / `TimeLineSample` | +| Declination curves | `DeclinationCurve` / `DeclinationCurveAt` | Segmented sample chains by declination or by date | Hour-angle step in degrees | +| Special dials | `EquatorialNorthDial` / `EquatorialSouthDial` / `HorizontalDial` / `VerticalDial` | Equatorial (north/south face), horizontal and vertical dials | Latitude and normal azimuth in degrees | + +The snippets below omit shared preamble variables: `date` (instant, civil time scale), `lon` (longitude, east positive, degrees), `lat` (latitude, degrees). + +### True, mean solar time and the equation of time + +```go +fmt.Println(sundial.TrueSolarTime(date, lon)) +fmt.Println(sundial.MeanSolarTime(date, lon)) +fmt.Println(sundial.TrueSolarTime(date, lon).Sub(sundial.MeanSolarTime(date, lon))) // equation of time +``` + +All three share the `sun` conventions: `TrueSolarTime` is apparent solar time, `MeanSolarTime` is local mean solar time, and their difference is the equation of time (apparent minus mean). + +### Hour angle + +```go +fmt.Println(sundial.HourAngle(date, lon)) // apparent solar hour angle, negative in the morning +fmt.Println(sundial.MeanSolarHourAngle(date, 9.5)) // hour angle for local mean solar time 9:30 +fmt.Println(sundial.ZoneTimeHourAngle(date, lon, 9.5)) // hour angle for zone clock time 9:30 +``` + +`HourAngle` solves the hour angle for an absolute instant; the other two answer "given a clock reading, where does the shadow point", one in local mean solar time and one in zone time. + +### Horizontal hour-line angle + +```go +fmt.Println(sundial.HorizontalHourLineAngle(31.2304, -45)) +fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, 31.2304)) +``` + +The first takes latitude and a signed hour angle, the second takes the instant and coordinates directly; both return the angle of the hour line relative to the noon line. + +### Planar dial core + +```go +dial := sundial.PlanarDial{ + Latitude: 31.2304, PlaneNormalAzimuth: 180, PlaneNormalZenithDistance: 90, StylusLength: 10, +} +g := dial.Geometry() +fmt.Println(g.HasFiniteCenter, g.PolarStylusLength, g.PolarStylusPlaneAngle) +p := dial.ShadowPointByHourAngleDeclination(-45, 23.44) +fmt.Println(p.X, p.Y, p.Illuminated) +``` + +The four `PlanarDial` fields are latitude, plate-normal azimuth, normal zenith distance and stylus length. `Geometry` returns the dial centre (where the polar stylus is fixed), the polar-stylus length and its angle to the plate; `ShadowPointByHourAngleDeclination` takes a signed hour angle and the solar declination, while `ShadowPointAt` takes the instant and longitude instead. + +### Plate illumination intervals + +```go +for _, iv := range dial.PlaneIlluminatedHourAngleIntervals(23.44) { + fmt.Println(iv.Start, iv.End) +} +for _, iv := range dial.IlluminatedHourAngleIntervals(23.44) { + fmt.Println(iv.Start, iv.End) +} +``` + +The first only answers "does the plate face the Sun" (geometric illumination); the second also requires the Sun to be above the horizon and therefore gives the **final usable** hour-angle intervals. + +Intervals live in `[-180, 180]` with `Start <= End`. + +### Time lines and declination curves + +```go +dates := []time.Time{date, date.Add(30 * time.Minute), date.Add(time.Hour)} +fmt.Println(len(dial.MeanSolarTimeLine(dates, 9.5))) +segs := dial.DeclinationCurve(23.44, 1.0) +segsAt := dial.DeclinationCurveAt(date, 1.0) +fmt.Println(len(segs), len(segsAt)) +``` + +A time line projects the equal-instant points of a mean-solar-time line straight onto the dial as `TimeLineSample` values; a declination curve samples the plate for a fixed declination or for the declination of the day, and each segment's `Interval` is the usable hour-angle interval described above. + +### Equatorial, horizontal and vertical dials + +```go +h := sundial.HorizontalDial(31.2304, 10) +n := sundial.EquatorialNorthDial(31.2304, 10) +s := sundial.EquatorialSouthDial(31.2304, 10) +v := sundial.VerticalDial(31.2304, 180, 10) +fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth) +fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance) +``` + +Each constructor sets the plate normal differently; when drawing, read it from the `PlanarDial` fields: + +| Constructor | `PlaneNormalAzimuth` | `PlaneNormalZenithDistance` | +| --- | --- | --- | +| `HorizontalDial` | `180°` | `0°` (normal points at the zenith; x east, y north) | +| `EquatorialNorthDial` | `0°` | `90° - latitude` | +| `EquatorialSouthDial` | `180°` | `90° + latitude` | +| `VerticalDial` | argument normalised to `[0°, 360°)` | `90°` | + +In the northern hemisphere the north-face equatorial dial serves the spring/summer half-year (positive solar declination) and the south face the autumn/winter half-year. + +For `VerticalDial` the normal azimuth runs from north toward east, so a south-facing wall is `180` and an east-facing wall is `90`. + +### Returned structures + +| Type | Field | Meaning | +| --- | --- | --- | +| `PlanarShadowPoint` | `X` / `Y` | Shadow-point coordinates in the stylus-length unit | +| | `DenominatorQ` | Projection denominator; approaching zero means the shadow runs to infinity | +| | `SunAboveHorizon` / `PlaneIlluminated` / `Illuminated` | Sun above the horizon, plate lit, and the final combined test | +| `PlanarGeometry` | `CenterX` / `CenterY` | Dial centre (where the polar stylus is fixed) | +| | `PolarStylusLength` / `PolarStylusPlaneAngle` | Polar-stylus length and its angle to the plate | +| | `HasFiniteCenter` | False when the centre degenerates to infinity; the related quantities are `NaN` | +| `HourAngleInterval` | `Start` / `End` | Signed hour-angle interval in degrees, `Start <= End` | +| `TimeLineSample` | `Date` / `Declination` / `HourAngle` / `Point` | Instant, solar declination, apparent hour angle and the shadow point | +| `DeclinationCurveSegment` | `Declination` / `Interval` / `Samples` | Segment declination, usable hour-angle interval and sample chain | + +### Complete example + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/sundial" +) + +func main() { + date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) + lon, lat := 121.4737, 31.2304 + + trueSolar := sundial.TrueSolarTime(date, lon) + hourAngle := sundial.HourAngle(date, lon) + lineAngle := sundial.HorizontalHourLineAngle(lat, -45) + lineAngleNow := sundial.HorizontalHourLineAngleAt(date, lon, lat) + + fmt.Println(trueSolar) + fmt.Printf("hour angle=%.6f line@9am=%.6f line@now=%.6f\n", hourAngle, lineAngle, lineAngleNow) +} +``` + +Output: + +```text +2026-06-21 09:34:10.438158222 +0805 LTZ +hour angle=-36.456508 line@9am=-27.405871 line@now=-20.959182 +``` + +The zone in the first line is a synthetic local apparent solar time zone (`+08:05`, `LTZ`, for longitude `121.4737`), so the printed value already shows how far local apparent solar time is from clock time. + +### Combined example: usable hour angles and a time line + +```go +dial := sundial.HorizontalDial(31.2304, 10) +for _, seg := range dial.DeclinationCurveAt(date, 1.0) { + fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples)) +} +mean := sundial.MeanSolarTime(date, 121.4737) +samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5) +fmt.Println(len(samples), samples[0].HourAngle) +``` + +Measured with `date = 2026-06-21 09:30 CST`, `121.4737 E, 31.2304 N` and a stylus of length 10: + +```text +decl=23.44 usable=-105.24..105.24 samples=211 +2 -37.92998436772365 +``` + +The first line says that with the solar declination at `23.44` degrees, a horizontal dial at latitude `31.2304` is usable over hour angles `[-105.24, +105.24]` (about 14 hours) sampled at 211 points; the second gives the two samples of the "local mean solar time 9:30" time line and the first hour angle. Together the two quantities are enough to draw a horizontal dial with its usable range marked. + +### Common pitfalls + +- Treating the `date` of `ZoneTimePoint` as the site's local apparent solar time - it uses the date and zone only, and takes the clock reading from `zoneTimeHours`. +- Using `PlaneIlluminated` to decide whether a dial is usable during the day - use `Illuminated`, which also requires the Sun above the horizon. +- Passing the wall orientation to `VerticalDial` - `planeNormalAzimuth` is the **normal** azimuth, so a south-facing wall is `180`. +- Flipping the hour-angle sign - `HourAngle` is negative in the morning and positive in the afternoon, and `HorizontalHourLineAngle` follows the same convention. +- Assuming one interval per day - a day crossing midnight splits into several, so iterate over the returned slice. + +## Usage examples + +### Apparent solar time versus clock time + +```go +trueSolar := sundial.TrueSolarTime(date, lon) +mean := sundial.MeanSolarTime(date, lon) +fmt.Println(trueSolar, mean) +fmt.Println(trueSolar.Sub(mean)) // equation of time +``` + +```text +2026-06-21 09:34:10.438158222 +0805 LTZ 2026-06-21 09:35:53.532790863 +0805 LTZ +-1m43.094632641s +``` + +- `TrueSolarTime` returns an instant in a synthetic zone, so subtracting your own clock time gives "how far apparent solar time runs ahead or behind". +- To turn a **clock reading** into an hour angle (for example to lay out shadow marks in zone time), use `MeanSolarHourAngle` / `ZoneTimeHourAngle` instead of adding the equation of time by hand. + +### Horizontal hour-line angles and the shadow point + +```go +dial := sundial.HorizontalDial(lat, 10) +fmt.Println(sundial.HorizontalHourLineAngle(lat, -45)) // hour-line angle at hour angle -45 +fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, lat)) // hour-line angle right now +p := dial.ShadowPointAt(date, lon) +fmt.Println(p.X, p.Y, p.Illuminated) +``` + +```text +-27.40587112370779 +-20.95918157094186 +-6.511723246549 0.507600045956 true +``` + +- A horizontal dial uses `x` pointing east and `y` pointing north; `Illuminated` is the final test "Sun above the horizon and plate facing the Sun", so draw a shadow only when it is true. +- With an hour angle but no instant, use `ShadowPointByHourAngleDeclination`, which gives `-6.511402572556 0.506762879485 true` for the same geometry. + +### Plate illumination intervals and time lines + +```go +dial := sundial.HorizontalDial(31.2304, 10) +for _, seg := range dial.DeclinationCurveAt(date, 1.0) { + fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples)) +} +mean := sundial.MeanSolarTime(date, 121.4737) +samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5) +fmt.Println(len(samples), samples[0].HourAngle) +``` + +```text +decl=23.44 usable=-105.24..105.24 samples=211 +2 -37.92998436772365 +``` + +- `PlaneIlluminatedHourAngleIntervals` only asks whether the plate faces the Sun, while `IlluminatedHourAngleIntervals` also requires the Sun above the horizon; use the latter to answer "how long is this dial usable in a day". +- Intervals live in `[-180, 180]` with `Start <= End`, and a day crossing midnight splits into several; the full declination-curve and time-line APIs are under [Time lines and declination curves](#time-lines-and-declination-curves). + +### Degenerate geometry and per-face dial conventions + +```go +h := sundial.HorizontalDial(31.2304, 10) +n := sundial.EquatorialNorthDial(31.2304, 10) +s := sundial.EquatorialSouthDial(31.2304, 10) +v := sundial.VerticalDial(31.2304, 180, 10) +fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth) +fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance) +``` + +```text +0 0 180 +180 90 +``` + +- **Degenerate case**: when the plate normal is perpendicular to the polar axis (equivalently the polar stylus is parallel to the plate), `Geometry().HasFiniteCenter` is `false`, `CenterX`/`CenterY`/`PolarStylusLength` are `NaN` and `PolarStylusPlaneAngle` is 0 - latitude `45`, normal azimuth `180` and normal zenith distance `45` is exactly such a point, and nothing drawn relative to the centre is usable. +- **Per-face conventions**: a horizontal plate's normal points at the zenith (zenith distance `0`); a vertical plate has zenith distance `90` and its `planeNormalAzimuth` is the **normal** direction (south-facing wall `180`, east-facing wall `90`); the equatorial north and south faces use `90 - latitude` and `90 + latitude`. + + The full table of the four constructors is under [Equatorial, horizontal and vertical dials](#equatorial-horizontal-and-vertical-dials). + +## Parameter and result conventions + +- **Units**: hour angles, hour-line angles, declination, latitude, normal azimuth and normal zenith distance are all **degrees**. + + `PlanarDial.StylusLength` and the returned `X`/`Y` share one length unit, which may be anything self-consistent (millimetres, metres or canvas coordinates). +- **Time scale**: observing inputs are civil instants. `TrueSolarTime` / `MeanSolarTime` return local solar clock readings; do not pass those readings back as new observing instants. See [Time scales](timescale.md). +- **Hour-angle sign**: `HourAngle` is negative in the morning and positive in the afternoon; `HourAngleInterval` uses `[-180, 180]` and guarantees `Start <= End`, so a day crossing midnight splits into several intervals. +- **The time zone of `date` (easy to get wrong)**: for `MeanSolarTimePoint` / `MeanSolarTimeLine` the `date` is the **local mean solar time of the target site** (usually the value returned by `MeanSolarTime(...)`). + + `ZoneTimePoint` / `ZoneTimeLine` ignore the hour, minute and second of `date`, keep only its date and zone, and substitute the `zoneTimeHours` argument for the clock reading. Passing the wrong convention shifts the whole time line. +- **Three booleans gate a valid shadow**: `SunAboveHorizon` says the Sun is up, `PlaneIlluminated` says the plate faces the Sun, and `Illuminated` is the final test requiring both; draw a shadow only when `Illuminated` is true. +- **Degenerate case**: when the plate normal is perpendicular to the polar axis (equivalently, when the polar stylus is parallel to the plate), `PlanarGeometry.HasFiniteCenter` is `false`, `CenterX`/`CenterY`/`PolarStylusLength` are `NaN` and `PolarStylusPlaneAngle` is `0` - the centre is at infinity and nothing drawn relative to it is usable. + + Latitude `45`, normal azimuth `180` and normal zenith distance `45` is exactly such a point. +- **`PlaneIlluminated` versus `Illuminated`**: the former is geometric only, the latter also requires the Sun above the horizon; use the latter when answering "how long is this dial usable in a day". + +## Related manuals + +- Apparent solar time, equation of time and solar position: [Sun and Moon](sun-moon.md) +- Hour angle, sidereal time and horizontal transforms: [Coordinate tools](coord.md) +- Time scale in figures: [Time Scale Declaration](map-geojson.md#time-scale-declaration) diff --git a/doc/manual/en/timescale.md b/doc/manual/en/timescale.md new file mode 100644 index 0000000..a39fc1f --- /dev/null +++ b/doc/manual/en/timescale.md @@ -0,0 +1,219 @@ +# Time scales + +[中文](../timescale.md) | [README](../../../README.en.md) + +UTC, UT1 and TT express the same physical instant using different scales. UTC is civil time, UT1 follows Earth's rotation, and TT is used for ephemerides. The relevant differences are `ΔT = TT − UT1` and `DUT1 = UT1 − UTC`, both in seconds. + +## Contents + +- [Time arguments](#time-arguments) +- [time.Time conversion API](#timetime-conversion-api) +- [Julian-day conversion API](#julian-day-conversion-api) +- [TCG, TCB and TDB](#tcg-tcb-and-tdb) +- [ΔT models](#δt-models) + - [Comparing models](#comparing-models) + - [Supplying an external model](#supplying-an-external-model) +- [UTC assumptions beyond the observed interval](#utc-assumptions-beyond-the-observed-interval) +- [SVG, GeoJSON and KML labels](#svg-geojson-and-kml-labels) + +## Time arguments + +Most observing APIs accept a civil instant as `time.Time`, obtain its UTC value, and convert to UT1 or TT as needed. Changing `Location` changes the displayed clock reading. + +Date-based searches such as rise/set also use it to identify the local civil date. + +Before 1972-01-01, this library treats civil time as UT1. This is a library convention, not a claim that UTC did not exist. From 1972 onward, the leap-second table, selected policy or explicit override supplies TT−UTC, while the ΔT model supplies TT−UT1. + +Some inputs have separate conventions: + +| Input | Interpretation | +| --- | --- | +| Argument to `astro.TTFromUTC` / `UT1FromUTC` | Civil instant in any time zone | +| Argument to `astro.UTCFromTT` / `UTCFromUT1` | TT / UT1 reading carried in a UTC Location | +| Argument to `astro.TCGFromTT` / `TCBFromTT` / `TDBFromTT` | TT reading, not a civil UTC instant | +| `orbit.Elements.EpochJD` / `TpJD` | TT/TDB Julian day; see [Orbits](orbit.md) | +| Numerical JD arguments in `basic` | Scale specified by the function contract; the number carries no scale metadata | +| `calendar.Date2JD` / `basic.Date2JD` | Reads calendar and clock fields without converting zones; pass `date.UTC()` for a UTC JD | +| Chinese calendar conversion | Date semantics described in [Calendars](calendar.md), using Beijing time by default | + +`time.Time` cannot represent the leap-second reading `23:59:60`. Historical dates also require care: the library's Julian/Gregorian transition differs from Go's proleptic Gregorian calendar. + +## time.Time conversion API + +These functions belong to the root package `b612.me/astro`. + +| Function | Result | +| --- | --- | +| `TTFromUTC(date)` | TT reading for the same instant | +| `UTCFromTT(tt)` | Civil instant corresponding to a TT reading | +| `UT1FromUTC(date)` | UT1 reading for the same instant | +| `UTCFromUT1(ut1)` | Civil instant corresponding to a UT1 reading | +| `DUT1(date)` | UT1−UTC in seconds | +| `LabelIn(scale, date)` | Original input for `TimeScaleUTC`; UT1 reading for `TimeScaleUT1` | +| `TCGFromTT(tt)` / `TTFromTCG(tcg)` | Convert between TT and Geocentric Coordinate Time | +| `TCBFromTT(tt)` / `TTFromTCB(tcb)` | Convert between TT and Barycentric Coordinate Time using a geocentric approximation | +| `TDBFromTT(tt)` / `TTFromTDB(tdb)` | Convert between TT and Barycentric Dynamical Time using a geocentric approximation | +| `TCBFromTDB(tdb)` / `TDBFromTCB(tcb)` | Linear conversions between TDB and TCB | +| `TCGMinusTT(tt)` / `TCBMinusTT(tt)` / `TDBMinusTT(tt)` | Offset from TT in seconds; input is a TT reading | + +Converted TT, UT1, TCG, TCB and TDB values use `time.Time` and a UTC Location, but their fields are readings on the named scale. Passing them to an API expecting civil time, such as the Sun and Moon functions, would shift the calculation instant again. + +Use the inverse conversion to recover civil time. + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro" +) + +func main() { + date := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + tt := astro.TTFromUTC(date) + ut1 := astro.UT1FromUTC(date) + fmt.Println("UTC:", date.Format(time.RFC3339Nano)) + fmt.Println("TT:", tt.Format("2006-01-02 15:04:05.000000")) + fmt.Println("UT1:", ut1.Format("2006-01-02 15:04:05.000000")) + fmt.Printf("DUT1: %.6f s\n", astro.DUT1(date)) + fmt.Println("UTC from TT:", astro.UTCFromTT(tt).Format(time.RFC3339Nano)) +} +``` + +Conversions pass through floating-point Julian days, so round trips can have small rounding differences. The TT/UT1 display formats omit `Z` to avoid presenting them as UTC timestamps. + +## Julian-day conversion API + +The following `basic` functions use `float64`. Conversion functions return Julian days; offset functions return seconds. + +| Function | Input → output | Notes | +| --- | --- | --- | +| `UTC2TT` | UTC JD → TT JD | Uses UT1 before 1972, then the TT−UTC model | +| `TT2UTC` | TT JD → UTC JD | Inverse conversion | +| `UT12TT` | UT1 JD → TT JD | Uses the active ΔT model | +| `TT2UT1` | TT JD → UT1 JD | Inverse conversion | +| `UTC2UT1` | UTC JD → UT1 JD | Equivalent to `TT2UT1(UTC2TT(jd))` | +| `UT12UTC` | UT1 JD → UTC JD | Inverse conversion | +| `TTMinusUTCSeconds` | UTC JD → seconds | `32.184 + (TAI−UTC)` in the built-in leap-second interval, unless overridden | +| `DUT1Seconds` | UTC JD → seconds | `(TT−UTC) − ΔT` | +| `DeltaT` | JD or decimal year → seconds | Second argument `true`: UT Julian day; `false`: decimal year | +| `TT2TCG` / `TCG2TT` | TT JD ↔ TCG JD | Linear conversion | +| `TT2TCB` / `TCB2TT` | TT JD ↔ TCB JD | Geocentric approximation | +| `TT2TDB` / `TDB2TT` | TT JD ↔ TDB JD | Geocentric approximation | +| `TCB2TDB` / `TDB2TCB` | TCB JD ↔ TDB JD | Linear conversion including TDB0 | +| `TCGMinusTTSeconds` / `TCBMinusTTSeconds` / `TDBMinusTTSeconds` | TT JD → seconds | Offset from TT | + +A leap second or simulated leap hour introduces a step. The inverse TT-to-civil conversion has an unrepresentable interval of that width, so pointwise round-trip identity cannot hold across it. + +`TTMinusUTCSeconds` and `DefaultTTMinusUTC()` expose the leap-table value (or the override for the former); they do not apply the future-policy extrapolation used by `UTC2TT`. For future civil-to-TT conversion, call `UTC2TT` or `TTFromUTC` directly. + +## TCG, TCB and TDB + +TCG is Geocentric Coordinate Time. TCB and TDB are Barycentric Coordinate Time and Barycentric Dynamical Time, referring to the Solar System barycenter rather than the center of the Sun. TT/TCG and TCB/TDB have defining linear relations. The TT/TDB relation implemented here is a geocentric approximation, without observer-dependent diurnal terms. + +With `T0 = 2443144.5003725`, `LG = 6.969290134e-10`, `LB = 1.550519768e-8` and `TDB0 = −65.5e-6` seconds, the relations for JD readings are: + +```text +TCG − TT = LG / (1 − LG) × (TT − T0) +TDB = TCB − LB × (TCB − T0) + TDB0 / 86400 +TCB − TT = [LB × (TT − T0) + (TDB − TT) − TDB0 / 86400] / (1 − LB) +``` + +The reference epoch T0 does not imply that all four scales have equal readings there. Near modern dates, the main annual TDB−TT term has an amplitude of about 1.7 ms. TCG−TT increases by about 0.022 seconds per year and TCB−TT by about 0.489 seconds per year. + +TDB−TT uses a 40-term truncated series with mass adjustments. Over years −3000 to +6000, the sum of omitted absolute amplitudes gives a conservative truncation-error bound of 11 µs relative to the full 787-term geocentric series. Sampling every 31 days gives a maximum difference of about 2.0 µs. A sampled maximum is not an all-time guarantee, and truncation error excludes the error of the full model itself. No accuracy is promised outside that interval. + +A single `float64` JD has a resolution of about 40 µs near modern dates; the `time.Time` wrappers pass through the same rounding. For microsecond-level offsets, use `*MinusTT` or `*MinusTTSeconds` rather than subtracting two full Julian days and converting to seconds. + +## ΔT models + +The default combines SMH2016 and Morrison 2021 splines and long-term extrapolation, using a monthly observed ΔT table wherever available. The table currently extends to September 1, 2026; the last point is a rapid observation, not yet a final solution. Constant endpoint adjustments keep extrapolation continuous. + +`DeltaT` returns `NaN` outside ±40000 years. + +| Constant | Model | +| --- | --- | +| `DeltaTModelDefault` / `DeltaTModelSMH2016` | Built-in default | +| `DeltaTModelMS2004` | Morrison & Stephenson 2004: `ΔT = −20 + 32u²`, `u = (year−1820)/100` | +| `DeltaTModelEspenakMeeus2006` | Espenak & Meeus 2006 piecewise polynomials | +| `DeltaTModelNASACanon2006` | Those polynomials plus the NASA canon pairing term `−0.000012932(year−1955)²` before 1955 | +| `DeltaTModelManual` | Status indicating an injected function; not an installable named model | + +`SetDeltaTModel(model, keepObserved)` changes the process-wide model and reports success. Unknown models leave the current state unchanged. With `keepObserved=true`, observations take precedence; `false` uses the selected model everywhere. + +`GetDeltaTModel()` returns both settings. + +### Comparing models + +`DeltaTModelSeconds` evaluates a model without changing process state: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro" + "b612.me/astro/basic" +) + +func main() { + date := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) + jd := basic.Date2JD(date) + for _, model := range []astro.DeltaTModel{ + astro.DeltaTModelSMH2016, + astro.DeltaTModelEspenakMeeus2006, + astro.DeltaTModelNASACanon2006, + } { + fmt.Println(model, astro.DeltaTModelSeconds(model, jd, false)) + } +} +``` + +Future ΔT is uncertain. The current Espenak–Meeus 2006 and default models differ by about 11, 21, 116 and 275 seconds in 2035, 2050, 2100 and 2200. This affects future eclipse times and ground paths. + +Match ΔT models before comparing eclipse catalogs. + +### Supplying an external model + +| Root-package function | Purpose | +| --- | --- | +| `DeltaT()` / `SetDeltaT(fn)` | Get/set a ΔT function of type `func(float64, bool) float64` | +| `DefaultDeltaT()` | Obtain the built-in default ΔT function | +| `TTMinusUTC()` / `SetTTMinusUTC(fn)` | Get/set a TT−UTC override of type `func(float64) float64`; argument is a civil JD | +| `DefaultTTMinusUTC()` | Built-in leap-second-table function, ignoring overrides and future policy | + +Both callbacks return seconds. `SetDeltaT(nil)` restores default ΔT; `SetTTMinusUTC(nil)` restores the built-in leap-second table and future policy. `TTMinusUTC()` returns `nil` when no override is installed. + +The corresponding `basic` functions are `GetDeltaTFn` / `SetDeltaTFn` and `GetTTMinusUTCFn` / `SetTTMinusUTCFn`. They share root-package state. A TT−UTC override takes precedence over future policy for queries from 1972 onward; dates before 1972 retain the UT1 convention. + +These settings affect subsequent calculations throughout the process. Configure them before calculating. Restore the original function or named model after temporary comparisons; a model change is not a per-call option. + +## UTC assumptions beyond the observed interval + +`SetTimeScaleFuturePolicy` and `GetTimeScaleFuturePolicy` select the civil-time conversion policy, defaulting to `TimeScaleLeapSecond`. All policies apply only after the observed interval except `TimeScaleUT1Civil`, which replaces the scale throughout the timeline. An explicit TT−UTC override takes precedence from 1972 onward. This setting is separate from the output-scale options `TimeScaleUTC` and `TimeScaleUT1`. + +| Policy | Assumption | +| --- | --- | +| `TimeScaleLeapSecond` (zero value, default) | Applies the fewest integer-second corrections to the final TT−UTC offset to bring extrapolated DUT1 within ±0.9 seconds | +| `TimeScaleAssumeUT1Tracking` | Holds the final observed DUT1 constant, allowing TT−UTC to follow extrapolated ΔT smoothly | +| `TimeScaleFreezeUTCOffset` | Holds TT−UTC at its final built-in value, currently 69.184 seconds | +| `TimeScaleLeapHour` | Applies the fewest whole-hour corrections to bring DUT1 within ±3600 seconds; intended for scenario calculations | +| `TimeScaleUT1Civil` | Treats civil time as UT1 throughout the timeline, including the historical leap-table interval; DUT1 is zero unless explicitly overridden | + +These are calculation assumptions, not predictions of future leap seconds or international decisions. Integer-second or hour corrections depend on ΔT at the queried instant; they do not track prior corrections or restrict steps to announcement calendar boundaries. Pointwise round trips need not be identical near a step. A stepping policy returns `NaN` when its ΔT is invalid or outside the model range, rather than falling back to a normal offset. + +The default does not guarantee an error below 0.9 seconds against real future UTC. Precise civil timing requires published data for the relevant date. + +## SVG, GeoJSON and KML labels + +SVG and GeoJSON default to civil labels. For UT1, use a `...InUT1` result-conversion function or set `TimeScale: astro.TimeScaleUT1` in export options. UT1 output requires `Location` to be `nil` or `time.UTC`. + +Changing a label scale leaves existing geometry at the same physical instant. Time markers aligned to whole ticks of the selected scale may sample different instants, so their positions can move. GeoJSON records the scale in `time_scale`. + +KML requires UTC `` values, so the converter changes UT1 labels back to UTC using the active model and preserves the original `time_scale` property. + +Use the same time-scale model when generating GeoJSON and converting it to KML. Export options, marker steps and KML playback are described in [Maps and data export](map-geojson.md). diff --git a/doc/manual/formula.md b/doc/manual/formula.md new file mode 100644 index 0000000..84050cd --- /dev/null +++ b/doc/manual/formula.md @@ -0,0 +1,331 @@ +# 研究公式 + +[English](en/formula.md) | [返回 README](../../README.md) + +`formula` 包放的是和具体日期、星历表无关的常用公式,适合科普估算、小说设定和教学演示。 + +它不做任何时标换算,也不读星历表:输入只有温度、波长、距离、口径、高度角这类瞬时或常量参数。高度角与天顶距的口径沿用[观测角语义](sun-moon.md#观测角语义);需要坐标层的折射修正时改用 `coord` 的[大气质量](coord.md#大气质量);整体精度与适用范围见[适用范围与精度](accuracy.md)。 + +## 目录 + +- [星等、会合周期与黑体辐射](#星等会合周期与黑体辐射) +- [API 参考](#api-参考) + - [黑体与辐射](#黑体与辐射) + - [会合周期](#会合周期) + - [星等与距离](#星等与距离) + - [距离单位换算](#距离单位换算) + - [望远镜指标](#望远镜指标) + - [恒星参数换算](#恒星参数换算) + - [大气质量模型](#大气质量模型) +- [常用场景](#常用场景) + - [黑体峰值、总辐射与恒星参数](#黑体峰值总辐射与恒星参数) + - [会合周期与星等距离](#会合周期与星等距离) + - [望远镜极限星等与分辨率](#望远镜极限星等与分辨率) + - [大气质量模型对比](#大气质量模型对比) +- [参数与返回值约定](#参数与返回值约定) + - [单位约定](#单位约定) + - [时标](#时标) + - [角度象限与弧度/度的边界](#角度象限与弧度度的边界) + - [零值与无效输入](#零值与无效输入) + - [精度与适用范围](#精度与适用范围) + +## 星等、会合周期与黑体辐射 + +```go +package main + +import ( + "fmt" + + "b612.me/astro/formula" +) + +func main() { + // 70mm 小折射镜,观测地裸眼极限取 6 等。 + fmt.Printf("limiting=%.6f\n", formula.LimitingMagnitudeEmpirical(70, 6)) + + // 地球和金星的会合周期,输入周期单位都是天,输出也是天。 + fmt.Printf("synodic=%.6f\n", formula.SynodicPeriod(365.25636, 224.70069)) + + // 太阳这样的绝对星等天体放到 100pc 处的视星等。 + fmt.Printf("apparent=%.6f\n", formula.ApparentMagnitudeFromAbsolute(4.83, 100)) + + // 把太阳近似为 5772K 黑体,计算峰值波长和单位面积总辐射出射度。 + fmt.Printf("peak=%.9em flux=%.6e\n", + formula.WienPeakWavelength(5772), + formula.StefanBoltzmannFlux(5772), + ) +} +``` + +输出结果: + +```text +limiting=11.000000 +synodic=583.920635 +apparent=9.830000 +peak=5.020394932e-07m flux=6.293859e+07 +``` + +## API 参考 + +下面按计算内容列出接口、单位和返回值。 + +分组片段省略公共前置:`fmt` 已在文件头导入,`formula` 指 `b612.me/astro/formula`。 + +### 黑体与辐射 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `PlanckRadianceByWavelength` | 按波长的普朗克谱辐亮度 | 波长米、温度 K;返回 W·sr⁻¹·m⁻³;非正温度或非正波长返回 NaN | +| `WienPeakWavelength` | 维恩位移峰值波长 | 温度 K;返回米;温度 `≤ 0` 或非有限返回 NaN | +| `StefanBoltzmannFlux` | 单位面积总辐射出射度 | 温度 K;返回 W/m²;温度 `0 K` 合法且返回 `0` | +| `SolarEffectiveTemperature` | 内置太阳有效温度常数 | 无参数;返回 K,当前为 `5772` | + +```go +// 把太阳近似为 5772 K 黑体:峰值波长、总出射度,以及 500 nm 处的谱辐亮度。 +tSun := formula.SolarEffectiveTemperature() +fmt.Printf("peak=%.9e m flux=%.6e W/m^2\n", + formula.WienPeakWavelength(tSun), formula.StefanBoltzmannFlux(tSun)) +fmt.Printf("radiance@500nm=%.6e W·sr^-1·m^-3\n", + formula.PlanckRadianceByWavelength(500e-9, tSun)) +``` + +### 会合周期 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `SynodicPeriod` | 两个周期天体的会合周期 | 两个输入单位必须一致,输出同单位;周期 `≤ 0` 或非有限返回 NaN,两周期相等返回 `+Inf` | + +```go +// 地球与其他行星的会合周期,输入输出都是天。 +earth := 365.25636 +fmt.Printf("venus=%.6f mars=%.6f jupiter=%.6f\n", + formula.SynodicPeriod(earth, 224.70069), + formula.SynodicPeriod(earth, 686.980), + formula.SynodicPeriod(earth, 4332.589)) +``` + +### 星等与距离 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `DistanceModulus` | 距离模数 | 距离 pc;返回 `m − M`;`10 pc` 处为 `0`,距离 `≤ 0` 返回 NaN | +| `ApparentMagnitudeFromAbsolute` | 绝对星等 + 距离 → 视星等 | 星等 mag、距离 pc;等于 `M + 距离模数` | +| `AbsoluteMagnitudeFromApparent` | 视星等 + 距离 → 绝对星等 | 星等 mag、距离 pc;等于 `m − 距离模数` | + +```go +// 太阳绝对星等 4.83,放到 10 pc / 100 pc / 1 kpc 处的视星等与反解。 +for _, d := range []float64{10, 100, 1000} { + m := formula.ApparentMagnitudeFromAbsolute(4.83, d) + fmt.Printf("d=%.0f pc m=%.6f M=%.6f mod=%.6f\n", + d, m, formula.AbsoluteMagnitudeFromApparent(m, d), formula.DistanceModulus(d)) +} +``` + +### 距离单位换算 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Distance` | 把秒差距、光年或天文单位换算为秒差距 | 输入正数 + `DistanceUnit`;非正数、NaN、未知单位返回 NaN | + +| 常量 | 含义 | +| --- | --- | +| `DistanceParsec` | 秒差距 pc,恒等换算 | +| `DistanceLightYear` | 光年 ly | +| `DistanceAU` | 天文单位 AU | + +```go +// 天狼星视差 0.375 角秒,折合 2.667 pc,也就是 8.70 光年。 +pc := formula.Distance(1/0.375, formula.DistanceParsec) +fmt.Printf("%.3f pc = %.2f ly\n", pc, formula.Distance(pc, formula.DistanceParsec)/formula.Distance(1, formula.DistanceLightYear)) +``` + +口径:天文单位取 `149597870.7` km,光年取 IAU 定义值 `9460730472580.8` km,秒差距由精确关系 `648000/π` 天文单位导出,因此 `1 pc = 3.261563777 ly`。 + +### 望远镜指标 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `DawesLimitArcsec` | Dawes 极限分辨角 | 口径 mm;返回角秒,经验式 `116 / D` | +| `RayleighLimitArcsec` | Rayleigh 极限分辨角 | 口径 mm;返回角秒,经验式 `138.4 / D` | +| `LightGatheringPowerRatio` | 集光力比值 | 两个口径 mm;返回 `(D1 / D2)²`,无量纲 | +| `LimitingMagnitudeEmpirical` | 经验极限星等 | 口径 mm、裸眼极限 mag;按 `裸眼极限 + 5·log10(D / 7)` 估算,7 mm 为内置暗适应瞳径 | + +```go +// 70 mm 小折射镜:分辨极限、相对 7 mm 暗瞳的集光力、裸眼极限 6 等的经验极限星等。 +fmt.Printf("dawes=%.6f rayleigh=%.6f\n", + formula.DawesLimitArcsec(70), formula.RayleighLimitArcsec(70)) +fmt.Printf("power=%.6f limiting=%.6f\n", + formula.LightGatheringPowerRatio(70, 7), + formula.LimitingMagnitudeEmpirical(70, 6)) +``` + +### 恒星参数换算 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `LuminosityFromRadiusTemperature` | 半径 + 温度 → 光度 | 半径米、温度 K;返回 W;按 `4πR²σT⁴` | +| `LuminositySolarFromRadiusTemperature` | 同上,太阳单位 | 半径 R☉、温度 K;返回 L☉ | +| `RadiusFromLuminosityTemperature` | 光度 + 温度 → 半径 | 光度 W、温度 K;返回米 | +| `RadiusSolarFromLuminosityTemperature` | 同上,太阳单位 | 光度 L☉、温度 K;返回 R☉ | +| `EffectiveTemperatureFromLuminosityRadius` | 光度 + 半径 → 有效温度 | 光度 W、半径米;返回 K | +| `EffectiveTemperatureFromLuminositySolarRadius` | 同上,太阳单位 | 光度 L☉、半径 R☉;返回 K | +| `SolarEffectiveTemperature` | 内置太阳有效温度 | 无参数;返回 K | + +```go +// 半径 2.5 R☉、光度 20 L☉ 的主序星:反解温度,再正算回光度与半径。 +t := formula.EffectiveTemperatureFromLuminositySolarRadius(20, 2.5) +fmt.Printf("Teff=%.6f K\n", t) +fmt.Printf("L=%.6f Lsun R=%.6f Rsun\n", + formula.LuminositySolarFromRadiusTemperature(2.5, t), + formula.RadiusSolarFromLuminosityTemperature(20, t)) +// 同一组量的 MKS 版本;太阳半径取内置常数的 6.957e8 m。 +rM := 2.5 * 6.957e8 +lW := formula.LuminosityFromRadiusTemperature(rM, t) +fmt.Printf("L=%.6e W R=%.6e m Teff=%.6f K\n", + lW, formula.RadiusFromLuminosityTemperature(lW, t), + formula.EffectiveTemperatureFromLuminosityRadius(lW, rM)) +``` + +### 大气质量模型 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `AirmassPlaneParallel` | 平行平板模型 | 输入真高度角、度;等价 `sec(z)`,`0°` 处返回 `+Inf` | +| `AirmassPlaneParallelByZenithDistance` | 平行平板模型(天顶距) | 输入天顶距、度;`90°` 处返回 `+Inf` | +| `AirmassKastenYoung` | Kasten-Young 1989 | 输入视高度角、度;低空比 `sec(z)` 稳健 | +| `AirmassPickering` | Pickering 2002 | 输入视高度角、度;面向低空观测修正 | + +四者都把输入限制在 `[0,90]`,越界或非有限返回 NaN;高度角以地平 `0°`、天顶 `+90°` 计,天顶距与高度角互补,口径见[观测角语义](sun-moon.md#观测角语义)。 + +```go +fmt.Println(formula.AirmassPlaneParallel(30)) +fmt.Println(formula.AirmassKastenYoung(5)) +fmt.Println(formula.AirmassPickering(5)) +fmt.Println(formula.AirmassPlaneParallelByZenithDistance(60)) +``` + +如果不需要坐标层的折射修正,`formula` 也直接提供三种大气质量模型,输入语义更直接: + +- `AirmassPlaneParallel`:输入真高度角,等价于 `sec(z)` 几何近似 +- `AirmassPlaneParallelByZenithDistance`:直接输入天顶距 +- `AirmassKastenYoung` / `AirmassPickering`:输入视高度角,不会自动做折射修正 + +## 常用场景 + +### 黑体峰值、总辐射与恒星参数 + +```go +fmt.Println(formula.WienPeakWavelength(5772)) // 峰值波长(米) +fmt.Println(formula.StefanBoltzmannFlux(5772)) // 单位面积总辐射(W/m²) +fmt.Println(formula.RadiusSolarFromLuminosityTemperature(1, 5772)) // 由光度与温度解半径 +fmt.Println(formula.EffectiveTemperatureFromLuminositySolarRadius(1, 1)) // 由光度与半径解温度 +``` + +```text +5.020394932432432e-07 +6.293859246828887e+07 +1.0000011882005775 +5772.003429145848 +``` + +三组互算函数互为逆运算,同口径下回到输入值(示例里 `1 → 1.0000012`、`5772 → 5772.0034` 的残差来自双方都取 5772 K 的近似);带 `...Solar` 的变体用太阳单位,不带的使用 SI。 + +### 会合周期与星等距离 + +```go +fmt.Println(formula.SynodicPeriod(365.25636, 224.70069)) // 地球与金星的会合周期(天) +fmt.Println(formula.DistanceModulus(10)) // 10 pc 处的距离模数 +fmt.Println(formula.ApparentMagnitudeFromAbsolute(4.83, 100)) // 绝对星等 4.83 放到 100 pc +fmt.Println(formula.AbsoluteMagnitudeFromApparent(4.83, 100)) // 逆换算 +``` + +```text +583.9206352820089 +0 +9.83 +-0.16999999999999993 +``` + +`DistanceModulus(10)` 为 0,因为 10 pc 就是绝对星等的定义距离;会合周期的输入输出都是天,参数顺序不影响结果。 + +### 望远镜极限星等与分辨率 + +```go +fmt.Println(formula.DawesLimitArcsec(70), formula.RayleighLimitArcsec(70)) // 70 mm 口径的两种分辨极限 +fmt.Println(formula.LightGatheringPowerRatio(200, 70)) // 200 mm 相对 70 mm 的集光力 +fmt.Println(formula.LimitingMagnitudeEmpirical(70, 6)) // 裸眼 6 等时 70 mm 的极限星等 +``` + +```text +1.6571428571428573 1.9771428571428573 +8.16326530612245 +11 +``` + +Dawes 与 Rayleigh 相差一个系数(70 mm 下 1.66″ 与 1.98″),报告里要写明用的是哪一条;`LimitingMagnitudeEmpirical` 的第二个参数是观测地裸眼极限星等,换观测地要一起改。 + +### 大气质量模型对比 + +```go +for _, alt := range []float64{5, 30, 60, 90} { + fmt.Printf("alt=%.0f KY=%.6f Pickering=%.6f plane=%.6f\n", alt, + formula.AirmassKastenYoung(alt), formula.AirmassPickering(alt), formula.AirmassPlaneParallel(alt)) +} +``` + +```text +alt=5 KY=10.305791 Pickering=10.333706 plane=11.473713 +alt=30 KY=1.994293 Pickering=1.993154 plane=2.000000 +alt=60 KY=1.153992 Pickering=1.154058 plane=1.154701 +alt=90 KY=0.999712 Pickering=1.000000 plane=1.000000 +``` + +三种模型在中高空几乎重合,低空(5°)差异最大(Kasten-Young 与平面平行差约 1.2 个大气质量);平面平行模型在 0° 发散,低空精细估算用 Kasten-Young 或 Pickering。带气压与气温修正的版本在 [坐标工具](coord.md#大气质量),本包只给纯公式。 + +## 参数与返回值约定 + +### 单位约定 + +- 黑体族:波长米、温度开尔文;`PlanckRadianceByWavelength` 返回谱辐亮度 `W·sr⁻¹·m⁻³`,`StefanBoltzmannFlux` 返回 `W/m²`,`WienPeakWavelength` 返回米。 +- 恒星族:MKS 变体用半径米、光度瓦特、温度开尔文;Solar 变体用太阳半径 R☉、太阳光度 L☉、温度开尔文,输入输出都是无量纲的太阳倍数。 +- 星等族:距离秒差距 pc、星等 mag;`DistanceModulus` 返回 `m − M`,单位也是 mag。 +- 距离单位:`Distance` 只做单位换算,输入单位由 `DistanceUnit` 指定,返回值一律是秒差距 pc。 +- 望远镜族:口径毫米 mm;`DawesLimitArcsec`、`RayleighLimitArcsec` 返回**角秒**,不是度;`LightGatheringPowerRatio` 与 `LimitingMagnitudeEmpirical` 分别为无量纲比值与 mag。 +- 大气质量族:高度角(或天顶距)为度;返回值是以天顶为 1 的无量纲相对大气质量。 +- 会合周期:单位由调用者自定,两个输入必须一致,输出与之一致;本包不假定“天”。 +- 本包不产生视直径/视半径。日食、月掩手册里的视半径字段以角秒计,本包只有 Dawes/Rayleigh 两个极限角用角秒,其余角度一律为度,不要混用。 + +### 时标 + +- 全部接口都不接收时刻,也不做任何时标换算:公式只依赖温度、波长、距离、口径、高度角等参数,与 UTC、UT1、TT 无关。 +- 会合周期给的是周期长度,不是“下一次会合的时刻”。要落到日期,需要配合行星包的会合接口与民用时刻,时标口径见[时标约定](timescale.md)。 +- 本包没有 ΔT、闰秒或 UT1 修正入口;这些只在 `coord`、`eclipse`、`moon` 等依赖时刻的链路里出现。 + +### 角度象限与弧度/度的边界 + +- 角度参数一律为度,内部转弧度计算,返回值也回到度或角秒,不会把弧度泄漏给调用者。 +- 高度角与天顶距都限制在 `[0,90]`:地平为 `0°`、天顶为 `+90°`,天顶距与高度角互补。本包不做象限折叠,负高度(地平线以下)直接被判为无效,不会自动取绝对值或折到天顶。 +- 天顶距 `z` 与高度角 `h` 的关系是 `z = 90° − h`;`AirmassPlaneParallel` 收 `h`,`AirmassPlaneParallelByZenithDistance` 收 `z`,两者对同一几何应给出一致结果。 +- Dawes/Rayleigh 的返回值是角秒;若要与其他手册的“度”角度换算,需除以 3600。 + +### 零值与无效输入 + +- 黑体族对无效输入的处理刻意不一致:`WienPeakWavelength` 与 `PlanckRadianceByWavelength` 在温度 `≤ 0` 或非有限时返回 NaN;`StefanBoltzmannFlux` 只在温度 `< 0` 或非有限时返回 NaN,`0 K` 是合法输入并返回 `0`(0 K 的通量为 0,而峰值波长无定义)。 +- 会合周期:任一周期 `≤ 0` 或非有限返回 NaN;两个周期相等时频率差为 0,返回 `+Inf`。 +- 星等族:`distanceParsec ≤ 0` 或非有限时 `DistanceModulus` 返回 NaN,两个换算函数随之返回 NaN;`DistanceModulus(10)` 恒为 `0`。 +- 望远镜族:口径(或第二个口径)`≤ 0` 或非有限返回 NaN;`LightGatheringPowerRatio` 的第二个口径为 `0` 会先被判为无效而不是除零;`LimitingMagnitudeEmpirical` 不接收瞳径参数,7 mm 是内置常数。 +- 恒星族:所有输入必须 `> 0`,否则返回 NaN;`EffectiveTemperatureFromLuminositySolarRadius`、`LuminositySolarFromRadiusTemperature`、`RadiusSolarFromLuminosityTemperature` 先把太阳单位换成 SI 再计算。 +- 大气质量族:高度角或天顶距落在 `[0,90]` 之外(含负值)或非有限都返回 NaN;`AirmassPlaneParallel(0)` 与 `AirmassPlaneParallelByZenithDistance(90)` 返回 `+Inf`;`AirmassKastenYoung(0)`、`AirmassPickering(0)` 仍是有限值。 +- 本包没有 `(值, error)` 或 `(值, ok)` 形式的返回:无效输入统一用 NaN 表达,调用者用 `math.IsNaN` 判断。 + +### 精度与适用范围 + +- 黑体族是理想黑体模型,不含吸收线、星际消光与大气消光。`WienPeakWavelength` 使用维恩位移常数 `b = 2.897771955e-3 m·K`;按频率与按波长的峰值口径不同,严格意义上 `b/T` 是波长口径的峰值。 +- 常数口径:`h = 6.62607015e-34 J·s`、`c = 299792458 m/s`、`k = 1.380649e-23 J/K`、`σ = 5.670374419e-8 W·m⁻²·K⁻⁴`;太阳参数 `L☉ = 3.828e26 W`、`R☉ = 6.957e8 m`、`Teff = 5772 K`,后者的公开入口是 `SolarEffectiveTemperature`。 +- 星等族假设无消光、无 K 修正、无宇宙学项;两个换算函数只是加减 `DistanceModulus`,因此精度完全取决于外部给的绝对星等与距离。 +- 望远镜族是可见光经验值:Dawes 与 Rayleigh 用固定系数(`116`、`138.4`,口径 mm),不随波长变化;`LightGatheringPowerRatio` 只比较口径平方,不含中心遮挡、透过率与副镜损失;`LimitingMagnitudeEmpirical` 不含天空背景、倍率、透过率与观测经验修正。 +- 恒星族由 `L = 4πR²σT⁴` 互相反解,假设球对称、无临边昏暗修正、无自转与磁场效应;Solar 变体与 MKS 变体共用同一组太阳常数,两者之间的差异只来自这一组常数的口径。 +- 大气质量族:平行平板是纯几何 `sec(z)`,只在中高空可作近似,接近地平线时发散;Kasten-Young(1989) 与 Pickering(2002) 是经验拟合,中高空彼此接近,低空差异最大。已经有视高度角时直接用 `AirmassKastenYoung` / `AirmassPickering`;只有真高度角而需要折射时用 `coord` 的[大气质量](coord.md#大气质量)。 +- 本包全部函数都是无状态纯函数,不做缓存、不读全局时标状态,可以安全地在任意顺序、任意并发(调用方自行同步)下调用。 diff --git a/doc/manual/map-geojson.md b/doc/manual/map-geojson.md new file mode 100644 index 0000000..9661bdb --- /dev/null +++ b/doc/manual/map-geojson.md @@ -0,0 +1,485 @@ +# 天象地图、GeoJSON 与 KML + +[English](en/map-geojson.md) | [返回 README](../../README.md) + +> 本手册的完整示例以仓库根目录为工作目录执行,生成的图片写入 `doc/img/`。 + +## 目录 + +- [导出日食 GeoJSON](#导出日食-geojson) +- [API 参考](#api-参考) +- [常用场景](#常用场景) + - [给事件选投影](#给事件选投影) + - [导出 GeoJSON 与时间标记](#导出-geojson-与时间标记) + - [时标声明与 UT1](#时标声明与-ut1) + - [单时刻足迹与图层词表](#单时刻足迹与图层词表) +- [地图投影](#地图投影) +- [时标声明](#时标声明) +- [GeoJSON](#geojson) + - [图层筛选](#图层筛选) + - [坐标、时间与反经线](#坐标时间与反经线) + - [按时刻计算日食阴影](#按时刻计算日食阴影) + - [地平线闭合与插值](#地平线闭合与插值) + - [ΔT 与地面位置](#δt-与地面位置) + - [月掩瞬时足迹](#月掩瞬时足迹) + - [先查事件再计算几何](#先查事件再计算几何) +- [KML](#kml) + - [转换文件](#转换文件) + - [Options](#options) + - [图层与样式](#图层与样式) + - [时间轴与静态叠加](#时间轴与静态叠加) + - [控制文件大小和取景](#控制文件大小和取景) + - [属性与输入校验](#属性与输入校验) + +## 导出日食 GeoJSON + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2009, 7, 22, 0, 0, 0, 0, cst) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, + eclipse.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}) + if !ok { + log.Fatal("no solar eclipse") + } + path, ok := eclipse.SolarEclipseCentralPath(date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}) + var centralPath *eclipse.SolarEclipsePath + if ok { + centralPath = &path + } + data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, centralPath, + geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: cst}) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) +} +``` + +偏食可以没有中心路径,因此 `centralPath` 允许为 `nil`。本例把 GeoJSON 写到标准输出,可重定向到文件;SVG 与 KML 用法见后文。 + +## API 参考 + +SVG 片段使用导入别名 `eclipsesvg "b612.me/astro/eclipse/svg"`;月掩图使用 `moonsvg "b612.me/astro/moon/svg"`。日期和时区沿用首例。 + +| 名称 | 用途 | 备注 | +| --- | --- | --- | +| `eclipsesvg.EclipseMapProjectionAuto` / `...Equirectangular` / `...NorthPolar` / `...SouthPolar` / `...Orthographic` | 日月食地图投影 | 零值即自动 | +| `moonsvg.MapProjectionAuto` / `...Equirectangular` / `...NorthPolar` / `...SouthPolar` / `...Orthographic` | 月掩地图投影 | 同上 | +| `SolarEclipseMapSVG` / `LunarEclipseMapSVG` | 日食、月食全球图 | 返回 `(string, bool)` | +| `StarOccultationPathSVG` / `PlanetOccultationPathSVG` | 月掩全球掩带图 | 返回 `(string, error)` | +| `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` | 日食 GeoJSON | 无/带时间标记 | +| `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` / `MarshalLunarEclipseWithOptions` | 月食 GeoJSON | 同上 | +| `MarshalStarOccultation` / `MarshalPlanetOccultation`(及 `...WithTimeMarkers`) | 月掩 GeoJSON | 同上 | +| `NewSolarEclipseShadowSolver` / `MarshalSolarEclipseShadowInstant` | 单时刻足迹及其 GeoJSON | 拖动时间轴用 | +| `TimeMarkerOptions` | 时间标记 `Step`、`Location`、`TimeScale` | `Step` 零值 30 分钟,最多 1440 个 | +| `astro.TimeScaleUT1` / `astro.DUT1` | UT1 口径与 DUT1 差值 | UT1 时 `Location` 必须为 UTC | +| `kml.FromGeoJSON` | 把 GeoJSON 转成 KML 2.2 | 只依赖标准库;按 `role` 分层并给默认调色板 | + +## 常用场景 + +### 给事件选投影 + +```go +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + }) + fmt.Println(spec.name, ok, len(svg)) +} +``` + +```text +equirectangular true 265827 +north-polar true 181602 +south-polar true 91902 +orthographic true 537001 +``` + +投影只影响 SVG 表达、不改变底层 WGS84 地理结果;`Auto`(零值)按事件自动选极图,需要固定版式时才显式指定。正射球面视点取食甚点、只画朝向视点的半球,并切换成 NASA 摆法版式——代价与底图反解过程见[地图投影](#地图投影)。 + +### 导出 GeoJSON 与时间标记 + +```go +data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, centralPath, + geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: cst}) +fmt.Println(err, json.Valid(data), len(data)) +``` + +```text + true 426253 +``` + +- `WithTimeMarkers` 会追加 `role=time-marker` 的 Point 要素:`label` 按 `Location` 本地化显示,`time` 始终是 UTC RFC 3339。 +- `Step` 零值为 30 分钟,正值至少 1 分钟,单次导出最多 1440 个标记;不需要标记就用不带后缀的 `MarshalSolarEclipse`。 +- 月食与月掩有对称入口(`MarshalLunarEclipse*`、`MarshalStarOccultation*`、`MarshalPlanetOccultation*`);GeoJSON 不携带底图、边界、样式或投影,Web Mercator 与瓦片选择由应用决定。 + +### 时标声明与 UT1 + +```go +fmt.Println(astro.DUT1(date)) // UT1−UTC,单位秒 +``` + +```text +0.23 +``` + +出图默认按 UTC 口径并在图上或图注声明;`TimeScale: astro.TimeScaleUT1` 改成 UT1 读数并在图里写出 `DUT1 = UT1−UTC = +0.23 s`,此时 `Location` 必须是 UTC。GeoJSON 的对应成员是 `time_scale`(UTC 口径省略,UT1 口径写 `"UT1"`),完整约定见[时标声明](#时标声明)。 + +### 单时刻足迹与图层词表 + +```go +solver := eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{}) +instant, ok := solver.ShadowAt(date) +shadow, err := geojson.MarshalSolarEclipseShadowInstant(instant) +fmt.Println(ok, err, json.Valid(shadow), len(shadow)) +``` + +```text +true true 4217 +``` + +- 单时刻接口只算"这一瞬间的本影/半影足迹"与站心日月几何,不产生可见带、食分线、升落边界或南北界;本影不在地球上时返回空值而不是错误。 +- 插值前先比 `interp_signature`:只有相同的相邻时刻才适合按顶点插值,`closed` 翻转、段数/顶点数变化、空↔非空(U1/U4 附近)都必须改取精确几何。 +- 可降级图层用 `data-source` 标注实际几何来源,取值词表见[地图投影](#地图投影)。 + +## 地图投影 + +日食、月食与月掩的 SVG 共用同一套投影:等经纬、北极方位等距、南极方位等距与正射球面。投影只影响 SVG 表达,不改变底层 WGS84 地理结果;日食与月掩的自动投影(零值 `...Auto`)会在适合时选择极图,月食默认使用等经纬投影。 + +| 投影 | 常量(日食/月食 · 月掩) | 适用场景 | 画布建议 | +| --- | --- | --- | --- | +| 自动 | `EclipseMapProjectionAuto` · `MapProjectionAuto` | 默认;按事件自动选极图 | 1200×800 | +| 等经纬 | `...Equirectangular` | 跨反经线的长食带或掩带 | 1200×800、1414×1000 | +| 北极/南极方位等距 | `...NorthPolar` / `...SouthPolar` | 事件整体落在高纬 | 1200×800、1000×1414 | +| 正射球面 | `...Orthographic` | NASA 版式的半球图,视点取事件中心 | 1000×1414 | + +`EclipseMapProjectionOrthographic` / `MapProjectionOrthographic` 以食甚点(月掩取事件中心)为视点,只显示朝向视点的半球。该投影使用 NASA 风格的居中球面版式,建议画布为 `1000×1414`。 + +同一事件批量出四种投影: + +```go +date := time.Date(2009, 7, 22, 12, 0, 0, 0, cst) +for _, spec := range []struct { + name string + p eclipsesvg.EclipseMapProjection +}{ + {"equirectangular", eclipsesvg.EclipseMapProjectionEquirectangular}, + {"north-polar", eclipsesvg.EclipseMapProjectionNorthPolar}, + {"south-polar", eclipsesvg.EclipseMapProjectionSouthPolar}, + {"orthographic", eclipsesvg.EclipseMapProjectionOrthographic}, +} { + options := eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: spec.p, + } + svg, ok := eclipsesvg.SolarEclipseMapSVG(date, options) + if !ok { + continue + } + _ = os.WriteFile("solar-eclipse-"+spec.name+".svg", []byte(svg), 0o644) +} +``` + +各族的选项、图层开关与带图示例见[日食与月食手册](eclipse.md#全球见食图与月食出图)与[月掩手册](occultation.md#月掩出图);GeoJSON 侧只携带地理结果,投影由应用自行选择。 + +可降级的图层用 `data-source` 标注实际几何来源,取值词表见 `eclipse/svg` 包注释:`partial-band-union`、`sampled-footprint-sweep`、`partial-band-contours`、`rise-set-phase-lines`、`magnitude-contours`、`greatest-time-isochrones`、`besselian-critical-envelope`、`paired-limit-chords`、`sampled-open-sweep`、`central-path-limits`、`penumbral-outlines`、`central-shadow-outlines`、`p1-p4-visibility-regions`、`p1-p4-horizon-boundaries`。 + +## 时标声明 + +图内时刻的口径由 `TimeScale` 选项决定,日食全球图、月食全球图、月食详细版式与月掩三族图都会在图上或图注里写明: + +- 默认(`astro.TimeScaleUTC`,零值):写 `图中时刻为 UTC`;展示时区不是 UTC 时写 `图中时刻为 UTC(显示时区 CST,UTC+08:00)`,把时标与展示时区分开声明。 +- `astro.TimeScaleUT1`:写 `图中时刻为 UT1(世界时),DUT1 = UT1−UTC = +0.05 s。`,差值随事件日期变化;此时 `Location` 必须是 UTC,否则渲染返回 `false`(月掩侧直接返回错误)。 + +GeoJSON 侧的对应成员是 `time_scale`:UTC 口径省略该成员,UT1 口径写 `"UT1"`,表示所有 `time` 字符串与 `HH:MM` 标注都是 UT1 读数(RFC 3339 的 `Z` 后缀严格说不等于 UT1)。切时标不改掩带、足迹、地平线与等值线几何,只有写出的时刻文字换成 UT1 读数;时间标记按输出时标的整点取点(整点读数本身就是另一个物理时刻),位置随所标时刻移动。 + +## GeoJSON + +`geojson` 接收已经计算好的日食、月食或月掩结果,返回 `[]byte`。这段字节是完整的 UTF-8 RFC 7946 `FeatureCollection` JSON,不是图片,也不是压缩数据,可以直接写入 `.geojson`、交给 `encoding/json`,或发送给前端地图组件。 + +```go +package main + +import ( + "encoding/json" + "fmt" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func main() { + date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints( + date, + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, + }, + ) + if !ok { + return + } + central, hasCentral := eclipse.SolarEclipseCentralPath( + date, + eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20}, + ) + var centralPath *eclipse.SolarEclipsePath + if hasCentral { + centralPath = ¢ral + } + + data, err := geojson.MarshalSolarEclipseWithTimeMarkers( + partial, centralPath, + geojson.TimeMarkerOptions{ + Step: 30 * time.Minute, + Location: time.FixedZone("CST", 8*3600), + }, + ) + fmt.Println(err, json.Valid(data)) +} +``` + +对应的无时间标记和带时间标记入口包括: + +- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers` +- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers` +- `MarshalLunarEclipseWithOptions`:用 `LunarEclipseOptions` 一次给出时间标记、`SkipRoles` 与时间包络的采样档位。`SkipRoles` 可跳过时间包络和仅见半影带;跳过两个时间包络时不会执行包络采样。`EnvelopeSweepSamples` 默认 48,限制在 `[2, 192]`;`EnvelopeLongitudePoints` 默认 `max(360, boundaryPoints)`,限制在 `[12, 720]`。 +- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers` +- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers` +- `MarshalSolarEclipseWithOptions`:用 `SolarEclipseOptions` 一次给出时间标记与 `SkipRoles`,等价于上面的日食入口再加图层裁剪。`SkipRoles` 里最常用的是 `partial-footprint`(瞬时半影轮廓);`partial-band` 是日食带边缘(食分 0 界限),通常要留着。 + +### 图层筛选 + +`SolarEclipseOptions.SkipRoles` 按 `role` 排除输出要素,零值保留全部图层。可通过 `MarshalSolarEclipseWithOptions` 同时指定时间标记与筛选条件。 + +筛选发生在编码阶段,几何仍按完整采样计算。因此,跳过 `partial-footprint` 会去掉逐时刻的半影足迹,但不会降低总偏食区边缘 `partial-band` 的精度,也不影响中心带、食分线和升落边界。全部要素被排除时返回错误。 + +### 坐标、时间与反经线 + +坐标为 WGS84 `[经度, 纬度]`,单位度。跨反经线的线和面会拆分,并在接缝两侧补出同一个交点。触及极点的环用 `[±180, ±90]` 两个坐标表示同一极点。 + +闭合环含有用于闭合的 ±180° 接缝段。填充时需要这些边;描绘真实边界时应跳过接缝。`band-outline`、`total-band-outline` 等闭合轮廓线也需按此规则处理。 + +带时间路径的 `times` 与各段坐标逐点对应。`WithTimeMarkers` 额外加入 `role=time-marker` 的 Point 要素:`label` 用于显示,默认 `time` 为 UTC RFC 3339;显式选择 UT1 时由 `time_scale` 区分。详见[时标](timescale.md)。 + +| `TimeMarkerOptions` 字段 | 行为 | +| --- | --- | +| `Step` | 零值为 30 分钟,正值至少 1 分钟;一次导出最多 1440 个标记 | +| `Location` | 标签的显示时区,`nil` 使用 UTC | +| `TimeScale` | 默认 UTC;UT1 要求 `Location` 为 `nil` 或 `time.UTC` | + +GeoJSON 不携带底图、样式或投影,应用可自行选择瓦片、Web Mercator 或极区投影。 + +### 按时刻计算日食阴影 + +交互地图按时间查询阴影时,可复用 `eclipse.NewSolarEclipseShadowSolver` 返回的求解器: + +| 方法 | 输入与结果 | +| --- | --- | +| `ShadowAt(date)` | 民用 `time.Time`,返回瞬时全球阴影足迹 | +| `ShadowAtJDE(jdeTT)` | TT 儒略日,返回相同类型的足迹 | +| `StationStateAt` / `StationStateAtJDE` | 指定时刻与站点的食分、遮蔽率、站心角距、视半径、太阳高度/方位与全食/环食状态 | +| `ShadowBetween(start, end, step)` | 按步长返回足迹序列,无阴影的时刻保留空条目 | +| `StationStatesBetween` | 按时间采样站点状态 | + +默认计算本影或反本影;`Kind: SolarEclipseShadowPenumbra` 改为半影。单时刻接口不计算整场事件的可见带、食分线、南北限或中心线;阴影不在地球上时返回空结果。 + +`geojson.MarshalSolarEclipseShadowInstant(instant)` 将结果编码为 GeoJSON。输出属性包括 `time`、`source_boundary_closed`、`geometry_role`、`closure`、`delta_t_seconds`、`model` 与 `interp_signature`。没有阴影时返回空 FeatureCollection。 + +| 阴影 | 区域 role | 物理边界 role | +| --- | --- | --- | +| 本影/反本影 | `central-shadow-footprint` | `central-shadow-boundary` | +| 半影 | `partial-footprint` | `partial-footprint-boundary` | + +单时刻半影与整场偏食采样使用相同的默认边界参数(96 点、200 km 加密),可以对照同一时刻的结果。已有单机测量中,96 点瞬时足迹约 64 µs,站点瞬时状态约 20 µs;这些数据只用于估算成本,首次查询和批量查询会受缓存状态影响。 + +### 地平线闭合与插值 + +`central-shadow-footprint` 只输出 Polygon 或 MultiPolygon。被地平线切断时,物理边界延伸到两个地平擦地点,再沿地平弧闭合为覆盖区域;此时 `source_boundary_closed=false`,`closure` 用 `kind`、`time`、`subsolar` 描述闭合弧。 + +只画真实阴影边缘时使用 `central-shadow-boundary`;需要填色时使用 footprint。阴影在 U1/U4 收缩为空时不输出该要素,不会退化成折线。`source_boundary_closed=true` 表示物理边界本身已经闭合。 + +采样的半影足迹也带这些属性。足迹是瞬时覆盖区域,静态掩带是整场事件的包络,两者不能互相替代。 + +相邻帧可先比较 `interp_signature`,例如 `umbra-closed-seg1-pt97`。只有签名一致、分段数与顶点数对应时才适合按顶点插值;这不代表插值具有严格误差上界。闭合状态改变、反经线分段改变或空/非空切换时,应查询精确几何。 + +移动距离也会随事件阶段变化:已有两分钟采样的对照中,足迹质心在中段移动约 78–232 km,接触附近可达约 520 km。因此不能仅凭固定时间步长判断动画误差。 + +### ΔT 与地面位置 + +求解器的 `DeltaTSeconds > 0` 可为该句柄指定 ΔT;小于等于 0 时采用进程级模型。结果回传实际使用的值。 + +同一 TT 下,改变 ΔT 会改变地球自转相位,而不改变日月在空间中的相对几何。地面经向位移的近似量级为 `0.4651 × |ΔΔT| × cos(纬度)` 千米,函数 `basic.DeltaTGroundShiftKM` 可用于换算。库不提供 ΔT 不确定度模型,误差输入需由调用方给出。 + +### 月掩瞬时足迹 + +`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` 的结果可传给 `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`。 + +它们同样输出 `delta_t_seconds`、`source_boundary_closed`、`geometry_role` 和 `interp_signature`。月球地平线闭合采用 `closure.kind=target-horizon`、`body=moon` 与 `sublunar` 月下点。月掩计算沿用进程级 ΔT,并在结果中报告实际值。 + +### 先查事件再计算几何 + +只需要事件列表时,可先用 `eclipse.SolarEclipseCandidates(start, end, options)` 获取食甚时刻、食型、中心食类型、食分、伽马和可选沙罗信息;这个结果不包含地理几何。 + +固定地点的中心食搜索使用 `SearchLocalCentralSolarEclipse`,选项包括 `Kind`、`MaxYears`、`Backward`、`Geometric`、`Model`,返回 `(info, status)`。`status.Exhausted` 表示已用尽搜索范围。 + +`MaxYears<=0` 使用默认搜索预算(6000 次候选步进,约 992 年)。找到事件后再计算路径、足迹或 SVG,可以避免为不需要的事件生成地图数据。 + +## KML + +`kml.FromGeoJSON(data []byte, options kml.Options) ([]byte, error)` 把 GeoJSON FeatureCollection 转成 KML 2.2。输入可来自本库,也可以是第三方文件;转换器读取已有几何和时间属性,不计算新的星历或动画帧。 + +### 转换文件 + +下面的程序把 `eclipse.geojson` 转成 `eclipse.kml`,可在 Google Earth 中打开: + +```go +package main + +import ( + "log" + "os" + + "b612.me/astro/kml" +) + +func main() { + data, err := os.ReadFile("eclipse.geojson") + if err != nil { + log.Fatal(err) + } + result, err := kml.FromGeoJSON(data, kml.Options{ + Name: "2009-07-22 日食", + }) + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile("eclipse.kml", result, 0644); err != nil { + log.Fatal(err) + } +} +``` + +### Options + +| 字段 | 零值或默认行为 | 设置后的作用 | +| --- | --- | --- | +| `Name` | 按事件类型与最早时刻推导文档名 | 指定 `Document/name` | +| `Language` | `"zh"` | `"en"` 使用英文图层名;未收录的角色保留原始 `role` | +| `Styles` | 内置配色 | 以 `role` 或 `event/role` 为键覆盖样式,后者优先 | +| `NoLookAt` | 自动设置取景 | `true` 不写 `Document/LookAt` | +| `SkipRoles` | 保留所有角色 | 按 `role` 删除整层,包括几何、样式和取景贡献 | +| `FillContext` | 只填充高亮中心带和掩星带 | `true` 为其他面图层添加浅灰半透明填充 | +| `NoTimes` | 写入输入中存在的时间 | `true` 不输出时间元素,生成静态叠加 | + +### 图层与样式 + +要素按 `event` 和 `role` 分组到 Folder。同一集合含多个事件类型时,图层名带事件前缀;第三方 `event` 与 `role` 也可使用相同的样式覆盖规则。 + +| 图层 | role | 默认样式 | +| --- | --- | --- | +| 日食中心带与本影 | `central-band`、`central-shadow`、`central-shadow-footprint`、`central-shadow-sweep`、`total-footprint` | 红色;面填充约 35% 不透明度 | +| 中心线 | `center-line` | 黑色,3 px | +| 日食食甚等时线 | `greatest-time-line` | 绿色 | +| 食分线 | `magnitude-line`、`magnitude-one-envelope` | 黄色 | +| 掩星全掩与偏掩带 | `total-band`、`partial-band`、`total-band-outline`、`occultation-band` | 黄色,带半透明填充 | +| 升落可见边界、月食时间包络、日食偏食带边缘 | `visibility-boundary`、`p1-horizon`、`p4-horizon`、`visible-at-p1`、`visible-at-p4`、`visible-during-eclipse`、`visible-throughout-eclipse`、日食的 `partial-band` | 橙色 | +| 仅见半影的月出/月落带 | `penumbra-moonrise`、`penumbra-moonset` | 月出蓝紫、月落紫红,带半透明填充 | +| 其他限界、轮廓、足迹与时间标记 | 其他 `role` | 灰色,面默认只画轮廓 | + +`Style` 的零值字段沿用默认样式: + +| 字段 | 含义 | +| --- | --- | +| `LineColor` | 线色,KML `aabbggrr` 十六进制 | +| `FillColor` | 面填充色;点和线忽略此项 | +| `LineWidth` | 像素;小于等于 0 时使用默认线宽 | +| `NoFill` | 强制不填充,优先于 `FillColor` 与默认配色 | + +KML 颜色顺序是透明通道、蓝、绿、红。例如 `ff0000ff` 为不透明红,`590000ff` 为约 35% 不透明度的红;它与 CSS `rrggbb` 的顺序不同。 + +```go +options := kml.Options{ + Language: "zh", + Styles: map[string]kml.Style{ + "center-line": {LineColor: kml.ColorBlack, LineWidth: 4}, + "solar-eclipse/central-band": {FillColor: "590000ff"}, + "partial-footprint": {NoFill: true}, + }, +} +result, err := kml.FromGeoJSON(data, options) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result)) +``` + +无填充的面会转成轮廓线。有填充的面与描边分别表示,反经线拆分产生的接缝不作为真实边界绘制。这样既可保持面闭合,也可避免沿 ±180° 经线出现贯穿地图的描边。 + +### 时间轴与静态叠加 + +| GeoJSON 时间数据 | KML 输出 | +| --- | --- | +| 单个 `time` 属性 | 包含该要素全部子几何的 Placemark 带一个 `TimeStamp` | +| MultiLineString 的 `times` | 按线段拆成 Placemark,各段取首个时间作为 `TimeStamp` | +| 至少一个有效时间戳 | Document 带覆盖最早至最晚时间戳的 `TimeSpan` | +| `time_scale: "UT1"` | 先按当前时标模型换回 UTC,再写 `` | +| `NoTimes: true` | 不输出 `TimeStamp` 或 `TimeSpan` | + +时间戳保留源数据的小数秒。要素含 `label` 时用它作名称;自动生成的名称只显示到秒。UT1 数据的原 `time_scale` 保留在属性中;转换时应与生成 GeoJSON 时使用同一 ΔT 模型。 + +时间轴中的帧来自输入数据。`times` 不会让折线上的每个顶点自动变成独立动画帧;需要本影或半影逐时刻变化时,应先用 GeoJSON 侧的采样选项生成这些要素。 + +Google Earth 会按所选时间窗口隐藏带时间戳的要素。查看静态全路径时可设置 `NoTimes: true`;播放时则将时间窗口移到事件日期,并调整可见时间段的宽度。 + +### 控制文件大小和取景 + +半影足迹通常覆盖很大区域。密集采样、大量顶点与半透明面重叠都会增加客户端绘制开销;可增大采样步长,或按用途去掉部分图层。 + +只需要总偏食区边缘和中心食路径时,可跳过 `partial-footprint`,保留 `partial-band`: + +```go +result, err := kml.FromGeoJSON(data, kml.Options{ + SkipRoles: []string{"partial-footprint"}, + NoTimes: true, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result)) +``` + +若在生成 GeoJSON 时已经确定不需要某层,可通过 `geojson.MarshalSolarEclipseWithOptions` 的 `SkipRoles` 先排除,减少中间数据。KML 的 `SkipRoles` 适用于已有文件。 + +自动取景优先使用中心线、中心带、限界和本影等路径图层,缺少这些图层时再使用全部要素。包络考虑跨反经线情况,`LookAt/range` 的单位为米,下限 200 km。需要客户端自行定位时设 `NoLookAt: true`。 + +### 属性与输入校验 + +通过 `SkipRoles` 筛选后,所有保留要素都具有且值相同的属性才提升到 `Document/ExtendedData`。其余属性留在各自 Placemark,嵌套对象也会保留;`times` 已转换成时间戳,不重复写入属性。 + +坐标至少含经纬度两项。经度超出 ±180° 时回绕,原本的 +180° 与 −180° 保持不变;纬度必须在 ±90° 内。额外的高度分量被忽略,输出采用 `clampToGround`。 + +多边形环会补闭合,并调整为外环逆时针、内环顺时针。支持 Point、MultiPoint、LineString、MultiLineString、Polygon、MultiPolygon 和 GeometryCollection。 + +以下情况返回错误:JSON 或几何类型无效、坐标非有限或纬度越界、时间数组与线段结构不匹配、过滤后没有剩余要素,或所有要素均没有可绘制几何。两个相同坐标组成的单点路径片段会被跳过。输入本来就是空 FeatureCollection 时,返回合法的空 Document。 diff --git a/doc/manual/occultation.md b/doc/manual/occultation.md new file mode 100644 index 0000000..9b04022 --- /dev/null +++ b/doc/manual/occultation.md @@ -0,0 +1,526 @@ +# 月掩 + +[English](en/occultation.md) | [返回 README](../../README.md) + +月掩接口位于 `moon`,按用户给定的目标搜索,不会遍历恒星表(=-=可自行维护常见被掩星表)。固定地点接口直接接收 `start`、`end`、经度、纬度和椭球高;全球路径接口返回 WGS84 经纬度采样,可继续交给 `moon/svg` 或 `geojson`或`kml`处理。 + +目标和接触语义分为两类: + +- **恒星**按点光源处理,返回掩始 `Immersion`、掩甚 `Greatest` 和掩终 `Emersion`。 +- **行星**按有限圆盘处理(纯圆),外切为 C1/C4,完全被月面覆盖时另有内切 C2/C3;偏掩和擦掩没有 C2/C3。行星半径取赤道本体半径,不含行星环、大气延伸和扁率。 +- `FindBestStarOccultations` / `FindBestPlanetOccultations` 返回全球海平面几何掩甚点,不按地平线、月高、可见时长或食分评分;`VisibleAtGreatest` 仅报告该点的可见性。 +- 搜索时间窗按掩甚时刻选择事件。命中后会返回完整接触时刻或完整全球路径,不会把结果裁剪到查询端点。 +- 接触时刻按目标与月面边缘的站心几何求解,不加入大气折射。`MoonAltitudeAtGreatest` 是月心真高度,`VisibleAtGreatest` 表示它是否不低于几何地平线。 + +## 目录 + +- [简单示例:搜索指定地点的恒星月掩](#简单示例搜索指定地点的恒星月掩) +- [API 参考](#api-参考) +- [常用场景](#常用场景) + - [某地今晚有没有月掩](#某地今晚有没有月掩) + - [行星月掩与有限圆盘](#行星月掩与有限圆盘) + - [全球掩带图与详细版式](#全球掩带图与详细版式) + - [掠掩、等时线与掩带宽口径](#掠掩等时线与掩带宽口径) +- [恒星月掩](#恒星月掩) + - [搜索与路径采样选项](#搜索与路径采样选项) + - [路径算法与轮廓](#路径算法与轮廓) + - [掩甚时刻等时线](#掩甚时刻等时线) +- [行星月掩](#行星月掩) +- [月掩出图](#月掩出图) + - [全球掩带图](#全球掩带图) + - [详细版式](#详细版式) + - [站心事件图](#站心事件图) + - [时标口径与 UT1](#时标口径与-ut1) + +## 简单示例:搜索指定地点的恒星月掩 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) + end := start.AddDate(0, 0, 1) + target := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, // 室女座25,进贤增九 和它滴赤经赤纬 + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), //赤经赤纬的历元信息,上面是J2000.0的坐标,若坐标是该时刻的视位置,必须同时把 Frame 设为 apparent_of_date + Frame: moon.CoordinateFrameJ2000,//坐标系,可选 icrs / j2000 / apparent_of_date + ProperMotionRACosDecMasPerYear: -28, //赤经方向自行,毫角秒/年 + ProperMotionDecMasPerYear: -18, //赤纬方向自行,毫角秒/年 + // 距离可选:不给就退回二维自行;给了DistanceLightYear(或ParallaxMas)就走三维空间运动, + // 此时RadialVelocityKmPerSecond才参与计算,不填按径向速度为0处理。 + } + events, err := moon.FindStarOccultations(start, end, target, + 121.56601, 6.80706, 0, moon.OccultationSearchOptions{}) + if err != nil { + log.Fatal(err) + } + if len(events) == 0 { + fmt.Println("no occultation in this window") + return + } + for _, event := range events { + fmt.Println(event.Type, event.Immersion, event.Greatest, event.Emersion) + } +} +``` + +空切片表示搜索窗内没有命中事件,是正常结果。搜索按掩甚时刻归属窗口,返回的掩始、掩终可能在窗口之外。 + +## API 参考 + +后续片段沿用首例的目标与搜索窗口。SVG 调用使用导入别名 `moonsvg "b612.me/astro/moon/svg"`。 + +| 名称 | 用途 | 备注 | +| --- | --- | --- | +| `moon.FindStarOccultations` / `FindStarOccultationPaths` | 恒星月掩事件 / 全球路径 | 目标为 `moon.StarCoordinate` | +| `moon.FindPlanetOccultations` / `FindPlanetOccultationPaths` | 行星月掩事件 / 全球路径 | 按有限圆盘求解 | +| `moon.StarCoordinateFromStarData` | 由内置星表构造目标 | 需先加载星表 | +| `moonsvg.FindStarOccultationSVGs` / `FindPlanetOccultationSVGs` | 搜索并渲染全球图 | 返回 `([]string, error)` | +| `moonsvg.FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs` | 搜索并渲染站心图 | 同上 | +| `moonsvg.StarOccultationPathSVG` / `PlanetOccultationPathSVG` | 渲染已有全球路径 | 返回 `(string, error)` | +| `moonsvg.StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG` | 一页详细版式 | 固定正射球面 | +| `moonsvg.StarOccultationSVGOptions` / `moonsvg.OccultationDetailedSVGOptions` | 出图选项(画布、投影、时标、标记步长) | 投影用 `MapProjection*`,时标用 `astro.TimeScale*` | +| `moon.OccultationMercury` … `moon.OccultationNeptune` | 行星目标常量 | 传给行星入口 | +| `moon.OccultationSearchOptions` / `moon.OccultationPathOptions` | 搜索与路径选项 | 路径选项含 `Step`、`TargetSpacingKM`、`GreatestTimeStep` | + + + +## 常用场景 + +| 场景列表 | 入口 | 返回 | +| --- | --- | --- | +| 某地某晚有没有月掩 | `moon.FindStarOccultations(start, end, star, lon, lat, height, searchOptions)` | `[]moon.StarOccultationInfo` | +| 全球几何掩甚点 | `moon.FindBestStarOccultations(start, end, star, searchOptions)` | `[]moon.StarOccultationInfo` | +| 全球掩带几何 | `moon.FindStarOccultationPaths(start, end, star, pathOptions)` | `[]moon.StarOccultationPath` | +| 某一时刻的全球可见足迹 | `moon.StarOccultationFootprintAt(at, star)` | `moon.StarOccultationInstant` | +| 搜索并一步出全球掩带图 | `moonsvg.FindStarOccultationSVGs(start, end, star, pathOptions, svgOptions)` | `([]string, error)` | +| 已有路径只渲染 | `moonsvg.StarOccultationPathSVG(path, svgOptions)` | `(string, error)` | +| 一页详细版式 | `moonsvg.StarOccultationDetailedSVG(path, star, detailedOptions)` | `(string, error)` | +| 搜索并出指定地点的站心图 | `moonsvg.FindLocalStarOccultationSVGs(start, end, star, lon, lat, height, searchOptions, localOptions)` | `([]string, error)` | +| 渲染已有的固定地点事件 | `moonsvg.LocalStarOccultationSVG(info, star, localOptions)` | `(string, error)` | +| 只要站心轨迹几何数据(不出图) | `moon.StarOccultationDiagram(info, star, diagramOptions)` | `moon.StarOccultationDiagramResult` | +| 交给 GIS | `geojson.MarshalStarOccultation(path)` / `MarshalStarOccultationWithTimeMarkers(path, markerOptions)` / `MarshalStarOccultationFootprint(instant)` | `([]byte, error)` | + + +### 某地今晚有没有月掩 + +```go +events, err := moon.FindStarOccultations(start, end, target, 121.56601, 6.80706, 0, moon.OccultationSearchOptions{}) +if err != nil { + panic(err) +} +for _, e := range events { + fmt.Println(e.Type, e.Immersion.Format("15:04:05.000"), + e.Greatest.Format("15:04:05.000"), e.Emersion.Format("15:04:05.000")) +} +``` + +```text +total 19:14:01.071 20:02:06.314 20:50:10.715 +``` + +- `FindStarOccultations` 给站心接触时刻:`Immersion`(掩始)、`Greatest`(掩甚)、`Emersion`(掩终);`Type` 区分全掩与掠掩。 +- 目标既可以直接给赤经赤纬,也可以先加载内置星表再用 `moon.StarCoordinateFromStarData` 转换;**搜索本身不加载星表**,只有调用星表接口时才加载。 +- 只要全球结果、不要某地接触时,用 `FindStarOccultationPaths` 一步拿到路径。 + +### 行星月掩与有限圆盘 + +```go +start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) +events, err := moon.FindPlanetOccultations(start, start.Add(24*time.Hour), moon.OccultationSaturn, + 104.52219613, 55.25401991, 0, moon.OccultationSearchOptions{}) +if err != nil { + panic(err) +} +for _, e := range events { + fmt.Println(e.TargetID, e.Type, e.HasInternalContacts) + fmt.Println(e.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST"), e.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) + fmt.Println(e.Greatest.Format("2006-01-02 15:04:05.000 MST"), e.InternalEmersion.Format("2006-01-02 15:04:05.000 MST"), e.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) +} +``` + +```text +Saturn total true +2025-02-01 11:29:09.710 CST 2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST 2025-02-01 12:32:46.415 CST 2025-02-01 12:33:18.312 CST +``` + +行星按**有限圆盘**求解接触:`HasInternalContacts` 为真时才有 C2/C3(内切),`OccultationPlanet` 决定圆盘半径;土星环既不参与接触计算、也不作为圆盘边界绘制。 + +### 全球掩带图与详细版式 + +```go +paths, err := moon.FindStarOccultationPaths(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}) +if err != nil { + panic(err) +} +if len(paths) == 0 { + fmt.Println("no occultation path") + return +} +svg, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, Projection: moonsvg.MapProjectionSouthPolar, +}) +detailed, detailErr := moonsvg.StarOccultationDetailedSVG(paths[0], target, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst}) +fmt.Println(err, len(svg), detailErr, len(detailed)) +``` + +```text + 120119 633489 +``` + +- 掩带图返回 `(string, error)`,四档投影与日食图一致;详细版式固定正射球面、不接受其它投影,版面随画布长宽自动推导。 +- 只渲染已有路径用 `StarOccultationPathSVG`;"搜索 + 渲染"一步到位用 `FindStarOccultationSVGs`(见上方两条链)。 +- 画布下限:单独掩带图 `640×480`;详细版宽度下限是 `480`,但整幅版式实测要 `670×595` 以上才放得下地图、数据块与页脚,低于下限直接返回错误。 + + +### 掠掩、等时线与掩带宽口径 + +- **掠掩没有中心线**:`HasTotalBand` 为假时全球图只画南北限,此情形不要假设存在中心线。 +- **掩带宽口径**:图上标注的"掩带宽"是掩甚处南北限的地面间距 `GreatestLimitSeparationKM`(HR 4799 示例约 `3666.6 km`),与中心线横向宽度 `Greatest.WidthKM`(约 `3582.4 km`)口径不同、不可互换。 +- **等时线**:要在路径层显式请求 `moon.OccultationPathOptions.GreatestTimeStep`;`moon/svg` 只绘制路径结果里已有的 `GreatestTimeContours`,位置由核心口径决定(对齐 UTC 整刻度)。 +- **UT1 口径**:`TimeScale: astro.TimeScaleUT1` 出 UT1 读数与 DUT1 差值,此时 `Location` 必须为 UTC,配非 UTC 时区直接返回错误而不是画错图。 + +## 恒星月掩 + +> 图内与图注的时标声明见[时标声明](map-geojson.md#时标声明)。 + +恒星由调用者传入 `StarCoordinate`。`RA` / `Dec` 单位为度,`Epoch` 和 `Frame` 必填;自行单位为 `mas/year`,其中 `ProperMotionRACosDecMasPerYear` 使用星表常见的 `dRA*cos(Dec)` 口径。 + + +| 字段 | 类型 | 零值 | 合法范围与报错 | 作用 | +| --- | --- | --- | --- | --- | +| `ID` | `string` | `""` | 无限制 | 展示标签,出现在结果和图题里,不参与任何计算 | +| `RA` | `float64` | — | 必填,`[0, 360)`,否则 `ErrInvalidOccultationInput` | 赤经,单位**度**(不是时分秒) | +| `Dec` | `float64` | — | 必填,`[-90, 90]` | 赤纬,单位度,北正南负 | +| `Epoch` | `time.Time` | `time.Time{}` | 零值报错 | 上面两个角度所属的历元时刻,比如J2000.0 | +| `Frame` | `moon.CoordinateFrame` | `""` | 只认 `icrs` / `j2000` / `apparent_of_date`,空值报错 | 参考系,见下 | +| `ProperMotionRACosDecMasPerYear` | `float64` | `0` | 必须是有限值 | 赤经方向自行,**mas/年**,口径是 `dRA·cos(Dec)` | +| `ProperMotionDecMasPerYear` | `float64` | `0` | 必须是有限值 | 赤纬方向自行,mas/年 | +| `ParallaxMas` | `float64` | `0` | 必须有限且 ≥ 0 | 周年视差,mas;`0` 表示未提供距离,此时退回二维自行并跳过视差修正 | +| `DistanceLightYear` | `float64` | `0` | 必须有限且 ≥ 0 | 距离,光年;`ParallaxMas` 的替代输入,仅当视差为 `0` 时生效 | +| `RadialVelocityKmPerSecond` | `float64` | `0` | 必须有限且 \|v\| ≤ 1000 | 径向速度,km/s;`0` 合法,只在距离已知时参与三维空间运动 | + +距离的两种给法满足一条优先级:`ParallaxMas > 0` 时以它为准,否则由 `DistanceLightYear` 折算视差。给距离即启用三维空间运动;不给距离时自行只推进赤经赤纬两个角分量,`RadialVelocityKmPerSecond` 不参与。 + +`Epoch` 与 `Frame` 的额外说明: + +- `j2000`:坐标是 J2000.0 平位置,`Epoch` 填 `2000-01-01 12:00 UTC`。库内岁差起点硬编码为 J2000.0,`Epoch` 只决定自行从哪一年开始外推;把非 J2000 历元填进来会让自行重复计一段。 +- `icrs`:坐标是 ICRS 星表位置,先过一次 ~17 mas 的框架偏差矩阵;`Epoch` 填星表历元(Hipparcos `1991.25`、Gaia `2016.0`)。 +- `apparent_of_date`:坐标是**该时刻的视位置**(已含岁差、章动、光行差),`Epoch` 必须填那一刻;库会在该时刻反解回平位置再向前传播。直接从星图软件(如stellarium)显示的"当前坐标"时使用这个参数。 + + +可以直接构造坐标: + +```go +target := moon.StarCoordinate{ + ID: "HR 4799", + RA: 189.1975, + Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, + ProperMotionDecMasPerYear: -18, +} +``` + +也可以显式加载内置 9100 星表,再用 `StarCoordinateFromStarData` 转换。月掩搜索本身不会加载星表;只有调用 `star.InitStarDatabase`、`StarDataByName`、`StarDataByHR` 等星表接口时才会加载。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/star" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst) + end := start.Add(24 * time.Hour) + + if err := star.InitStarDatabase(); err != nil { + panic(err) + } + data, err := star.StarDataByName("进贤增九") + if err != nil { + panic(err) + } + target, err := moon.StarCoordinateFromStarData(data) + if err != nil { + panic(err) + } + + events, err := moon.FindStarOccultations( + start, end, target, + 121.56601, 6.80706, 0, + moon.OccultationSearchOptions{}, + ) + + if err != nil { + panic(err) + } + for _, event := range events { + fmt.Println(event.TargetID, event.Type) + fmt.Println( + event.Immersion.Format("2006-01-02 15:04:05.000 MST"), + event.Greatest.Format("2006-01-02 15:04:05.000 MST"), + event.Emersion.Format("2006-01-02 15:04:05.000 MST"), + ) + fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest) + } + + paths, err := moon.FindStarOccultationPaths( + start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + + if err != nil { + panic(err) + } + for _, path := range paths { + fmt.Println( + path.Start.Time.Format("2006-01-02 15:04:05.000 MST"), + path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"), + path.End.Time.Format("2006-01-02 15:04:05.000 MST"), + ) + fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n", + path.Greatest.Longitude, path.Greatest.Latitude, + path.Greatest.WidthKM, len(path.CenterLine)) + } +} +``` + +输出结果: + +```text +进贤增九 total +2025-06-05 19:14:01.076 CST 2025-06-05 20:02:06.311 CST 2025-06-05 20:50:10.721 CST +altitude=75.561 visible=true +2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST +greatest=121.566009 6.807079 width=3582.4km center=108 +``` + +### 搜索与路径采样选项 + +`OccultationSearchOptions` 的零值采用默认步长和安全余量,`MaxEvents > 0` 限制返回数量。全球路径的采样由 `OccultationPathOptions` 控制: + +| 字段 | 作用 | +| --- | --- | +| `Step` | 基础时间步长 | +| `TargetSpacingKM` | 按地面距离加密中心线;请求超出采样预算时返回 `ErrOccultationPathSamplingLimit` | +| `RiseSetStep` | 六类初掩、掩甚、终掩月升/月落阶段线的步长,零值为 5 分钟 | +| `DisableRiseSet` | 跳过六类升落阶段线 | +| `DisableFootprints` | 跳过密集瞬时足迹,以稀疏支撑样本生成紧凑掩带;保留中心线、边界和升落阶段线 | +| `IncludeFootprintTimeline` | 在紧凑掩带之外保留瞬时足迹序列 | +| `FootprintTimelineStep` | 上述瞬时足迹序列的采样步长 | + +`GreatestLimitSeparationKM` 是结果中掩甚处南北限的地面间距,图中的“掩带宽”使用该值。它与 `Greatest.WidthKM` 定义不同,不能互换。 + +### 路径算法与轮廓 + +`OccultationPathOptions.Algorithm` 控制恒星和行星全球路径的星历分支:零值或 `moon.OccultationPathAlgorithmOptimized` 默认使用经抽检的 30 分钟节点矢量插值,保留现有站心方程、连续包络和升落曲线;`moon.OccultationPathAlgorithmExact` 在候选筛选时可使用插值,最终求解使用全项星历。优化分支在抽检不合格时回退到全项求解,超出插值时间窗时使用精确星历。 + +抽检不是全时段严格误差证明;两个分支的几何目标相同,但不保证采样点或 GeoJSON 字节完全相同。此选项不影响仅查询事件、指定站点接触或独立单时刻月影接口,也不影响日月食。 + +两个分支的全球起止、掩甚标记和中心线宽度均保留全项星历计算。绘图时应传入完整返回路径,包括可见性轮廓;丢弃该轮廓会调用瞬时足迹回退逻辑,其边界不能替代完整解析可见集。 + +路径中的 `BandContours` 是静态掩带的接触包络,`VisibilityContours` 是月亮处于地平线以上时的可见时间包络;两者与 `Footprints` 的瞬时采样分别承担静态边界、可见性边界和时间轴细节,不应互相替代。 + +### 掩甚时刻等时线 + +`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` 请求**掩甚时刻等时线**。与日食不同,`GreatestTimeValues []float64` 给的是力学时儒略日,最多保留 64 条(先去掉重复的时刻取值,按时间先后排序,超出时保留最早的 64 条),掩可见窗口之外或没有可用支路的时刻取值不会出现在结果里;它为空时改用 `GreatestTimeStep`,同样只在为正值时生效,且对齐到 UTC 整刻度。 + +结果写入 `StarOccultationPath.GreatestTimeContours`(行星路径是同名字段),元素类型 `OccultationGreatestTimeContour` 的 `JDE`、`Time`、`Segments` 与日食同义:`Time` 在按步长生成时是原始对齐时刻,显式给出的时刻取值则由 `JDE` 换算并抹到毫秒,两者都落在 UTC 时区,而支路点的时刻仍按路径时区;日食公共层的 `Time` 则直接落在输入时区。 + +边界口径同样一致:只出现在目标盘面与月面确有重叠且月亮在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或掩可见域边界,纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路;不请求时既有输出不变。 + +```go +options := moon.OccultationPathOptions{ + Algorithm: moon.OccultationPathAlgorithmExact, // 最终求解使用全项星历 + DisableFootprints: true, +} +``` + +## 行星月掩 + +行星目标使用 `OccultationMercury` 到 `OccultationNeptune` 常量。下面以 `2025-02-01` 月掩土星为例,在靠近全球几何掩甚点的位置求 C1-C4: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst) + events, err := moon.FindPlanetOccultations( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + 104.52219613, 55.25401991, 0, + moon.OccultationSearchOptions{}, + ) + if err != nil { + panic(err) + } + for _, event := range events { + fmt.Println(event.TargetID, event.Type, event.HasInternalContacts) + fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1 + fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2 + fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST")) + fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3 + fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4 + } +} +``` + +输出结果: + +```text +Saturn total true +2025-02-01 11:29:09.710 CST +2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST +2025-02-01 12:32:46.415 CST +2025-02-01 12:33:18.312 CST +``` + +`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和启用时的瞬时足迹都带采样时刻。 + +## 月掩出图 + +`moon/svg` 分“搜索并渲染”和“渲染已计算结果”两组入口: + +| 入口 | 用途 | +| --- | --- | +| `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs` | 搜索窗口内全部月掩,逐事件渲染全球掩带图 | +| `StarOccultationPathSVG` / `PlanetOccultationPathSVG` | 渲染已有的全球路径(`Find...Paths` 的返回值) | +| `StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG` | 一页详细版式 | +| `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs` | 指定地点的站心月面轨迹、白道与接触阶段图 | +| `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG` | 渲染已有的固定地点事件 | + +`eclipse/svg` 的入口返回 `(string, bool)`,`moon/svg` 的入口返回 `(string, error)` 或 `([]string, error)`:月掩侧的第二个返回值是真正的错误(UT1 配非 UTC 时区、画布低于下限、路径非法),不是“画不出来”的标志。恒星按点光源处理,行星按有限圆盘处理:行星月掩的接触时刻由圆盘与月缘的几何求解,`OccultationPlanet` 决定圆盘半径;土星环既不参与接触计算、也不作为圆盘边界绘制。 + +掠掩(`HasTotalBand` 为假)可以整条没有中心线,此时全球图只画南北限。点源恒星按日月中心角距定食甚,有限盘面行星按外接触度量,分别与 `StarOccultationInfo.Greatest`、`PlanetOccultationInfo.Greatest` 同口径。 + +### 全球掩带图 + +`MapProjection` 提供与日食图一致的四档投影。投影只影响 SVG 表达,不改变底层 WGS84 地理结果: + +| 选项 | 说明 | +| --- | --- | +| `MapProjectionAuto`(零值) | 按事件自动选择,高纬事件可能落到极图 | +| `MapProjectionEquirectangular` | 等经纬,跨反经线的长掩带最直观 | +| `MapProjectionNorthPolar` / `MapProjectionSouthPolar` | 极点居中的方位等距投影 | +| `MapProjectionOrthographic` | 正射球面,视点取事件中心,只画朝向视点的半球 | + +正射球面版(`2025-06-05` 月掩 HR 4799): + +```go +paths, err := moon.FindStarOccultationPaths(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}) +if err != nil || len(paths) == 0 { + return +} +svg, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, + Projection: moonsvg.MapProjectionOrthographic, +}) +if err != nil { + return +} +fmt.Println(len(svg)) +``` + +![2025 月掩 HR 4799 正射全球掩带图](../img/lunar-occultation-hr4799-2025-06-05-global.svg) + +同一路径改为南极投影,掩带落在高纬时比等经纬更清楚: + +```go +south, err := moonsvg.StarOccultationPathSVG(paths[0], moonsvg.StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: cst, + TimeLabelStep: 30 * time.Minute, + Projection: moonsvg.MapProjectionSouthPolar, +}) +if err != nil { + return +} +``` + +![2025 月掩 HR 4799 南极区全球掩带图](../img/lunar-occultation-hr4799-2025-06-05-southpolar.svg) + +把搜索与渲染合成一步时用 `Find...SVGs`,返回切片与命中事件一一对应: + +```go +svgs, err := moonsvg.FindStarOccultationSVGs(start, end, target, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + moonsvg.StarOccultationSVGOptions{Width: 1200, Height: 800, Location: cst}) +fmt.Println(err, len(svgs)) +``` + +`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线上的时刻标记。单独的全球掩带地图最小 `640×480`,更小的画布返回 `ErrInvalidStarOccultationSVGOptions`(行星入口对应 `ErrInvalidPlanetOccultationSVGOptions`);图例、页脚与经纬刻度都按画布高度预留,画布越矮越容易压到页脚;本手册的南极示例用 `1200×800` 即可。 + +### 详细版式 + +详细版式把整场月掩合成一页 `1000×1414`:居中摘要、日月与目标天体的地心/站心数据块、一张**正射球面**的全球掩带图(南北限、可见/几何中心线、掩甚点、初掩/掩甚/终掩阶段点与 30 分钟时间标记),以及页脚说明。球面视点取事件中心,这一版式固定用正射球面,不接受其它投影;只需要单独的掩带地图时用上面的 `StarOccultationPathSVG` / `FindStarOccultationSVGs`。 + +```go +detailed, err := moonsvg.StarOccultationDetailedSVG(paths[0], target, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst}) +if err == nil { + _ = os.WriteFile("doc/img/lunar-occultation-hr4799-2025-06-05-detailed.svg", []byte(detailed), 0o644) +} +``` + +![2025 月掩 HR 4799 详细版式](../img/lunar-occultation-hr4799-2025-06-05-detailed.svg) + +页内数据分为月亮地心坐标、目标天体、掩带路径点、接触时刻、历表与常数、天平动六块;横版画布把数据块排在地图右侧两栏三行,竖版把数据块排在球面下方三栏两行。球面掩带图使用 Natural Earth `1:50m` 海岸线,不含行政边界。 + +详细版按画布推导版式,实测最小画布 `670×595`(宽度下限 `480` 只是参数校验):地图与数据块放不下时返回 `ErrInvalidOccultationDetailedSVGOptions`(`800×600`、`1000×1414`、`1414×1000` 均可出图,`660×600`、`800×590`、`640×420`、`900×400` 会被拒绝)。 + +图上标注的“掩带宽”是掩甚处南北限的地面间距 `GreatestLimitSeparationKM`(HR 4799 样例约 `3666.6 km`),与中心线横向宽度 `Greatest.WidthKM`(约 `3582.4 km`)口径不同、不可互换。 + +掩甚时刻等时线要在路径层显式请求 `moon.OccultationPathOptions.GreatestTimeStep`:`moon/svg` 自身不提供开关,只绘制路径结果里已有的 `GreatestTimeContours`,因此请求必须在计算路径时提出,线的位置也由核心口径决定(`GreatestTimeStep` 对齐 UTC 整刻度;要按展示时区对齐,就先换算成力学时儒略日再传给 `GreatestTimeValues`)。 + +月掩的全球可见窗口通常只有数小时(HR 4799 样例为 4 小时 33 分),常用间隔比日食更密,为 15–30 分钟量级。使用 `DisableFootprints` 的紧凑掩带首次渲染会合并一次,之后同一路径走缓存。 + +### 站心事件图 + +```go +localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( + start, end, target, + 121.56601, 6.80706, 0, + moon.OccultationSearchOptions{}, + moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 720, Location: cst}, +) +fmt.Println(err, len(localSVGs)) +``` + +本地图按指定观测者的站心几何绘制,下图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。 + +![2025 月掩进贤增九指定地点见掩图](../img/lunar-occultation-hr4799-2025-06-05-local.svg) + +### 时标口径与 UT1 + +月掩图默认按 UTC 口径出图并图内声明;`TimeScale: astro.TimeScaleUT1` 出 UT1 读数与 `DUT1 = UT1−UTC` 差值,此时 `Location` 必须是 UTC,配非 UTC 时区会直接返回错误而不是画错图。 + +GeoJSON 导出(`MarshalStarOccultation*` / `MarshalPlanetOccultation*` 的 `TimeMarkerOptions.TimeScale`)遵守同一契约:UT1 口径写 `time_scale` 成员、把全部 `time` 属性换成 UT1 读数,几何保持不变。完整约定见[时标声明](map-geojson.md#时标声明)。 diff --git a/doc/manual/orbit.md b/doc/manual/orbit.md new file mode 100644 index 0000000..5e42456 --- /dev/null +++ b/doc/manual/orbit.md @@ -0,0 +1,441 @@ +# 通用小天体轨道 + +[English](en/orbit.md) | [返回 README](../../README.md) + +`orbit` 包用于按日心二体轨道根数传播天体位置,支持小行星、彗星、矮行星和自定义假想轨道。七大行星仍由各行星包使用内置 VSOP87 解析项计算。 + +`orbit.Elements` 支持两种常见写法: + +- 经典椭圆根数:`A/E/I/Omega/W/M0` +- 近日点形式:`Q/E/I/Omega/W/TpJD`,适合彗星和高偏心率轨道 + +`orbit.Elements` 的参考系固定为 J2000 平黄道/平春分点。 + +站心与升落接口的经度东正西负、纬度北正南负、椭球高单位米;位置与站心量也能与[恒星](star.md#恒星)、[坐标工具](coord.md#坐标工具)手册里的接口配合使用。 + +## 目录 + +- [用轨道根数计算谷神星位置](#用轨道根数计算谷神星位置) +- [API 参考](#api-参考) + - [轨道根数](#轨道根数) + - [位置](#位置) + - [几何量](#几何量) + - [升落与中天](#升落与中天) + - [测光](#测光) + - [视双星](#视双星) +- [常用场景](#常用场景) + - [按位置层次取小行星坐标](#按位置层次取小行星坐标) + - [今晚会不会升起、现在多高](#今晚会不会升起现在多高) + - [距日距地、相位角与 H-G 视星等](#距日距地相位角与-h-g-视星等) + - [轨道类型与位置层次](#轨道类型与位置层次) +- [参数与返回值约定](#参数与返回值约定) + - [位置层次](#位置层次) + - [单位与坐标口径](#单位与坐标口径) + - [时标与输入格式](#时标与输入格式) + - [零值与越界](#零值与越界) + - [精度与适用范围](#精度与适用范围) + - [常见误用](#常见误用) +- [相关手册](#相关手册) + +## 用轨道根数计算谷神星位置 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/orbit" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + when := time.Date(2025, 11, 21, 20, 0, 0, 0, cst) + ceres := orbit.Elements{ + EpochJD: 2461000.5, A: 2.765615651508659, E: 0.07957631994408416, + I: 10.58788658206854, Omega: 80.24963090816965, + W: 73.29975464616518, M0: 231.5397330043706, + } + pos := orbit.ApparentGeocentricEquatorial(when, ceres) + fmt.Printf("RA=%.6f Dec=%.6f deg distance=%.6f AU\n", pos.RA, pos.Dec, pos.Distance) + rise, err := orbit.RiseTime(when, ceres, 121.4737, 31.2304, 20, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +根数使用 J2000 平黄道参考系,历元为 TT/TDB 儒略日。这里是日心二体传播;长时间跨度或近距离掠过行星时,摄动误差需要另外评估。 + +## API 参考 + +### 轨道根数 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Elements` | 日心二体圆锥曲线根数 | `EpochJD`/`TpJD` 为 TT/TDB 儒略日;`A`/`Q` 为 AU,`I`/`Omega`/`W`/`M0` 为度,`E` 无量纲;`ADot`…`MDot` 为每天变化量,只作用于经典椭圆形式 | +| `MeanMotion` | 平均角速度 | 度/日;抛物线与双曲线返回 `NaN`;`MDot` 非零时直接取它 | +| `MeanAnomaly` | 平近点角 | 度(`[0,360)`);抛物线与双曲线返回 `NaN` | +| `TrueAnomaly` | 真近点角 | 度(`[0,360)`);椭圆、抛物、双曲线都有解,根数非法时返回 `NaN` | + +平近点角与真近点角按同一组根数求解,`MDot` 可以替代默认平均角速度: + +```go +fmt.Println(orbit.MeanMotion(ceres), orbit.MeanAnomaly(when, ceres), orbit.TrueAnomaly(when, ceres)) + +// 抛物线与双曲线只能走近日点形式,平均角速度与平近点角没有定义。 +parabolic := orbit.Elements{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +hyperbolic := orbit.Elements{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +fmt.Println(orbit.MeanMotion(parabolic), orbit.MeanAnomaly(when, parabolic)) +fmt.Println(orbit.TrueAnomaly(when, parabolic), orbit.TrueAnomaly(when, hyperbolic)) +``` + +完整示例同时给出椭圆根数与近日点根数两条链路: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/orbit" +) + +func main() { + // 1 Ceres 的一组经典椭圆根数,参考系为 J2000 平黄道/平春分点。 + ceres := orbit.Elements{ + EpochJD: 2461000.5, + A: 2.765615651508659, + E: 0.07957631994408416, + I: 10.58788658206854, + Omega: 80.24963090816965, + W: 73.29975464616518, + M0: 231.5397330043706, + } + ceresPos := orbit.ApparentGeocentricEquatorial( + time.Date(2025, 11, 12, 0, 0, 0, 0, time.UTC), + ceres, + ) + fmt.Printf("ceres ra=%.6f dec=%.6f distance=%.6f\n", ceresPos.RA, ceresPos.Dec, ceresPos.Distance) + + // 哈雷彗星示例:用近日点距离 Q 和近日点通过时刻 TpJD 描述。 + halley := orbit.Elements{ + Q: 0.5870992, + E: 0.9671429, + I: 162.26269, + Omega: 58.42008, + W: 111.33249, + TpJD: 2446467.395, + } + halleyPos := orbit.ApparentGeocentricEquatorial( + time.Date(1986, 2, 9, 0, 0, 0, 0, time.UTC), + halley, + ) + fmt.Printf("halley ra=%.6f dec=%.6f distance=%.6f\n", halleyPos.RA, halleyPos.Dec, halleyPos.Distance) +} +``` + +输出结果: + +```text +ceres ra=7.739532 dec=-10.625981 distance=2.164391 +halley ra=312.112360 dec=-11.826451 distance=1.533936 +``` + +轨道根数本身有历元,离历元越远,静态根数误差越明显。若数据源提供 `ADot/EDot/IDot/OmegaDot/WDot/MDot` 这类长期线性变化率,也可以填入 `Elements`,用于减轻中长期漂移。 + +### 位置 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `EclipticPosition` | 黄道球坐标返回值 | `Lon`/`Lat` 度,`Distance` AU | +| `EquatorialPosition` | 赤道球坐标返回值 | `RA`/`Dec` 度,`Distance` AU | +| `HeliocentricEclipticJ2000` | 日心 J2000 平黄道 | 几何量,不加光行时 | +| `HeliocentricEcliptic` | 日心历元黄道 | 几何量,参考系为当日平分点 | +| `GeocentricEclipticJ2000` | 地心 J2000 平黄道 | 几何量,地球与目标同取该时刻位置 | +| `GeocentricEcliptic` | 地心历元黄道 | 几何量,参考系为当日平分点 | +| `GeocentricEquatorialJ2000` | 地心 J2000 平赤道 | 几何量,黄赤交角用 J2000 值 | +| `GeocentricEquatorial` | 地心历元平赤道 | 几何量,黄赤交角取当日值 | +| `AstrometricGeocentricEquatorialJ2000` | 地心测算 J2000 赤道 | 在几何量上加光行时,可与 J2000 星表直接比对 | +| `ApparentGeocentricEcliptic` | 地心视黄道 | 光行时 + 章动,不含完整光行差 | +| `ApparentGeocentricEquatorial` | 地心视赤道 | 光行时 + 章动,不含完整光行差 | +| `ApparentTopocentricEquatorial` | 站心视赤道 | 在视赤道上再叠加站心视差修正 | + +同一时刻沿"几何 → 光行时 → 章动 → 站心"逐层加码: + +```go +h := orbit.HeliocentricEcliptic(when, ceres) // 日心历元黄道,几何量 +g := orbit.GeocentricEquatorialJ2000(when, ceres) // 地心 J2000 平赤道 +a := orbit.ApparentGeocentricEquatorial(when, ceres) // 地心视赤道 +t := orbit.ApparentTopocentricEquatorial(when, ceres, 121.4737, 31.2304, 20) +e := orbit.ApparentGeocentricEcliptic(when, ceres) +fmt.Printf("h=%.6f %.6f %.6f\n", h.Lon, h.Lat, h.Distance) +fmt.Printf("g=%.6f %.6f %.6f\n", g.RA, g.Dec, g.Distance) +fmt.Printf("a=%.6f %.6f t=%.6f %.6f e=%.6f\n", a.RA, a.Dec, t.RA, t.Dec, e.Lon) +``` + +`...J2000` 与不带后缀的历元量是两套参考系:前者固定到 J2000 平黄道/平春分点,适合与星表比对和长期存档;后者使用当日平黄道/平春分点,适合表达“当天天空”。地心量里的 `Distance` 在几何接口上是该时刻的瞬时距离,在 `Astrometric...` 上是光行时收敛后的距离,两者相差约光行时对应的位移。 + +### 几何量 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `SunDistance` | 日心距离 | AU,几何量 | +| `EarthDistance` | 地心距离 | AU,几何量 | +| `Elongation` | 日距角 | 度,地心视方向上的角距 | +| `PhaseAngle` | 相位角 | 度,0° 为全亮面朝向观测者 | +| `IlluminatedFraction` | 被照亮比例 | 无量纲,通常落在 `[0,1]` | +| `Phase` | 被照亮比例别名 | 与 `IlluminatedFraction` 同义 | +| `ParallacticAngle` | 视差角(天顶方向角) | 度,时角与赤纬取自同一次站心求解 | + +`orbit` 也提供了常见观测几何量和轻量测光接口: + +```go +r := orbit.SunDistance(when, ceres) // 日心距离 +delta := orbit.EarthDistance(when, ceres) // 地心距离 +elong := orbit.Elongation(when, ceres) // 日距角 +phase := orbit.PhaseAngle(when, ceres) // 相位角 +k := orbit.IlluminatedFraction(when, ceres) // 被照亮比例 +mag := orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12) // H-G 视星等 +q := orbit.ParallacticAngle(when, ceres, 121.4737, 31.2304, 20) // 站心视差角 + +fmt.Printf("r=%.6f delta=%.6f elong=%.6f phase=%.6f k=%.6f mag=%.3f q=%.6f\n", + r, delta, elong, phase, k, mag, q) +``` + +日距角接近 180° 时相位角接近 0°,`IlluminatedFraction` 接近 1;这三个量都由同一组地心几何推出。 + +`ParallacticAngle` 不另求赤纬,它和 `HourAngle` 复用同一次站心求解,避免时角与赤纬来自两条相差约 1 ULP 的儒略日路径而引入亚纳度漂移。几何量都只依赖 `date` 的绝对时刻,不含观测者参数;含站心参数的只有 `ParallacticAngle` 一个。 + +### 升落与中天 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `Altitude` | 视高度角 | 度,站心视位置与观测者当地民用时刻 | +| `Zenith` | 天顶距 | 度,等于 `90 - Altitude` | +| `Azimuth` | 视方位角 | 度,正北 0°、向东增加 | +| `HourAngle` | 站心视时角 | 度 | +| `CulminationTime` | 中天时刻 | `time.Time`,保持输入 `date` 的时区 | +| `RiseTime` | 升起时刻 | `(time.Time, error)`,第二返回值为哨兵错误 | +| `SetTime` | 落下时刻 | `(time.Time, error)`,第二返回值为哨兵错误 | +| `ERR_ORBIT_NEVER_RISE` | 目标当日永不升起的哨兵错误 | 由 `RiseTime` 返回 | +| `ERR_ORBIT_NEVER_SET` | 目标当日永不落下的哨兵错误 | 由 `SetTime` 返回 | + +```go +fmt.Println(orbit.Zenith(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.HourAngle(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.CulminationTime(when, ceres, 121.4737, 31.2304, 20).Format(time.RFC3339)) + +day := time.Date(2025, 11, 21, 0, 0, 0, 0, site) +set, err := orbit.SetTime(day, ceres, 121.4737, 31.2304, 20, true) +if errors.Is(err, orbit.ERR_ORBIT_NEVER_SET) { + fmt.Println("当日不落", set) +} +``` + +已有轨道根数时,也可以把它当作一个“可观测目标”来求站心观测量: + +```go +site := time.FixedZone("CST", 8*3600) +when := time.Date(2025, 11, 21, 20, 0, 0, 0, site) + +alt := orbit.Altitude(when, ceres, 121.4737, 31.2304, 20) // 视高度角 +az := orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20) // 视方位角 +rise, _ := orbit.RiseTime(time.Date(2025, 11, 21, 0, 0, 0, 0, site), ceres, 121.4737, 31.2304, 20, true) // 升起时刻 + +fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) +``` + +这些观测接口基于站心视坐标计算,适合直接拿去做小行星、彗星或自定义二体目标的升落和指向辅助。 + +`aero` 为假时判据是几何地平线,为真时把目标高度取到 `-0.5667°` 并叠加依椭球高、纬度算出的地平俯角。`RiseTime`/`SetTime` 的第二个返回值是真正的错误:只有当日确实没有升/落才映射成 `ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET`,其余失败原样透传。 + +### 测光 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `AsteroidMagnitudeHG` | 小行星 H-G 模型视星等 | `absoluteMagnitude` 为绝对星等 H,`slopeParameter` 为斜率参数 G;无单位 | + +```go +fmt.Printf("H-G magnitude=%.3f\n", orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12)) +fmt.Printf("r=%.6f delta=%.6f elong=%.6f\n", + orbit.SunDistance(when, ceres), orbit.EarthDistance(when, ceres), orbit.Elongation(when, ceres)) +fmt.Printf("phase=%.6f k=%.6f k2=%.6f\n", + orbit.PhaseAngle(when, ceres), orbit.IlluminatedFraction(when, ceres), orbit.Phase(when, ceres)) +``` + +H-G 模型只用日心距、地心距和相位角,不引入目标的半径、反照率或自转;`G` 的取值由外部星表给出,本包不做默认值填充。 + +### 视双星 + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `VisualBinaryElements` | 视双星轨道要素 | `PeriodYears` 平太阳年,`PeriastronYear` 带小数的年,`SemiMajorAxis` 角秒,`Inclination`/`AscendingNode`/`PeriastronArgument` 度,`Eccentricity` 无量纲 | +| `VisualBinaryPosition` | 视双星计算结果 | `MeanAnomaly`/`EccentricAnomaly`/`TrueAnomaly`/`PositionAngle` 度,`Radius`/`Separation` 角秒 | +| `VisualBinary` | 按时刻求视双星位置 | 先把时刻换算为 UTC 小数年,再套经典视轨道公式 | +| `VisualBinaryByYear` | 按小数年求视双星位置 | 直接给小数年,跳过时刻换算 | + +```go +gammaVir := orbit.VisualBinaryElements{ + PeriodYears: 171.37, PeriastronYear: 1836.433, Eccentricity: 0.8808, + SemiMajorAxis: 3.746, Inclination: 146.05, AscendingNode: 31.78, PeriastronArgument: 252.88, +} +vb := orbit.VisualBinaryByYear(2026.0, gammaVir) +fmt.Printf("theta=%.6f rho=%.6f M=%.6f\n", vb.PositionAngle, vb.Separation, vb.MeanAnomaly) +``` + +`orbit` 里还带了一个视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距: + +```go +gammaVir := orbit.VisualBinaryElements{ + PeriodYears: 171.37, + PeriastronYear: 1836.433, + Eccentricity: 0.8808, + SemiMajorAxis: 3.746, + Inclination: 146.05, + AscendingNode: 31.78, + PeriastronArgument: 252.88, +} +vb := orbit.VisualBinary(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), gammaVir) +fmt.Printf("theta=%.6f rho=%.6f\n", vb.PositionAngle, vb.Separation) // 位置角与角距 +``` + +位置角按北为 0°、东为 90° 度量,角距离与径矢 `Radius` 的单位都是角秒。 + +## 常用场景 + +### 按位置层次取小行星坐标 + +```go +helio := orbit.HeliocentricEcliptic(when, ceres) +geo := orbit.GeocentricEclipticJ2000(when, ceres) +ast := orbit.AstrometricGeocentricEquatorialJ2000(when, ceres) +app := orbit.ApparentGeocentricEquatorial(when, ceres) +fmt.Println(helio.Lon, helio.Lat, helio.Distance) +fmt.Println(geo.Lon, geo.Lat, geo.Distance) +fmt.Println(ast.RA, ast.Dec) +fmt.Println(app.RA, app.Dec, app.Distance) +``` + +```text +19.251489 -9.315340 2.912174 +2.147652 -12.026350 2.262445 +6.795211 -10.172420 +7.125008 -10.028726 2.262489 +``` + +四个层次含义不同:`Heliocentric*` 是相对太阳的位置(第一个数就是日心距),`Geocentric*J2000` 是 J2000 口径的地心位置,`Astrometric*J2000` 去掉光行时、适合与星表对表,`Apparent*` 是当日视位置、观测与出图用它。需要站心视位置用 `ApparentTopocentricEquatorial(when, ceres, lon, lat, height)`。 + +### 今晚会不会升起、现在多高 + +```go +fmt.Println(orbit.RiseTime(when, ceres, 121.4737, 31.2304, 20, true)) +fmt.Println(orbit.CulminationTime(when, ceres, 121.4737, 31.2304, 20)) +fmt.Println(orbit.Altitude(when, ceres, 121.4737, 31.2304, 20), + orbit.Azimuth(when, ceres, 121.4737, 31.2304, 20)) +``` + +```text +2025-11-21 14:41:48.913 CST +2025-11-21 20:19:34 CST +48.472628 172.699168 +``` + +- `aero = true` 用蒙气差与视半径修正后的地平;`height` 是椭球高(米);经度东正西负。 +- 环极或极区目标没有升落:`RiseTime`/`SetTime` 返回 `orbit.ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET` 哨兵错误,用 `errors.Is` 判定。 + +### 距日距地、相位角与 H-G 视星等 + +```go +fmt.Println(orbit.SunDistance(when, ceres), orbit.EarthDistance(when, ceres)) +fmt.Println(orbit.Elongation(when, ceres), orbit.PhaseAngle(when, ceres)) +fmt.Println(orbit.IlluminatedFraction(when, ceres), orbit.AsteroidMagnitudeHG(when, ceres, 3.34, 0.12)) +``` + +```text +2.912174 2.262445 +122.264630 16.670089 +0.978986 8.360 +``` + +- `PhaseAngle` 是太阳-天体-地球夹角(度),`IlluminatedFraction` 是照明比例;`Phase` 只是 `IlluminatedFraction` 的别名,别把它当成相位角。 +- H-G 视星等要传绝对星等 `H` 与斜率参数 `G`(示例用谷神星的 `3.34` 与 `0.12`)。 + +### 轨道类型与位置层次 + +```go +parabolic := orbit.Elements{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +hyperbolic := orbit.Elements{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2461000.5} +fmt.Println(orbit.MeanMotion(parabolic), orbit.MeanAnomaly(when, parabolic)) +fmt.Println(orbit.TrueAnomaly(when, parabolic), orbit.TrueAnomaly(when, hyperbolic)) +fmt.Println(orbit.MeanMotion(ceres)) +``` + +```text +NaN NaN +0.817533 0.537610 +0.21429712142765137 +``` + +- 抛物线与双曲线只能走近日点形式(`Q` + `TpJD`):平均角速度与平近点角没有定义、返回 `NaN`,真近点角三种轨道都有解。 +- 与外部星历对表时先统一位置层次与参考系(`Elements` 固定为 J2000 平黄道/平春分点);`ADot…WDot` 只作用于经典椭圆形式,`MDot` 非零时可直接替代默认平均角速度。 + +## 参数与返回值约定 + +### 位置层次 + +| 层次 | 代表接口 | 包含的改正 | +| --- | --- | --- | +| 日心几何 | `HeliocentricEcliptic` / `HeliocentricEclipticJ2000` | 无 | +| 地心几何 | `GeocentricEcliptic` / `GeocentricEclipticJ2000` / `GeocentricEquatorial` / `GeocentricEquatorialJ2000` | 减去地球日心位置 | +| 地心测算 | `AstrometricGeocentricEquatorialJ2000` | 光行时 | +| 地心视 | `ApparentGeocentricEcliptic` / `ApparentGeocentricEquatorial` | 光行时 + 章动 | +| 站心视 | `ApparentTopocentricEquatorial` 及全部升落接口 | 光行时 + 章动 + 站心视差 | + +### 单位与坐标口径 + +- 角度一律用度,距离用 AU,站心与升落接口的椭球高用米,时间用 `time.Time`。 +- `Elements` 的参考系是 J2000 平黄道/平春分点;`...J2000` 结尾的接口保持该参考系,其余 `HeliocentricEcliptic`/`GeocentricEcliptic`/`GeocentricEquatorial` 是历元(of date)量,换参考系时不要混用。 +- 位置分三层:`Heliocentric*`/`Geocentric*` 是几何量,`AstrometricGeocentricEquatorialJ2000` 在几何量上加光行时(按距离迭代求解,上限 8 次、`1e-12` 天收敛),`Apparent*` 在光行时之上再加章动。仓库的行星口径就是“光行时 + 章动、不含完整外部光行差模型”,所以 `Apparent*` 与行星包同级,不应把它当成全项视位置。 +- `MeanMotion`/`MeanAnomaly`/`TrueAnomaly` 输出度;`MeanMotion` 与 `MeanAnomaly` 对抛物线和双曲线没有定义。 + +### 时标与输入格式 + +- `EpochJD` 与 `TpJD` 都是 TT/TDB 儒略日;坐标、几何量与测光接口把 `date` 当绝对时刻处理(`date.UTC()` 后经 `UTC2TT` 换成 TT/TDB)。`UTC2TT` 在 1972-01-01 之前把民用时刻按 UT1 处理,窗口内使用内置闰秒表,闰秒表可用 `astro.SetTTMinusUTC` 覆盖。 +- 瞬时站心量将 `date` 的当地字段与 `date.Zone()` 偏移一起换回绝对时刻。同一时刻用 UTC 或当地时区表示,所得位置相同。升落搜索还按当地日期选事件,因此应选择观测日历所用的时区。 +- `CulminationTime`、`RiseTime`、`SetTime` 的结果保持输入 `date` 的时区,是民用时刻;三者内部在 `date.Hour() > 12` 时先回退 12 小时,以保持搜索锚点在所选日期内。 +- 公开 API 的含 `time.Time` 输出一律是民用时刻(UTC 标签);UT1 与 TT 只出现在内部换算里,需要显式换算时用根包的 `astro.UT1FromUTC` / `astro.TTFromUTC`。 + +### 零值与越界 + +- `Elements` 零值不是合法轨道。经典椭圆形式要求 `A` 有限且为正、`E` 落在 `[0,1)`、`EpochJD` 与 `M0` 有限;`E >= 1` 的抛物线与双曲线只能走近日点形式,此时要求 `Q` 有限且为正、`TpJD` 有限、`E >= 0`,三个角度 `I`/`Omega`/`W` 都必须有限。 +- `Q > 0` 且 `TpJD` 有限时优先按近日点形式解释,`A`/`M0`/`EpochJD` 被忽略;此时 `ADot`/`EDot`/`IDot`/`OmegaDot`/`WDot` 都不生效,只有 `MDot` 非零时会被当作平均角速度使用。 +- 根数非法时 `MeanMotion`、`MeanAnomaly` 返回 `NaN`,位置接口返回三个 `NaN`;`AsteroidMagnitudeHG` 在输入非有限或日心距、地心距、相位角非正时返回 `NaN`,H-G 相位混合项为零时返回 `+Inf`。 +- `RiseTime` 与 `SetTime` 在给定当地日内找不到升/落时返回哨兵错误 `ERR_ORBIT_NEVER_RISE` / `ERR_ORBIT_NEVER_SET`,其它失败原样透传;成功时第二个返回值为 `nil`。 +- `VisualBinary` 与 `VisualBinaryByYear` 在 `PeriodYears <= 0`、`SemiMajorAxis <= 0`、`Eccentricity` 不落在 `[0,1)` 或任一要素非有限时,把 `VisualBinaryPosition` 的全部数值字段填成 `NaN`。 + +### 精度与适用范围 + +- `orbit` 是二体圆锥曲线传播:只含所给根数,不含行星摄动、非引力项与相对论改正;离历元越远,静态根数的误差越大,`ADot`…`WDot` 只能缓解线性漂移,不能替代重新拟合或数值积分。 +- `Apparent*` 与站心量的差别只在几何与章动,不含大气折射;升落接口的 `aero=true` 才把地平折射算进判据(目标高度取 `-0.5667°`,再叠加依椭球高与纬度算出的地平俯角)。 +- `RiseTime`/`SetTime` 内部用 `round(observerLon/15)` 的标称时区迭代升落几何,因此观测点经度最好落在时区中心附近;只调用一次求根,极区或拱极目标的边界情况以哨兵错误为准。 +- 视双星求解器用的是《天文算法》第 55 章的经典视轨道公式,`Eccentricity >= 1` 时不适用;它只做几何投影,不含质量、光度或摄动信息。 + +### 常见误用 + +- 把 `HeliocentricEcliptic` 的返回值当作地心坐标:日心量的原点是太阳,地心量已经减掉了地球的日心位置。 +- 把 `Apparent*` 当作含大气折射的视位置:折射只出现在升落判据与观测类接口里,坐标接口不含折射。 +- 用零值 `Elements` 探路:`MeanMotion` 与位置接口会安静地返回 `NaN`,不会报错。 +- 在近日点形式里期待 `ADot`…`WDot` 生效:这些变化率只在经典椭圆形式下参与传播。 + +## 相关手册 + +- 恒星与星表:[恒星](star.md#恒星) +- 坐标系换算与站心量:[坐标工具](coord.md#坐标工具) +- 日出日落、月出月落的同类接口:[太阳与月亮](sun-moon.md#日出日落月出月落) + +> 需要把这里的黄道/赤道坐标接到恒星表或站心量上时,参见[恒星](star.md#恒星)与[坐标工具](coord.md#坐标工具)。 diff --git a/doc/manual/planets.md b/doc/manual/planets.md new file mode 100644 index 0000000..3a9bb14 --- /dev/null +++ b/doc/manual/planets.md @@ -0,0 +1,872 @@ +# 行星 + +[English](en/planets.md) | [返回 README](../../README.md) + +七大行星各对应一个同名包:`mercury`、`venus`、`mars`、`jupiter`、`saturn`、`uranus`、`neptune`。它们的公开接口按同一套形状组织:以 `time.Time` 传入民用时刻,以 `float64` 返回角度或距离,事件搜索返回 `time.Time` 或结构体。内行星(水星、金星)额外提供上合/下合、大距与地心凌日;外行星(火星到海王星)额外提供冲日与方照;木星独有伽利略卫星,土星独有土星环参数。 + +低层的 VSOP87 级数与日月解析级数放在 `planet` 包,被这七个包与 `sun` / `moon` 共用。 + +- 七个包公共能力的函数名一致(例如都提供 `ApparentRa`、`ApparentDec` 与 `ApparentRaDec`),差异只体现在各自额外的那几族接口上;调用时必须带对应包名前缀,不能跨包混用。 +- 位置接口给出的是**地心视位置**;站心量与地平坐标单独由 `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` 提供,公式入口见[坐标工具](coord.md)。 +- 事件搜索族都成对出现(`Last...` / `Next...`,部分另有 `Closest...`),一律取"当前或之前/之后最近一次"并包含端点。 +- 行星本身没有独立的 SVG 出图入口;月掩相关出图(含土星环月掩)见[月掩手册](occultation.md#月掩出图)。 + +## 目录 + +- [火星的位置与升起时刻](#火星的位置与升起时刻) +- [API 参考](#api-参考) + - [按行星横向对照](#按行星横向对照) + - [通用能力与单位](#通用能力与单位) + - [`...N` 截断族](#n-截断族) +- [常用场景](#常用场景) + - [今晚能看到哪颗行星](#今晚能看到哪颗行星) + - [冲日、合日、大距、留与逆行](#冲日合日大距留与逆行) + - [水星与金星凌日](#水星与金星凌日) + - [相位、视直径、视星等与节点](#相位视直径视星等与节点) + - [物理星历与木星伽利略卫星](#物理星历与木星伽利略卫星) + - [与外部资料的对照口径](#与外部资料的对照口径) +- [基础示例](#基础示例) + - [内行星](#内行星) + - [外行星](#外行星) +- [分主题示例](#分主题示例) + - [位置与坐标](#位置与坐标) + - [升落与中天](#升落与中天) + - [合冲留与方照](#合冲留与方照) + - [大距与地心凌日](#大距与地心凌日) + - [节点、相位、视星等、视直径与视差角](#节点相位视星等视直径与视差角) + - [与其他手册的分工](#与其他手册的分工) + - [物理星历](#物理星历) + - [木星伽利略卫星](#木星伽利略卫星) + - [与 `planet` 包共用的类型与常量](#与-planet-包共用的类型与常量) +- [参数与返回值约定](#参数与返回值约定) + - [时标与民用时刻](#时标与民用时刻) + - [单位与口径](#单位与口径) + - [零值、越界与哨兵错误](#零值越界与哨兵错误) + - [站心与地心](#站心与地心) + - [精度与适用范围](#精度与适用范围) + +## 火星的位置与升起时刻 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/mars" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 108.93, 34.27, 0.0 + ra, dec := mars.ApparentRaDec(date) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", ra, dec) + rise, err := mars.RiseTime(date, lon, lat, height, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +`ApparentRaDec` 返回地心视赤经、视赤纬,单位度。升落接口还需要观测地和椭球高;没有升起事件时返回错误。 + +## API 参考 + +### 按行星横向对照 + +下表按能力对照七个行星包。调用时加包名前缀,例如 `mars.NextOpposition`;`/` 分隔并列函数,`—` 表示该包没有对应接口。 + +| 能力 | `mercury` | `venus` | `mars` | `jupiter` | `saturn` | `uranus` | `neptune` | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 视黄经 / 视黄纬 | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | `ApparentLo` / `ApparentBo` | +| 视赤经 / 视赤纬 | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | `ApparentRa` / `ApparentDec` / `ApparentRaDec` | +| 视星等 | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | `ApparentMagnitude` | +| 地心距 / 日心距 | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | `EarthDistance` / `SunDistance` | +| 轨道升交点 / 降交点 | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | `AscendingNode` / `DescendingNode` | +| 视直径 / 视半径 | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | `Diameter` / `Semidiameter` | +| 相位角 / 照亮比例 / 亮面位置角 | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | `PhaseAngle` / `Phase` / `IlluminatedFraction` / `BrightLimbPositionAngle` | +| 站心地平量 | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | `Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` | +| 升 / 落 / 中天 | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | `RiseTime` / `SetTime` / `DownTime` / `CulminationTime` | +| 物理星历 | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` | `Physical` / `PhysicalN` / `CentralMeridians` / `CentralMeridiansN` | `Physical` / `PhysicalN` / `PhysicalSystemIII` / `Ring` | `Physical` / `PhysicalN` / `PhysicalSystemIII` | `Physical` / `PhysicalN` | +| 合日 | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | `LastConjunction` / `NextConjunction` | +| 上合 / 下合 | `LastSuperiorConjunction` / `NextSuperiorConjunction` / `LastInferiorConjunction` / `NextInferiorConjunction` | `LastSuperiorConjunction` / `NextSuperiorConjunction` / `LastInferiorConjunction` / `NextInferiorConjunction` | — | — | — | — | — | +| 留 | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde`,另有 `LastRetrograde` / `NextRetrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde`,另有 `LastRetrograde` / `NextRetrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | `LastProgradeToRetrograde` / `NextProgradeToRetrograde` / `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde` | +| 冲日 | — | — | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | `LastOpposition` / `NextOpposition` | +| 方照 | — | — | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | `LastEasternQuadrature` / `NextEasternQuadrature` / `LastWesternQuadrature` / `NextWesternQuadrature` | +| 大距 | `LastGreatestElongation` / `NextGreatestElongation` / `LastGreatestElongationEast` / `NextGreatestElongationEast` / `LastGreatestElongationWest` / `NextGreatestElongationWest` | `LastGreatestElongation` / `NextGreatestElongation` / `LastGreatestElongationEast` / `NextGreatestElongationEast` / `LastGreatestElongationWest` / `NextGreatestElongationWest` | — | — | — | — | — | +| 地心凌日 | `LastTransit` / `NextTransit` / `ClosestTransit` | `LastTransit` / `NextTransit` / `ClosestTransit` | — | — | — | — | — | +| 伽利略卫星 | — | — | — | `Satellites` / `SatellitePhenomena` / `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent` / `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent` | — | — | — | +| 结果类型 | `PhysicalInfo` / `TransitInfo` | `PhysicalInfo` / `TransitInfo` | `PhysicalInfo` | `PhysicalInfo` / `CentralMeridianInfo` / `GalileanSatellitesInfo` / `GalileanPhenomenaInfo` / `GalileanSatellitePosition` / `GalileanSatellitePhenomenon` / `GalileanPhenomenonEvent` / `GalileanPhenomenonContactEvent` | `PhysicalInfo` / `RingInfo` | `PhysicalInfo` | `PhysicalInfo` | + +七个包的**截断族**都叫同样的名字:给任意一个瞬时求值接口加 `N` 后缀即可,`n < 0` 用全部内置项、`n >= 0` 截断,详见 [`...N` 截断族](#n-截断族)。事件搜索族(`Last...` / `Next...` / `Closest...`)没有 `N` 版本。 + +### 通用能力与单位 + +| 能力 | 用途 | 单位与口径 | +| --- | --- | --- | +| `ApparentLo` / `ApparentBo` | 地心视黄经、视黄纬 | 度;当日真春分点,含光行时、光行差与章动 | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | 地心视赤经、视赤纬 | 度;当日真赤道;`ApparentRaDec` 一次返回两者 | +| `ApparentMagnitude` | 视星等 | 星等(无量纲) | +| `Altitude` / `Azimuth` / `Zenith` / `HourAngle` | 站心地平坐标 | 度;方位角自正北向东增加,`Zenith` 等于 `90 - Altitude` | +| `RiseTime` / `SetTime` / `DownTime` | 当地民用日内的升起、落下 | `time.Time`,保持输入时区;`DownTime` 是 `SetTime` 的兼容别名 | +| `CulminationTime` | 上中天时刻 | `time.Time`,保持输入时区 | +| `ParallacticAngle` | 视差角(天顶方向角) | 度 | +| `Diameter` / `Semidiameter` | 地心视直径、视半径 | 角秒 | +| `PhaseAngle` | 太阳–行星–地球夹角 | 度 | +| `IlluminatedFraction` / `Phase` | 被照亮比例 | `0–1`;`Phase` 是 `IlluminatedFraction` 的别名 | +| `BrightLimbPositionAngle` | 亮面中心位置角 | 度 | +| `EarthDistance` / `SunDistance` | 地心距、日心距 | AU | +| `AscendingNode` / `DescendingNode` | 轨道面与黄道面交点的黄经 | 度;同一时刻两者相差约 `180°` | +| `Physical` | 盘面朝向、子地/子日经纬度、北极位置角 | 度;经度正方向按各天体 IAU 约定 | +| `planet.WherePlanet` / `planet.WherePlanetN` | VSOP87 黄经、黄纬、日心距 | 度 / AU;越界返回 `NaN` 而不 panic | + +### `...N` 截断族 + +每个瞬时求值接口都有 `...N` 后缀版本,用来在精度与开销之间取舍:`n < 0` 使用全部内置项,与非 `N` 接口等价;`n >= 0` 时按约 `n` 个主项截断并等比缩短高阶项。事件搜索族(`Last...` / `Next...` / `Closest...`)没有 `N` 版本。 + +带 `N` 的族包括 `ApparentLoN`、`ApparentBoN`、`ApparentRaN`、`ApparentDecN`、`ApparentRaDecN`、`ApparentMagnitudeN`、`EarthDistanceN`、`SunDistanceN`、`AltitudeN`、`AzimuthN`、`ZenithN`、`HourAngleN`、`CulminationTimeN`、`RiseTimeN`、`SetTimeN`、`DownTimeN`、`ParallacticAngleN`、`DiameterN`、`SemidiameterN`、`PhaseAngleN`、`PhaseN`、`IlluminatedFractionN`、`BrightLimbPositionAngleN`、`AscendingNodeN`、`DescendingNodeN`、`PhysicalN`,以及木星的 `CentralMeridiansN`、土星的 `RingN` 与 `PhysicalSystemIIIN`、天王星的 `PhysicalSystemIIIN`。 + +```go +fmt.Println(mars.ApparentLo(date), mars.ApparentLoN(date, 8)) // 视黄经:全部内置项 / 截断到约 8 项 +fmt.Println(mars.SunDistance(date), mars.SunDistanceN(date, 8)) // 日心距,AU +``` + +## 常用场景 + +### 今晚能看到哪颗行星 + +```go +fmt.Println(venus.RiseTime(date, lon, lat, height, true)) // 金星当日升起 +fmt.Println(jupiter.CulminationTime(date, lon)) // 木星上中天 +fmt.Println(mars.Altitude(date, lon, lat), mars.Azimuth(date, lon, lat)) // 火星此刻高度与方位 +``` + +```text +2020-01-01 10:02:34.350145161 +0800 CST +2020-01-01 12:32:17.585815787 +0800 CST +31.194578177219057 152.07031660415714 +``` + +`Altitude` 大于 `0` 才在地平线上,方位角自正北向东增加;`aero = true` 按标准大气折射把几何地平线降到约 `-0.5667°`,逐参数口径与极区哨兵错误见[升落与中天](#升落与中天)。 + +### 冲日、合日、大距、留与逆行 + +```go +fmt.Println(mars.NextOpposition(date)) // 火星下次冲日 +fmt.Println(jupiter.NextConjunction(date)) // 木星下次合日 +fmt.Println(saturn.NextProgradeToRetrograde(date)) // 土星下次顺转逆的留 +``` + +```text +2020-10-14 07:25:50.441412627 +0800 CST +2021-01-29 09:39:33.697994649 +0800 CST +2020-05-11 17:26:53.961271941 +0800 CST +``` + +事件搜索取"当前或之后最近一次"并保持输入时区,成对的 `Last...` 与不区分方向的 `NextRetrograde` 见[合冲留与方照](#合冲留与方照);水星、金星的 `NextGreatestElongationEast` / `...West` 见[大距与地心凌日](#大距与地心凌日)。 + +### 水星与金星凌日 + +```go +transit := mercury.NextTransit(date) // 2020 年之后下一场地心水星凌日 +fmt.Println(transit.Valid, transit.Start, transit.Greatest) // 是否有凌日、一触与凌甚 +fmt.Println(transit.Duration, transit.MinimumSeparationArcsec) // 历时与凌甚最小角距 +``` + +```text +true 2032-11-13 14:41:13.161198198 +0800 CST 2032-11-13 16:54:12.821315824 +0800 CST +4h26m2.695272267s 572.0643215495325 +``` + +`TransitInfo.Valid` 为假表示搜索窗口内没有凌日、其余字段是零值;凌日只判断地心几何,不判断观测地当时太阳是否在地平线上。四触、偏凌与内切口径见[大距与地心凌日](#大距与地心凌日)。 + +### 相位、视直径、视星等与节点 + +```go +fmt.Println(venus.PhaseAngle(date), venus.Phase(date)) // 相位角(度)与被照亮比例 +fmt.Println(venus.Diameter(date), venus.ApparentMagnitude(date)) // 视直径(角秒)与视星等 +fmt.Println(mars.AscendingNode(date), mars.DescendingNode(date)) // 升降交点黄经(度) +``` + +```text +49.98145049145023 0.8215177914415865 +13.059409604614839 -4 +49.71479005849112 229.71479005849113 +``` + +`Phase` 是 `IlluminatedFraction` 的别名,取值 `0–1`;升降交点同一时刻相差约 `180°`。各量的定义、别名与截断版见[节点、相位、视星等、视直径与视差角](#节点相位视星等视直径与视差角)。 + +### 物理星历与木星伽利略卫星 + +```go +j := jupiter.Physical(date) // 木星物理星历 +fmt.Println(j.DS, j.DE, j.CentralMeridianSystemIII) // 子日/子地赤纬与 System III 中央经线 +sats := jupiter.Satellites(date) // 四颗伽利略卫星的瞬时位置 +fmt.Println(sats.Io.OffsetXJupiterR, sats.Io.InFrontOfJupiter) // 木卫一偏移与是否在盘面前方 +``` + +```text +-56.55778470155335 -2.039966127259664 311.37430665615585 +3.8223302102343975 false +``` + +土星环另有 `saturn.Ring`(`EarthLatitude`、`MinorAxis` 等,角度单位度、长短轴单位角秒);盘面朝向与中央经线的完整口径见[物理星历](#物理星历),伽利略卫星的公开入口在 `jupiter` 包,`basic` 的 `JupiterGalilean*` 是接收儒略日的低层入口,见[木星伽利略卫星](#木星伽利略卫星)。 + +### 与外部资料的对照口径 + +```go +fmt.Println(mars.ApparentLo(date), mars.ApparentLoN(date, 8)) // 全部内置项 / 约 8 项截断的视黄经 +_, err := mars.RiseTime(date, 0, 89, 0, true) // 极区观测点 +fmt.Println(errors.Is(err, mars.ERR_MARS_NEVER_RISE), errors.Is(err, mars.ERR_MARS_NEVER_SET)) +``` + +```text +238.38840227925655 238.39637464888327 +true false +``` + +`ApparentLo` 等是**当日真春分点**的地心视位置,与外部 J2000 或平位置对表前先用 `coord.Precess` 归算;截断族 `n < 0` 用全部内置项、`n >= 0` 截断。伽利略卫星接触事件与 JPL Horizons / IMCCE 年表的口径差异见[与外部资料对照](#与外部资料对照),整体边界见[参数与返回值约定](#参数与返回值约定)。 + +## 基础示例 + +下面两段是最小可运行示例,沿用 `date = 2020-01-01 08:08:08 CST` 与西安市坐标。 + +### 内行星 + +```go +package main + +import ( + "b612.me/astro/mercury" + "b612.me/astro/venus" + "fmt" + "time" +) + +func main() { + // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // 指定观测时刻。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + //水星上次下合时间 + fmt.Println(mercury.LastInferiorConjunction(date)) + //金星下次上合时间 + fmt.Println(venus.NextSuperiorConjunction(date)) + //水星上次留(顺转逆)时间(水逆) + fmt.Println(mercury.LastProgradeToRetrograde(date)) + //金星下次留(逆转顺)时间 + fmt.Println(venus.NextRetrogradeToPrograde(date)) + //水星上次东大距时间 + fmt.Println(mercury.LastGreatestElongationEast(date)) + //金星下次西大距时间 + fmt.Println(venus.NextGreatestElongationWest(date)) + //西安市今日金星升起,降落时间 + fmt.Println(venus.RiseTime(date, lon, lat, height, true)) + fmt.Println(venus.SetTime(date, lon, lat, height, true)) + //金星当前视星等 + fmt.Println(venus.ApparentMagnitude(date)) + //金星相位角、被照亮比例、亮面中心位置角 + fmt.Println(venus.PhaseAngle(date)) + fmt.Println(venus.Phase(date)) + fmt.Println(venus.BrightLimbPositionAngle(date)) + //金地距离 + fmt.Println(venus.EarthDistance(date)) + //金日距离 + fmt.Println(venus.SunDistance(date)) +} +``` + +输出结果: + +``` +2019-11-11 23:21:41.971051096 +0800 CST // 水星上次下合 +2021-03-26 14:57:42.052354216 +0800 CST // 金星下次上合 +2019-11-01 04:31:49.749019145 +0800 CST // 水星上次由顺行转逆行的留 +2020-06-25 02:07:41.599749326 +0800 CST // 金星下次由逆行转顺行的留 +2019-10-20 12:01:37.740152478 +0800 CST // 水星上次东大距 +2020-08-13 08:14:46.304587125 +0800 CST // 金星下次西大距 +2020-01-01 10:02:34.172435402 +0800 CST // 西安当天金星升起时刻;无错误 +2020-01-01 20:25:37.36411482 +0800 CST // 西安当天金星落下时刻;无错误 +-4 // 金星视星等 +49.98145049145023 // 金星相位角,单位度 +0.8215177914415865 // 金星被照亮比例 +255.63802053541346 // 金星亮面中心位置角,单位度 +1.2778819631550336 // 金地距离,单位 AU +0.7262651056423838 // 金日距离,单位 AU +``` + +内外行星同样提供 `Diameter` / `Semidiameter`(以及 `N` 版),返回地心视直径/视半径,单位为角秒。 + +行星视直径或轨道节点也可以单独查询: + +```go +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) +fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date)) +``` + +这里的“升交点 / 降交点”指天体轨道面与黄道面的两个交点: + +- `AscendingNode`:天体从黄道南侧穿到黄道北侧时对应的黄经 +- `DescendingNode`:天体从黄道北侧穿到黄道南侧时对应的黄经 +- 返回值单位都是度;对同一时刻而言,降交点通常与升交点相差约 `180°` + +以上面 `date := 2020-01-01 08:08:08 CST` 的示例来说,输出结果是: + +```text +4.287299886569956 2.143649943284978 // 火星视直径、视半径,单位角秒 +76.86008484515058 256.8600848451506 // 金星升交点、降交点黄经,单位度 +``` + +水星和金星还提供 `NextTransit` / `LastTransit` / `ClosestTransit` 地心凌日查询。这里的“地心”指从地球中心看到的行星圆面经过太阳圆面,不判断某个地点当时太阳是否在地平线上;如果要做观测计划,还需要结合本地太阳高度角和天气条件。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/mercury" + "b612.me/astro/venus" +) + +func main() { + // 查询 2019 年之后下一次地心水星凌日。 + mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)) + fmt.Println(mercuryTransit.Valid) + fmt.Println(mercuryTransit.Start) + fmt.Println(mercuryTransit.InternalStart) + fmt.Println(mercuryTransit.Greatest) + fmt.Println(mercuryTransit.InternalEnd) + fmt.Println(mercuryTransit.End) + fmt.Println(mercuryTransit.Duration) + fmt.Println(mercuryTransit.MinimumSeparationArcsec) + fmt.Println(mercuryTransit.SunSemidiameterArcsec) + fmt.Println(mercuryTransit.PlanetSemidiameterArcsec) + + // 查询 2012 年之后下一次地心金星凌日。 + venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC)) + fmt.Println(venusTransit.Valid) + fmt.Println(venusTransit.Start) + fmt.Println(venusTransit.InternalStart) + fmt.Println(venusTransit.Greatest) + fmt.Println(venusTransit.InternalEnd) + fmt.Println(venusTransit.End) + fmt.Println(venusTransit.Duration) +} +``` + +输出结果: + +```text +true // 找到一次有效的地心水星凌日 +2019-11-11 12:35:31.567597389 +0000 UTC // 一触:水星外切进入太阳圆面 +2019-11-11 12:37:12.817581295 +0000 UTC // 二触:水星完全进入太阳圆面 +2019-11-11 15:19:48.36056292 +0000 UTC // 凌甚:水星中心最接近太阳中心 +2019-11-11 18:02:29.176982045 +0000 UTC // 三触:水星开始离开太阳圆面 +2019-11-11 18:04:10.637948513 +0000 UTC // 四触:水星外切离开太阳圆面 +5h28m39.070351124s // 一触到四触的地心凌日持续时间 +75.92400059923187 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒 +968.8881519533047 // 凌甚时太阳视半径,单位角秒 +4.978442871670873 // 凌甚时水星视半径,单位角秒 +true // 找到一次有效的地心金星凌日 +2012-06-05 22:09:47.466886639 +0000 UTC // 一触:金星外切进入太阳圆面 +2012-06-05 22:27:35.865356326 +0000 UTC // 二触:金星完全进入太阳圆面 +2012-06-06 01:29:35.572371482 +0000 UTC // 凌甚:金星中心最接近太阳中心 +2012-06-06 04:31:35.068444311 +0000 UTC // 三触:金星开始离开太阳圆面 +2012-06-06 04:49:23.25597167 +0000 UTC // 四触:金星外切离开太阳圆面 +6h39m35.789085031s // 一触到四触的地心凌日持续时间 +``` + +### 外行星 + +```go +package main + +import ( + "b612.me/astro/jupiter" + "b612.me/astro/mars" + "b612.me/astro/neptune" + "b612.me/astro/saturn" + "b612.me/astro/uranus" + "fmt" + "time" +) + +func main() { + // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // 指定观测时刻。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + //火星下次冲日时间 + fmt.Println(mars.NextOpposition(date)) + //木星下次合日时间 + fmt.Println(jupiter.NextConjunction(date)) + //土星上次留(顺转逆)时间(土逆) + fmt.Println(saturn.LastProgradeToRetrograde(date)) + //土星环观测参数 + ring := saturn.Ring(date) + fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f dU=%.6f major=%.6f minor=%.6f\n", + ring.EarthLatitude, + ring.SunLatitude, + ring.PositionAngle, + ring.DeltaU, + ring.MajorAxis, + ring.MinorAxis, + ) + //天王星下次留(逆转顺)时间 + fmt.Println(uranus.NextRetrogradeToPrograde(date)) + //海王星上次东方照时间 + fmt.Println(neptune.LastEasternQuadrature(date)) + //火星下次西方照时间 + fmt.Println(mars.NextWesternQuadrature(date)) + //西安市今日火星升起,降落时间 + fmt.Println(mars.RiseTime(date, lon, lat, height, true)) + fmt.Println(mars.SetTime(date, lon, lat, height, true)) + //火星当前视星等 + fmt.Println(mars.ApparentMagnitude(date)) + //地火距离 + fmt.Println(mars.EarthDistance(date)) + //日火距离 + fmt.Println(mars.SunDistance(date)) +} +``` + +输出结果: + +``` +2020-10-14 07:25:50.441412627 +0800 CST // 火星下次冲日 +2021-01-29 09:39:33.697994649 +0800 CST // 木星下次合日 +2019-04-30 10:28:00.187439918 +0800 CST // 土星上次由顺行转逆行的留 +saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴 +2020-01-11 15:23:23.360308706 +0800 CST // 天王星下次由逆行转顺行的留 +2019-12-08 17:00:15.517960488 +0800 CST // 海王星上次东方照 +2020-06-07 03:11:00.026179254 +0800 CST // 火星下次西方照 +2020-01-01 04:41:29.621566236 +0800 CST // 西安当天火星升起时刻;无错误 +2020-01-01 14:55:32.963508367 +0800 CST // 西安当天火星落下时刻;无错误 +1.57 // 火星视星等 +2.1844284956325937 // 地火距离,单位 AU +1.5897860004265403 // 日火距离,单位 AU + +``` + +`saturn.Ring` 返回 `RingInfo`:`EarthLatitude` 是土星环张角 B,`SunLatitude` 是 B',`PositionAngle` 是北半短轴位置角,`DeltaU` 是太阳与地球在环面内的土星心黄经差,`MajorAxis` / `MinorAxis` 是土星环外缘长短轴,单位为角秒。 + +## 分主题示例 + +下面按主题给出短片段,每段都接得上本手册的公共变量 `date`、`lon`、`lat`、`height`;完整可运行版本见前面的基础示例。 + +### 位置与坐标 + +`ApparentLo` / `ApparentBo` / `ApparentRa` / `ApparentDec` / `ApparentRaDec` 给的是**地心视位置**:几何地心位置经光行时、光行差与章动改正,再换算到当日真赤道与真春分点。这些包不提供 J2000 或平位置输出;要对 J2000 口径时,用 `coord.Precess` 做岁差归算、用 `coord.Nutation2000B` 处理章动,站心量用 `coord.TopocentricEquatorial` 与 `coord.EquatorialToHorizontal`,详见[坐标工具](coord.md)。 + +```go +// 当日视位置:视黄经/视黄纬与视赤经/视赤纬,单位度。 +lo, bo := venus.ApparentLo(date), venus.ApparentBo(date) +ra, dec := venus.ApparentRaDec(date) +fmt.Println(lo, bo, venus.ApparentRa(date), venus.ApparentDec(date), ra, dec) + +// 地心距与日心距,单位 AU。 +fmt.Println(venus.EarthDistance(date), venus.SunDistance(date)) +``` + +### 升落与中天 + +`RiseTime` / `SetTime` / `DownTime` 按**当地民用日**搜索:`date` 用于确定当地日期与输出时区(当地小时数大于 12 时内部先回退 12 小时),`height` 是椭球高(米),`aero` 为 `true` 时加入标准大气折射(几何地平线降到约 `-0.5667°`)。极昼、极夜或当天没有过零时返回[哨兵错误](#参数与返回值约定)而不是时刻。`CulminationTime` 给上中天;`Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` 是站心瞬时量族,几何公式见[坐标工具](coord.md)。 + +```go +// 西安市当地民用日内的升起、落下与中天;aero=true 含标准大气折射。 +rise, err := mars.RiseTime(date, lon, lat, height, true) +set, err := mars.SetTime(date, lon, lat, height, true) +fmt.Println(rise, set, err) +fmt.Println(mars.CulminationTime(date, lon)) +``` + +```go +// 站心地平量:经度东正西负、纬度北正南负,返回值单位度。 +fmt.Println(mars.Altitude(date, lon, lat), mars.Azimuth(date, lon, lat)) +fmt.Println(mars.Zenith(date, lon, lat), mars.HourAngle(date, lon)) +fmt.Println(mars.ParallacticAngle(date, lon, lat)) +``` + +```go +// DownTime 是 SetTime 的兼容别名;N 版按截断项求值。 +set, err := mars.DownTime(date, lon, lat, height, true) +fmt.Println(set, err) +fmt.Println(mars.CulminationTimeN(date, lon, 12)) +``` + +### 合冲留与方照 + +事件搜索一律取"当前或之前/之后最近一次"(含端点),返回时区与输入一致。内行星有 `LastConjunction` / `NextConjunction` 与上合下合族;外行星有 `LastConjunction` / `NextConjunction`、`LastOpposition` / `NextOpposition` 与东西方照族。 + +留分两个方向:`LastProgradeToRetrograde` / `NextProgradeToRetrograde`(顺转逆)与 `LastRetrogradeToPrograde` / `NextRetrogradeToPrograde`(逆转顺);水星、金星另有不区分方向的 `LastRetrograde` / `NextRetrograde`,外行星没有这两个名字。 + +合日对内行星是太阳同侧,对外行星还额外分冲日与方照:只有地球轨道之外的这五颗行星会出现冲日(太阳–地球–行星成一线)与东西方照(行星与太阳黄经相差约 90°),水星和金星在地球轨道以内,所以这两族接口只出现在外行星包里。 + +```go +// 内行星:上合/下合,以及不区分方向的留。 +fmt.Println(mercury.LastSuperiorConjunction(date), mercury.NextInferiorConjunction(date)) +fmt.Println(mercury.LastRetrograde(date), mercury.NextRetrograde(date)) +``` + +```go +// 外行星:合日、冲日与东西方照。 +fmt.Println(jupiter.NextConjunction(date), mars.NextOpposition(date)) +fmt.Println(mars.NextEasternQuadrature(date), neptune.LastWesternQuadrature(date)) +``` + +```go +// 留的两个方向分别有 Last/Next 两版。 +fmt.Println(saturn.LastProgradeToRetrograde(date), saturn.NextProgradeToRetrograde(date)) +fmt.Println(saturn.LastRetrogradeToPrograde(date), saturn.NextRetrogradeToPrograde(date)) +``` + +### 大距与地心凌日 + +大距只对水星、金星有意义:`LastGreatestElongation` / `NextGreatestElongation` 不区分东西,`LastGreatestElongationEast` / `NextGreatestElongationEast` 与 `...West` 区分。 + +地心凌日的公开入口同样只在 `mercury` / `venus`:`LastTransit` / `NextTransit` / `ClosestTransit` 返回 `TransitInfo`。`Valid` 为假表示搜索窗口内没有凌日,其余字段都是零值;偏凌日没有内切,此时 `HasInternal` 为假、`InternalStart` / `InternalEnd` 是零值。凌日只判断地心几何,不判断某地当时太阳是否在地平线上。 + +```go +// 大距:区分东西用 ...East / ...West,不区分时用 NextGreatestElongation。 +fmt.Println(mercury.NextGreatestElongationEast(date), venus.LastGreatestElongationWest(date)) +fmt.Println(venus.NextGreatestElongation(date)) +``` + +```go +// 地心凌日:Valid 为假时其余字段为零值。 +transit := mercury.NextTransit(date) +if transit.Valid { + fmt.Println(transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End) + fmt.Println(transit.Duration, transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec) +} +``` + +```go +// 凌日结果的其余字段;无内切时 InternalDuration 为 0。 +transit := venus.ClosestTransit(date) +fmt.Println(transit.HasInternal, transit.InternalDuration, transit.MinimumSeparationArcsec) +fmt.Println(transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec) +``` + +### 节点、相位、视星等、视直径与视差角 + +`AscendingNode` / `DescendingNode` 是行星轨道面与黄道面两个交点的黄经,单位度,同一时刻两者相差约 `180°`。`PhaseAngle` 是太阳–行星–地球夹角(度);`IlluminatedFraction`(别名 `Phase`)是 `0–1` 的被照亮比例;`BrightLimbPositionAngle` 是亮面中心位置角(度)。`Diameter` / `Semidiameter` 返回地心视直径/视半径,单位角秒。 + +`ParallacticAngle` 返回站心视差角(天顶方向角,度),站心坐标入口见[坐标工具](coord.md)。 + +```go +fmt.Println(mars.AscendingNode(date), mars.DescendingNode(date)) // 升降交点黄经,度 +fmt.Println(mars.PhaseAngle(date), mars.IlluminatedFraction(date), mars.Phase(date)) // 相位角,度;照亮比例 +fmt.Println(mars.ApparentMagnitude(date), mars.BrightLimbPositionAngle(date)) // 视星等、亮面位置角 +``` + +```go +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // 视直径、视半径,单位角秒 +fmt.Println(mars.ParallacticAngle(date, lon, lat)) // 视差角,度 +``` + +```go +// 交点与视直径同样有截断版。 +fmt.Println(mars.AscendingNodeN(date, 12), mars.DescendingNodeN(date, 12)) +fmt.Println(mars.DiameterN(date, 12), mars.SemidiameterN(date, 12)) +``` + +### 与其他手册的分工 + +- 恒星时、岁差章动、站心与地平坐标换算见[坐标工具](coord.md)。 +- 太阳与月亮的位置、月相、月出月落、朔望弦见[日月手册](sun-moon.md)。 +- 行星月掩、掩带与月面视圆图见[月掩手册](occultation.md#月掩出图)。 +- 小行星、彗星等按轨道根数计算的天体见[通用轨道](orbit.md)。 +- 时标声明、UT1 口径与 GeoJSON 输出见[天象地图与 GeoJSON](map-geojson.md);时标本身的约定见[时标声明](map-geojson.md#时标声明)。 +- 权威能力清单、依赖矩阵与精度汇总见根目录 [README](../../README.md)。 + +### 物理星历 + +七大行星都提供 `Physical` / `PhysicalN`,用于查看盘面朝向、子地/子日经纬度和北极位置角等物理观测参数。木星额外提供 System I/II/III 中央经线,土星额外提供土星环参数。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/jupiter" + "b612.me/astro/saturn" +) + +func main() { + date := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) + + // 木星:DS/DE 分别是太阳、地球相对木星赤道的行星中心赤纬。 + // CMI/CMII/CMIII 是木星 System I/II/III 中央经线,单位度。 + j := jupiter.Physical(date) + fmt.Printf("jupiter DS=%.6f DE=%.6f CMI=%.6f CMII=%.6f CMIII=%.6f\n", + j.DS, + j.DE, + j.CentralMeridianSystemI, + j.CentralMeridianSystemII, + j.CentralMeridianSystemIII, + ) + + // 土星环:B/B' 是地球、太阳看到的环面纬度,P 是环面短轴位置角。 + ring := saturn.Ring(date) + fmt.Printf("saturn B=%.6f Bp=%.6f P=%.6f major=%.6f minor=%.6f\n", + ring.EarthLatitude, + ring.SunLatitude, + ring.PositionAngle, + ring.MajorAxis, + ring.MinorAxis, + ) +} +``` + +输出结果: + +```text +jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // 木星子日/子地赤纬,System I/II/III 中央经线,单位度 +saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒 +``` + +只需要中央经线时,可以单独调用 `CentralMeridians`: + +```go +cm := jupiter.CentralMeridians(date) +fmt.Printf("CMI=%.6f CMII=%.6f CMIII=%.6f\n", cm.SystemI, cm.SystemII, cm.SystemIII) // 木星 System I/II/III 中央经线 +``` + +土星和天王星则额外保留了显式的 `System III` 语义别名,便于按行星自转系统来写调用代码: + +```go +sat3 := saturn.PhysicalSystemIII(date) +ura3 := uranus.PhysicalSystemIII(date) +fmt.Printf("saturn systemIII lon=%.6f lat=%.6f P=%.6f\n", sat3.SubEarthLongitude, sat3.SubEarthLatitude, sat3.NorthPolePositionAngle) // 土星子地经纬度与北极位置角 +fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude, ura3.SubEarthLatitude, ura3.NorthPolePositionAngle) // 天王星子地经纬度与北极位置角 +``` + +七个包的 `Physical` 都返回各自的 `PhysicalInfo`,字段与 `basic.PlanetPhysicalInfo` 一一对应;`SubEarthLongitude` / `SubSolarLongitude` 的正方向按各天体当前 IAU/Horizons 制图约定,水星、火星、木星、土星、海王星取西经为正,金星、天王星取东经为正。土星环参数只用于盘面与掩带表达,不参与月掩接触计算,出图见[月掩手册](occultation.md#月掩出图)。 + +```go +p := uranus.Physical(date) // 天王星子地/子日经纬度与北极位置角,单位度 +fmt.Println(p.SubEarthLongitude, p.SubEarthLatitude, p.SubSolarLongitude, p.SubSolarLatitude, p.NorthPolePositionAngle) +``` + +```go +// 土星环与木星中央经线的截断版。 +fmt.Println(saturn.RingN(date, 12).MinorAxis, jupiter.CentralMeridiansN(date, 12).SystemIII) +``` + +### 木星伽利略卫星 + +这组接口的公开入口在 **`jupiter` 包**(`jupiter.Satellites`、`jupiter.SatellitePhenomena`、`jupiter.NextGalileanPhenomenonEvent` 等),接收 `time.Time` 并返回 `jupiter` 自己的类型。 + +`basic` 里的 `JupiterGalilean*`(如 `basic.JupiterGalileanSatelliteObservations`、`basic.NextJupiterGalileanPhenomenonEvent`)是同一实现的低层入口,接收儒略日并返回 `basic` 类型;写应用时用 `jupiter` 这一层即可。 + +```go +// 入口在 jupiter 包;卫星编号用 jupiter.GalileanSatelliteIo 等常量。 +sats := jupiter.Satellites(date) +fmt.Println(sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) +``` + +卫星编号与现象类型都有具名常量,不必手写数字或字符串: + +- 卫星编号:`GalileanSatelliteIo`、`GalileanSatelliteEuropa`、`GalileanSatelliteGanymede`、`GalileanSatelliteCallisto` +- 现象类型(`GalileanPhenomenonType`):`GalileanPhenomenonTransit`、`GalileanPhenomenonOccultation`、`GalileanPhenomenonEclipse`、`GalileanPhenomenonShadowTransit` +- 接触阶段(`GalileanPhenomenonContactPhase`):`GalileanPhenomenonContactDisappearance`、`GalileanPhenomenonContactReappearance` + +```go +// 卫星编号与现象类型都用常量,避免手写数字与字符串。 +event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteCallisto, jupiter.GalileanPhenomenonShadowTransit) +fmt.Println(event.Type == jupiter.GalileanPhenomenonShadowTransit) +``` + +`jupiter` 包提供四颗伽利略卫星的视位置、瞬时现象和事件搜索。 + +常用接口: + +- `Satellites`:四颗卫星相对木星盘面的瞬时视位置 +- `SatellitePhenomena`:瞬时凌日、掩蔽、食、影凌状态 +- `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`:搜索整场现象区间 +- `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`:搜索 IMCCE 风格的 D/F 接触事件 + +两个口径的区别如下,以木卫一凌日为例: + +- `GalileanPhenomenonEvent` 把卫星看作一个点,判断“卫星圆心是否进入/离开木星圆面”。它返回整段凌日的起止区间,适合快速搜索现象和程序内部状态判断。 +- `GalileanPhenomenonContactEvent` 把卫星自身的有限圆盘考虑进去,区分初亏到复圆的完整接触过程。它返回消失阶段(D)和再现阶段(R)各自的接触起止与模型中心穿越时刻,适合和 IMCCE 年表中的 `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` 逐项对照。 + +两个口径的差异在持续时间上最多约 7 分钟,差异来自模型定义不同。用于观测预报或与公开年表逐项核对时,取 `GalileanPhenomenonContactEvent`。 + +#### 代码示例 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/jupiter" +) + +func main() { + date := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + + // 四颗卫星相对木星中心的瞬时位置。 + sats := jupiter.Satellites(date) + fmt.Printf("io x=%.6f y=%.6f front=%v\n", sats.Io.OffsetXJupiterR, sats.Io.OffsetYJupiterR, sats.Io.InFrontOfJupiter) + fmt.Printf("europa ra=%.6f dec=%.6f\n", sats.Europa.ApparentRA, sats.Europa.ApparentDec) + + // 瞬时现象标志。 + ph := jupiter.SatellitePhenomena(date) + fmt.Printf("io transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Io.Transit, ph.Io.Occultation, ph.Io.Eclipse, ph.Io.ShadowTransit) + fmt.Printf("europa transit=%v occultation=%v eclipse=%v shadow=%v\n", ph.Europa.Transit, ph.Europa.Occultation, ph.Europa.Eclipse, ph.Europa.ShadowTransit) + + // 下一次木卫一凌日整场事件。 + event := jupiter.NextGalileanPhenomenonEvent(date, jupiter.GalileanSatelliteIo, jupiter.GalileanPhenomenonTransit) + fmt.Printf("event valid=%v sat=%d type=%s\n", event.Valid, event.Satellite, event.Type) + fmt.Println(event.Start) + fmt.Println(event.Greatest) + fmt.Println(event.End) + fmt.Println(event.Duration) + + // 下一次木卫二掩蔽的 IMCCE 风格接触窗口。 + contact := jupiter.NextGalileanPhenomenonContactEvent(date, jupiter.GalileanSatelliteEuropa, jupiter.GalileanPhenomenonOccultation) + fmt.Printf("contact valid=%v sat=%d type=%s\n", contact.Valid, contact.Satellite, contact.Type) + fmt.Println(contact.Disappearance.Start) + fmt.Println(contact.Disappearance.ModelCrossing) + fmt.Println(contact.Disappearance.End) + fmt.Println(contact.Greatest) + fmt.Println(contact.Reappearance.Start) + fmt.Println(contact.Reappearance.ModelCrossing) + fmt.Println(contact.Reappearance.End) +} +``` + +输出结果: + +```text +io x=-0.675026 y=-0.032798 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方 +europa ra=110.769133 dec=22.335828 // 木卫二视赤经、视赤纬,单位度 +io transit=true occultation=false eclipse=false shadow=true // 木卫一正在凌日,且影子正在凌日 +europa transit=false occultation=false eclipse=false shadow=false // 木卫二此刻无凌日、掩蔽、木星食或影凌 +event valid=true sat=1 type=transit // 下一次有效事件为木卫一凌日 +2026-01-16 16:32:47.552742362 +0000 UTC // 木卫一凌日开始 +2026-01-16 17:40:44.189371168 +0000 UTC // 木卫一凌日中点 +2026-01-16 18:48:40.287077128 +0000 UTC // 木卫一凌日结束 +2h15m52.734334766s // 木卫一凌日持续时间 +contact valid=true sat=2 type=occultation // 下一次有效接触事件为木卫二掩蔽 +2026-01-17 01:00:34.99533087 +0000 UTC // 木卫二掩蔽消失阶段开始 +2026-01-17 01:02:31.714070141 +0000 UTC // 木卫二掩蔽消失阶段模型中心穿越 +2026-01-17 01:04:28.432809412 +0000 UTC // 木卫二掩蔽消失阶段结束 +2026-01-17 02:27:37.807798683 +0000 UTC // 木卫二掩蔽最深时刻 +2026-01-17 03:50:48.120300471 +0000 UTC // 木卫二掩蔽再现阶段开始 +2026-01-17 03:52:43.901527225 +0000 UTC // 木卫二掩蔽再现阶段模型中心穿越 +2026-01-17 03:54:39.68275398 +0000 UTC // 木卫二掩蔽再现阶段结束 +``` + +#### 与外部资料对照 + +木卫能力主要对照了两类外部基线: + +- **JPL Horizons**:用于四颗卫星相对木星中心的视位置,以及影凌时影心相对木星盘面的偏移。 +- **IMCCE 2026 年表**:用于凌日、掩蔽、木星食、影凌等事件和 D/F 接触窗口。 + +对照结果: + +- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.054"`、`Y=0.048"`。 +- `SatellitePhenomena` 的影凌影心偏移,对 JPL Horizons 的样例最大偏差约为 `X=0.051"`、`Y=0.016"`,现象布尔标志在样例中一致。 +- `GalileanPhenomenonContactEvent` 与 IMCCE 2026 年表的 D/F 接触时刻最大偏差约 `79 s`,接触持续时间最大偏差约 `17 s`。 +- `GalileanPhenomenonEvent` 与 IMCCE 的 D/F 接触定义不同,起止时刻的差异可达约 `7` 分钟。 + +### 与 `planet` 包共用的类型与常量 + +`planet` 包是低层解析级数入口,被七个行星包与 `sun` / `moon` 共用。它只导出函数,**没有导出类型**,所以行星包之间真正共用的是同一套数值口径与截断语义,而不是共享类型:各包的 `PhysicalInfo` 是 `basic.PlanetPhysicalInfo` 的镜面结构,`TransitInfo` 是 `basic.PlanetTransitResult` 的镜面结构,字段一一对应,类型本身仍属于各自的包。 + +| 入口 | 作用 | 单位 | +| --- | --- | --- | +| `planet.WherePlanet` / `planet.WherePlanetN` | VSOP87 结果:`xt` 取 `1..7` 依次为水星到海王星、`-1` 或 `0` 为地球,`zn` 取 `0` 黄经、`1` 黄纬、`2` 日心距;`xt` / `zn` 越界返回 `NaN` 而不 panic | 度 / AU | +| `planet.Distance` | 日地距离 | AU | +| `planet.SunLo` / `planet.SunM` / `planet.SunMidFun` / `planet.SunTrueLo` / `planet.SunApparentLo` | 太阳几何黄经、平近点角、中心差、真黄经、视黄经 | 度 | +| `planet.Earthe` / `planet.EarthPI` | 地球轨道偏心率、地球近日点黄经 | 无量纲 / 度 | +| `planet.MoonLo` / `planet.MoonM` / `planet.MoonLonX` / `planet.SunMoonAngle` | 月球平黄经、平近点角、到升交点的平角距、日月距角 | 度 | +| `planet.MoonI` / `planet.MoonB` / `planet.MoonR` | 月球黄经、黄纬、距离周期项(ELP2000/82 风格截断级数) | `10⁻⁶` 度 / `10⁻⁶` 度 / `10⁻³ km` | +| `planet.MoonTrueLo` / `planet.MoonTrueBo` / `planet.MoonAway` | 月球真黄经、真黄纬、地心距 | 度 / 度 / km | + +```go +// xt=1..7 对应水星..海王星;zn=0 黄经、1 黄纬、2 日心距(AU)。 +fmt.Println(planet.WherePlanet(4, 2, 2460000.5)) // 木星日心距,AU +fmt.Println(planet.WherePlanetN(4, 2, 2460000.5, 12)) // 截断版,保留约 12 个主项 +fmt.Println(planet.WherePlanet(8, 0, 2460000.5)) // xt 越界:NaN +``` + +```go +// 地球日心黄经(xt=-1)与行星日心黄经可放在同一口径下比较。 +fmt.Println(planet.WherePlanet(-1, 0, 2460000.5), planet.WherePlanet(4, 0, 2460000.5)) +``` + +## 参数与返回值约定 + +下面几条是七个包一致的口径;按能力汇总的返回值单位另见[通用能力与单位](#通用能力与单位)。 + +### 时标与民用时刻 + +公开 API 一律把 `time.Time` 当**民用时刻**(UTC 标签)使用:位置与物理接口内部先取 `date.UTC()` 再换算到 TT 求星历;升落、中天与站心地平量族额外读取 `date.Zone()` 参与地方时计算。 + +本库将 1972-01-01 之前的民用时间按 UT1 处理;1972 年以后用内置闰秒表,窗口末端之后按当前的 UTC 跟随政策处理。需要显式换算时用根包的 `astro.UT1FromUTC`、`astro.TTFromUTC` 与 `astro.DUT1`;图内/图注的时标声明与 UT1 口径见[时标声明](map-geojson.md#时标声明)。 + +### 单位与口径 + +角度一律为度;视直径与视半径为角秒;距离按函数名区分——`EarthDistance` / `SunDistance` 与 `planet.WherePlanet` 的 `zn=2` 用 AU,`planet.MoonAway` 用 km。 + +`PhaseAngle` 为度,`IlluminatedFraction` 与别名 `Phase` 为 `0–1`;`ApparentMagnitude` 为星等(无量纲);升落、中天与所有事件搜索返回 `time.Time`,时区与输入一致。 + +事件结构里的历时字段(`TransitInfo.Duration`、`TransitInfo.InternalDuration`、`GalileanPhenomenonEvent.Duration`、`GalileanPhenomenonContact.Duration`)是 Go 的 `time.Duration`,不是儒略日或天数;`TransitInfo` 的 `Start` / `Greatest` / `End` / `InternalStart` / `InternalEnd` 保持调用者输入的时区。 + +地心量、站心量与距离是三个不同口径:`ApparentLo` / `ApparentBo` / `ApparentRa` / `ApparentDec` / `ApparentRaDec` 是地心视位置,`Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` 是站心量,`EarthDistance` / `SunDistance` 是地心几何距离。 + +### 零值、越界与哨兵错误 + +事件搜索在窗口内没有事件时返回零值或 `Valid=false` 的结构(`TransitInfo.Valid`、`GalileanPhenomenonEvent.Valid`、`GalileanPhenomenonContactEvent.Valid`),不返回错误;`TransitInfo` 在 `HasInternal` 为假时 `InternalStart` / `InternalEnd` 为零值,偏凌日没有内切。 + +`planet.WherePlanet` / `planet.WherePlanetN` 的 `xt` / `zn` 越界返回 `NaN` 而不 panic;`...N` 截断族的 `n < 0` 用全部内置项、`n >= 0` 截断。`RiseTime` / `SetTime` / `DownTime`(含 `N` 版)是唯一返回 `error` 的一族:当天没有几何升/落时返回下面的哨兵错误,时刻为零值。 + +每个行星包各有一套极昼/极夜错误,名字里的 `NEVER_RISE` 指"当天永不升起"(极夜),`NEVER_SET` 指"当天永不落下"(极昼),`NEVER_DOWN` 是 `NEVER_SET` 的兼容别名。 + +| 包 | 永不升起 | 永不落下 | 落下别名 | +| --- | --- | --- | --- | +| `mercury` | `ERR_MERCURY_NEVER_RISE` | `ERR_MERCURY_NEVER_SET` | `ERR_MERCURY_NEVER_DOWN` | +| `venus` | `ERR_VENUS_NEVER_RISE` | `ERR_VENUS_NEVER_SET` | `ERR_VENUS_NEVER_DOWN` | +| `mars` | `ERR_MARS_NEVER_RISE` | `ERR_MARS_NEVER_SET` | `ERR_MARS_NEVER_DOWN` | +| `jupiter` | `ERR_JUPITER_NEVER_RISE` | `ERR_JUPITER_NEVER_SET` | `ERR_JUPITER_NEVER_DOWN` | +| `saturn` | `ERR_SATURN_NEVER_RISE` | `ERR_SATURN_NEVER_SET` | `ERR_SATURN_NEVER_DOWN` | +| `uranus` | `ERR_URANUS_NEVER_RISE` | `ERR_URANUS_NEVER_SET` | `ERR_URANUS_NEVER_DOWN` | +| `neptune` | `ERR_NEPTUNE_NEVER_RISE` | `ERR_NEPTUNE_NEVER_SET` | `ERR_NEPTUNE_NEVER_DOWN` | + +```go +// 极昼/极夜:升落接口返回哨兵错误,时刻是 time.Time 零值。 +rise, err := mercury.RiseTime(date, lon, lat, height, true) +switch { +case errors.Is(err, mercury.ERR_MERCURY_NEVER_RISE): + fmt.Println("极夜:当天永不升起", rise.IsZero()) +case errors.Is(err, mercury.ERR_MERCURY_NEVER_SET): + fmt.Println("极昼:当天永不落下", rise.IsZero()) +} +``` + +### 站心与地心 + +`Altitude` / `Azimuth` / `Zenith` / `HourAngle` / `ParallacticAngle` 使用 `date` 的时区做地方时计算,经度东正西负、纬度北正南负,`height` 是椭球高(米,不是海拔正高)。它们与 `ApparentRa` / `ApparentDec` 的地心视位置不是一个口径,跨口径使用前先经[坐标工具](coord.md)换算。 + +### 精度与适用范围 + +行星包使用内置 VSOP87 截断级数,覆盖 J2000 前后约 4000 年;相对完整 VSOP87 的截断误差量级见[太阳与行星](accuracy.md#太阳与行星),整体适用范围见[适用范围与精度](accuracy.md)。`...N` 截断版会在这条基线之上继续放宽,适合批量扫描或前端实时刷新。 + +`saturn.Ring` 与物理星历只影响盘面与掩带表达:土星环既不参与月掩接触计算、也不作为圆盘边界绘制。行星没有独立出图入口,月掩与土星环的出图见[月掩手册](occultation.md#月掩出图)。 diff --git a/doc/manual/star.md b/doc/manual/star.md new file mode 100644 index 0000000..ecb003c --- /dev/null +++ b/doc/manual/star.md @@ -0,0 +1,311 @@ +# 恒星 + +[English](en/star.md) | [返回 README](../../README.md) + +> 本手册的完整示例以仓库根目录为工作目录执行。 + +本程序自带 9100 颗恒星的数据库(BSC / HR 编号 `1–9110`,视星等 `-1.46`~`7.96`),能够自动计算自行。星表里存的是 **J2000 历元**的赤经赤纬(`InnerStarData.Ra`/`Dec`),要得到某一时刻的位置必须再做自行、岁差与章动修正,入口是 `StarData.RaDecByDate(date)`;升落、站心量与星座判定都应传入修正后的赤经赤纬。 + +## 目录 + +- [查询天狼星的位置与升起时刻](#查询天狼星的位置与升起时刻) +- [API 参考](#api-参考) + - [星座判定](#星座判定) + - [恒星库](#恒星库) + - [升落与中天](#升落与中天) + - [站心量](#站心量) + - [恒星时](#恒星时) + - [遍历亮星并计算观测量](#遍历亮星并计算观测量) + - [完整示例](#完整示例) + - [批量查询与缓存](#批量查询与缓存) +- [常用场景](#常用场景) + - [今晚能不能看到这颗星](#今晚能不能看到这颗星) + - [把 J2000 位置用到具体时刻](#把-j2000-位置用到具体时刻) + - [星座判定与亮星表](#星座判定与亮星表) + - [极区边界与星表口径](#极区边界与星表口径) +- [参数与返回值约定](#参数与返回值约定) +- [相关手册](#相关手册) + +## 查询天狼星的位置与升起时刻 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/star" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 115.0, 40.0, 0.0 + sirius, err := star.StarDataByName("天狼") + if err != nil { + log.Fatal(err) + } + ra, dec := sirius.RaDecByDate(date) + rise, err := star.RiseTime(date, ra, dec, lon, lat, height, true) + if err != nil { + log.Fatal(err) + } + fmt.Println(star.Constellation(ra, dec, date)) + fmt.Printf("RA=%.6f Dec=%.6f deg\n", ra, dec) + fmt.Println(rise.Format(time.RFC3339)) +} +``` + +星表坐标的历元是 J2000。`RaDecByDate` 加入自行、岁差与章动后,才用于指定日期的升落、高度角和星座判定。 + +自行归算按**儒略年**(365.25 日)计,`RaDecByDate` 先把民用时刻换算成 TT 再取历元差。只要记录带距离(`Pc > 0`),`RaDecByJde` 就按**三维空间运动**推进:自行给出切向速度、`RadVel` 给出视向分量,位置矢量线性外推后取方向;没有距离的记录退回二维,即只推进赤经赤纬两个角分量,等价于把恒星当作无穷远。两者的差别是二阶项——大圆路径的曲率和径向运动改变距离后对视角尺度的拉伸,26 年内最亮的高自行星也只有约 0.09 角秒。 + +## API 参考 + +| 分组 | 入口 | 用途 | 单位与口径 | +| --- | --- | --- | --- | +| 星座判定 | `Constellation` / `ConstellationEN` / `ConstellationCode` | 星座中文名 / 英文名 / IAU 三字母代码 | 输入当日赤经赤纬(度)与时刻 | +| 恒星库 | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `TopBrightStars` | 初始化内置星表、按 HR 编号或中文名取星、取最亮恒星样本 | HR `1–9110`;`Mag` 为视星等 | +| 坐标修正 | `StarData.RaDecByDate`(`basic` 侧对应 `RaDecByJde`) | 把 J2000 位置修正到指定时刻 | 返回赤经赤纬,单位度 | +| 升落与中天 | `RiseTime` / `SetTime` / `CulminationTime`(`DownTime` 是 `SetTime` 的废弃别名) | 升起、落下、中天时刻 | 返回民用时刻;极区返回哨兵错误 | +| 站心量 | `Altitude` / `ApparentAltitude` / `Azimuth` / `Zenith` / `ApparentZenith` | (视)高度角、方位角、(视)天顶距 | 度 | +| 时角与视差角 | `HourAngle` / `ParallacticAngle` | 恒星时角、天顶方向视差角 | 度 | +| 恒星时 | `MeanSiderealTime` / `ApparentSiderealTime` | 平恒星时、真恒星时 | 小时 | + +`star` 包没有 `...N` 截断入口;需要截断解析项的场合在 `sun`、`moon`、行星与 `coord` 的对应函数上,语义是 `n < 0` 用本仓库内置的全部解析项、`n >= 0` 截断(见各自手册)。 + +以下片段省略公共前置变量:`date`(观测时刻,民用时标)、`lon`/`lat`(观测点经纬度,东经/北纬为正,度)、`height`(观测点高度,**椭球高**,米)、`aero`(是否计入蒙气差与视半径修正)。 + +### 星座判定 + +```go +sirius, _ := star.StarDataByName("天狼") +ra, dec := sirius.RaDecByDate(date) +fmt.Println(star.Constellation(ra, dec, date)) // 大犬座 +fmt.Println(star.ConstellationEN(ra, dec, date)) // Canis Major +fmt.Println(star.ConstellationCode(ra, dec, date)) // CMA +``` + +三个入口共用同一份星座边界表,只是输出口径不同;判定用的是**当日视位置**,所以必须传入 `RaDecByDate` 的结果而不是星表里的 J2000 坐标。 + +### 恒星库 + +```go +_ = star.InitStarDatabase() +s, _ := star.StarDataByHR(2491) +fmt.Println(s.HR, s.ChineseName, s.CommonName, s.Mag) // 2491 天狼 Sirius -1.46 +bright, _ := star.TopBrightStars() +fmt.Println(len(bright), bright[0].HR) // 最亮恒星样本条数与首项 HR +``` + +数据库走 `sync.Once` 懒加载,`InitStarDatabase()` 只是提前预热:它幂等,并且能把首次加载的错误显式暴露出来,不调用也会在第一次查询时自动加载。`TopBrightStars()` 返回 169 颗视星等约不高于 3、按亮到暗大致排列的内置样本。 + +`basic.StarData` 在 `InnerStarData` 之上补了名称字段;字段口径如下: + +| 字段 | 含义 | +| --- | --- | +| `HR` / `HD` / `HIP` | 亮星编号(`1–9110`)/ 亨利·德雷伯编号 / 依巴谷编号 | +| `Ra` / `Dec` | **J2000** 赤经、赤纬,单位度(要用当日位置先过 `RaDecByDate`) | +| `Mag` | 视星等 | +| `PmRA` / `PmDec` | 赤经投影年自行 `cos(dec)·dRA/dt` 与赤纬年自行,单位角秒/年 | +| `RadVel` / `RotVel` | 径向速度与自行速度,单位 km/s | +| `Pc` | 距离,单位秒差距;`> 0` 时自行按三维空间运动推进 | +| `ChineseName` / `ChineseAlias` / `ChineseBayerName` | 中文名、别名、中文拜耳名 | +| `CommonName` / `CommonAliasName` | 英文常用名与别名 | +| `Cst` / `CstChinese` | 星座英文名与中文名 | + +按中文名查询只匹配库内中文名(如 `天狼`、`织女一`),英文名不参与匹配;需要按编号取星用 `StarDataByHR`。 + +### 升落与中天 + +```go +ra, dec := 101.28715533, -16.71611586 // 天狼星 J2000 附近示例坐标 +rise, _ := star.RiseTime(date, ra, dec, lon, lat, height, true) +set, _ := star.SetTime(date, ra, dec, lon, lat, height, true) +fmt.Println(rise, set) +fmt.Println(star.CulminationTime(date, ra, lon)) +``` + +`aero` 为真时按标准蒙气差修正后的几何地平求升落,为假时用几何地平;`CulminationTime` 只需要赤经与经度。极夜/极昼下 `RiseTime`/`SetTime` 不返回时刻而是哨兵错误,见下文。 + +### 站心量 + +```go +fmt.Println(star.Altitude(date, ra, dec, lon, lat)) +fmt.Println(star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10)) +fmt.Println(star.Azimuth(date, ra, dec, lon, lat), star.Zenith(date, ra, dec, lon, lat)) +fmt.Println(star.HourAngle(date, ra, lon), star.ParallacticAngle(date, ra, dec, lon, lat)) +``` + +`ApparentAltitude`/`ApparentZenith` 多两个参数:气压(hPa)与气温(°C),用来做蒙气差修正;`ParallacticAngle` 常用于旋转相机与光谱缝方向。 + +### 恒星时 + +```go +fmt.Println(star.MeanSiderealTime(date), star.ApparentSiderealTime(date)) +``` + +两者都返回小时;恒星时与恒星时角、地平转换的关系见 `coord` 手册。 + +### 遍历亮星并计算观测量 + +```go +bright, _ := star.TopBrightStars() +for _, s := range bright[:3] { + ra, dec := s.RaDecByDate(date) + alt := star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10) + fmt.Printf("%-6s %-10s mag=%.2f alt=%.3f\n", s.ChineseName, star.ConstellationEN(ra, dec, date), s.Mag, alt) +} +``` + +上面这段在 `date = 2020-01-01 08:08:08 CST`、观测点 `115°E, 40°N`、气压 `1010 hPa`、气温 `10 °C` 下实际输出: + +```text +天狼 Canis Major mag=-1.46 alt=-30.180 +老人 Carina mag=-0.72 alt=-48.661 +大角 Bootes mag=-0.04 alt=68.926 +``` + +### 完整示例 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/star" + "b612.me/astro/tools" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // 指定观测时刻。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + + // 初始化恒星数据库。 + _ = star.InitStarDatabase() + sirius, _ := star.StarDataByName("天狼") + ra, dec := sirius.RaDecByDate(date) + // 天狼星升起时间。 + riseDate, _ := star.RiseTime(date, ra, dec, 115, 40, 0, true) + fmt.Println(riseDate) + // 天狼星落下时间。 + setDate, _ := star.SetTime(date, ra, dec, 115, 40, 0, true) + fmt.Println(setDate) + fmt.Println(star.Constellation(ra, dec, date)) + + // 织女星。 + vega, _ := star.StarDataByName("织女一") + ra, dec = vega.RaDecByDate(time.Date(13600, 1, 1, 0, 0, 0, 0, time.Local)) + // 织女星在公元 13600 年的赤经。 + fmt.Println(tools.Format(ra/15, 1)) + // 织女星在公元 13600 年的赤纬。 + fmt.Println(tools.Format(dec, 0)) + + bright, _ := star.TopBrightStars() + fmt.Println(bright[0].ChineseName, bright[0].CommonName, bright[0].Mag) +} +``` + +输出结果: + +```text +2019-12-31 19:22:56.144202053 +0800 CST // 天狼星升起时刻 +2020-01-01 05:30:39.802506566 +0800 CST // 天狼星落下时刻 +大犬座 // 天狼星所在星座 +6h3m46.61s // 织女一在公元 13600 年的赤经 +84°18′27.15″ // 织女一在公元 13600 年的赤纬 +天狼 Sirius -1.46 // 最亮恒星表第一项:中文名、英文常用名、视星等 +``` + +### 批量查询与缓存 + +```go +for _, hr := range []int{2491, 2326, 5340} { + s, err := star.StarDataByHR(hr) + if err != nil { + continue + } + ra, dec := s.RaDecByDate(date) + fmt.Println(s.ChineseName, star.ConstellationCode(ra, dec, date), s.Mag) +} +``` + +星表只在第一次访问时加载一次,之后是只读缓存;跨请求、跨线程复用同一份数据即可,不需要自己缓存。查不到的编号或名字会返回错误,批量场景按 `err != nil` 跳过而不必区分错误类型。 + +## 常用场景 + +### 今晚能不能看到这颗星 + +```go +ra, dec := sirius.RaDecByDate(date) +fmt.Println(star.RiseTime(date, ra, dec, lon, lat, height, true)) // 升起 +fmt.Println(star.SetTime(date, ra, dec, lon, lat, height, true)) // 落下 +fmt.Println(star.CulminationTime(date, ra, lon)) // 中天 +fmt.Println(star.ApparentAltitude(date, ra, dec, lon, lat, 1010, 10), star.Azimuth(date, ra, dec, lon, lat)) +``` + +`aero = true` 用标准蒙气差修正后的地平,更接近"刚露出地平"的目视时刻;`false` 是几何地平,两者差 2–3 分钟量级。判断此刻能否看到用视高度 `ApparentAltitude`,`1010 hPa / 10 °C` 是常用默认气象值。`height` 是**椭球高**(米),只有海拔正高时要先加大地水准面差距,见[观测点高度约定](coord.md#观测点高度)。 + +### 把 J2000 位置用到具体时刻 + +```go +ra, dec := sirius.RaDecByDate(date) // J2000 -> 当日(自行 + 岁差 + 章动) +fmt.Println(star.ApparentSiderealTime(date)) // 真恒星时,单位小时 +fmt.Println(star.HourAngle(date, ra, lon)) // 时角,单位度 +``` + +星表存的是 J2000,跳过 `RaDecByDate` 会让升落与星座判定出现度级偏差。恒星时、地平转换与岁差章动的细节见[坐标工具](coord.md);时间参数一律民用时刻(UTC 标签,1972-01-01 前等于 UT1),约定见[时标声明](map-geojson.md#时标声明)。 + +### 星座判定与亮星表 + +```go +bright, _ := star.TopBrightStars() +for _, s := range bright[:3] { + ra, dec := s.RaDecByDate(date) + fmt.Println(s.ChineseName, star.ConstellationCode(ra, dec, date), s.Mag, s.CommonName) +} +``` + +```text +天狼 CMA -1.46 Sirius +老人 CAR -0.72 Canopus +大角 BOO -0.04 Arcturus +``` + +星座三个入口同源、只是输出口径不同:`Constellation` 给中文名、`ConstellationEN` 给英文名、`ConstellationCode` 给 IAU 三字母代码;判定用的是当日视位置。星表字段(`HR`/`HD`/`HIP`、J2000 赤经赤纬、视星等、自行、视差距离与各名称字段)见 [API 参考](#恒星库) 的字段表。 + +### 极区边界与星表口径 + +```go +_, err := star.RiseTime(date, ra, dec, 0, 89, 0, true) +if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVER_SET) { + fmt.Println("该日无升落:", err) +} +``` + +- **极区哨兵错误**:`RiseTime` 在极夜返回 `star.ERR_STAR_NEVER_RISE`(该日永远在地平线下),`SetTime` 在极昼返回 `star.ERR_STAR_NEVER_SET`(该日永远在地平线上),用 `errors.Is` 判定;`ERR_STAR_NEVER_DOWN` 是 `ERR_STAR_NEVER_SET` 的废弃别名,`DownTime` 是 `SetTime` 的废弃别名。 +- **蒙气差模型的区间**:Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——深在地平线以下时视高度角与几何高度角完全相等。 +- **星表口径**:BSC / HR `1–9110`,视星等 `-1.46`~`7.96`;按名字查询只匹配库内中文名(如 `天狼`、`织女一`),英文名不参与。 +- **与外部星表对表**:库内位置是 J2000 平位置叠加自行与岁差章动;与外部星表逐位对表时先用 `RaDecByDate` 统一到同一时刻,再比较。 + +## 参数与返回值约定 + +- **单位**:赤经、赤纬、高度角、天顶距、方位角、时角、视差角均为**度**,恒星时为**小时**;`tools.Format` 负责把它们格式化成度分秒或时分秒展示。星表里的 `PmRA`/`PmDec` 是角秒/年,`RadVel`/`RotVel` 是 km/s,`Pc` 是秒差距。 +- **时标**:所有公开 API 的时间参数与返回值都是民用时刻(UTC 标签,1972-01-01 之前等于 UT1),约定见[时标声明](map-geojson.md#时标声明)。 +- **坐标口径**:星表是 J2000;`RaDecByDate` 叠加自行、岁差与章动。直接用 `InnerStarData.Ra`/`Dec` 会让升落与星座判定出现可观偏差(百年量级的岁差就是度级)。 +- **极区哨兵错误**:`RiseTime` 在极夜返回 `star.ERR_STAR_NEVER_RISE`(该日永远在地平线下),`SetTime` 在极昼返回 `star.ERR_STAR_NEVER_SET`(该日永远在地平线上),用 `errors.Is` 判定;`ERR_STAR_NEVER_DOWN` 是 `ERR_STAR_NEVER_SET` 的废弃别名。 +- **观测点高度**:`height` 是椭球高(大地高),单位米;手上只有海拔正高时要先加大地水准面差距,约定见「观测点高度约定」。 +- **蒙气差模型的有效区间**:`ApparentAltitude`/`ApparentZenith` 用的 Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——所以深在地平线以下时视高度角与几何高度角完全相等(示例里天狼星 `alt=-30.180` 与 `Altitude` 同值就是这个原因)。气压必须为正、气温必须高于绝对零度,否则返回 `NaN`。 +- **`aero` 语义**:真值按标准蒙气差修正后的地平求升落,假值走几何地平;两者的差值在低纬度约 2–3 分钟,高纬度会明显放大。 +- **星表范围**:HR `1–9110`、视星等 `-1.46`~`7.96`,按中文名查询只匹配库内中文名。 + +## 相关手册 + +- 恒星时、地平转换、岁差与章动、视差角:[坐标工具](coord.md) +- 升落语义与太阳/月亮对照:[太阳与月亮](sun-moon.md) +- 出图时标:[时标声明](map-geojson.md#时标声明) diff --git a/doc/manual/sun-moon.md b/doc/manual/sun-moon.md new file mode 100644 index 0000000..90896b7 --- /dev/null +++ b/doc/manual/sun-moon.md @@ -0,0 +1,1001 @@ +# 太阳与月亮 + +[English](en/sun-moon.md) | [返回 README](../../README.md) + +`sun` 与 `moon` 是主链,`lite/sun` 与 `lite/moon` 是独立近似实现。没有特殊说明时,角度单位为度,视直径与视半径为角秒,`sun.EarthDistance` 为 AU、`moon.EarthDistance` 为千米;观测接口的 `time.Time` 通常表示民用时刻,真太阳时的返回值另按地方太阳时解释,见[时标手册](timescale.md)。 + +本手册覆盖日月本体:位置、升落与中天、站心量、相位与朔望、近远地点与交点、最大赤纬、天平动、视直径与物理星历。日月食几何见[日食与月食手册](eclipse.md#全球见食图与月食出图),月掩见[月掩手册](occultation.md#月掩出图),站心几何与折射的通用换算见[坐标工具](coord.md)。 + +## 目录 + +- [日出日落与月相](#日出日落与月相) +- [API 参考](#api-参考) + - [sun](#sun) + - [moon](#moon) + - [lite/sun](#litesun) + - [lite/moon](#litemoon) + - [截断项族](#截断项族) +- [常用场景](#常用场景) + - [今天的日出日落与晨昏朦影](#今天的日出日落与晨昏朦影) + - [月出月落与此刻的月亮高度](#月出月落与此刻的月亮高度) + - [月相与下次朔望弦](#月相与下次朔望弦) + - [视直径、地月距离与天平动](#视直径地月距离与天平动) + - [地心量与站心量的区别](#地心量与站心量的区别) + - [主链与 lite 的误差对照](#主链与-lite-的误差对照) +- [观测角语义](#观测角语义) +- [综合示例:日月升落与位置](#综合示例日月升落与位置) + - [日出日落/月出月落](#日出日落月出月落) + - [日月位置](#日月位置) +- [太阳](#太阳) + - [位置](#位置) + - [升落与中天](#升落与中天) + - [站心量与视差角](#站心量与视差角) + - [真太阳时与均时差](#真太阳时与均时差) + - [物理与视直径](#物理与视直径) + - [地球轨道极值](#地球轨道极值) +- [月亮](#月亮) + - [位置](#位置-1) + - [升落与中天](#升落与中天-1) + - [月相](#月相) + - [近地点与远地点](#近地点与远地点) + - [交点](#交点) + - [最大赤纬](#最大赤纬) + - [天平动与亮边位置角](#天平动与亮边位置角) + - [视直径与地月距离](#视直径与地月距离) +- [轻量链路](#轻量链路) + - [lite/sun](#litesun-1) + - [lite/moon](#litemoon-1) + - [与主链的差异与误差量级](#与主链的差异与误差量级) +- [参数与返回值约定](#参数与返回值约定) + - [单位与角口径](#单位与角口径) + - [时标](#时标) + - [高度与 aero](#高度与-aero) + - [零值与越界](#零值与越界) + - [精度与适用范围](#精度与适用范围) + +## 日出日落与月相 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2020, 1, 1, 8, 8, 8, 0, cst) + lon, lat, height := 108.93, 34.27, 0.0 + rise, err := sun.RiseTime(date, lon, lat, height, true) //日出时间 + if err != nil { + log.Fatal(err) + } + set, err := sun.SetTime(date, lon, lat, height, true) //日落时间 + if err != nil { + log.Fatal(err) + } + fmt.Println(rise.Format(time.RFC3339), set.Format(time.RFC3339)) + fmt.Println(moon.Phase(date), moon.PhaseDesc(date)) //月相(被照亮比例)和月相描述 +} +``` + +`aero=true` 采用含折射的升落判据。极昼、极夜或当天没有事件时,升落接口返回错误。`moon.Phase` 是受照比例,不是月龄。 + +## API 参考 + +下表按能力分组列出四个包的导出接口。多数求值接口另有 `...N` 截断变体,统一在[截断项族](#截断项族)里说明;表中所有 `time.Time` 参数都是绝对时刻,`lon`/`lat` 均为东正西负、北正南负。 + +### sun + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `TrueLo` / `ApparentLo` | 太阳真黄经 / 视黄经 | 度,地心 | +| `TrueBo` | 太阳真黄纬 | 度,地心;库内不单独提供视黄纬 | +| `GeometricLo` / `MidFunc` | 太阳几何黄经 / 中心差 | 度 | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | 太阳视赤经 / 视赤纬 | 度,地心 | +| `EclipticObliquity` | 黄赤交角 | 度;第二参数为 `true` 时加入交角章动 | +| `EclipticNutation` / `EclipticNutation1980` | 黄经章动 | 度,IAU 2000B / IAU 1980 | +| `AxialtiltNutation` / `AxialtiltNutation1980` | 交角章动 | 度,IAU 2000B / IAU 1980 | +| `RiseTime` / `SetTime` | 日出 / 日落 | `(time.Time, error)`;`aero` 与 `height` 见参数与返回值约定 | +| `DownTime` | 日落别名 | 已废弃,内部转调 `SetTime` | +| `CulminationTime` | 上中天 | `time.Time` | +| `MorningTwilight` / `EveningTwilight` | 晨光始 / 暮光终 | `(time.Time, error)`;角度常用 -6 / -12 / -18 度 | +| `Altitude` / `Zenith` / `Azimuth` / `HourAngle` | 几何高度角 / 天顶距 / 方位角 / 时角 | 度,站心 | +| `ApparentAltitude` / `ApparentZenith` | 视高度角 / 视天顶距 | 度;需要气压 hPa 与气温 ℃ | +| `ParallacticAngle` | 视差角(天顶方向角) | 度,有符号 | +| `ApparentSolarTime` | 真太阳时 | `time.Time`,结果时区按经度换算 | +| `EquationTime` | 均时差 | 小时 | +| `Diameter` / `Semidiameter` | 视直径 / 视半径 | 角秒 | +| `EarthDistance` | 日地距离 | AU | +| `Physical` | 日面物理量 | 返回 `PhysicalInfo`,字段单位为度 | + +### moon + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `TrueLo` / `TrueBo` / `ApparentLo` | 地心真黄经 / 真黄纬 / 视黄经 | 度 | +| `TrueRa` / `TrueDec` / `TrueRaDec` | 地心真赤道坐标 | 度 | +| `GeocentricApparentRa` / `GeocentricApparentDec` / `GeocentricApparentRaDec` | 地心视赤道坐标 | 度 | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | 站心视赤道坐标 | 度;需要观测者经纬度 | +| `Altitude` / `Zenith` / `Azimuth` / `HourAngle` | 站心高度角 / 天顶距 / 方位角 / 时角 | 度 | +| `ApparentAltitude` / `ApparentZenith` | 视高度角 / 视天顶距 | 度;需要气压 hPa 与气温 ℃ | +| `ParallacticAngle` | 视差角 | 度,有符号,显式依赖观测者经纬度 | +| `RiseTime` / `SetTime` | 月出 / 月落 | `(time.Time, error)` | +| `DownTime` | 月落别名 | 已废弃,内部转调 `SetTime` | +| `CulminationTime` | 上中天 | `time.Time`;需要经度与纬度 | +| `Phase` / `PhaseDesc` | 受照比例 / 中文月相描述 | 比例 `[0,1]` / 字符串 | +| `SunMoonLoDiff` | 日月视黄经差 | 度,`[0,360)` | +| `ShuoYue` / `ShangXianYue` / `WangYue` / `XiaXianYue` | 以小数年为锚点的朔 / 上弦 / 望 / 下弦 | `time.Time`,UTC | +| `NewMoon` / `FullMoon` / `FirstQuarter` / `LastQuarter` | 上四相的英文 alias | `time.Time`,UTC | +| `Next*` / `Last*` / `Closest*` | 下一次 / 上一次 / 最近一次相位与最大赤纬 | `time.Time`;结果保持输入时区 | +| `NextConjunctionWithPlanet` / `LastConjunctionWithPlanet` / `ClosestConjunctionWithPlanet` | 行星合月(赤经合) | `time.Time`;目标用 `ConjunctionPlanet` 常量 | +| `PerigeesInMonth` / `ApogeesInMonth` | 指定年月内的近地点 / 远地点 | `[]ApsisInfo`,距离 km | +| `MaximumNorthDeclinationsInMonth` / `MaximumSouthDeclinationsInMonth` | 指定年月内的最大北 / 南赤纬事件 | `[]MaximumDeclinationInfo`,赤纬为度 | +| `AscendingNode` / `DescendingNode` | 升交点 / 降交点黄经 | 度 | +| `Physical` / `TopocentricPhysical` | 地心 / 站心天平动与自转轴位置角 | 返回 `PhysicalInfo`,字段单位为度 | +| `BrightLimbPositionAngle` / `TopocentricBrightLimbPositionAngle` | 地心 / 站心亮边位置角 | 度 | +| `Diameter` / `Semidiameter` | 视直径 / 视半径 | 角秒 | +| `EarthDistance` | 地月距离 | 千米 | + +`moon` 还提供月掩接口,其参数、结果和完整示例见[月掩手册](occultation.md)。 + +| 分组 | 导出接口与类型 | +| --- | --- | +| 事件与路径 | `FindStarOccultations`, `FindPlanetOccultations`, `FindBestStarOccultations`, `FindBestPlanetOccultations`, `FindStarOccultationPaths`, `FindPlanetOccultationPaths` | +| 瞬时足迹与视圆几何 | `StarOccultationFootprintAt`, `PlanetOccultationFootprintsAt`, `StarOccultationDiagram`, `PlanetOccultationDiagram` | +| UT1 标签换算 | `StarOccultationInfoInUT1`, `StarOccultationPathInUT1`, `PlanetOccultationInfoInUT1`, `PlanetOccultationPathInUT1` | +| 恒星结果 | `StarOccultationInfo`, `StarOccultationPath`, `StarOccultationInstant` | +| 行星结果 | `PlanetOccultationInfo`, `PlanetOccultationPath`, `PlanetOccultationInstant`, `PlanetOccultationFootprint` | +| 路径数据 | `OccultationFootprint`, `OccultationPathPoint`, `OccultationGreatestTimeContour`, `OccultationRiseSetCurve` | +| 搜索与路径选项 | `OccultationSearchOptions`, `OccultationPathOptions`, `OccultationPathAlgorithm` | +| 视圆图数据 | `StarOccultationDiagramFrame`, `StarOccultationDiagramOptions`, `StarOccultationDiagramResult`, `PlanetOccultationDiagramFrame`, `PlanetOccultationDiagramOptions`, `PlanetOccultationDiagramResult` | +| 目标与坐标 | `StarData`, `StarCoordinate`, `StarCoordinateFromStarData`, `Observer`, `CoordinateFrame`, `CoordinateFrameICRS`, `CoordinateFrameJ2000`, `CoordinateFrameApparentOfDate` | +| 事件类型 | `OccultationPlanet`, `OccultationType`, `OccultationTotal`, `OccultationPartial`, `OccultationGrazing` | +| 路径算法 | `OccultationPathAlgorithmOptimized`, `OccultationPathAlgorithmExact` | +| 升落阶段 | `RiseSetPhase`, `RiseSetDirection`, `RiseSetPhaseStart`, `RiseSetPhaseGreatest`, `RiseSetPhaseEnd`, `RiseSetDirectionRise`, `RiseSetDirectionSet` | +| 错误 | `ErrInvalidOccultationInput`, `ErrOccultationPathSamplingLimit` | + +行星目标常量为 `OccultationMercury` 至 `OccultationNeptune`。 + +### lite/sun + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `TrueLo` / `ApparentLo` | 轻量真黄经 / 视黄经 | 度,地心 | +| `TrueRa` / `TrueDec` / `TrueRaDec` | 轻量真赤道坐标 | 度,地心 | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | 轻量视赤道坐标 | 度,地心 | +| `Distance` | 轻量日地距离 | AU | +| `HourAngle` / `Azimuth` / `Altitude` / `Zenith` | 轻量时角 / 方位角 / 高度角 / 天顶距 | 度,站心 | +| `RiseTime` / `SetTime` | 轻量日出 / 日落 | `(time.Time, error)` | +| `ERR_SUN_NEVER_RISE` / `ERR_SUN_NEVER_SET` | 极夜 / 极昼 | 错误值 | + +### lite/moon + +| 名称 | 用途 | 单位与口径 | +| --- | --- | --- | +| `TrueLo` / `TrueBo` | 轻量地心真黄经 / 真黄纬 | 度 | +| `TrueRa` / `TrueDec` / `TrueRaDec` | 轻量地心真赤道坐标 | 度 | +| `ApparentRa` / `ApparentDec` / `ApparentRaDec` | 轻量站心视赤道坐标 | 度;需要观测者经纬度 | +| `HourAngle` / `Azimuth` / `Altitude` / `Zenith` | 轻量时角 / 方位角 / 高度角 / 天顶距 | 度,站心 | +| `SunMoonLoDiff` / `Phase` / `PhaseAge` | 轻量日月黄经差 / 受照比例 / 月龄 | 度 / `[0,1]` / 天 | +| `RiseTime` / `SetTime` | 轻量月出 / 月落 | `(time.Time, error)` | +| `ERR_MOON_NEVER_RISE` / `ERR_MOON_NEVER_SET` / `ERR_NOT_TODAY` | 极夜 / 极昼 / 事件不在当天 | 错误值 | + +### 截断项族 + +`sun` 与 `moon` 的多数求值接口提供 `...N` 变体:名字是原接口名加 `N`,参数在末尾多一个 `n int`,返回形状不变。`n < 0` 使用当前仓库内嵌的全部解析项,结果与不带 `N` 的版本一致;`n >= 0` 把级数截断到 `n` 项,适合性能对比、批量粗算或误差敏感性实验。 + +- sun:`TrueLoN`、`TrueBoN`、`AltitudeN`、`ZenithN`、`AzimuthN`、`HourAngleN`、`ParallacticAngleN`、`ApparentAltitudeN`、`ApparentZenithN`、`DiameterN`、`SemidiameterN`、`PhysicalN`、`RiseTimeN`、`SetTimeN`、`DownTimeN`、`CulminationTimeN`、`MorningTwilightN`、`EveningTwilightN`、`ApparentSolarTimeN` +- moon:`TrueLoN`、`TrueBoN`、`AscendingNodeN`、`DescendingNodeN`、`DiameterN`、`SemidiameterN`、`PhysicalN`、`TopocentricPhysicalN`、`BrightLimbPositionAngleN`、`TopocentricBrightLimbPositionAngleN` + +站心赤道坐标(`ApparentRa` / `ApparentDec` / `ApparentRaDec`)、月亮的升落(`RiseTime` / `SetTime`)与相位族没有 `N` 变体,它们不受截断开关控制。 + +## 常用场景 + +### 今天的日出日落与晨昏朦影 + +```go +fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) // 民用晨光始 +fmt.Println(sun.RiseTime(date, lon, lat, height, true)) +fmt.Println(sun.SetTime(date, lon, lat, height, true)) +fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) // 民用暮光终 +``` + +```text +2020-01-01 07:22:28.138198256 +0800 CST +2020-01-01 07:49:52.591398954 +0800 CST +2020-01-01 17:45:09.366609156 +0800 CST +2020-01-01 18:12:33.801986575 +0800 CST +``` + +朦影角度换成 `-12` / `-18` 就是航海与天文朦影;`aero = true` 按蒙气差与视半径修正后的地平求升落,与几何地平的差别见[升落与中天](#升落与中天)。 + +### 月出月落与此刻的月亮高度 + +```go +rise, _ := moon.RiseTime(date, lon, lat, height, true) +set, _ := moon.SetTime(date, lon, lat, height, true) +fmt.Println(rise) +fmt.Println(set) +fmt.Println(moon.Altitude(date, lon, lat), moon.Azimuth(date, lon, lat)) +``` + +```text +2020-01-01 11:52:50.042243599 +0800 CST +2020-01-01 23:26:49.498263895 +0800 CST +-45.349728852972675 67.63824603392399 +``` + +月球升落按当地自然日计算,升落之间可以没有连续性,`date` 之后的完整周期要看升起与落下时刻的先后关系,细节见[日出日落/月出月落](#日出日落月出月落);高度角为负说明月亮在地平线下,这里 `-45.35°` 就是此刻看不见。 + +### 月相与下次朔望弦 + +```go +fmt.Println(moon.Phase(date), moon.PhaseDesc(date)) // 受照比例与月相名 +fmt.Println(moon.NextShuoYue(date)) // 下次朔 +fmt.Println(moon.NextWangYue(date)) // 下次望 +``` + +```text +0.30004130960877884 上峨眉月 +2020-01-25 05:41:58.271192908 +0800 CST +2020-01-11 03:21:17.159625291 +0800 CST +``` + +`Next*` / `Last*` / `Closest*` 是下一次 / 上一次 / 最近一次三种检索口径,上弦与下弦分别是 `moon.NextShangXianYue` / `moon.NextXiaXianYue`;结果保持输入时区,四种相位与朔望月的完整口径见[月相](#月相)。 + +### 视直径、地月距离与天平动 + +```go +fmt.Println(moon.Diameter(date), moon.EarthDistance(date)) +p := moon.Physical(date) +fmt.Println(p.LibrationLongitude, p.LibrationLatitude, p.PositionAngle) +``` + +```text +1774.6658461637385 404238.6096080479 +0.7655535663486027 6.382898400777244 -23.672356410246774 +``` + +`Diameter` 单位角秒、`EarthDistance` 单位千米;`1774.67″`(约 29.6′)对应约 40.4 万 km 的远地点附近,比平均视直径小约 5%。`Physical` 给地心天平动,站心版本 `TopocentricPhysical` 与字段口径见[天平动与亮边位置角](#天平动与亮边位置角)。 + +### 地心量与站心量的区别 + +```go +geoRa, geoDec := moon.GeocentricApparentRaDec(date) // 地心视位置 +topRa, topDec := moon.ApparentRaDec(date, lon, lat) // 站心视位置 +fmt.Printf("geocentric %.4f %.4f\n", geoRa, geoDec) +fmt.Printf("topocentric %.4f %.4f\n", topRa, topDec) +fmt.Printf("delta dRA=%.4f dDec=%.4f\n", topRa-geoRa, topDec-geoDec) +fmt.Println(sun.ApparentRaDec(date)) // 太阳只给地心量 +``` + +```text +geocentric 349.2322 -9.9506 +topocentric 349.7343 -10.3485 +delta dRA=0.5021 dDec=-0.3978 +280.8950939694744 -23.05840775453492 +``` + +月亮离得近,站心与地心能差半度左右(这里赤经差 `0.50°`、赤纬差 `0.40°`),合起来比一个月面视直径还大,所以给观测者用的位置必须走 `moon.ApparentRaDec`;太阳在 1 AU 外视差可忽略,只提供地心量(当然也可以调用站心坐标转换接口得到站心位置=-=)。口径定义见[观测角语义](#观测角语义)。 + +### 主链与 lite 的误差对照 + +```go +fmt.Println(sun.Altitude(date, lon, lat), litesun.Altitude(date, lon, lat)) // 主链 / 轻量几何高度角 +fmt.Println(moon.Phase(date), litemoon.Phase(date)) // 受照比例 +fmt.Println(litemoon.PhaseAge(date)) // 轻量月龄(天) +fmt.Println(litesun.RiseTime(date, lon, lat, height, true)) // 轻量日出 +``` + +```text +2.40091496867759 2.403576774819768 +0.30004130960877884 0.2978124633132848 +5.42608394367707 +2020-01-01 07:49:51.69717729 +0800 CST +``` + +轻量链路与主链形状同构、精度不同:同一时刻的高度角只差 `0.0027°` 量级,月相只差 `0.0022`;2026 全年 8 站点统计下 `lite/sun` 日出平均绝对误差 `0.02 min`(P95 `0.04 min`、最大 `0.31 min`),`lite/moon` 月出 `0.28 min`(P95 `0.57 min`、最大 `1.44 min`),`lite/moon` 的 `Phase()` 最大绝对误差 `0.00243`。 + +完整对照与截断误差见[与主链的差异与误差量级](#与主链的差异与误差量级)与 README 的 [Lite 轻量链路](accuracy.md#lite-轻量链路)。 + +## 观测角语义 + +- `Altitude`:高度角,地平线为 `0°`,天顶为 `+90°` +- `Zenith`:天顶距,天顶为 `0°`,地平线为 `90°` +- `Zenith` 与 `Altitude` 互补,两者相加为 `90°` +- `Azimuth`:方位角,正北为 `0°`,向东增加,取值范围 `[0°, 360°)` +- `HourAngle`:时角,上中天为 `0°`,向西(下午)增大,归一化到 `[0°, 360°)` +- `ParallacticAngle`:视差角(天顶方向角),有符号,单位度;方向定义与站心几何的统一口径见[坐标工具](coord.md) +- `Altitude` / `Azimuth` 是几何中心高度与方位,不含大气折射和视半径修正;`ApparentAltitude` / `ApparentZenith` 加入大气折射,需要传入气压(hPa)与气温(℃) +- 太阳的赤道坐标是地心量;月亮的 `TrueRaDec` 与 `GeocentricApparentRaDec` 是地心量,`ApparentRa` / `ApparentDec` / `ApparentRaDec` 是站心量,必须给出观测者经纬度 + +## 综合示例:日月升落与位置 + +下面两个完整示例覆盖最常用的入口,公共前置是西安(`108.93°E, 34.27°N`)与 `2020-01-01 08:08:08 CST`。后续各节的片段都省略这类公共变量,只保留与本组能力相关的语句。 + +### 日出日落/月出月落 + +> ⚠️ 月球升降时间按当天日期计算,升降时间点之间不一定具有连续性。 +> +> 例如月亮可能在凌晨1点落下、中午12点再次升起,此时升起时间会晚于降落时间;这一场景晚上的月落时间对应次日日期。 +> +> 完整的升降周期由升起时间与降落时间的先后关系决定:判断升起时间是否在降落时间之后,即可确定后续的正确时间点。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/sun" +) + +func main() { + // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 + var lon, lat, height float64 = 108.93, 34.27, 0 + cst := time.FixedZone("CST", 8*3600) + // 指定 2020-01-01 08:08:08 CST,所有"今日"语义都以这个本地自然日为基准。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + // 西安市2020年1月1日民用晨朦影开始时间 + // 民用朦影,太阳位于地平线下6度,航海朦影=地平线下12度,天文朦影=地平线下18度 + fmt.Println(sun.MorningTwilight(date, lon, lat, -6)) + // 西安市2020年1月1日日出时间,按动态标准折射和实时太阳视半径计算上缘过地平线 + fmt.Println(sun.RiseTime(date, lon, lat, height, true)) + // 西安市2020年1月1日太阳上中天时间 + fmt.Println(sun.CulminationTime(date, lon)) + // 西安市2020年1月1日日落时间,按动态标准折射和实时太阳视半径计算上缘过地平线 + fmt.Println(sun.SetTime(date, lon, lat, height, true)) + // 西安市2020年1月1日民用昏朦影结束时间 + fmt.Println(sun.EveningTwilight(date, lon, lat, -6)) + + // 西安市2020年1月1日月出时间,按动态标准折射和实时月球视半径计算上缘过地平线 + fmt.Println(moon.RiseTime(date, lon, lat, height, true)) + // 西安市2020年1月1日月亮上中天时间 + fmt.Println(moon.CulminationTime(date, lon, lat)) + // 西安市2020年1月1日月落时间,按动态标准折射和实时月球视半径计算上缘过地平线 + fmt.Println(moon.SetTime(date, lon, lat, height, true)) +} +``` + +输出结果: + +```text +2020-01-01 07:22:27.960488498 +0800 CST +2020-01-01 07:49:52.413689196 +0800 CST +2020-01-01 12:47:35.933117866 +0800 CST +2020-01-01 17:45:09.188657999 +0800 CST +2020-01-01 18:12:33.624035418 +0800 CST +2020-01-01 11:52:49.860912859 +0800 CST +2020-01-01 17:36:48.811488747 +0800 CST +2020-01-01 23:26:49.313553571 +0800 CST +``` + +### 日月位置 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" + "b612.me/astro/star" + "b612.me/astro/sun" + "b612.me/astro/tools" +) + +func main() { + // 以陕西省西安市为例,设置西安市经纬度,设置地平高度为0米 + var lon, lat float64 = 108.93, 34.27 + cst := time.FixedZone("CST", 8*3600) + // 指定观测时刻。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + // 太阳此刻的视黄经,单位度。 + fmt.Println(sun.ApparentLo(date)) + // 此刻黄赤交角,第二个参数 true 表示使用真黄赤交角。 + fmt.Println(sun.EclipticObliquity(date, true)) + //太阳此刻视赤经、视赤纬 + ra, dec := sun.ApparentRaDec(date) + fmt.Println("赤经:", tools.Format(ra/15, 1), "赤纬:", tools.Format(dec, 0)) + //太阳当前所在星座 + fmt.Println(star.Constellation(ra, dec, date)) + //此刻西安市的太阳方位角、高度角、天顶距 + fmt.Println("方位角:", sun.Azimuth(date, lon, lat), "高度角:", sun.Altitude(date, lon, lat), "天顶距:", sun.Zenith(date, lon, lat)) + //此刻日地距离,单位为天文单位(AU) + fmt.Println(sun.EarthDistance(date)) + + //月亮此刻站心视赤经、视赤纬 + ra, dec = moon.ApparentRaDec(date, lon, lat) + fmt.Println("赤经:", tools.Format(ra/15, 1), "赤纬:", tools.Format(dec, 0)) + //月亮当前所在星座 + fmt.Println(star.Constellation(ra, dec, date)) + //此刻西安市的月亮方位角、高度角、天顶距 + fmt.Println("方位角:", moon.Azimuth(date, lon, lat), "高度角:", moon.Altitude(date, lon, lat), "天顶距:", moon.Zenith(date, lon, lat)) + //此刻地月距离,单位为千米 + fmt.Println(moon.EarthDistance(date)) +} +``` + +输出结果: + +```text +280.01526210031136 +23.4362178391013 +赤经: 18h43m34.82s 赤纬: -23°3′30.27″ +人马座 +方位角: 120.19477090015224 高度角: 2.4014437419430097 天顶距: 87.59855625805699 +0.983292937163176 +赤经: 23h18m56.24s 赤纬: -10°20′54.42″ +宝瓶座 +方位角: 67.63889332004852 高度角: -45.34916937173283 天顶距: 135.34916937173284 +404238.6096080479 +``` + +## 太阳 + +本节片段省略公共前置:`cst := time.FixedZone("CST", 8*3600)`、`date := time.Date(2026, 1, 1, 12, 0, 0, 0, cst)`、`var lon, lat float64 = 108.93, 34.27`(西安),并假定已导入 `fmt`、`time`、`sun`、`moon`。 + +### 位置 + +太阳位置接口只依赖绝对时刻,不依赖观测者。真黄纬由 `TrueBo` 给出,库内不单独提供视黄纬;`EclipticObliquity` 的第二个参数决定是否加入交角章动。 + +```go +// 太阳真黄经、视黄经与真黄纬,单位度。 +fmt.Println(sun.TrueLo(date), sun.ApparentLo(date), sun.TrueBo(date)) +// 太阳视赤经与视赤纬。 +ra, dec := sun.ApparentRaDec(date) +fmt.Println("视赤经:", ra, "视赤纬:", dec) +fmt.Println(sun.ApparentRa(date), sun.ApparentDec(date)) +// 黄赤交角、几何黄经与中心差。 +fmt.Println(sun.EclipticObliquity(date, true), sun.GeometricLo(date), sun.MidFunc(date)) +``` + +输出结果: + +```text +280.742671383543 280.7383965677222 0.00018280886212040676 +视赤经: 281.6786097810291 视赤纬: -23.00369182413533 +281.6786097810291 -23.00387403948847 +23.438148552330773 280.83169623098 -0.08703499790144194 +``` + +同一族接口都提供 `...N` 截断变体。下面的对照用 `n = 8` 截断,`n < 0` 时结果与不带 `N` 的版本逐位一致: + +```go +// n<0 使用全部内嵌 VSOP 项,n>=0 截断解析项。 +fmt.Println(sun.TrueLo(date), sun.TrueLoN(date, 8)) +fmt.Println(sun.Altitude(date, lon, lat), sun.AltitudeN(date, lon, lat, 8)) +fmt.Println(sun.Diameter(date), sun.DiameterN(date, 8)) +``` + +输出结果: + +```text +280.742671383543 280.7439900413756 +31.61569462953789 31.615524473491835 +1950.9979407481142 1950.9994358395434 +``` + +### 升落与中天 + +`RiseTime` / `SetTime` 以 `date` 所在时区的当地自然日为锚点,返回值保持同一个时区;`height` 是按椭球高(大地高)解读的观测点高程,单位米。`aero` 为 `true` 时按动态标准大气折射与实时视半径计算**上缘**过地平线,为 `false` 时只做几何中心高度过地平线的判定。 + +朦影接口把目标高度角作为参数:民用朦影 `-6°`、航海朦影 `-12°`、天文朦影 `-18°`,晨昏两侧分别是 `MorningTwilight` 与 `EveningTwilight`。 + +```go +// 民用、航海、天文晨朦影的目标高度角。 +for _, angle := range []float64{-6, -12, -18} { + t, err := sun.MorningTwilight(date, lon, lat, angle) + fmt.Println(angle, t.Format("15:04:05"), err) +} +// 上中天与日出;极区无事件时 err 非空。 +fmt.Println(sun.CulminationTime(date, lon).Format("15:04:05")) +t, err := sun.RiseTime(date, lon, lat, 0, true) +fmt.Println(t.Format("15:04:05"), err) +``` + +输出结果: + +```text +-6 07:22:47 +-12 06:51:31 +-18 06:21:00 +12:47:50 +07:50:10 +``` + +### 站心量与视差角 + +`Altitude` / `Zenith` / `Azimuth` / `HourAngle` 走几何链路,不含折射;`ApparentAltitude` / `ApparentZenith` 加入大气折射,需要给气压与气温;`ParallacticAngle` 是有符号的视差角。站心几何与折射的更一般换算(含大气折射、站心赤道坐标)见[坐标工具](coord.md)。 + +```go +fmt.Println(sun.Azimuth(date, lon, lat), sun.Altitude(date, lon, lat), sun.Zenith(date, lon, lat)) +fmt.Println(sun.ApparentAltitude(date, lon, lat, 1010, 10), sun.ApparentZenith(date, lon, lat, 1010, 10)) +// 时角与有符号视差角。 +fmt.Println(sun.HourAngle(date, lon, lat), sun.ParallacticAngle(date, lon, lat)) +``` + +输出结果: + +```text +167.09774780715728 31.61569462953789 58.38430537046211 +31.643010360459822 58.356989639540174 +348.07820699607544 -11.564174033740159 +``` + +### 真太阳时与均时差 + +`ApparentSolarTime` 返回给定经度处的真太阳时,结果时区是按经度换算出来的固定偏移时区,不是调用方传入的时区;`EquationTime` 给出同一时刻的均时差,单位小时。`sundial.TrueSolarTime` 与 `ApparentSolarTime` 口径相同,`sundial` 另提供地方平太阳时与日晷几何,见[日晷与真太阳时](sundial.md)。 + +```go +// 真太阳时;结果时区按经度换算。 +fmt.Println(sun.ApparentSolarTime(date, lon).Format("2006-01-02 15:04:05 -0700")) +// 均时差,单位小时。 +fmt.Println(sun.EquationTime(date)) +// sundial.TrueSolarTime 与其口径相同。 +fmt.Println(sundial.TrueSolarTime(date, lon).Format("2006-01-02 15:04:05 -0700")) +``` + +输出结果: + +```text +2026-01-01 11:12:18 +0715 +-0.05674946079069686 +2026-01-01 11:12:18 +0715 +``` + +### 物理与视直径 + +`sun.Physical` 返回 `PhysicalInfo`:`P` 是太阳北极位置角,`B0` 是日面中心的太阳纬度,`L0` 是日面中心的卡林顿经度,单位都是度。`Diameter` / `Semidiameter` 给视直径与视半径,单位角秒;`EarthDistance` 给日地距离,单位 AU。 + +```go +// 视直径、视半径(角秒)与日地距离(AU)。 +fmt.Println(sun.Diameter(date), sun.Semidiameter(date), sun.EarthDistance(date)) +// 日面物理量 P/B0/L0,单位度。 +p := sun.Physical(date) +fmt.Println(p.P, p.B0, p.L0) +``` + +输出结果: + +```text +1950.9979407481142 975.4989703740571 0.9833237486528845 +1.979086377118846 -3.0131029209723916 296.9595333604375 +``` + +日、月与七大行星的同名接口形状一致,可以并排对照: + +```go +fmt.Println(sun.Diameter(date), sun.Semidiameter(date)) +fmt.Println(sun.Physical(date)) +fmt.Println(moon.Diameter(date), moon.Semidiameter(date)) +fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) +``` + +### 地球轨道极值 + +日地距离的极值由 `earth` 包给出,时间按 UTC,距离单位 AU;只关心某一时刻的轨道偏心率时直接调 `EarthEccentricity`: + +```go +// 2026 年地球近日点、远日点,时间为 UTC,距离单位 AU。 +peri := earth.Perihelion(2026) +aphe := earth.Aphelion(2026) +fmt.Printf("earth perihelion=%s distance=%.9fAU\n", peri.Time.Format(time.RFC3339), peri.Distance) +fmt.Printf("earth aphelion=%s distance=%.9fAU\n", aphe.Time.Format(time.RFC3339), aphe.Distance) +``` + +输出结果: + +```text +earth perihelion=2026-01-03T17:15:35Z distance=0.983302050AU +earth aphelion=2026-07-06T17:31:24Z distance=1.016643936AU +``` + +```go +fmt.Printf("earth e=%.9f\n", earth.EarthEccentricity(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) +``` + +## 月亮 + +本节的片段沿用太阳一节的公共前置,`date` 仍是 `2026-01-01 12:00:00 CST`、观测点是西安。 + +### 位置 + +月亮的赤道坐标分三层:`TrueRaDec` 是地心真位置,`GeocentricApparentRaDec` 是地心视位置,`ApparentRaDec` 是站心视位置。黄道侧只有地心量:`TrueLo` 真黄经、`TrueBo` 真黄纬、`ApparentLo` 视黄经。 + +```go +// 地心真赤道坐标。 +fmt.Println(moon.TrueRaDec(date)) +// 地心视赤道坐标。 +fmt.Println(moon.GeocentricApparentRaDec(date)) +// 站心视赤道坐标。 +fmt.Println(moon.ApparentRaDec(date, lon, lat)) +// 真黄经、真黄纬与视黄经。 +fmt.Println(moon.TrueLo(date), moon.TrueBo(date), moon.ApparentLo(date)) +``` + +输出结果: + +```text +66.66309709020791 26.830370234236764 +66.66476688311091 26.830608930005567 +67.0275694157259 25.982665403390747 +69.21422925147913 5.060516750865828 69.21574418700706 +``` + +### 升落与中天 + +月球升落与太阳一样以当地自然日为锚点:`RiseTime` / `SetTime` 返回当天的事件时刻,`CulminationTime` 返回当天上中天时刻。月球的升降之间可以没有连续性,`date` 之后的完整周期要靠升起时间与降落时间的先后关系决定;当某个事件落在查询日期之外时,升落接口返回 `ERR_NOT_TODAY`。 + +```go +// 月出、上中天与月落,按当天日期计算。 +rise, err := moon.RiseTime(date, lon, lat, 0, true) +fmt.Println(rise.Format("15:04:05"), err) +fmt.Println(moon.CulminationTime(date, lon, lat).Format("15:04:05")) +set, err := moon.SetTime(date, lon, lat, 0, true) +fmt.Println(set.Format("15:04:05"), err) +``` + +输出结果: + +```text +16:17:11 +00:03:16 +06:41:37 +``` + +### 月相 + +`Phase` 返回 `[0,1]` 的受照比例,`PhaseDesc` 返回中文月相名,`SunMoonLoDiff` 返回归一化到 `[0,360)` 的日月视黄经差(朔附近接近 `0°`、望附近接近 `180°`)。`Next*` / `Last*` / `Closest*` 是三种检索口径,结果保持输入时区;四个相位同时提供拼音名与英文 alias,例如 `ShuoYue` / `NewMoon`、`WangYue` / `FullMoon`、`ShangXianYue` / `FirstQuarter`、`XiaXianYue` / `LastQuarter`。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/moon" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + // 指定观测时刻。 + date := time.Date(2020, 1, 1, 8, 8, 8, 8, cst) + //月亮此刻被照亮的比例(月相) + fmt.Println(moon.Phase(date)) + //月相具体描述 + fmt.Println(moon.PhaseDesc(date)) + //下次朔月时间;也可用 moon.NextNewMoon(date) + fmt.Println(moon.NextShuoYue(date)) + //下次上弦月时间;也可用 moon.NextFirstQuarter(date) + fmt.Println(moon.NextShangXianYue(date)) + //下次望月时间;也可用 moon.NextFullMoon(date) + fmt.Println(moon.NextWangYue(date)) + //下次下弦月时间;也可用 moon.NextLastQuarter(date) + fmt.Println(moon.NextXiaXianYue(date)) +} +``` + +输出结果: + +```text +0.30004130960877884 // 月面约有 30% 被太阳照亮 +上峨眉月 // 当前月相描述 +2020-01-25 05:41:58.271192908 +0800 CST // 下一次朔月 +2020-01-03 12:45:23.229190707 +0800 CST // 下一次上弦 +2020-01-11 03:21:17.159625291 +0800 CST // 下一次望月,也就是满月 +2020-01-17 20:58:23.396406769 +0800 CST // 下一次下弦 +``` + +`Last*`、`Closest*` 与按小数年锚点求解的 `ShuoYue` / `FullMoon` 等返回 UTC;`ClosestConjunctionWithPlanet` 求最近一次行星合月,目标用 `ConjunctionPlanet` 常量给出: + +```go +// 朔与望的上一次、最近一次。 +fmt.Println(moon.LastShuoYue(date), moon.ClosestShuoYue(date)) +fmt.Println(moon.LastWangYue(date), moon.ClosestWangYue(date)) +// 上弦与下弦。 +fmt.Println(moon.LastFirstQuarter(date), moon.ClosestLastQuarter(date)) +// 以小数年为锚点的解,结果为 UTC。 +fmt.Println(moon.ShuoYue(2025.5).Format(time.RFC3339), moon.FullMoon(2025.5).Format(time.RFC3339)) +// 最近一次行星合月(赤经合)。 +fmt.Println(moon.ClosestConjunctionWithPlanet(date, moon.ConjunctionJupiter)) +``` + +输出结果: + +```text +2025-12-20 09:43:19.074603617 +0800 CST 2025-12-20 09:43:19.074603617 +0800 CST +2025-12-05 07:14:03.670351803 +0800 CST 2026-01-03 18:02:53.55531156 +0800 CST +2025-12-28 03:09:50.141303837 +0800 CST 2026-01-10 23:48:22.03346461 +0800 CST +2025-06-25T10:31:35Z 2025-07-10T20:36:46Z +2026-01-04 05:59:19.9425897 +0800 CST +``` + +### 近地点与远地点 + +`PerigeesInMonth` / `ApogeesInMonth` 返回指定公历月内的全部近地点与远地点事件,元素是 `ApsisInfo`,含 `Time`(UTC)与 `Distance`(km),一个月可能有零个、一个或多个事件。 + +```go +// 2026 年 1 月的月球近地点、远地点,距离单位 km。 +perigees := moon.PerigeesInMonth(2026, time.January) +apogees := moon.ApogeesInMonth(2026, time.January) +fmt.Printf("moon perigee=%s distance=%.1fkm count=%d\n", perigees[0].Time.Format(time.RFC3339), perigees[0].Distance, len(perigees)) +fmt.Printf("moon apogee=%s distance=%.1fkm count=%d\n", apogees[0].Time.Format(time.RFC3339), apogees[0].Distance, len(apogees)) +``` + +输出结果: + +```text +moon perigee=2026-01-01T21:44:24Z distance=360348.1km count=2 +moon apogee=2026-01-13T20:47:13Z distance=405437.9km count=1 +``` + +### 交点 + +月球也提供升交点和降交点黄经,适合做食季、轨道几何和月球轨道研究: + +```go +nodeDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) +fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate)) +``` + +这里的“升交点 / 降交点”与行星章节中的定义相同: + +- `AscendingNode`:月球轨道从黄道南侧穿到黄道北侧时的黄经 +- `DescendingNode`:月球轨道从黄道北侧穿到黄道南侧时的黄经 +- 单位都是度;同一时刻两者通常相差约 `180°` + +以上面 `nodeDate := 2026-01-01 00:00:00 UTC` 的示例来说,输出结果是: + +```text +340.95708624505863 160.9570862450587 +``` + +### 最大赤纬 + +月球赤纬在一个交点月内达到南北极值,`MaximumDeclinationInfo` 含 `Time`(事件时刻)与 `Declination`(该时刻的地心赤纬,度)。逐月接口返回当月全部事件,`Next*` / `Last*` / `Closest*` 按时刻检索: + +```go +// 最近最大北赤纬与上一次最大南赤纬。 +north := moon.ClosestMaximumNorthDeclination(date) +south := moon.LastMaximumSouthDeclination(date) +fmt.Println(north.Time.Format(time.RFC3339), north.Declination) +fmt.Println(south.Time.Format(time.RFC3339), south.Declination) +// 下一次最大北赤纬与当月全部事件。 +fmt.Println(moon.NextMaximumNorthDeclination(date).Time.Format(time.RFC3339)) +events := moon.MaximumNorthDeclinationsInMonth(2026, time.January) +fmt.Println(len(events)) +for _, event := range events { + fmt.Println(event.Time.Format(time.RFC3339), event.Declination) +} +``` + +输出结果: + +```text +2026-01-02T16:10:49+08:00 28.266373428242343 +2025-12-20T07:06:57+08:00 -28.23514705130737 +2026-01-02T16:10:49+08:00 +2 2026-01-02T08:10:49Z 28.266373428242343 +``` + +同一组事件的逐月列表口径如下(`2026-01-01 00:00:00 UTC` 输入): + +```go +// 2026 年 1 月的月球最大北/南赤纬。 +north := moon.MaximumNorthDeclinationsInMonth(2026, time.January) +south := moon.MaximumSouthDeclinationsInMonth(2026, time.January) +fmt.Printf("north=%s dec=%.6f\n", north[0].Time.Format(time.RFC3339), north[0].Declination) +fmt.Printf("south=%s dec=%.6f\n", south[0].Time.Format(time.RFC3339), south[0].Declination) +``` + +输出结果: + +```text +north=2026-01-02T08:10:49Z dec=28.266373 +south=2026-01-16T05:15:14Z dec=-28.304184 +``` + +### 天平动与亮边位置角 + +`Physical` 返回地心天平动,`TopocentricPhysical` 返回站心天平动,二者都是 `PhysicalInfo`,含光学、物理与总天平动的经纬分量以及自转轴位置角 `PositionAngle`,单位度。亮边位置角的 `0°` 从月面北点起、向东增加。 + +```go +// 地心天平动与自转轴位置角。 +p := moon.Physical(date) +fmt.Println(p.LibrationLongitude, p.LibrationLatitude, p.PositionAngle) +// 站心天平动与自转轴位置角。 +topo := moon.TopocentricPhysical(date, lon, lat, 0) +fmt.Println(topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) +// 地心与站心亮边位置角。 +fmt.Println(moon.BrightLimbPositionAngle(date), moon.TopocentricBrightLimbPositionAngle(date, lon, lat, 0)) +``` + +输出结果: + +```text +-0.9680924808747591 -6.547834757841939 -9.025022841390472 +-0.7780085060449551 -5.659649431558411 -8.883877708625544 +269.08333384819935 267.8559531949645 +``` + +站心量的完整示例(上海,`height = 4 m`): + +```go +// 月球天平动和自转轴位置角。 +physical := moon.Physical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) +fmt.Printf("libration lon=%.6f lat=%.6f pa=%.6f\n", physical.LibrationLongitude, physical.LibrationLatitude, physical.PositionAngle) +// 月亮明亮边缘位置角;0° 从月面北点起,向东增加。 +fmt.Printf("bright limb=%.6f\n", moon.BrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) +// 上海站心看到的月球天平动、自转轴位置角和亮边位置角。 +topo := moon.TopocentricPhysical(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4) +fmt.Printf("topo libration lon=%.6f lat=%.6f pa=%.6f\n", topo.LibrationLongitude, topo.LibrationLatitude, topo.PositionAngle) +fmt.Printf("topo bright limb=%.6f\n", moon.TopocentricBrightLimbPositionAngle(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 121.4737, 31.2304, 4)) +``` + +输出结果: + +```text +libration lon=-1.278902 lat=-6.531444 pa=-9.967050 +bright limb=267.364849 +topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 +topo bright limb=266.038258 +``` + +### 视直径与地月距离 + +`Diameter` / `Semidiameter` 给视直径与视半径(角秒),`EarthDistance` 给地月距离(千米)。三者都只依赖绝对时刻: + +```go +// 视直径、视半径(角秒)与地月距离(千米)。 +fmt.Println(moon.Diameter(date), moon.Semidiameter(date), moon.EarthDistance(date)) +``` + +输出结果: + +```text +1986.4975069969655 993.2487534984828 360488.4234539985 +``` + +## 轻量链路 + +`lite/sun` 与 `lite/moon` 是独立于主链的近似实现:不依赖 VSOP87 或主链的 ELP2000/82 级数,面向 CPU / 内存受限环境,调用方式与主链同形。升落搜索用固定步长扫描加二分,不走主链的高精度章动迭代。 + +```go +package main + +import ( + "fmt" + "time" + + litemoon "b612.me/astro/lite/moon" + litesun "b612.me/astro/lite/sun" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 1, 1, 20, 0, 0, 0, cst) + + fmt.Println(litesun.Altitude(date, 121.4737, 31.2304)) + fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) + + fmt.Println(litemoon.Phase(date)) + fmt.Println(litemoon.PhaseAge(date)) + fmt.Println(litemoon.RiseTime(date, 121.4737, 31.2304, 0, true)) +} +``` + +下面的片段沿用这一节的公共前置:上海(`121.4737°E, 31.2304°N`)与 `2026-01-01 20:00:00 CST`。 + +### lite/sun + +轻量太阳链路提供黄经、赤道坐标、距离、地平坐标与升落,但不提供视直径、日面物理量与朦影接口。`Distance` 是日地距离(AU):需要注意,主链同名能力叫 `EarthDistance`,轻量链路叫 `Distance`。 + +```go +// 轻量太阳真黄经、视黄经与日地距离(AU)。 +fmt.Println(litesun.TrueLo(date), litesun.ApparentLo(date), litesun.Distance(date)) +// 轻量视赤经与视赤纬。 +ra, dec := litesun.ApparentRaDec(date) +fmt.Println(ra, dec) +// 轻量时角、方位角、高度角与天顶距。 +fmt.Println(litesun.HourAngle(date, 121.4737, 31.2304), litesun.Azimuth(date, 121.4737, 31.2304), litesun.Altitude(date, 121.4737, 31.2304), litesun.Zenith(date, 121.4737, 31.2304)) +// 轻量升落。 +fmt.Println(litesun.RiseTime(date, 121.4737, 31.2304, 0, true)) +fmt.Println(litesun.SetTime(date, 121.4737, 31.2304, 0, true)) +``` + +输出结果: + +```text +281.0835889076667 281.0793650422643 0.9833163427233701 +282.0475744673639 -22.973803828458102 +120.58011725187828 263.4579582386061 -37.07676039520773 127.07676039520773 +2026-01-01 06:52:24.6475178 +0800 CST +2026-01-01 17:02:44.014452695 +0800 CST +``` + +### lite/moon + +轻量月球链路提供少量摄动项的月球位置、轻量站心修正、月相与月龄、升落,但不提供天平动、视直径、地月距离与交点。`PhaseAge` 返回月龄(天),只在轻量链路提供: + +```go +// 轻量真黄经与真黄纬。 +fmt.Println(litemoon.TrueLo(date), litemoon.TrueBo(date)) +// 轻量地心真赤道坐标。 +ra, dec := litemoon.TrueRaDec(date) +fmt.Println(ra, dec) +// 轻量站心视赤道坐标。 +fmt.Println(litemoon.ApparentRaDec(date, 121.4737, 31.2304)) +// 日月黄经差、受照比例与月龄。 +fmt.Println(litemoon.SunMoonLoDiff(date), litemoon.Phase(date), litemoon.PhaseAge(date)) +// 轻量地平坐标。 +fmt.Println(litemoon.Altitude(date, 121.4737, 31.2304), litemoon.Azimuth(date, 121.4737, 31.2304), litemoon.Zenith(date, 121.4737, 31.2304)) +``` + +输出结果: + +```text +74.25630740893 5.078407838127742 +72.2518040645064 27.549605139478064 +72.74256784654426 27.432483413326057 +153.17694236666568 0.9462021494002484 12.565014741082448 +63.55513206820331 90.53047230027812 26.444867931796693 +``` + +### 与主链的差异与误差量级 + +轻量链路与主链的差别集中在实现与误差上,接口形状基本一致。位置与月相等纯求值接口相对主链约快 `8.3–27.3x`,升落接口约 `1.0–3.7x`,计算链路零堆分配;升落搜索步长为 `lite/sun` `30` 分钟、`lite/moon` `15` 分钟。与 `sun` / `moon` 的误差(2026 全年,8 个站点)如下,数据见[适用范围与精度](accuracy.md#lite-轻量链路): + +| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | +| --- | --- | --- | --- | +| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | +| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | +| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | +| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | +| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | +| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | +| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | +| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | + +两个包都不提供 `...N` 截断族。太阳侧的极夜 / 极昼错误是 `ERR_SUN_NEVER_RISE` / `ERR_SUN_NEVER_SET`;月球侧除 `ERR_MOON_NEVER_RISE` / `ERR_MOON_NEVER_SET` 外还有 `ERR_NOT_TODAY`,语义与主链一致。 + +## 参数与返回值约定 + +### 单位与角口径 + +- 角度一律为度,`RA`、`Lon`、`Azimuth` 归一化到 `[0°, 360°)`,赤纬与黄纬取值 `[−90°, 90°]` +- 视直径与视半径为角秒;`sun.EarthDistance` 为 AU,`moon.EarthDistance` 为千米 +- `Phase` 是 `[0,1]` 的受照比例,`PhaseAge`(轻量链路)为天,`EquationTime` 为小时 +- 距离类极值:`earth.Perihelion` / `earth.Aphelion` 为 AU,`ApsisInfo.Distance` 为千米 + +### 时标 + +观测输入与事件时刻采用民用时间约定;本库将 `1972-01-01` 以前的读数按 UT1 处理。`ApparentSolarTime` 另返回地方太阳时读数。`time.Time` 携带的时区只影响“当地自然日”的划分与返回值的时区,不改变绝对时刻。UT1 与图内时标的完整声明见[时标声明](map-geojson.md#时标声明)。 + +### 高度与 aero + +- 升落接口的 `height` 是按椭球高(大地高)解读的观测点高程,单位米,不是正高;换算约定见[观测点高度约定](coord.md#观测点高度) +- `aero = true`:按动态标准大气折射与实时视半径计算上缘过地平线;`aero = false`:只做几何中心高度过地平线的判定 +- `ApparentAltitude` / `ApparentZenith` 的 `pressureHPa`、`temperatureC` 是观测时的气压(hPa)与气温(℃) + +### 零值与越界 + +- 极夜时升落接口返回 `ERR_SUN_NEVER_RISE` / `ERR_MOON_NEVER_RISE`;极昼时返回 `ERR_SUN_NEVER_SET` / `ERR_MOON_NEVER_SET`,此时第一个返回值为零值 `time.Time` +- 朦影不存在时返回 `ERR_TWILIGHT_NOT_EXISTS` +- 月亮升降事件落在查询日期之外时返回 `ERR_NOT_TODAY`,对应上面“月出月落按当天日期计算”的语义 +- `DownTime` / `DownTimeN` 是 `SetTime` 的废弃别名,`ERR_SUN_NEVER_DOWN` / `ERR_MOON_NEVER_DOWN` 是极昼错误的废弃别名,新代码不应使用 +- `...N` 中 `n < 0` 用全部内嵌项、`n >= 0` 截断;`n` 只影响级数长度,不改变返回单位与时区 + +### 精度与适用范围 + +太阳与行星用内置 VSOP87 解析项,月球用内置 ELP2000/82 风格截断级数,覆盖 J2000 前后约 4000 年,无需外部星历文件。截断误差、月球链路的能力边界和 lite 链路的量化误差见[适用范围与精度](accuracy.md)。本手册的太阳 / 月球接口适合历法、观测辅助、科普与业余预报;航天导航、精确掩星预报和严格动力学积分需要 JPL DE 等专业星历。 diff --git a/doc/manual/sundial.md b/doc/manual/sundial.md new file mode 100644 index 0000000..2d0a71e --- /dev/null +++ b/doc/manual/sundial.md @@ -0,0 +1,337 @@ +# 日晷与真太阳时 + +[English](en/sundial.md) | [返回 README](../../README.md) + +> 本手册的完整示例以仓库根目录为工作目录执行。 + +`sundial` 把 `sun` 包的真太阳时、太阳时角与日晷绘制所需的几何量集中在一起,不引入另一套算法。日晷部分按经典平面日晷模型工作:一根指向天极的极轴晷针,其影子投在任意平面上;坐标约定由构造器给定,水平日晷是 **x 轴向东、y 轴向北**。 + +## 目录 + +- [计算水平日晷的影子位置](#计算水平日晷的影子位置) +- [API 参考](#api-参考) + - [真太阳时、平太阳时与均时差](#真太阳时平太阳时与均时差) + - [时角](#时角) + - [水平日晷时线角](#水平日晷时线角) + - [平面日晷核心](#平面日晷核心) + - [盘面受光区间](#盘面受光区间) + - [时间线与赤纬曲线](#时间线与赤纬曲线) + - [赤道、水平与垂直日晷特例](#赤道水平与垂直日晷特例) + - [返回结构](#返回结构) + - [完整示例](#完整示例) + - [综合示例:一天的可用时角与等时线](#综合示例一天的可用时角与等时线) + - [常见坑](#常见坑) +- [常用场景](#常用场景) + - [真太阳时与钟表时间的差](#真太阳时与钟表时间的差) + - [水平日晷的时线角与落影](#水平日晷的时线角与落影) + - [盘面受光区间与等时线](#盘面受光区间与等时线) + - [退化情形与各面日晷口径](#退化情形与各面日晷口径) +- [参数与返回值约定](#参数与返回值约定) +- [相关手册](#相关手册) + +## 计算水平日晷的影子位置 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/sundial" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 6, 21, 9, 30, 0, 0, cst) + lon, lat := 121.4737, 31.2304 + fmt.Println(sundial.TrueSolarTime(date, lon)) + fmt.Println(sundial.HourAngle(date, lon)) + dial := sundial.HorizontalDial(lat, 10) + shadow := dial.ShadowPointAt(date, lon) + if !shadow.Illuminated { + fmt.Println("no illuminated shadow") + return + } + fmt.Printf("x=%.6f y=%.6f\n", shadow.X, shadow.Y) +} +``` + +`HorizontalDial` 的长度参数与返回坐标使用同一单位;例如晷针长度填厘米,影子坐标也是厘米。x 轴向东、y 轴向北,`Illuminated` 为真时才有可用的受光落影。 + +## API 参考 + +| 分组 | 入口 | 用途 | 单位与口径 | +| --- | --- | --- | --- | +| 真/平太阳时 | `TrueSolarTime` / `MeanSolarTime` | 该绝对时刻在指定经度上的地方真/平太阳时 | 返回地方太阳时读数;经度东正西负(度) | +| 太阳时角 | `HourAngle` | 真太阳时角 | 度,上午为负、下午为正 | +| 时角换算 | `MeanSolarHourAngle` / `ZoneTimeHourAngle` | 把地方平太阳时或区时钟面读数换成视太阳时角 | 小时数与经度(度) | +| 水平时线角 | `HorizontalHourLineAngle` / `HorizontalHourLineAngleAt` | 水平日晷时线相对午线的角度 | 度 | +| 平面日晷核心 | `PlanarDial`(字段)+ `Geometry` / `ShadowPointByHourAngleDeclination` / `ShadowPointAt` | 任意平面的几何量与落影点 | 坐标与晷针长度为同一长度单位 | +| 盘面受光 | `PlaneIlluminatedHourAngleIntervals` / `IlluminatedHourAngleIntervals` | 盘面受光时角区间与最终可用时角区间 | 度,`[-180, 180]` | +| 时间线 | `MeanSolarTimePoint` / `ZoneTimePoint` / `MeanSolarTimeLine` / `ZoneTimeLine` | 把平太阳时线或区时线直接接到日晷几何 | 返回 `PlanarShadowPoint` / `TimeLineSample` | +| 赤纬曲线 | `DeclinationCurve` / `DeclinationCurveAt` | 按赤纬或日期生成分段采样点列 | 时角步长单位为度 | +| 特例构造 | `EquatorialNorthDial` / `EquatorialSouthDial` / `HorizontalDial` / `VerticalDial` | 赤道(南北面)、水平、垂直日晷 | 纬度与法线方位角单位为度 | + +以下片段省略公共前置变量:`date`(时刻,民用时标)、`lon`(经度,东正西负,度)、`lat`(纬度,度)。 + +### 真太阳时、平太阳时与均时差 + +```go +fmt.Println(sundial.TrueSolarTime(date, lon)) +fmt.Println(sundial.MeanSolarTime(date, lon)) +fmt.Println(sundial.TrueSolarTime(date, lon).Sub(sundial.MeanSolarTime(date, lon))) // 均时差 +``` + +三者共用 `sun` 包的口径:`TrueSolarTime` 是视太阳时,`MeanSolarTime` 是地方平太阳时,两者之差即均时差(真 − 平)。 + +### 时角 + +```go +fmt.Println(sundial.HourAngle(date, lon)) // 真太阳时角,上午为负 +fmt.Println(sundial.MeanSolarHourAngle(date, 9.5)) // 地方平太阳时 9:30 对应的时角 +fmt.Println(sundial.ZoneTimeHourAngle(date, lon, 9.5)) // 区时钟面 9:30 对应的时角 +``` + +`HourAngle` 直接用绝对时刻求时角;后两者用于"给定钟面读数求落影方向",区别在于一个按地方平太阳时、一个按区时。 + +### 水平日晷时线角 + +```go +fmt.Println(sundial.HorizontalHourLineAngle(31.2304, -45)) +fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, 31.2304)) +``` + +前者给定纬度与带符号时角,后者直接用时刻与经纬度求当前时线角;返回值是时线相对午线的夹角。 + +### 平面日晷核心 + +```go +dial := sundial.PlanarDial{ + Latitude: 31.2304, PlaneNormalAzimuth: 180, PlaneNormalZenithDistance: 90, StylusLength: 10, +} +g := dial.Geometry() +fmt.Println(g.HasFiniteCenter, g.PolarStylusLength, g.PolarStylusPlaneAngle) +p := dial.ShadowPointByHourAngleDeclination(-45, 23.44) +fmt.Println(p.X, p.Y, p.Illuminated) +``` + +`PlanarDial` 的四个字段分别是纬度、盘面法线方位角、法线天顶距与晷针长度。`Geometry` 给出日晷中心(极轴晷针固定点)、极轴晷针长度以及它与盘面的夹角;`ShadowPointByHourAngleDeclination` 给定带符号时角与太阳赤纬求落影点,`ShadowPointAt` 则直接用时刻与经度。 + +### 盘面受光区间 + +```go +for _, iv := range dial.PlaneIlluminatedHourAngleIntervals(23.44) { + fmt.Println(iv.Start, iv.End) +} +for _, iv := range dial.IlluminatedHourAngleIntervals(23.44) { + fmt.Println(iv.Start, iv.End) +} +``` + +前者只回答"盘面是否朝向太阳"(几何受光),后者叠加太阳在地平线以上与盘面朝向两个条件,给出**最终可用**的时角区间;区间约定在 `[-180, 180]` 且 `Start <= End`。 + +### 时间线与赤纬曲线 + +```go +dates := []time.Time{date, date.Add(30 * time.Minute), date.Add(time.Hour)} +fmt.Println(len(dial.MeanSolarTimeLine(dates, 9.5))) +segs := dial.DeclinationCurve(23.44, 1.0) +segsAt := dial.DeclinationCurveAt(date, 1.0) +fmt.Println(len(segs), len(segsAt)) +``` + +时间线把"地方平太阳时线上的等时刻点"直接投影成 `TimeLineSample`;赤纬曲线按固定赤纬或当日赤纬分段采样,段内 `Interval` 就是上面那条可用时角区间。 + +### 赤道、水平与垂直日晷特例 + +```go +h := sundial.HorizontalDial(31.2304, 10) +n := sundial.EquatorialNorthDial(31.2304, 10) +s := sundial.EquatorialSouthDial(31.2304, 10) +v := sundial.VerticalDial(31.2304, 180, 10) +fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth) +fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance) +``` + +四个构造器给出的盘面法线口径不同,画图时按 `PlanarDial` 字段判断即可: + +| 构造器 | `PlaneNormalAzimuth` | `PlaneNormalZenithDistance` | +| --- | --- | --- | +| `HorizontalDial` | `180°` | `0°`(法线指向天顶;x 轴向东、y 轴向北) | +| `EquatorialNorthDial` | `0°` | `90° - 纬度` | +| `EquatorialSouthDial` | `180°` | `90° + 纬度` | +| `VerticalDial` | 入参归一化到 `[0°, 360°)` | `90°` | + +北赤道日晷在北半球用于春夏半年(太阳赤纬为正),南赤道日晷用于秋冬半年;垂直日晷的 `planeNormalAzimuth` 按正北 `0°`、向东增加,朝南墙面取 `180°`、朝东墙面取 `90°`。 + +### 返回结构 + +| 类型 | 字段 | 含义 | +| --- | --- | --- | +| `PlanarShadowPoint` | `X` / `Y` | 落影点坐标,与 `StylusLength` 同单位 | +| | `DenominatorQ` | 投影分母,趋近 0 表示影子趋于无穷远 | +| | `SunAboveHorizon` / `PlaneIlluminated` / `Illuminated` | 太阳在地平线上、盘面受光、两者同时成立的最终判据 | +| `PlanarGeometry` | `CenterX` / `CenterY` | 日晷中心(极轴晷针固定点)坐标 | +| | `PolarStylusLength` / `PolarStylusPlaneAngle` | 极轴晷针长度、它与盘面的夹角 | +| | `HasFiniteCenter` | 为 false 时中心退化到无穷远,相关量为 `NaN` | +| `HourAngleInterval` | `Start` / `End` | 有符号时角区间(度),保证 `Start <= End` | +| `TimeLineSample` | `Date` / `Declination` / `HourAngle` / `Point` | 时刻、太阳赤纬、真太阳时角与对应落影点 | +| `DeclinationCurveSegment` | `Declination` / `Interval` / `Samples` | 该段的赤纬、可用时角区间与采样点列 | + +### 完整示例 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro/sundial" +) + +func main() { + date := time.Date(2026, 6, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) + lon, lat := 121.4737, 31.2304 + + trueSolar := sundial.TrueSolarTime(date, lon) + hourAngle := sundial.HourAngle(date, lon) + lineAngle := sundial.HorizontalHourLineAngle(lat, -45) + lineAngleNow := sundial.HorizontalHourLineAngleAt(date, lon, lat) + + fmt.Println(trueSolar) + fmt.Printf("hour angle=%.6f line@9am=%.6f line@now=%.6f\n", hourAngle, lineAngle, lineAngleNow) +} +``` + +输出结果: + +```text +2026-06-21 09:34:10.438158222 +0805 LTZ +hour angle=-36.456508 line@9am=-27.405871 line@now=-20.959182 +``` + +第一行的时区是合成的当地真太阳时区(经度 `121.4737°` 对应 `+08:05` 的 `LTZ`),所以打印值本身就体现了地方真太阳时与钟表时间的差。 + +### 综合示例:一天的可用时角与等时线 + +```go +dial := sundial.HorizontalDial(31.2304, 10) +for _, seg := range dial.DeclinationCurveAt(date, 1.0) { + fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples)) +} +mean := sundial.MeanSolarTime(date, 121.4737) +samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5) +fmt.Println(len(samples), samples[0].HourAngle) +``` + +实测(`date = 2026-06-21 09:30 CST`、`121.4737°E, 31.2304°N`、晷针长 10): + +```text +decl=23.44 usable=-105.24..105.24 samples=211 +2 -37.92998436772365 +``` + +第一行说明当天在纬度 `31.2304°` 的水平日晷上,太阳赤纬 `23.44°` 时盘面能用的时角区间是 `[-105.24°, +105.24°]`(约 14 小时),采样 211 点;第二行是"地方平太阳时 9:30 这条时线"的两个采样点与其时角。这两个量配合就能直接画出一张带可用范围的水平日晷。 + +### 常见坑 + +- 把 `ZoneTimePoint` 的 `date` 当成目标地点的地方真太阳时 → 它只用年月日与时区,钟面时间来自 `zoneTimeHours`。 +- 用 `PlaneIlluminated` 判断"这张日晷一天里能不能用" → 应该看 `Illuminated`,它才叠加了太阳在地平线以上这个条件。 +- 垂直日晷传"墙面朝向" → `planeNormalAzimuth` 是**法线**方位角,朝南墙是 `180°`。 +- 时角符号写反 → `HourAngle` 上午为负、下午为正,`HorizontalHourLineAngle` 也沿用同一符号。 +- 以为时角区间一定是一段 → 跨零点的一天会被拆成多段,必须遍历返回的切片。 + +## 常用场景 + +### 真太阳时与钟表时间的差 + +```go +trueSolar := sundial.TrueSolarTime(date, lon) +mean := sundial.MeanSolarTime(date, lon) +fmt.Println(trueSolar, mean) +fmt.Println(trueSolar.Sub(mean)) // 均时差 +``` + +```text +2026-06-21 09:34:10.438158222 +0805 LTZ 2026-06-21 09:35:53.532790863 +0805 LTZ +-1m43.094632641s +``` + +- `TrueSolarTime` 返回的是带合成时区的时刻,直接与自己钟表时间相减就得到"真太阳时快/慢多少"。 +- 要把**钟面读数**换成时角(例如按区时排影子刻度),用 `MeanSolarHourAngle` / `ZoneTimeHourAngle`,不要手工加减均时差。 + +### 水平日晷的时线角与落影 + +```go +dial := sundial.HorizontalDial(lat, 10) +fmt.Println(sundial.HorizontalHourLineAngle(lat, -45)) // 时角 -45° 的时线角 +fmt.Println(sundial.HorizontalHourLineAngleAt(date, lon, lat)) // 当前时刻的时线角 +p := dial.ShadowPointAt(date, lon) +fmt.Println(p.X, p.Y, p.Illuminated) +``` + +```text +-27.40587112370779 +-20.95918157094186 +-6.511723246549 0.507600045956 true +``` + +- 水平日晷的坐标约定是 `x` 轴向东、`y` 轴向北;`Illuminated` 是"太阳在地平线上且盘面朝向太阳"的最终判据,只画有效影子时看它。 +- 只有时角、没有具体时刻时用 `ShadowPointByHourAngleDeclination`,同一几何给 `-6.511402572556 0.506762879485 true`。 + +### 盘面受光区间与等时线 + +```go +dial := sundial.HorizontalDial(31.2304, 10) +for _, seg := range dial.DeclinationCurveAt(date, 1.0) { + fmt.Printf("decl=%.2f usable=%.2f..%.2f samples=%d\n", seg.Declination, seg.Interval.Start, seg.Interval.End, len(seg.Samples)) +} +mean := sundial.MeanSolarTime(date, 121.4737) +samples := dial.MeanSolarTimeLine([]time.Time{mean, mean.Add(30 * time.Minute)}, 9.5) +fmt.Println(len(samples), samples[0].HourAngle) +``` + +```text +decl=23.44 usable=-105.24..105.24 samples=211 +2 -37.92998436772365 +``` + +- `PlaneIlluminatedHourAngleIntervals` 只判断"盘面是否朝向太阳",`IlluminatedHourAngleIntervals` 再叠加太阳在地平线以上,回答"这盘一天能用多久"要用后者。 +- 区间约定是 `[-180, 180]` 且 `Start <= End`,跨零点会拆成多段;赤纬曲线与时间线的完整接口见 [API 参考](#时间线与赤纬曲线)。 + +### 退化情形与各面日晷口径 + +```go +h := sundial.HorizontalDial(31.2304, 10) +n := sundial.EquatorialNorthDial(31.2304, 10) +s := sundial.EquatorialSouthDial(31.2304, 10) +v := sundial.VerticalDial(31.2304, 180, 10) +fmt.Println(h.PlaneNormalZenithDistance, n.PlaneNormalAzimuth, s.PlaneNormalAzimuth) +fmt.Println(v.PlaneNormalAzimuth, v.PlaneNormalZenithDistance) +``` + +```text +0 0 180 +180 90 +``` + +- **退化条件**:盘面法线与极轴垂直(等价于极轴晷针与盘面平行)时,`Geometry().HasFiniteCenter` 为 `false`,`CenterX`/`CenterY`/`PolarStylusLength` 为 `NaN`、`PolarStylusPlaneAngle` 为 0——纬度 `45°`、法线方位 `180°`、法线天顶距 `45°` 正落在这个点上,此时以中心为基准的绘制不可用。 +- **各面口径**:水平盘法线指向天顶(天顶距 `0°`);垂直盘法线天顶距 `90°`,其 `planeNormalAzimuth` 是**法线**方向(朝南墙 `180°`、朝东墙 `90°`);赤道南北面分别用 `90-纬度` 与 `90+纬度`。四个构造器的完整表见[赤道、水平与垂直日晷特例](#赤道水平与垂直日晷特例)。 + +## 参数与返回值约定 + +- **单位**:时角、时线角、赤纬、纬度、法线方位角与法线天顶距都是**度**;`PlanarDial` 的 `StylusLength` 与返回点 `X`/`Y` 共用同一长度单位,可以是任意自洽单位(毫米、米或画布坐标)。 +- **时标**:观测输入为民用时刻;`TrueSolarTime` / `MeanSolarTime` 的返回字段表示地方太阳时读数,不应直接作为新的观测时刻传入星历接口。换算约定见[时标手册](timescale.md)。 +- **时角符号**:`HourAngle` 上午为负、下午为正;`HourAngleInterval` 的取值范围是 `[-180, 180]` 且保证 `Start <= End`。跨零点的一天会被拆成多段。 +- **`date` 的时区语义(易错)**:`MeanSolarTimePoint` / `MeanSolarTimeLine` 的 `date` 表示**目标地点的地方平太阳时**(通常是 `MeanSolarTime(...)` 的返回值);`ZoneTimePoint` / `ZoneTimeLine` 会忽略传入 `date` 的时分秒,只用它的年月日与时区,再用参数 `zoneTimeHours` 替换钟面时间。传错会把整条时线平移。 +- **落影有效性的三个布尔量**:`SunAboveHorizon` 表示太阳在地平线以上,`PlaneIlluminated` 表示盘面朝向太阳,`Illuminated` 是两者同时成立后的最终判据;只画影子时必须看 `Illuminated`。 +- **退化情形**:当盘面法线与极轴垂直(等价于极轴晷针与盘面平行)时,`PlanarGeometry.HasFiniteCenter` 为 `false`,`CenterX`/`CenterY`/`PolarStylusLength` 返回 `NaN`、`PolarStylusPlaneAngle` 为 `0`——此时中心在无穷远,所有以中心为基准的绘制都不可用。例如纬度 `45°`、法线方位 `180°`、法线天顶距 `45°` 就落在这个退化点上。 +- **`PlaneIlluminated` 与 `Illuminated` 的区别**:前者只做几何受光判断,后者还要求太阳在地平线以上;做"这张日晷一天里能用多久"的结论时用后者。 + +## 相关手册 + +- 真太阳时、均时差与太阳位置:[太阳与月亮](sun-moon.md) +- 时角、恒星时与地平转换:[坐标工具](coord.md) +- 出图时标:[时标声明](map-geojson.md#时标声明) diff --git a/doc/manual/timescale.md b/doc/manual/timescale.md new file mode 100644 index 0000000..38df956 --- /dev/null +++ b/doc/manual/timescale.md @@ -0,0 +1,207 @@ +# 时标 + +[English](en/timescale.md) | [README](../../README.md) + +同一个物理时刻可以用 UTC、UT1 或 TT 表示。UTC(协调世界时)用于民用时间,UT1(世界时)反映地球自转,TT 用于星历计算。它们之间的两个差值是 `ΔT = TT − UT1` 和 `DUT1 = UT1 − UTC`,单位都是秒。 + +## 目录 + +- [时间参数](#时间参数) +- [time.Time 换算 API](#timetime-换算-api) +- [儒略日换算 API](#儒略日换算-api) +- [TCG、TCB 与 TDB](#tcgtcb-与-tdb) +- [ΔT 模型](#δt-模型) + - [比较不同模型](#比较不同模型) + - [接入外部模型](#接入外部模型) +- [未来的 UTC 对齐口径假设](#未来的-utc-对齐口径假设) +- [SVG、GeoJSON 与 KML 的时间标签](#svggeojson-与-kml-的时间标签) + +## 时间参数 + +一般观测接口接收表示民用时刻的 `time.Time`,先取其 UTC 值,再按计算需要转换到 UT1 或 TT。改变 `Location` 只改变同一时刻的显示方式;日出日落等按日搜索的接口还会用它确定当地日期。 + +本库约定:1972-01-01 以前的民用时间按 UT1 处理;这是库的历算约定,不表示当时不存在 UTC。1972 年以后,TT−UTC 由内置闰秒表、所选政策或显式覆盖决定,ΔT 模型负责 TT−UT1。 + +以下输入需要另外区分: + +| 输入 | 解释 | +| --- | --- | +| `astro.TTFromUTC` / `UT1FromUTC` 的参数 | 民用时刻,可带任意时区 | +| `astro.UTCFromTT` / `UTCFromUT1` 的参数 | TT / UT1 读数,以 UTC Location 承载 | +| `astro.TCGFromTT` / `TCBFromTT` / `TDBFromTT` 的参数 | TT 读数,不是民用 UTC 时刻 | +| `orbit.Elements.EpochJD` / `TpJD` | TT/TDB 儒略日,见[轨道手册](orbit.md) | +| `basic` 的 JD 数值接口 | 由函数名和参数契约指定时标,数值本身不记录时标 | +| `calendar.Date2JD` / `basic.Date2JD` | 读取年月日和钟面字段,不自动转换时区;需要 UTC JD 时先传入 `date.UTC()` | +| 公农历转换 | 按[历法手册](calendar.md)解释日期,默认使用北京时间 | + +`time.Time` 不能表示闰秒的 `23:59:60`。历史日期还须考虑本库的儒略历/格里高利历切换与 Go 前推格里高利历之间的差别。 + +## time.Time 换算 API + +这些接口位于根包 `b612.me/astro`。 + +| 接口 | 返回值 | +| --- | --- | +| `TTFromUTC(date)` | 同一时刻的 TT 读数 | +| `UTCFromTT(tt)` | TT 读数对应的UTC时刻 | +| `UT1FromUTC(date)` | 同一时刻的 UT1 读数 | +| `UTCFromUT1(ut1)` | UT1 读数对应的UTC时刻 | +| `DUT1(date)` | UT1−UTC,秒 | +| `LabelIn(scale, date)` | `TimeScaleUTC` 保留输入;`TimeScaleUT1` 返回 UT1 读数 | +| `TCGFromTT(tt)` / `TTFromTCG(tcg)` | TT 与地心坐标时 TCG 互换 | +| `TCBFromTT(tt)` / `TTFromTCB(tcb)` | TT 与太阳系质心坐标时 TCB 互换,采用地心近似 | +| `TDBFromTT(tt)` / `TTFromTDB(tdb)` | TT 与太阳系质心力学时 TDB 互换,采用地心近似 | +| `TCBFromTDB(tdb)` / `TDBFromTCB(tcb)` | TDB 与 TCB 的线性互换 | +| `TCGMinusTT(tt)` / `TCBMinusTT(tt)` / `TDBMinusTT(tt)` | 相应时标与 TT 的秒差,输入为 TT 读数 | + +TT、UT1、TCG、TCB、TDB 的换算结果虽然使用 `time.Time` 类型和 UTC Location,但字段表示的是相应时标的读数。不能把这个值直接传回要求民用时刻的太阳、月亮等接口,否则相当于再次移动了计算时刻。需要还原时,调用对应逆变换。 + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro" +) + +func main() { + date := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + tt := astro.TTFromUTC(date) + ut1 := astro.UT1FromUTC(date) + fmt.Println("UTC:", date.Format(time.RFC3339Nano)) + fmt.Println("TT:", tt.Format("2006-01-02 15:04:05.000000")) + fmt.Println("UT1:", ut1.Format("2006-01-02 15:04:05.000000")) + fmt.Printf("DUT1: %.6f s\n", astro.DUT1(date)) + fmt.Println("UTC from TT:", astro.UTCFromTT(tt).Format(time.RFC3339Nano)) +} +``` + +数值换算经过浮点儒略日,往返结果可能有微小舍入差。TT/UT1 的显示格式不加 `Z`,以免被当成 UTC 时间戳。 + +## 儒略日换算 API + +下列函数位于 `basic`,参数与返回值是 `float64`。换算函数返回儒略日,差值函数返回秒。 + +| 接口 | 输入 → 输出 | 说明 | +| --- | --- | --- | +| `UTC2TT` | UTC JD → TT JD | 1972 年前按 UT1,之后使用 TT−UTC 模型 | +| `TT2UTC` | TT JD → UTC JD | 上述逆变换 | +| `UT12TT` | UT1 JD → TT JD | 使用当前 ΔT 模型 | +| `TT2UT1` | TT JD → UT1 JD | 上述逆变换 | +| `UTC2UT1` | UTC JD → UT1 JD | 等于 `TT2UT1(UTC2TT(jd))` | +| `UT12UTC` | UT1 JD → UTC JD | 上述逆变换 | +| `TTMinusUTCSeconds` | UTC JD → 秒 | 内置闰秒段为 `32.184 + (TAI−UTC)`;也可由注入函数覆盖 | +| `DUT1Seconds` | UTC JD → 秒 | `(TT−UTC) − ΔT` | +| `DeltaT` | JD 或十进制年 → 秒 | 第二参数为 `true` 时输入是 UT 儒略日,为 `false` 时是十进制年 | +| `TT2TCG` / `TCG2TT` | TT JD ↔ TCG JD | 线性换算 | +| `TT2TCB` / `TCB2TT` | TT JD ↔ TCB JD | 地心近似 | +| `TT2TDB` / `TDB2TT` | TT JD ↔ TDB JD | 地心近似 | +| `TCB2TDB` / `TDB2TCB` | TCB JD ↔ TDB JD | 线性换算,含 TDB0 常数 | +| `TCGMinusTTSeconds` / `TCBMinusTTSeconds` / `TDBMinusTTSeconds` | TT JD → 秒 | 相应时标与 TT 的秒差 | + +闰秒或模拟闰时带来阶跃时,TT→民用时间的逆变换存在与阶跃宽度相同的不可表示区间;不能要求跨该区间的逐值往返恒等。 + +`TTMinusUTCSeconds` 与 `DefaultTTMinusUTC()` 读取闰秒表值(前者可被注入函数覆盖),不应用 `UTC2TT` 的未来政策外推。换算未来民用时刻时直接使用 `UTC2TT` 或 `TTFromUTC`。 + +## TCG、TCB 与 TDB + +TCG 是地心坐标时;TCB 与 TDB 分别是太阳系质心坐标时和太阳系质心力学时。TT 与 TCG、TCB 与 TDB 的关系为线性定义;TT 与 TDB 的关系在这里采用地心近似,不含观测者位置引起的日周项。 + +令 `T0 = 2443144.5003725`、`LG = 6.969290134e-10`、`LB = 1.550519768e-8`、`TDB0 = −65.5e-6` 秒,时标读数用 JD 表示: + +```text +TCG − TT = LG / (1 − LG) × (TT − T0) +TDB = TCB − LB × (TCB − T0) + TDB0 / 86400 +TCB − TT = [LB × (TT − T0) + (TDB − TT) − TDB0 / 86400] / (1 − LB) +``` + +参考历元 T0 不表示四个时标的读数在此全部相等。现代日期 TDB−TT 的主要年周期振幅约 1.7 ms;TCG−TT 每年增加约 0.022 秒,TCB−TT 每年增加约 0.489 秒。 + +TDB−TT 使用 40 项截断级数和质量调整项。在 −3000 至 +6000 年范围,相对完整 787 项地心级数,遗漏项绝对幅度之和给出的保守截断误差上界为 11 µs;每 31 天抽样的最大差约 2.0 µs。抽样最大差不是全时域误差保证,截断误差也不包含完整模型自身的误差;范围外不作精度承诺。 + +单个 `float64` JD 在现代日期附近的分辨率约为 40 µs,`time.Time` 包装同样经过这一步舍入。比较微秒级时标差值应使用 `*MinusTT` 或 `*MinusTTSeconds`,不要把两个完整 JD 相减后再换算成秒。 + +## ΔT 模型 + +默认模型为 SMH2016 与 Morrison 2021 的样条和长期外推,并在实测覆盖期内优先使用逐月 ΔT 表。当前表延伸到 2026 年 9 月 1 日,末点为快速观测值,尚非最终解;表外在端点作常值锚定以保持连续。`DeltaT` 对超出 ±40000 年的输入返回 `NaN`。 + +| 常量 | 模型 | +| --- | --- | +| `DeltaTModelDefault` / `DeltaTModelSMH2016` | 内置默认模型 | +| `DeltaTModelMS2004` | Morrison & Stephenson 2004,`ΔT = −20 + 32u²`,`u = (year−1820)/100` | +| `DeltaTModelEspenakMeeus2006` | Espenak & Meeus 2006 分段多项式 | +| `DeltaTModelNASACanon2006` | 上述多项式加 NASA 目录配对项:1955 年以前加 `−0.000012932(year−1955)²` | +| `DeltaTModelManual` | 查询状态时表示当前使用任意注入函数,不能作为命名模型安装 | + +`SetDeltaTModel(model, keepObserved)` 设置进程级模型,返回是否成功;未知模型不会改变当前状态。`keepObserved=true` 保留实测段,模型只用于表外;`false` 则全程使用所选模型。`GetDeltaTModel()` 返回模型标识和该开关。 + +### 比较不同模型 + +`DeltaTModelSeconds` 不改变进程状态,适合比较同一时刻的不同 ΔT 取值: + +```go +package main + +import ( + "fmt" + "time" + + "b612.me/astro" + "b612.me/astro/basic" +) + +func main() { + date := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) + jd := basic.Date2JD(date) + for _, model := range []astro.DeltaTModel{ + astro.DeltaTModelSMH2016, + astro.DeltaTModelEspenakMeeus2006, + astro.DeltaTModelNASACanon2006, + } { + fmt.Println(model, astro.DeltaTModelSeconds(model, jd, false)) + } +} +``` + +ΔT 的未来值不能精确预知。按现有模型计算,Espenak–Meeus 2006 与默认模型在 2035、2050、2100、2200 年的差异约为 11、21、116、275 秒。远期日月食的时刻和地面路径会受此影响;与日月食目录对照时,应先统一 ΔT 模型。 + +### 接入外部模型 + +| 根包接口 | 用途 | +| --- | --- | +| `DeltaT()` / `SetDeltaT(fn)` | 读取、设置 ΔT 函数;函数类型为 `func(float64, bool) float64` | +| `DefaultDeltaT()` | 取得内置默认 ΔT 函数 | +| `TTMinusUTC()` / `SetTTMinusUTC(fn)` | 读取、设置 TT−UTC 覆盖;函数类型为 `func(float64) float64`,参数是民用 JD | +| `DefaultTTMinusUTC()` | 取得内置闰秒表函数,不读取注入覆盖或未来政策 | + +两个回调均返回秒。`SetDeltaT(nil)` 恢复默认 ΔT;`SetTTMinusUTC(nil)` 恢复内置闰秒表与未来政策。未设置 TT−UTC 覆盖时,`TTMinusUTC()` 返回 `nil`。 + +`basic` 中对应的接口是 `GetDeltaTFn` / `SetDeltaTFn`、`GetTTMinusUTCFn` / `SetTTMinusUTCFn`,与根包共享状态。TT−UTC 覆盖作用于 1972 年以后的查询,优先于未来政策;1972 年前仍采用本库的 UT1 约定。 + +这些设置会影响同一进程内的后续计算。应用应在开始计算前设定;临时对照结束后恢复原来的函数或命名模型,不要把模型切换当成某一次函数调用的局部选项。 + +## 未来的 UTC 对齐口径假设 + +`SetTimeScaleFuturePolicy` 与 `GetTimeScaleFuturePolicy` 选择民用时标换算政策,默认是 `TimeScaleLeapSecond`。除 `TimeScaleUT1Civil` 替换全时轴外,其余政策只控制实测窗口之后的 TT−UTC;1972 年后显式注入的 TT−UTC 覆盖始终优先。它与出图的 `TimeScaleUTC` / `TimeScaleUT1` 选项用途不同。 + +| 政策 | 计算假设 | +| --- | --- | +| `TimeScaleLeapSecond`(零值、默认) | 从末端 TT−UTC 出发,以最少整数秒校正使外推 DUT1 回到 ±0.9 秒内 | +| `TimeScaleAssumeUT1Tracking` | 保留末端 DUT1,TT−UTC 随外推 ΔT 平滑变化 | +| `TimeScaleFreezeUTCOffset` | TT−UTC 固定在内置窗口末端值,当前为 69.184 秒 | +| `TimeScaleLeapHour` | 按 ΔT 外推,以最少整小时校正使 DUT1 回到 ±3600 秒内,用于情景计算 | +| `TimeScaleUT1Civil` | 全时轴将民用时间视为 UT1,含历史闰秒表覆盖期;无显式覆盖时 DUT1 恒为 0 | + +这些选项是计算假设,不是对未来闰秒或国际决议的预报。整数秒或小时校正由当前查询时刻的 ΔT 决定,不记录历次校正,也不强制落在公告的日历边界。阶跃附近不保证逐值往返恒等;步进政策依赖的 ΔT 无效或超出模型范围时返回 `NaN`,不会退回正常偏移。 + +默认模型也不能保证任意未来日期相对真实 UTC 的误差小于 0.9 秒。需要精确民用时刻时,应使用对应日期的已发布时标数据。 + +## SVG、GeoJSON 与 KML 的时间标签 + +SVG 和 GeoJSON 默认输出民用时间标签。需要 UT1 时,可使用 `...InUT1` 结果转换函数,或设置导出选项的 `TimeScale: astro.TimeScaleUT1`。UT1 输出的 `Location` 应为 `nil` 或 `time.UTC`。 + +已有几何换时标时仍对应同一个物理时刻。时间标记若按输出时标的整刻度重新取点,采样时刻会随之改变,标记位置也会移动。GeoJSON 用 `time_scale` 标明标签口径;KML 的 `` 必须是 UTC,转换器会把 UT1 标签按当前模型换回 UTC,并保留原 `time_scale` 属性。生成 GeoJSON 和转成 KML 时应使用一致的时标模型。 + +具体选项、时间标记步长和 KML 时间轴行为见[地图与数据导出](map-geojson.md)。 diff --git a/doc/solar-eclipse-arctic-2012-global-en.svg b/doc/solar-eclipse-arctic-2012-global-en.svg deleted file mode 100644 index 48d6335..0000000 --- a/doc/solar-eclipse-arctic-2012-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Rise/set phase linesAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08131.0542°E, 10.8905°NSun altitude +0.0°U106:06:18109.6294°E, 20.5716°NSun altitude +0.0°Central begins06:09:02108.6975°E, 21.1558°NSun altitude +0.0°U206:11:48107.7417°E, 21.7712°NSun altitude +0.0°Greatest07:52:47176.2687°E, 49.0973°NPath width 237.1 kmU309:33:42100.1492°W, 33.5217°NSun altitude +0.0°Central ends09:36:27101.1675°W, 32.9188°NSun altitude +0.0°U409:39:10102.1488°W, 32.3505°NSun altitude +0.0°P4 Partial ends10:49:21124.2780°W, 22.8049°NSun altitude +0.0°North-polar azimuthal equidistant projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-arctic-2012-global.svg b/doc/solar-eclipse-arctic-2012-global.svg deleted file mode 100644 index 231757b..0000000 --- a/doc/solar-eclipse-arctic-2012-global.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08131.0542°E, 10.8905°N太阳高度 +0.0°U106:06:18109.6294°E, 20.5716°N太阳高度 +0.0°中心食始06:09:02108.6975°E, 21.1558°N太阳高度 +0.0°U206:11:48107.7417°E, 21.7712°N太阳高度 +0.0°食甚07:52:47176.2687°E, 49.0973°N食带宽 237.1 kmU309:33:42100.1492°W, 33.5217°N太阳高度 +0.0°中心食终09:36:27101.1675°W, 32.9188°N太阳高度 +0.0°U409:39:10102.1488°W, 32.3505°N太阳高度 +0.0°P4 偏食终10:49:21124.2780°W, 22.8049°N太阳高度 +0.0°北极方位等距投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035-en.svg b/doc/solar-eclipse-beijing-2035-en.svg deleted file mode 100644 index dcbfa5a..0000000 --- a/doc/solar-eclipse-beijing-2035-en.svg +++ /dev/null @@ -1 +0,0 @@ -2035-09-02 Local Solar Eclipselon=116.4074 lat=39.9042 type=total magnitude=1.0255 obscuration=1.0000Greatest: 2035-09-02 08:33:37 CST Sun altitude 31.58 deg Sun in LeoSolar Saros 145 23/77 Totality 00:01:33Overview pathNEWSEclipticC1 287°C2 140°C3 256°C4 109°C1C2GEC3C4Phase disk panelsC107:24:28C2 Total begins08:32:51Greatest08:33:37C3 Total ends08:34:24C409:50:23Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 07:24:28 PA 286.9°C2 Total begins 08:32:51 PA 139.8°GE Greatest 08:33:37C3 Total ends 08:34:24 PA 255.8°C4 Last contact 09:50:23 PA 108.8° \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035-global-en.svg b/doc/solar-eclipse-beijing-2035-global-en.svg deleted file mode 100644 index b5f345d..0000000 --- a/doc/solar-eclipse-beijing-2035-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2035-09-02 Total Solar Eclipse Global VisibilityPartial begins 07:15:35 | Greatest 09:55:36 | Partial ends 12:35:47 (CST) | magnitude 1.032 | Gamma 0.3727path width 116.6 km | Solar Saros 145, member 23/77 | Sun alt 67.9° az 198.5° | central duration 02:54Global visibility and central path08:0009:0010:0011:0012:0008:3009:0010:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter linePenumbral outlines (60 min)Rise/set phase linesUmbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins07:15:35U108:15:55Central begins08:16:25U208:16:56P209:27:39Greatest09:55:36P310:23:51U311:34:27Central ends11:34:55U411:35:23P4 Partial ends12:35:47Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035-global.svg b/doc/solar-eclipse-beijing-2035-global.svg deleted file mode 100644 index ab8815e..0000000 --- a/doc/solar-eclipse-beijing-2035-global.svg +++ /dev/null @@ -1 +0,0 @@ -2035-09-02 日全食全球见食图偏食始 07:15:35 | 食甚 09:55:36 | 偏食终 12:35:47 (CST) | 食分 1.032 | Gamma 0.3727食带宽 116.6 km | 太阳沙罗 145,第 23/77 个成员 | 食甚点太阳高度 67.9° 方位 198.5° | 中心食持续 02:54全球见食范围与中心食带08:0009:0010:0011:0012:0008:3009:0010:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始07:15:35U108:15:55中心食始08:16:25U208:16:56P209:27:39食甚09:55:36P310:23:51U311:34:27中心食终11:34:55U411:35:23P4 偏食终12:35:47等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035.svg b/doc/solar-eclipse-beijing-2035.svg deleted file mode 100644 index 90166db..0000000 --- a/doc/solar-eclipse-beijing-2035.svg +++ /dev/null @@ -1 +0,0 @@ -2035-09-02 站心日全食经度=116.4074 纬度=39.9042 食型=日全食 食分=1.0255 掩食比=1.0000食甚:2035-09-02 08:33:37 CST 太阳高度 31.58 度 太阳位于狮子座沙罗 145 第 23/77 个成员 全食历时 00:01:33全局路径北东西南黄道C1 287°C2 140°C3 256°C4 109°C1C2食甚C3C4阶段视圆图C1 初亏07:24:28C2 食既08:32:51食甚08:33:37C3 生光08:34:24C4 复圆09:50:23太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 07:24:28 方位 286.9°C2 食既 08:32:51 方位 139.8°GE 食甚 08:33:37C3 生光 08:34:24 方位 255.8°C4 复圆 09:50:23 方位 108.8° \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-en.svg b/doc/solar-eclipse-xiamen-2012-en.svg deleted file mode 100644 index 6c94133..0000000 --- a/doc/solar-eclipse-xiamen-2012-en.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 Local Solar Eclipselon=118.0894 lat=24.4798 type=annular magnitude=0.9333 obscuration=0.8725Greatest: 2012-05-21 06:10:25 CST Sun altitude 9.57 deg Sun in TaurusSolar Saros 128 58/73 Annularity 00:04:19Overview pathNEWSEclipticC1 256°C2 272°C3 56°C4 73°C1C2GEC3C4Phase disk panelsC105:08:12C2 Annularity begins06:08:15Greatest06:10:25C3 Annularity ends06:12:34C407:20:55Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 05:08:12 PA 255.9°C2 Annularity begins 06:08:15 PA 272.3°GE Greatest 06:10:25C3 Annularity ends 06:12:34 PA 56.4°C4 Last contact 07:20:55 PA 72.8° \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-global-en.svg b/doc/solar-eclipse-xiamen-2012-global-en.svg deleted file mode 100644 index 7d66862..0000000 --- a/doc/solar-eclipse-xiamen-2012-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Rise/set phase linesAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08U106:06:18Central begins06:09:02U206:11:48Greatest07:52:47U309:33:42Central ends09:36:27U409:39:10P4 Partial ends10:49:21Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-global.svg b/doc/solar-eclipse-xiamen-2012-global.svg deleted file mode 100644 index 3a3f80f..0000000 --- a/doc/solar-eclipse-xiamen-2012-global.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08U106:06:18中心食始06:09:02U206:11:48食甚07:52:47U309:33:42中心食终09:36:27U409:39:10P4 偏食终10:49:21等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012.svg b/doc/solar-eclipse-xiamen-2012.svg deleted file mode 100644 index 4f77839..0000000 --- a/doc/solar-eclipse-xiamen-2012.svg +++ /dev/null @@ -1 +0,0 @@ -2012-05-21 站心日环食经度=118.0894 纬度=24.4798 食型=日环食 食分=0.9333 掩食比=0.8725食甚:2012-05-21 06:10:25 CST 太阳高度 9.57 度 太阳位于金牛座沙罗 128 第 58/73 个成员 环食历时 00:04:19全局路径北东西南黄道C1 256°C2 272°C3 56°C4 73°C1C2食甚C3C4阶段视圆图C1 初亏05:08:12C2 环食始06:08:15食甚06:10:25C3 环食终06:12:34C4 复圆07:20:55太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 05:08:12 方位 255.9°C2 环食始 06:08:15 方位 272.3°GE 食甚 06:10:25C3 环食终 06:12:34 方位 56.4°C4 复圆 07:20:55 方位 72.8° \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-en.svg b/doc/solar-eclipse-yangshan-2009-en.svg deleted file mode 100644 index 60e76d2..0000000 --- a/doc/solar-eclipse-yangshan-2009-en.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 Local Solar Eclipselon=121.9850 lat=30.6167 type=total magnitude=1.0770 obscuration=1.0000Greatest: 2009-07-22 09:40:20 CST Sun altitude 57.29 deg Sun in CancerSolar Saros 136 37/71 Totality 00:05:57Overview pathNEWSEclipticC1 287°C2 109°C3 291°C4 113°C1C2GEC3C4Phase disk panelsC108:23:54C2 Total begins09:37:22Greatest09:40:20C3 Total ends09:43:19C411:03:13Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 08:23:54 PA 287.2°C2 Total begins 09:37:22 PA 108.7°GE Greatest 09:40:20C3 Total ends 09:43:19 PA 290.8°C4 Last contact 11:03:13 PA 112.5° \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-global-en.svg b/doc/solar-eclipse-yangshan-2009-global-en.svg deleted file mode 100644 index 200442e..0000000 --- a/doc/solar-eclipse-yangshan-2009-global-en.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:15 | Greatest 10:35:18 | Partial ends 13:12:22 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606299All times are CST (UT+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.809:0009:3010:3011:30Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"Penumbral contactsP1 partial begins07:58:15P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:22Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Central path width258.3 kmCentral duration06:39Central begins08:52:50Central ends12:17:46Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071010000 km比例尺Partial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsEquirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-global.svg b/doc/solar-eclipse-yangshan-2009-global.svg deleted file mode 100644 index ed7d25b..0000000 --- a/doc/solar-eclipse-yangshan-2009-global.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 日全食全球见食图偏食始 07:58:15 | 食甚 10:35:18 | 偏食终 13:12:22 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606299图中时刻为 CST(UT+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.809:0009:3010:3011:30中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"半影外切 / 内切接触P1 半影外切07:58:15P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:22本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚点的地方情况食甚10:35:18站心食分1.0799中心食带宽258.3 km中心食时长06:39中心食始08:52:50中心食终12:17:46历表与常数历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071010000 km比例尺偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-globe-en.svg b/doc/solar-eclipse-yangshan-2009-globe-en.svg deleted file mode 100644 index 8570424..0000000 --- a/doc/solar-eclipse-yangshan-2009-globe-en.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:15 | Greatest 10:35:18 | Partial ends 13:12:22 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606299All times are CST (UT+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsNESW05000 kmScalePenumbral contactsP1 partial begins07:58:15P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:22Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Path width258.3 kmCentral duration06:39Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Sun at greatest eclipseApparent geocentric positionR.A.08h06m24.3sDec.+20°16'02.4"S.D.00°15'44.1"H.P.00°00'08.7"Moon at greatest eclipseTrue geocentric positionR.A.08h06m29.7sDec.+20°20'06.9"S.D.00°16'42.3"H.P.01°01'19.8"Ephemeris and constants历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"Geocentric libration (optical + physical)Libration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071Orthographic globe projection, centred on the greatest eclipse; one hemisphere only; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-globe.svg b/doc/solar-eclipse-yangshan-2009-globe.svg deleted file mode 100644 index 3d9c92f..0000000 --- a/doc/solar-eclipse-yangshan-2009-globe.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 日全食全球见食图偏食始 07:58:15 | 食甚 10:35:18 | 偏食终 13:12:22 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606299图中时刻为 CST(UT+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触NESW05000 km比例尺半影外切 / 内切接触P1 半影外切07:58:15P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:22食甚点的地方情况食甚10:35:18站心食分1.0799食带宽度258.3 km中心食时长06:39本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚时的太阳(地心坐标)地心视位置赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)地心真位置赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"历表与常数历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"地理天平动(光学 + 物理)经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071正射球面投影,视点取食甚点,只画朝向视点的半个地球;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009.svg b/doc/solar-eclipse-yangshan-2009.svg deleted file mode 100644 index 0c1e08a..0000000 --- a/doc/solar-eclipse-yangshan-2009.svg +++ /dev/null @@ -1 +0,0 @@ -2009-07-22 站心日全食经度=121.9850 纬度=30.6167 食型=日全食 食分=1.0770 掩食比=1.0000食甚:2009-07-22 09:40:20 CST 太阳高度 57.29 度 太阳位于巨蟹座沙罗 136 第 37/71 个成员 全食历时 00:05:57全局路径北东西南黄道C1 287°C2 109°C3 291°C4 113°C1C2食甚C3C4阶段视圆图C1 初亏08:23:54C2 食既09:37:22食甚09:40:20C3 生光09:43:19C4 复圆11:03:13太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 08:23:54 方位 287.2°C2 食既 09:37:22 方位 108.7°GE 食甚 09:40:20C3 生光 09:43:19 方位 290.8°C4 复圆 11:03:13 方位 112.5° \ No newline at end of file diff --git a/earth/apsis.go b/earth/apsis.go index 85841e5..10ace73 100644 --- a/earth/apsis.go +++ b/earth/apsis.go @@ -26,7 +26,7 @@ func Aphelion(year int) ApsisInfo { func convertEarthApsisInfo(event basic.ApsisEvent) ApsisInfo { return ApsisInfo{ - Time: basic.JDE2DateByZone(event.JDE, time.UTC, false), + Time: basic.JD2DateByZone(event.JD, time.UTC, false), Distance: event.Distance, } } diff --git a/earth/apsis_test.go b/earth/apsis_test.go index 29af25e..8456fef 100644 --- a/earth/apsis_test.go +++ b/earth/apsis_test.go @@ -11,8 +11,8 @@ import ( func TestApsisWrappersMatchBasic(t *testing.T) { peri := basic.EarthPerihelion(2026) periWrapped := Perihelion(2026) - if !periWrapped.Time.Equal(basic.JDE2DateByZone(peri.JDE, time.UTC, false)) { - t.Fatalf("perihelion time mismatch: got %s want %s", periWrapped.Time.Format(time.RFC3339Nano), basic.JDE2DateByZone(peri.JDE, time.UTC, false).Format(time.RFC3339Nano)) + if !periWrapped.Time.Equal(basic.JD2DateByZone(peri.JD, time.UTC, false)) { + t.Fatalf("perihelion time mismatch: got %s want %s", periWrapped.Time.Format(time.RFC3339Nano), basic.JD2DateByZone(peri.JD, time.UTC, false).Format(time.RFC3339Nano)) } if math.Float64bits(periWrapped.Distance) != math.Float64bits(peri.Distance) { t.Fatalf("perihelion distance mismatch: got %.12f want %.12f", periWrapped.Distance, peri.Distance) @@ -20,8 +20,8 @@ func TestApsisWrappersMatchBasic(t *testing.T) { aphe := basic.EarthAphelion(2026) apheWrapped := Aphelion(2026) - if !apheWrapped.Time.Equal(basic.JDE2DateByZone(aphe.JDE, time.UTC, false)) { - t.Fatalf("aphelion time mismatch: got %s want %s", apheWrapped.Time.Format(time.RFC3339Nano), basic.JDE2DateByZone(aphe.JDE, time.UTC, false).Format(time.RFC3339Nano)) + if !apheWrapped.Time.Equal(basic.JD2DateByZone(aphe.JD, time.UTC, false)) { + t.Fatalf("aphelion time mismatch: got %s want %s", apheWrapped.Time.Format(time.RFC3339Nano), basic.JD2DateByZone(aphe.JD, time.UTC, false).Format(time.RFC3339Nano)) } if math.Float64bits(apheWrapped.Distance) != math.Float64bits(aphe.Distance) { t.Fatalf("aphelion distance mismatch: got %.12f want %.12f", apheWrapped.Distance, aphe.Distance) diff --git a/earth/earth.go b/earth/earth.go index 8c3344b..0d9d5d4 100644 --- a/earth/earth.go +++ b/earth/earth.go @@ -11,6 +11,6 @@ import ( // 返回 date 对应绝对时刻的地球轨道偏心率,无量纲。 // Returns Earth's orbital eccentricity at the instant represented by date; the value is dimensionless. func EarthEccentricity(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.Earthe(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.Earthe(basic.UTC2TT(jd)) } diff --git a/eclipse/label_ut1.go b/eclipse/label_ut1.go new file mode 100644 index 0000000..76039e7 --- /dev/null +++ b/eclipse/label_ut1.go @@ -0,0 +1,165 @@ +package eclipse + +import ( + "time" + + "b612.me/astro" +) + +// 以下函数把结构里的民用时刻换成同一物理时刻的 UT1 时刻,供出图与导出共用。 +// 零值 time.Time 表示该点或阶段不存在,一律原样保留;切片返回新副本,不改写调用方数据。 + +func ut1Label(t time.Time) time.Time { + if t.IsZero() { + return t + } + return astro.LabelIn(astro.TimeScaleUT1, t) +} + +// TimeLabelsInUT1 返回换成 UT1 时刻的新切片 / returns a copy in UT1. +func TimeLabelsInUT1(times []time.Time) []time.Time { + return ut1Labels(times) +} + +func ut1Labels(times []time.Time) []time.Time { + out := make([]time.Time, len(times)) + for i := range times { + out[i] = ut1Label(times[i]) + } + return out +} + +func ut1PathPoint(p SolarEclipsePathPoint) SolarEclipsePathPoint { + p.Time = ut1Label(p.Time) + return p +} + +func ut1PathPoints(points []SolarEclipsePathPoint) []SolarEclipsePathPoint { + out := make([]SolarEclipsePathPoint, len(points)) + for i := range points { + out[i] = ut1PathPoint(points[i]) + } + return out +} + +func ut1PathPointGrid(grid [][]SolarEclipsePathPoint) [][]SolarEclipsePathPoint { + out := make([][]SolarEclipsePathPoint, len(grid)) + for i := range grid { + out[i] = ut1PathPoints(grid[i]) + } + return out +} + +func ut1PartialFootprints(list []SolarEclipsePartialFootprint) []SolarEclipsePartialFootprint { + out := make([]SolarEclipsePartialFootprint, len(list)) + for i := range list { + out[i] = list[i] + out[i].Time = ut1Label(list[i].Time) + out[i].Boundaries = ut1PathPointGrid(list[i].Boundaries) + out[i].HorizonEnds = ut1PathPoints(list[i].HorizonEnds) + } + return out +} + +// SolarEclipseInfoInUT1 返回各阶段时刻换成 UT1 时刻的副本 / returns a copy in UT1. +func SolarEclipseInfoInUT1(info SolarEclipseInfo) SolarEclipseInfo { + info.GreatestEclipse = ut1Label(info.GreatestEclipse) + info.PartialBeginOnEarth = ut1Label(info.PartialBeginOnEarth) + info.PartialEndOnEarth = ut1Label(info.PartialEndOnEarth) + info.CentralBeginOnEarth = ut1Label(info.CentralBeginOnEarth) + info.CentralEndOnEarth = ut1Label(info.CentralEndOnEarth) + return info +} + +// SolarEclipsePartialFootprintsInUT1 返回足迹、轮廓与接触点都换成 UT1 时刻的副本 / copy in UT1. +func SolarEclipsePartialFootprintsInUT1(info SolarEclipsePartialFootprintsInfo) SolarEclipsePartialFootprintsInfo { + info.Eclipse = SolarEclipseInfoInUT1(info.Eclipse) + info.Footprints = ut1PartialFootprints(info.Footprints) + info.CentralShadowFootprints = ut1PartialFootprints(info.CentralShadowFootprints) + info.CentralBandFootprints = ut1PartialFootprints(info.CentralBandFootprints) + info.CentralBandSegments = ut1PathPointGrid(info.CentralBandSegments) + info.CentralBandHorizonClosures = ut1PathPointGrid(info.CentralBandHorizonClosures) + info.PartialBandContours = ut1PathPointGrid(info.PartialBandContours) + contours := make([]SolarEclipseMagnitudeContour, len(info.MagnitudeContours)) + for i := range info.MagnitudeContours { + contours[i] = info.MagnitudeContours[i] + contours[i].Segments = ut1PathPointGrid(info.MagnitudeContours[i].Segments) + contours[i].NorthernLimit = ut1PathPoints(info.MagnitudeContours[i].NorthernLimit) + contours[i].SouthernLimit = ut1PathPoints(info.MagnitudeContours[i].SouthernLimit) + } + info.MagnitudeContours = contours + greatest := make([]SolarEclipseGreatestTimeContour, len(info.GreatestTimeContours)) + for i := range info.GreatestTimeContours { + greatest[i] = info.GreatestTimeContours[i] + greatest[i].Time = ut1Label(info.GreatestTimeContours[i].Time) + greatest[i].Segments = ut1PathPointGrid(info.GreatestTimeContours[i].Segments) + } + info.GreatestTimeContours = greatest + curves := make([]SolarEclipseRiseSetCurve, len(info.RiseSetCurves)) + for i := range info.RiseSetCurves { + curves[i] = info.RiseSetCurves[i] + curves[i].Segments = ut1PathPointGrid(info.RiseSetCurves[i].Segments) + } + info.RiseSetCurves = curves + info.P1 = ut1PathPoint(info.P1) + info.P2 = ut1PathPoint(info.P2) + info.P3 = ut1PathPoint(info.P3) + info.P4 = ut1PathPoint(info.P4) + info.U1 = ut1PathPoint(info.U1) + info.U2 = ut1PathPoint(info.U2) + info.U3 = ut1PathPoint(info.U3) + info.U4 = ut1PathPoint(info.U4) + return info +} + +// SolarEclipsePathInUT1 返回中心线与限界都换成 UT1 时刻的副本 / copy in UT1. +func SolarEclipsePathInUT1(path SolarEclipsePath) SolarEclipsePath { + path.Eclipse = SolarEclipseInfoInUT1(path.Eclipse) + path.Greatest = ut1PathPoint(path.Greatest) + path.CenterLine = ut1PathPoints(path.CenterLine) + path.NorthernLimit = ut1PathPoints(path.NorthernLimit) + path.SouthernLimit = ut1PathPoints(path.SouthernLimit) + path.CentralBandSegments = ut1PathPointGrid(path.CentralBandSegments) + return path +} + +// LunarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1. +func LunarEclipseInfoInUT1(info LunarEclipseInfo) LunarEclipseInfo { + info.PenumbralStart = ut1Label(info.PenumbralStart) + info.PartialStart = ut1Label(info.PartialStart) + info.TotalStart = ut1Label(info.TotalStart) + info.Maximum = ut1Label(info.Maximum) + info.TotalEnd = ut1Label(info.TotalEnd) + info.PartialEnd = ut1Label(info.PartialEnd) + info.PenumbralEnd = ut1Label(info.PenumbralEnd) + contacts := make([]LunarEclipseContactPoint, len(info.ContactPoints)) + for i := range info.ContactPoints { + contacts[i] = info.ContactPoints[i] + contacts[i].Time = ut1Label(info.ContactPoints[i].Time) + } + info.ContactPoints = contacts + return info +} + +// LocalSolarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1. +func LocalSolarEclipseInfoInUT1(info LocalSolarEclipseInfo) LocalSolarEclipseInfo { + info.GreatestEclipse = ut1Label(info.GreatestEclipse) + info.PartialStart = ut1Label(info.PartialStart) + info.PartialEnd = ut1Label(info.PartialEnd) + info.CentralStart = ut1Label(info.CentralStart) + info.CentralEnd = ut1Label(info.CentralEnd) + contacts := make([]LocalSolarEclipseContactPoint, len(info.ContactPoints)) + for i := range info.ContactPoints { + contacts[i] = info.ContactPoints[i] + contacts[i].Time = ut1Label(info.ContactPoints[i].Time) + } + info.ContactPoints = contacts + return info +} + +// SolarEclipseGeocentricPanelInUT1 返回两个朔时刻换成 UT1 时刻的副本 / copy in UT1. +func SolarEclipseGeocentricPanelInUT1(panel SolarEclipseGeocentricPanel) SolarEclipseGeocentricPanel { + panel.Conjunction = ut1Label(panel.Conjunction) + panel.RightAscensionConjunction = ut1Label(panel.RightAscensionConjunction) + return panel +} diff --git a/eclipse/lunar.go b/eclipse/lunar.go index 211a299..44d9698 100644 --- a/eclipse/lunar.go +++ b/eclipse/lunar.go @@ -108,6 +108,12 @@ type LunarEclipseInfo struct { PartialEnd time.Time // PenumbralEnd 半影终, penumbral eclipse ends. PenumbralEnd time.Time + // GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。 + // GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above. + GreatestJDE float64 + // DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。 + // DeltaTSeconds is the TT−UT1 used by this computation, in seconds. + DeltaTSeconds float64 // ContactPoints 是各接触时刻在月面上的接触点方位。 // ContactPoints are Moon-limb contact position angles at eclipse contacts. @@ -370,6 +376,8 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L TotalEnd: ttJDEToTime(result.TotalEnd, location), PartialEnd: ttJDEToTime(result.PartialEnd, location), PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location), + GreatestJDE: result.Maximum, + DeltaTSeconds: basic.DeltaT(result.Maximum, true), ContactPoints: lunarEclipseContactPointsFromBasic(result, location), HasPenumbral: result.HasPenumbral, HasPartial: result.HasPartial, @@ -482,13 +490,13 @@ func ttJDEToTime(ttJDE float64, location *time.Location) time.Time { if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) { return time.Time{} } - utcJDE := basic.TD2UT(ttJDE, false) - return basic.JDE2DateByZone(utcJDE, location, false) + utcJD := basic.TT2UTC(ttJDE) + return basic.JD2DateByZone(utcJD, location, false) } func timeToTTJDE(date time.Time) float64 { - utcJDE := basic.Date2JDE(date.UTC()) - return basic.TD2UT(utcJDE, true) + utcJD := basic.Date2JD(date.UTC()) + return basic.UTC2TT(utcJD) } func normalizeDegree180(angle float64) float64 { diff --git a/eclipse/lunar_local.go b/eclipse/lunar_local.go index 900b3a6..9b046ee 100644 --- a/eclipse/lunar_local.go +++ b/eclipse/lunar_local.go @@ -16,6 +16,28 @@ const ( localLunarEclipseQueryGeometric ) +// LocalLunarEclipseVisibility 站点在本次月食中的可见性类别 / how much of the eclipse stays above the local horizon. +type LocalLunarEclipseVisibility string + +const ( + // LocalLunarEclipseInvisible 食内月亮始终在地平线下 / the Moon never clears the local horizon. + LocalLunarEclipseInvisible LocalLunarEclipseVisibility = "invisible" + // LocalLunarEclipseMoonrise 带食月出:食始时月亮尚未升起 / moonrise falls inside the eclipse. + LocalLunarEclipseMoonrise LocalLunarEclipseVisibility = "moonrise" + // LocalLunarEclipseMoonset 带食月落:食终时月亮已经落下 / moonset falls inside the eclipse. + LocalLunarEclipseMoonset LocalLunarEclipseVisibility = "moonset" + // LocalLunarEclipseRiseAndSet 食内先月出后月落:半影首尾都在地平线下 / both a moonrise and a moonset fall inside the eclipse. + LocalLunarEclipseRiseAndSet LocalLunarEclipseVisibility = "rise-and-set" + // LocalLunarEclipseInterrupted 半影首尾可见,中途却落到地平线下 / above the horizon at both penumbral contacts but not in between. + LocalLunarEclipseInterrupted LocalLunarEclipseVisibility = "interrupted" + // LocalLunarEclipseFull 半影全程可见 / the Moon stays above the horizon for the whole eclipse. + LocalLunarEclipseFull LocalLunarEclipseVisibility = "full" + // LocalLunarEclipsePenumbraMoonrise 仅见半影的月出:月出落在食内,本影阶段整段在地平线下 / moonrise inside the eclipse with the whole umbral phase below the horizon. + LocalLunarEclipsePenumbraMoonrise LocalLunarEclipseVisibility = "penumbra-moonrise" + // LocalLunarEclipsePenumbraMoonset 仅见半影的月落:月落落在食内,本影阶段整段在地平线下 / moonset inside the eclipse with the whole umbral phase below the horizon. + LocalLunarEclipsePenumbraMoonset LocalLunarEclipseVisibility = "penumbra-moonset" +) + // LocalLunarEclipseInfo 站点月食信息, local lunar eclipse information. // // 所有时刻字段都保持用户输入的时区。 @@ -33,7 +55,7 @@ type LocalLunarEclipseInfo struct { Longitude float64 // Latitude 观测点纬度,北正南负, observer latitude, north positive. Latitude float64 - // Height 观测点海拔高度,单位米, observer height in meters. + // Height 观测点椭球高(大地高),单位米;只有正高 H 时须先加大地水准面差距 N, observer ellipsoidal height in meters. Height float64 // PenumbralMagnitude 半影食分, penumbral magnitude. @@ -62,6 +84,11 @@ type LocalLunarEclipseInfo struct { MoonAzimuth float64 // VisibleAtMaximum 食甚时月亮中心在本地几何地平线上方, Moon center above the local geometric horizon at maximum. VisibleAtMaximum bool + // Visibility 本次月食在站点的可见性类别;月出/月落落在食内且本影阶段整段在地平线下时取 + // penumbra-moonrise / penumbra-moonset(只看到半影)/ how much of the eclipse is above the local + // horizon; of the umbral phase stays below it, moonrise and moonset are reported as + // penumbra-moonrise and penumbra-moonset. + Visibility LocalLunarEclipseVisibility // HasPenumbral 有半影阶段, has penumbral phase. HasPenumbral bool @@ -386,7 +413,7 @@ func localLunarEclipseInfoFromBasic( moonAltitude := lunarAltitude(maximum, lon, lat) saros, hasSaros := lunarSarosInfo(result.Maximum) - return LocalLunarEclipseInfo{ + info := LocalLunarEclipseInfo{ HasSaros: hasSaros, Saros: saros, Type: mapBasicLunarEclipseType(result.Type), @@ -409,6 +436,8 @@ func localLunarEclipseInfoFromBasic( HasPartial: result.HasPartial, HasTotal: result.HasTotal, } + info.Visibility = localLunarEclipseVisibility(info) + return info } func localLunarEclipseOverlapsDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool { @@ -454,23 +483,103 @@ func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd return localLunarEclipseVisibleDuring(info, segmentStart, segmentEnd) } +// localLunarEclipseVisibility 按站点自己的中天对食内高度分类。 +func localLunarEclipseVisibility(info LocalLunarEclipseInfo) LocalLunarEclipseVisibility { + start, end, ok := localLunarEclipseRange(info) + if !ok { + return LocalLunarEclipseInvisible + } + startVisible := localLunarEclipseAltitudeVisible(start, info) + endVisible := localLunarEclipseAltitudeVisible(end, info) + umbralVisible := localLunarEclipseUmbralVisible(info) + switch { + case startVisible && endVisible: + if localLunarEclipseExtremumAltitude(info, start, end, false) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) { + return LocalLunarEclipseFull + } + return LocalLunarEclipseInterrupted + case endVisible: + if !umbralVisible { + return LocalLunarEclipsePenumbraMoonrise + } + return LocalLunarEclipseMoonrise + case startVisible: + if !umbralVisible { + return LocalLunarEclipsePenumbraMoonset + } + return LocalLunarEclipseMoonset + case localLunarEclipseVisibleDuring(info, start, end): + return LocalLunarEclipseRiseAndSet + default: + return LocalLunarEclipseInvisible + } +} + +// localLunarEclipseUmbralVisible 本影阶段是否有任何时刻月亮在地平线上;纯半影月食没有本影可看, +// 恒为 true,不把这类站点降级成"仅见半影"。极区掠射时 U1、食甚、U4 三点都可能在地平下而中途 +// 短暂露出,所以这里取整个本影区间的高度极值,不能只比三个采样点。 +func localLunarEclipseUmbralVisible(info LocalLunarEclipseInfo) bool { + if !info.HasPartial { + return true + } + start, end := info.PartialStart, info.PartialEnd + if start.IsZero() || end.IsZero() || !start.Before(end) { + return false + } + if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) { + return true + } + return localLunarEclipseExtremumAltitude(info, start, end, true) > + localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) +} + +const lunarEclipseVisibilityScanSamples = 48 + +// localLunarEclipseExtremumAltitude 先粗扫定位、再在相邻两格内三分细化地取有限窗口内的月心高度极值。 +func localLunarEclipseExtremumAltitude(info LocalLunarEclipseInfo, start, end time.Time, maximum bool) float64 { + if !start.Before(end) { + return lunarAltitude(start, info.Longitude, info.Latitude) + } + step := end.Sub(start) / lunarEclipseVisibilityScanSamples + best := lunarAltitude(start, info.Longitude, info.Latitude) + bestIndex := 0 + for index := 1; index <= lunarEclipseVisibilityScanSamples; index++ { + value := lunarAltitude(start.Add(step*time.Duration(index)), info.Longitude, info.Latitude) + if (value > best) == maximum { + best, bestIndex = value, index + } + } + lowIndex, highIndex := bestIndex-1, bestIndex+1 + if lowIndex < 0 { + lowIndex = 0 + } + if highIndex > lunarEclipseVisibilityScanSamples { + highIndex = lunarEclipseVisibilityScanSamples + } + low := start.Add(step * time.Duration(lowIndex)) + high := start.Add(step * time.Duration(highIndex)) + for iteration := 0; iteration < 30; iteration++ { + third := high.Sub(low) / 3 + first, second := low.Add(third), high.Add(-third) + if (lunarAltitude(first, info.Longitude, info.Latitude) < lunarAltitude(second, info.Longitude, info.Latitude)) == maximum { + low = first + } else { + high = second + } + } + refined := lunarAltitude(low.Add(high.Sub(low)/2), info.Longitude, info.Latitude) + if maximum { + return math.Max(best, refined) + } + return math.Min(best, refined) +} + func localLunarEclipseVisibleDuring(info LocalLunarEclipseInfo, start, end time.Time) bool { if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) { return true } - - for dayStart, _, _ := lunarEclipseLocalDayBounds(start); !dayStart.After(end); dayStart = nextLunarEclipseLocalDayStart(dayStart) { - _, culminationSeed, _ := lunarEclipseLocalDayBounds(dayStart) - culmination := lunarCulminationTime(culminationSeed, info.Longitude, info.Latitude) - if culmination.Before(start) || culmination.After(end) { - continue - } - if localLunarEclipseAltitudeVisible(culmination, info) { - return true - } - } - - return false + return localLunarEclipseExtremumAltitude(info, start, end, true) > + localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) } func localLunarEclipseAltitudeVisible(date time.Time, info LocalLunarEclipseInfo) bool { diff --git a/eclipse/lunar_local_test.go b/eclipse/lunar_local_test.go index f681154..646ddb3 100644 --- a/eclipse/lunar_local_test.go +++ b/eclipse/lunar_local_test.go @@ -234,8 +234,8 @@ func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) { defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height) chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height) - assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6) - assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6) + assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6) + assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6) if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) { t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude) @@ -341,6 +341,65 @@ func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) { } } +// TestLocalLunarEclipseVisibilityClass 固定站点自己的食内可见性八分类;rise-and-set 与 interrupted +// 是静态地平圈几何推不出来的,penumbra-moonrise / penumbra-moonset 是本影整段在地平线下的细分。 +func TestLocalLunarEclipseVisibilityClass(t *testing.T) { + date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) + previousDay := time.Date(2028, 12, 31, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) + testCases := []struct { + name string + date time.Time + lon float64 + lat float64 + want LocalLunarEclipseVisibility + }{ + {name: "mid latitude stays up", lon: 107.25, lat: 30.25, want: LocalLunarEclipseFull}, + {name: "moonset inside eclipse", lon: -161.25, lat: 3.25, want: LocalLunarEclipseMoonset}, + {name: "moonrise inside eclipse", lon: 20.25, lat: -2.75, want: LocalLunarEclipseMoonrise}, + {name: "south polar lens", lon: 108.75, lat: -62.75, want: LocalLunarEclipseRiseAndSet}, + {name: "reported station", lon: 108.729001, lat: -59.937452, want: LocalLunarEclipseRiseAndSet}, + {name: "arctic midday dip", lon: -71.25, lat: 64.75, want: LocalLunarEclipseInterrupted}, + {name: "far side never up", lon: -178.5, lat: -86.5, want: LocalLunarEclipseInvisible}, + // 只看到半影:本影阶段整段在地平线下,月出/月落仍落在食内。 + {name: "penumbra only moonrise", lon: -69.25, lat: 61.25, want: LocalLunarEclipsePenumbraMoonrise}, + {name: "penumbra only moonset", date: previousDay, lon: -179.25, lat: -61.25, want: LocalLunarEclipsePenumbraMoonset}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + at := testCase.date + if at.IsZero() { + at = date + } + geometric, geometricOK := GeometricLocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0) + if !geometricOK { + t.Fatal("expected a geometric lunar eclipse on the local date") + } + if geometric.Visibility != testCase.want { + t.Fatalf("visibility=%s want %s", geometric.Visibility, testCase.want) + } + _, visibleOK := LocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0) + if wantVisible := testCase.want != LocalLunarEclipseInvisible; visibleOK != wantVisible { + t.Fatalf("visible filter ok=%v want %v", visibleOK, wantVisible) + } + }) + } +} + +func TestLocalLunarEclipsePolarWindowScansBetweenContacts(t *testing.T) { + date := time.Date(1800, 4, 9, 0, 0, 0, 0, time.UTC) + info, ok := GeometricLocalLunarEclipseOnDate(date, 121, 82, 0) + if !ok { + t.Fatal("expected a geometric lunar eclipse") + } + if info.Visibility != LocalLunarEclipseRiseAndSet { + t.Fatalf("visibility=%s, want %s", info.Visibility, LocalLunarEclipseRiseAndSet) + } + if _, ok := LocalLunarEclipseOnDate(date, 121, 82, 0); !ok { + t.Fatal("the short above-horizon interval must make the eclipse visible") + } +} + func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunarEclipseInfo, tolerance time.Duration) { t.Helper() if got.Type != want.Type { @@ -348,3 +407,53 @@ func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunar } assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance) } + +// 纯半影月食没有本影接触,不把任何站点降级成 penumbra-moonrise / penumbra-moonset。 +func TestLocalLunarEclipsePenumbralOnlyKeepsOldClasses(t *testing.T) { + date := time.Date(2020, 1, 11, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) + info, ok := LunarEclipseOnDate(date) + if !ok || info.HasPartial { + t.Fatalf("expected a purely penumbral eclipse, HasPartial=%v ok=%v", info.HasPartial, ok) + } + seen := map[LocalLunarEclipseVisibility]int{} + for longitude := -179.5; longitude < 180; longitude += 5 { + for latitude := -89.5; latitude < 90; latitude += 5 { + local, found := GeometricLocalLunarEclipseOnDate(date, longitude, latitude, 0) + if !found { + continue + } + seen[local.Visibility]++ + } + } + if seen[LocalLunarEclipsePenumbraMoonrise] != 0 || seen[LocalLunarEclipsePenumbraMoonset] != 0 { + t.Fatalf("purely penumbral eclipse produced penumbra-only classes: %v", seen) + } + if seen[LocalLunarEclipseMoonrise] == 0 || seen[LocalLunarEclipseMoonset] == 0 { + t.Fatalf("purely penumbral eclipse lost its moonrise/moonset sites: %v", seen) + } +} + +// 极区掠射:U1、食甚、U4 三点都可能在地平下,但本影区间中途仍有短暂窗口高于地平;这类站点确实 +// 见到本影,必须留在 moonrise/moonset,只有本影区间极值也在地平下才算"仅见半影"。 +func TestLocalLunarEclipseKeepsUmbralGrazingWindow(t *testing.T) { + date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC) + for _, witness := range []struct { + name string + lon, lat float64 + want LocalLunarEclipseVisibility + }{ + {"本影窗口 +0.0006 度", -91.8, -88.7675, LocalLunarEclipseMoonrise}, + {"U1 已在地平上", -91.75, -88.75, LocalLunarEclipseMoonrise}, + {"U1 高度 +0.21 度(陡边界)", -75.5, 6.5, LocalLunarEclipseMoonset}, + {"本影区间极值 -0.005 度", -90.0, -88.65, LocalLunarEclipsePenumbraMoonset}, + } { + local, ok := GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0) + if !ok { + t.Fatalf("%s: 本影几何月食缺失", witness.name) + } + if local.Visibility != witness.want { + t.Fatalf("%s (%.4f,%.4f) visibility=%s want %s", + witness.name, witness.lon, witness.lat, local.Visibility, witness.want) + } + } +} diff --git a/eclipse/lunar_panel.go b/eclipse/lunar_panel.go index f4c6844..d5eafb7 100644 --- a/eclipse/lunar_panel.go +++ b/eclipse/lunar_panel.go @@ -91,7 +91,7 @@ func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basi } panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(result.Maximum) panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(result.Maximum) - panel.SunSemidiameterArcsec = basic.SunSemidiameter(result.Maximum) + panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(result.Maximum, basic.SolarEclipseSunRadiusStandard) panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(result.Maximum) panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio) panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio) diff --git a/eclipse/lunar_test.go b/eclipse/lunar_test.go index fd4d64b..329e222 100644 --- a/eclipse/lunar_test.go +++ b/eclipse/lunar_test.go @@ -205,8 +205,8 @@ func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) { defaultInfo := ClosestLunarEclipse(date) chauvenetInfo := ClosestLunarEclipseChauvenet(date) - assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6) - assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6) + assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6) + assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6) if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) { t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude) diff --git a/eclipse/observation_helpers.go b/eclipse/observation_helpers.go index f27b70a..7a3c0bc 100644 --- a/eclipse/observation_helpers.go +++ b/eclipse/observation_helpers.go @@ -8,57 +8,34 @@ import ( ) func moonSunLoDiff(date time.Time) float64 { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) sunLo := basic.HSunApparentLo(jde) moonLo := basic.HMoonApparentLo(jde) return tools.Limit360(moonLo - sunLo) } func solarAltitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.SunHeight(jde, lon, lat, float64(loc)/3600.0) + return basic.SunHeight(localJD, lon, lat, float64(loc)/3600.0) } func solarCulminationTime(date time.Time, lon float64) time.Time { - jde := basic.Date2JDE(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5 + localJD := basic.Date2JD(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5 _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.CulminationTime(jde, lon, timezone) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.CulminationTime(localJD, lon, timezone) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } func lunarAzimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonAzimuth(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonAzimuth(localJD, lon, lat, float64(loc)/3600.0) } func lunarAltitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonHeight(jde, lon, lat, float64(loc)/3600.0) -} - -func lunarCulminationTime(date time.Time, lon, lat float64) time.Time { - if date.Hour() > 12 { - date = date.Add(-12 * time.Hour) - } - jde := basic.Date2JDE(date) - _, loc := date.Zone() - culmination := basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true) - // MoonCulminationTime returns the nearest root to its internal midnight - // seed, which can land one civil day later when the input is local noon. - // Visibility scans need the root belonging to the supplied local day. - if culmination.Year() != date.Year() || culmination.YearDay() != date.YearDay() { - // Preserve the solved local clock time while moving it onto the - // requested civil date. This also avoids assuming every DST day is - // exactly 24 elapsed hours. - culmination = time.Date( - date.Year(), date.Month(), date.Day(), - culmination.Hour(), culmination.Minute(), culmination.Second(), culmination.Nanosecond(), - date.Location(), - ) - } - return culmination + return basic.HMoonHeight(localJD, lon, lat, float64(loc)/3600.0) } diff --git a/eclipse/provenance_test.go b/eclipse/provenance_test.go new file mode 100644 index 0000000..970d096 --- /dev/null +++ b/eclipse/provenance_test.go @@ -0,0 +1,47 @@ +package eclipse + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +// 三个 info 都带 provenance:GreatestJDE(力学时)经 TT2UTC 复现民用时刻字段; +// DeltaTSeconds 是 TT−UT1,不能用来做这次换算(会差一个 DUT1)。 +func TestEclipseInfoCarriesProvenance(t *testing.T) { + date := time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC) + solar, ok := SolarEclipseOnDate(date) + if !ok { + t.Fatal("缺少 2024-04-08 日食") + } + if solar.GreatestJDE == 0 || solar.DeltaTSeconds == 0 { + t.Fatalf("日食 provenance 未填充: JDE=%v ΔT=%v", solar.GreatestJDE, solar.DeltaTSeconds) + } + if got := solarEclipseTTJDEToTime(solar.GreatestJDE, solar.GreatestEclipse.Location()); !got.Equal(solar.GreatestEclipse) { + t.Errorf("GreatestJDE 未经 TT2UTC 复现食甚时刻: %v vs %v", got, solar.GreatestEclipse) + } + // DUT1 非零时,用 ΔT 换算出来的 UT1 时刻必须与民用时刻不同:两者的差就是 DUT1。 + ut1 := basic.JD2DateByZone(solar.GreatestJDE-solar.DeltaTSeconds/86400, solar.GreatestEclipse.Location(), false) + dut1 := (basic.TT2UTC(solar.GreatestJDE)-solar.GreatestJDE)*86400 - solar.DeltaTSeconds + if math.Abs(dut1) > 1e-3 && ut1.Equal(solar.GreatestEclipse) { + t.Errorf("民用时刻字段走了 ΔT(TT−UT1)换算: %v vs %v", ut1, solar.GreatestEclipse) + } + + local := ClosestLocalSolarEclipse(date, -96.8, 32.8, 0) + if local.GreatestJDE == 0 || local.DeltaTSeconds == 0 { + t.Fatalf("站心日食 provenance 未填充: JDE=%v ΔT=%v", local.GreatestJDE, local.DeltaTSeconds) + } + if got := solarEclipseTTJDEToTime(local.GreatestJDE, local.GreatestEclipse.Location()); !got.Equal(local.GreatestEclipse) { + t.Errorf("GreatestJDE+ΔT 未复现站心食甚: %v vs %v", got, local.GreatestEclipse) + } + + lunar, ok := LunarEclipseOnDate(time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)) + if !ok { + t.Fatal("缺少 2025-09-07 月食") + } + if lunar.GreatestJDE == 0 || lunar.DeltaTSeconds == 0 { + t.Fatalf("月食 provenance 未填充: JDE=%v ΔT=%v", lunar.GreatestJDE, lunar.DeltaTSeconds) + } +} diff --git a/eclipse/saros.go b/eclipse/saros.go index 29d9df5..3f55770 100644 --- a/eclipse/saros.go +++ b/eclipse/saros.go @@ -83,14 +83,14 @@ func buildSarosAnchorRanges() { ranges := make(map[int]sarosAnchorRange, len(anchors)+len(overrides)) for index, magic := range anchors { anchor := decodeSarosMagic(magic, base+index) - headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) + headTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) if math.IsNaN(headTT) { continue } ranges[int(anchor.Series)] = sarosAnchorRange{headTT: headTT, count: int(anchor.Count)} } for _, override := range overrides { - headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) if math.IsNaN(headTT) { continue } @@ -246,7 +246,7 @@ func matchSarosMagicOverrides(overrides []sarosHeadOverride, ttJDE float64) (Sar } func matchSarosMagicCandidate(ttJDE float64, anchor sarosAnchor, memberOffset int) (SarosInfo, float64, bool) { - headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) + headTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) if math.IsNaN(headTT) { return SarosInfo{}, 0, false } diff --git a/eclipse/saros_anchor_consistency_test.go b/eclipse/saros_anchor_consistency_test.go index 847f3d0..e365ff4 100644 --- a/eclipse/saros_anchor_consistency_test.go +++ b/eclipse/saros_anchor_consistency_test.go @@ -42,7 +42,7 @@ func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow { windows := make(map[int]sarosTestWindow, len(anchors)+len(overrides)) for index, magic := range anchors { value := uint32(magic) - headTT := basic.JDECalc( + headTT := basic.JDCalc( int((value>>sarosTestAnchorYearShift)&sarosTestAnchorYearMask)-sarosTestAnchorYearOffset, int((value>>sarosTestAnchorMonthShift)&sarosTestAnchorMonthMask), float64((value>>sarosTestAnchorDayShift)&sarosTestAnchorDayMask), @@ -56,7 +56,7 @@ func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow { } } for _, override := range overrides { - headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) if math.IsNaN(headTT) { t.Fatalf("invalid override head for series %d", override.Series) } @@ -214,7 +214,7 @@ func TestSarosOverrideWindowsCoverDeclaredMemberRange(t *testing.T) { } cycleDays := float64(sarosTestAnchorMemberInterval) * sarosTestSynodicMonthDays for _, override := range overrides { - headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) for _, member := range []int{1, int(override.Count)} { ttJDE := headTT + float64(member-1-int(override.MemberOffset))*cycleDays info, ok := sarosTestInfo(ttJDE, phase) diff --git a/eclipse/saros_extended.go b/eclipse/saros_extended.go index cc4eefe..9318f8d 100644 --- a/eclipse/saros_extended.go +++ b/eclipse/saros_extended.go @@ -85,7 +85,7 @@ func sarosNumber(k, phase int) int { anchors, base = lunarSarosAnchors[:], 1 } anchor := decodeSarosMagic(anchors[len(anchors)/2], base+len(anchors)/2) - refTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) + refTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) refK, _ := sarosLunation(refTT, phase) delta := k - refK column := (38 * delta) % sarosCycleLunations diff --git a/eclipse/saros_extended_test.go b/eclipse/saros_extended_test.go index 786782d..df16508 100644 --- a/eclipse/saros_extended_test.go +++ b/eclipse/saros_extended_test.go @@ -13,7 +13,7 @@ func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) { for phase, anchors := range [][]sarosMagic{solarSarosAnchors[:], lunarSarosAnchors[:]} { for index, magic := range anchors { anchor := decodeSarosMagic(magic, phase+index) - k, ok := sarosLunation(basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase) + k, ok := sarosLunation(basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase) if !ok { t.Fatalf("invalid catalog anchor: %+v", anchor) } @@ -133,6 +133,7 @@ func TestExtendedSarosMatchesLiveSeries(t *testing.T) { } func TestExtendedSarosRangeAndInvalidInput(t *testing.T) { + pinSarosWindowPolicy(t) if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) { t.Fatal("precomputed range must include both end years") } diff --git a/eclipse/saros_generate_test.go b/eclipse/saros_generate_test.go index 03f3f3f..18451a9 100644 --- a/eclipse/saros_generate_test.go +++ b/eclipse/saros_generate_test.go @@ -9,6 +9,8 @@ import ( "sort" "testing" "time" + + "b612.me/astro/basic" ) // 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -saros-generate 与环境变量。 @@ -20,6 +22,16 @@ var generateSarosTables = flag.Bool("saros-generate", false, "regenerate the ext const sarosGenerateEnv = "ASTRO_REGENERATE_SAROS_TABLE" +// 扩展表窗口按冻结偏移换算,不随默认政策变化。 +const sarosWindowPolicy = basic.TimeScaleFreezeUTCOffset + +func pinSarosWindowPolicy(t *testing.T) { + t.Helper() + previous := basic.GetTimeScaleFuturePolicy() + basic.SetTimeScaleFuturePolicy(sarosWindowPolicy) + t.Cleanup(func() { basic.SetTimeScaleFuturePolicy(previous) }) +} + func sarosTableGenerationEnabled() bool { return *generateSarosTables && os.Getenv(sarosGenerateEnv) == "1" } @@ -41,6 +53,7 @@ var sarosTableGoldens = []sarosTableGolden{ // 发布表必须与文档化的窗口、生成时的序列总数和若干金标准行一致。 func TestSarosExtensionTableIsFresh(t *testing.T) { + pinSarosWindowPolicy(t) if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) { t.Fatal("extended table window does not cover -3000..+6000") @@ -110,6 +123,7 @@ func TestGenerateSarosExtensionTables(t *testing.T) { if !sarosTableGenerationEnabled() { t.Skipf("set %s=1 and pass -saros-generate to rewrite saros_table_extended.go", sarosGenerateEnv) } + pinSarosWindowPolicy(t) startTT := timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) endTT := timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) var buf bytes.Buffer diff --git a/eclipse/saros_table_extended.go b/eclipse/saros_table_extended.go index 23d630c..f835107 100644 --- a/eclipse/saros_table_extended.go +++ b/eclipse/saros_table_extended.go @@ -5,8 +5,8 @@ package eclipse // UTC astronomical years -3000 through +6000, including both end years. // Series numbers follow NASA's Saros-Inex rules. Members are computed with // Split-K solar geometry and the union of Danjon/Chauvenet lunar detections. -const sarosExtendedStartTT = 625308.343822260154 -const sarosExtendedEndTT = 3912881.135478473268 +const sarosExtendedStartTT = 625308.343824885320 +const sarosExtendedEndTT = 3912880.500800740905 var solarSarosExtended = [...]sarosSpan{ {Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83}, diff --git a/eclipse/search_skip_test.go b/eclipse/search_skip_test.go index 029c56e..f27963b 100644 --- a/eclipse/search_skip_test.go +++ b/eclipse/search_skip_test.go @@ -59,8 +59,8 @@ func TestEclipseSearchStepDoesNotSkipPotentialCandidates(t *testing.T) { } func eclipseSearchTestCandidates(startYear, years, phaseType int, synodicMonthDays float64) []float64 { - startTT := basic.Date2JDE(time.Date(startYear, 1, 1, 0, 0, 0, 0, time.UTC)) - endTT := basic.Date2JDE(time.Date(startYear+years, 1, 1, 0, 0, 0, 0, time.UTC)) + startTT := basic.Date2JD(time.Date(startYear, 1, 1, 0, 0, 0, 0, time.UTC)) + endTT := basic.Date2JD(time.Date(startYear+years, 1, 1, 0, 0, 0, 0, time.UTC)) candidateTT := basic.CalcMoonSHByJDE(startTT, phaseType) candidates := make([]float64, 0, years*13) for candidateTT < endTT { diff --git a/eclipse/solar.go b/eclipse/solar.go index 1a44eff..82e2da2 100644 --- a/eclipse/solar.go +++ b/eclipse/solar.go @@ -26,6 +26,24 @@ const ( SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k" ) +// SolarEclipseSunRadiusModel 日食太阳半径口径, solar radius convention for solar eclipses. +type SolarEclipseSunRadiusModel = basic.SolarEclipseSunRadiusModel + +const ( + // SolarEclipseSunRadiusStandard 标准档,1 AU 处 959.639″, standard solar radius for catalogue reproduction. + SolarEclipseSunRadiusStandard = basic.SolarEclipseSunRadiusStandard + // SolarEclipseSunRadiusMeasured 边缘档,1 AU 处 959.95″, measured eclipse solar radius. + SolarEclipseSunRadiusMeasured = basic.SolarEclipseSunRadiusMeasured +) + +// SolarEclipseOptions 日食搜索的半径口径, radius conventions for a solar eclipse search. +type SolarEclipseOptions struct { + // RadiusModel 月亮半径模型,非 IAU Single-K 一律按 NASA bulletin Split-K, lunar radius model. + RadiusModel SolarEclipseRadiusModel + // SunRadiusModel 太阳半径口径,零值为标准档, solar radius convention. + SunRadiusModel SolarEclipseSunRadiusModel +} + // SolarEclipseType 全局日食食型, global solar eclipse type. type SolarEclipseType string @@ -61,6 +79,8 @@ const ( type SolarEclipseInfo struct { // Model 日食月亮半径模型, eclipse lunar radius model. Model SolarEclipseRadiusModel + // SunRadiusModel 日食太阳半径口径, solar radius convention. + SunRadiusModel SolarEclipseSunRadiusModel // Type 全局食型, global eclipse type. Type SolarEclipseType // Centrality 中心性, eclipse centrality. @@ -81,6 +101,12 @@ type SolarEclipseInfo struct { CentralBeginOnEarth time.Time // CentralEndOnEarth 地球范围中心食终, central eclipse ends on Earth. CentralEndOnEarth time.Time + // GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。 + // GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above. + GreatestJDE float64 + // DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。 + // DeltaTSeconds is the TT−UT1 used by this computation, in seconds. + DeltaTSeconds float64 // Magnitude 全局食分, global eclipse magnitude. Magnitude float64 @@ -92,8 +118,17 @@ type SolarEclipseInfo struct { // value catalogues publish; SolarEclipsePath.MaxCentralDuration is the maximum // along the whole track. CentralDuration time.Duration - // PathWidthKM 食甚处中心食带宽度, central path width at greatest eclipse. + // PathWidthKM 是食甚处中心食带宽度;非中心食为 0;单侧极限(中心带仅触及地球边缘)时该解析式失效 + // 并一并置 0,此时 PathWidthDefined 为 false,NASA 目录该栏印 '-'。 + // PathWidthKM is the central path width at greatest eclipse, 0 for a non-central + // event, and 0 when the analytic formula fails at a single-sided limit where the + // band only grazes the Earth's limb; PathWidthDefined is false there and + // catalogues print '-' for this column. PathWidthKM float64 + // PathWidthDefined 表示上面的带宽是否有定义:只有南北两限都存在时才为 true。 + // PathWidthDefined reports whether the width above is defined: it is true only + // when both band limits exist. + PathWidthDefined bool // GreatestLongitude 食甚点经度,东正西负, longitude of greatest eclipse, east positive. GreatestLongitude float64 @@ -130,6 +165,43 @@ func SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) { return solarEclipseOnDate(date, basic.SolarEclipseIAUSingleK) } +// SolarEclipseOnDateWithOptions 当地自然日全局日食查询(自定义半径口径) / local-date global solar eclipse query with custom radius conventions. +func SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool) { + return solarEclipseOnDate(date, solarEclipseCalculatorFor(options)) +} + +// LastSolarEclipseWithOptions 上次日食(自定义半径口径) / previous solar eclipse with custom radius conventions. +func LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo { + info, _ := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options)) + return info +} + +// NextSolarEclipseWithOptions 下次日食(自定义半径口径) / next solar eclipse with custom radius conventions. +func NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo { + info, _ := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options)) + return info +} + +// ClosestSolarEclipseWithOptions 最近一次日食(自定义半径口径) / closest solar eclipse with custom radius conventions. +func ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo { + last, hasLast := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options)) + next, hasNext := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options)) + return closestSolarEclipse(date, last, hasLast, next, hasNext) +} + +func solarEclipseCalculatorFor(options SolarEclipseOptions) solarEclipseCalculator { + basicOptions := basic.SolarEclipseOptions{ + RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel), + SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel), + } + if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK { + basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK + } + return func(seed float64) basic.SolarEclipseResult { + return basic.SolarEclipseWithOptions(seed, basicOptions) + } +} + func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator) (SolarEclipseInfo, bool) { location := date.Location() dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date) @@ -287,6 +359,7 @@ func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.L saros, hasSaros := solarSarosInfo(result.GreatestEclipse) return SolarEclipseInfo{ Model: mapBasicSolarEclipseModel(result.Model), + SunRadiusModel: result.SunRadiusModel, Type: mapBasicSolarEclipseType(result.Type), Centrality: mapBasicSolarEclipseCentrality(result.Centrality), HasSaros: hasSaros, @@ -296,10 +369,13 @@ func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.L PartialEndOnEarth: solarEclipseTTJDEToTime(result.PartialEndOnEarth, location), CentralBeginOnEarth: solarEclipseTTJDEToTime(result.CentralBeginOnEarth, location), CentralEndOnEarth: solarEclipseTTJDEToTime(result.CentralEndOnEarth, location), + GreatestJDE: result.GreatestEclipse, + DeltaTSeconds: basic.DeltaT(result.GreatestEclipse, true), Magnitude: result.Magnitude, Gamma: result.Gamma, CentralDuration: solarEclipseDurationFromDays(result.CentralDurationDays), PathWidthKM: result.PathWidthKM, + PathWidthDefined: result.PathWidthDefined, GreatestLongitude: result.GreatestLongitude, GreatestLatitude: result.GreatestLatitude, HasPartial: result.HasPartial, @@ -361,25 +437,15 @@ func solarEclipseRange(info SolarEclipseInfo) (time.Time, time.Time, bool) { } func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time { - return solarEclipseTTJDEToTimeWithDeltaT(ttJDE, basic.DeltaT(ttJDE, true), location) -} - -// solarEclipseTTJDEToTimeWithDeltaT 用显式 ΔT 把 TT 换算为时刻:显式 ΔT 的单时刻入口 -// 必须走这条路径,否则回填出来的 UT 会落到进程级模型上,与几何使用的 ΔT 不一致。 -// solarEclipseTTJDEToTimeWithDeltaT converts TT with an explicit ΔT; the single-instant -// entries must use it so the reported UT matches the ΔT their geometry used. -func solarEclipseTTJDEToTimeWithDeltaT( - ttJDE, deltaTSeconds float64, location *time.Location, -) time.Time { - // 与 lunar 侧一致:0 与 NaN/±Inf 都表示“该时刻不存在”。 + // 回填的是民用时刻,偏移必须与正向的 UTC2TT 成对(窗口内即闰秒表,窗口外按未来政策)。 + // 用 ΔT(TT−UT1)会让时刻整体偏一个 DUT1,并破坏等时线网格的整刻度对齐。 if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) { return time.Time{} } - utcJDE := ttJDE - deltaTSeconds/86400 - return basic.JDE2DateByZone(utcJDE, location, false) + return basic.JD2DateByZone(basic.TT2UTC(ttJDE), location, false) } func solarEclipseTimeToTTJDE(date time.Time) float64 { - utcJDE := basic.Date2JDE(date.UTC()) - return basic.TD2UT(utcJDE, true) + utcJD := basic.Date2JD(date.UTC()) + return basic.UTC2TT(utcJD) } diff --git a/eclipse/solar_bessel.go b/eclipse/solar_bessel.go new file mode 100644 index 0000000..ccf20ee --- /dev/null +++ b/eclipse/solar_bessel.go @@ -0,0 +1,105 @@ +package eclipse + +import "b612.me/astro/basic" + +// SolarEclipseBesselianPolynomial 是三次多项式系数,索引 n 对应 t 的 n 次幂,t 为自 T0 起算的 TT 小时数。 +// SolarEclipseBesselianPolynomial holds the cubic coefficients; index n multiplies t^n with t in TT hours from T0. +type SolarEclipseBesselianPolynomial = basic.SolarEclipseBesselianPolynomial + +// SolarEclipseBesselianElementsOptions 是贝塞尔根数表的生成选项 / options for a Besselian element table. +type SolarEclipseBesselianElementsOptions struct { + // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. + Model SolarEclipseRadiusModel + // SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention. + SunRadiusModel SolarEclipseSunRadiusModel + // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型,只改变地球自转相位 / explicit ΔT in seconds. + DeltaTSeconds float64 + // ReferenceJDE 多项式参考时刻 T0(TT 儒略日),<=0 取离食甚最近的整 TT 小时 / TT reference instant, the whole hour nearest greatest eclipse. + ReferenceJDE float64 + // ValidHours 有效窗口半径(小时),<=0 取 3 / validity window half-width in hours. + ValidHours float64 +} + +// SolarEclipseBesselianElementsResult 是一次日食的多项式贝塞尔根数及其口径 / polynomial Besselian elements and their conventions. +type SolarEclipseBesselianElementsResult struct { + // T0JDE 多项式参考时刻(TT 儒略日),t = (jde - T0JDE) * 24 / TT reference instant. + T0JDE float64 + // ValidHours 有效窗口半径(小时) / validity window half-width. + ValidHours float64 + // X 与 Y 是月心在基本面内的坐标,单位地球赤道半径 / fundamental-plane coordinates in equatorial Earth radii. + X, Y SolarEclipseBesselianPolynomial + // D 是影轴赤纬,单位度 / declination of the shadow axis in degrees. + D SolarEclipseBesselianPolynomial + // L1 与 L2 是基本面内的半影、本影半径,单位地球赤道半径;本影为负表示月心尚未越过本影锥顶点。 + // L1 and L2 are the penumbral and umbral radii in the fundamental plane, in equatorial Earth radii. + L1, L2 SolarEclipseBesselianPolynomial + // Mu 是影轴格林时角,单位度,窗口内连续、不折回 [0,360)。 + // + // 口径与已发布根数表不同:本库的恒星时取自 UT = TT - ΔT,得到真实格林时角;已发布表改用 T0 本身 + // 的恒星时,两者相差 ΔT × 15.041067/3600 度。要对表先用 SolarEclipseBesselianMuForPublishedTable。 + // Mu is the shadow axis' Greenwich hour angle in degrees, continuous and not folded into [0,360). + Mu SolarEclipseBesselianPolynomial + // TanF1 与 TanF2 是半影、本影锥半顶角正切,本次日食内为常数 / cone half-angle tangents, constant over the eclipse. + TanF1, TanF2 float64 + // Gamma 是食甚时刻影轴到地心的距离,单位地球赤道半径 / shadow-axis distance from the Earth's centre. + Gamma float64 + // Magnitude 是食甚时刻的全局食分 / global eclipse magnitude at greatest eclipse. + Magnitude float64 + // Model、SunRadiusModel、PenumbralK、UmbralK 与 DeltaTSeconds 是决定上述数值的口径 / the conventions behind the numbers above. + Model SolarEclipseRadiusModel + SunRadiusModel SolarEclipseSunRadiusModel + PenumbralK float64 + UmbralK float64 + DeltaTSeconds float64 +} + +// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false。 +// Polynomial Besselian elements for the solar eclipse near the given new-moon instant. +func SolarEclipseBesselianElements( + seedJDE float64, options SolarEclipseBesselianElementsOptions, +) (SolarEclipseBesselianElementsResult, bool) { + result, ok := basic.SolarEclipseBesselianElements(seedJDE, basic.SolarEclipseBesselianElementsOptions{ + Model: basic.SolarEclipseRadiusModel(options.Model), + SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel), + DeltaTSeconds: options.DeltaTSeconds, + ReferenceJDE: options.ReferenceJDE, + ValidHours: options.ValidHours, + }) + if !ok { + return SolarEclipseBesselianElementsResult{}, false + } + return solarEclipseBesselianElementsFromBasic(result), true +} + +// SolarEclipseBesselianMuForPublishedTable 把本库的 Mu 换算成与已发布根数表直接可比的取值。 +// 已发布表用 T0 本身的恒星时,本库用 UT = TT - ΔT,两者只差一个常数,因此只有常数项平移。 +// SolarEclipseBesselianMuForPublishedTable shifts Mu onto the argument used by published element tables. +func SolarEclipseBesselianMuForPublishedTable( + mu SolarEclipseBesselianPolynomial, deltaTSeconds float64, +) SolarEclipseBesselianPolynomial { + return basic.SolarEclipseBesselianMuForPublishedTable(mu, deltaTSeconds) +} + +func solarEclipseBesselianElementsFromBasic( + result basic.SolarEclipseBesselianElementsResult, +) SolarEclipseBesselianElementsResult { + return SolarEclipseBesselianElementsResult{ + T0JDE: result.T0JDE, + ValidHours: result.ValidHours, + X: result.X, + Y: result.Y, + D: result.D, + L1: result.L1, + L2: result.L2, + Mu: result.Mu, + TanF1: result.TanF1, + TanF2: result.TanF2, + Gamma: result.Gamma, + Magnitude: result.Magnitude, + Model: mapBasicSolarEclipseModel(result.Model), + SunRadiusModel: result.SunRadiusModel, + PenumbralK: result.PenumbralK, + UmbralK: result.UmbralK, + DeltaTSeconds: result.DeltaTSeconds, + } +} diff --git a/eclipse/solar_bessel_test.go b/eclipse/solar_bessel_test.go new file mode 100644 index 0000000..d6fde45 --- /dev/null +++ b/eclipse/solar_bessel_test.go @@ -0,0 +1,62 @@ +package eclipse + +import ( + "math" + "testing" + + "b612.me/astro/basic" +) + +// 公开包装层与被包装的 basic 结果逐字段一致,模型枚举按 eclipse 层取值。 + +func TestSolarEclipseBesselianElementsWrapsBasic(t *testing.T) { + options := SolarEclipseBesselianElementsOptions{ + DeltaTSeconds: 70.6, + ReferenceJDE: 2460409.25, + } + result, ok := SolarEclipseBesselianElements(2460409.262835, options) + if !ok { + t.Fatal("2024-04-08 should have a solar eclipse") + } + if result.Model != SolarEclipseModelNASABulletinSplitK { + t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelNASABulletinSplitK) + } + if math.Abs(result.Gamma-0.3431) > 1e-4 || math.Abs(result.Magnitude-1.0566) > 1e-3 { + t.Errorf("gamma/magnitude = %g / %g", result.Gamma, result.Magnitude) + } + if math.Abs(result.TanF1-0.0046683) > 1e-6 || math.Abs(result.TanF2-0.0046450) > 1e-6 { + t.Errorf("tan f = %g / %g", result.TanF1, result.TanF2) + } + published := SolarEclipseBesselianMuForPublishedTable(result.Mu, result.DeltaTSeconds) + if math.Abs(published.At(0)-89.59122) > 1e-4 { + t.Errorf("published mu(0) = %g, want 89.59122", published.At(0)) + } + if math.Abs(result.X.At(0)-(-0.318157)) > 2e-4 || math.Abs(result.Y.At(0)-0.219747) > 2e-4 { + t.Errorf("x/y at t0 = %g / %g", result.X.At(0), result.Y.At(0)) + } + if result.PenumbralK != basic.SolarEclipsePenumbralK || result.UmbralK != basic.SolarEclipseUmbralK { + t.Errorf("k = %g / %g", result.PenumbralK, result.UmbralK) + } +} + +func TestSolarEclipseBesselianElementsWithoutEclipse(t *testing.T) { + if _, ok := SolarEclipseBesselianElements(2460486.5, SolarEclipseBesselianElementsOptions{}); ok { + t.Fatal("this new moon has no solar eclipse") + } +} + +func TestSolarEclipseBesselianElementsIAUSingleK(t *testing.T) { + result, ok := SolarEclipseBesselianElements(2460409.262835, SolarEclipseBesselianElementsOptions{ + Model: SolarEclipseModelIAUSingleK, + DeltaTSeconds: 70.6, + }) + if !ok { + t.Fatal("2024-04-08 should have a solar eclipse") + } + if result.Model != SolarEclipseModelIAUSingleK { + t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelIAUSingleK) + } + if result.UmbralK != result.PenumbralK { + t.Errorf("k2 = %g, want k1 = %g", result.UmbralK, result.PenumbralK) + } +} diff --git a/eclipse/solar_local.go b/eclipse/solar_local.go index c817107..6102b47 100644 --- a/eclipse/solar_local.go +++ b/eclipse/solar_local.go @@ -52,6 +52,8 @@ type LocalSolarEclipseContactPoint struct { type LocalSolarEclipseInfo struct { // Model 日食月亮半径模型, eclipse lunar radius model. Model SolarEclipseRadiusModel + // SunRadiusModel 日食太阳半径口径, solar radius convention. + SunRadiusModel SolarEclipseSunRadiusModel // Type 站心食型, local eclipse type. Type SolarEclipseType // HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated). @@ -64,7 +66,7 @@ type LocalSolarEclipseInfo struct { Longitude float64 // Latitude 观测点纬度,北正南负, observer latitude, north positive. Latitude float64 - // Height 观测点海拔高度,单位米, observer height in meters. + // Height 观测点椭球高(大地高),单位米;只有正高 H 时须先加大地水准面差距 N, observer ellipsoidal height in meters. Height float64 // GreatestEclipse 食甚时刻, greatest eclipse. @@ -77,6 +79,12 @@ type LocalSolarEclipseInfo struct { CentralStart time.Time // CentralEnd 中心食终;对全食为生光,对环食为环食终, central eclipse ends. CentralEnd time.Time + // GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。 + // GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above. + GreatestJDE float64 + // DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。 + // DeltaTSeconds is the TT−UT1 used by this computation, in seconds. + DeltaTSeconds float64 // Magnitude 站心食分, local eclipse magnitude. Magnitude float64 @@ -142,6 +150,29 @@ func GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric) } +// LocalSolarEclipseOnDateWithOptions 当地站心日食查询(自定义半径口径) / local topocentric solar eclipse query with custom radius conventions. +func LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool) { + return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorFor(options), localSolarEclipseQueryVisible) +} + +func localSolarEclipseCalculatorFor(options SolarEclipseOptions) localSolarEclipseCalculator { + basicOptions := basic.SolarEclipseOptions{ + RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel), + SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel), + } + if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK { + basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK + } + return localSolarEclipseCalculator{ + global: func(seed float64) basic.SolarEclipseResult { + return basic.SolarEclipseWithOptions(seed, basicOptions) + }, + local: func(seed, lon, lat, height float64) basic.LocalSolarEclipseResult { + return basic.LocalSolarEclipseWithOptions(seed, lon, lat, height, basicOptions) + }, + } +} + func localSolarEclipseOnDate( date time.Time, lon, lat, height float64, @@ -470,6 +501,7 @@ func localSolarEclipseInfoFieldsFromBasic( saros, hasSaros := solarSarosInfo(result.GreatestEclipse) return LocalSolarEclipseInfo{ Model: mapBasicSolarEclipseModel(result.Model), + SunRadiusModel: result.SunRadiusModel, Type: mapBasicSolarEclipseType(result.Type), HasSaros: hasSaros, Saros: saros, @@ -481,6 +513,8 @@ func localSolarEclipseInfoFieldsFromBasic( PartialEnd: solarEclipseTTJDEToTime(result.PartialEnd, location), CentralStart: solarEclipseTTJDEToTime(result.CentralStart, location), CentralEnd: solarEclipseTTJDEToTime(result.CentralEnd, location), + GreatestJDE: result.GreatestEclipse, + DeltaTSeconds: basic.DeltaT(result.GreatestEclipse, true), Magnitude: result.Magnitude, Obscuration: result.Obscuration, Separation: result.Separation, @@ -502,7 +536,7 @@ func localSolarEclipseContactPointsFromBasic( if !result.HasPartial { return nil } - options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1} + options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1, SunRadiusModel: result.SunRadiusModel} var diagram basic.LocalSolarEclipseDiagramResult if result.Model == basic.SolarEclipseModelIAUSingleK { diagram = basic.LocalSolarEclipseDiagramIAUSingleK(result.GreatestEclipse, lon, lat, height, options) diff --git a/eclipse/solar_local_radius_convention_test.go b/eclipse/solar_local_radius_convention_test.go new file mode 100644 index 0000000..c6e0c1c --- /dev/null +++ b/eclipse/solar_local_radius_convention_test.go @@ -0,0 +1,32 @@ +package eclipse + +import ( + "testing" + "time" +) + +// 站心偏食口径:IAU Single-K 的半影外半径必须用本模型的单一 k,与 Split-K 不可互换。 + +func TestLocalSolarEclipseOnDateCarriesModelPenumbralK(t *testing.T) { + date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) + iau, okIAU := LocalSolarEclipseOnDateIAUSingleK(date, -87.65, 41.85, 0) + split, okSplit := LocalSolarEclipseOnDateNASABulletinSplitK(date, -87.65, 41.85, 0) + if !okIAU || !okSplit { + t.Fatalf("local eclipse missing: %v/%v", okIAU, okSplit) + } + if iau.PartialStart.Equal(split.PartialStart) || iau.PartialEnd.Equal(split.PartialEnd) { + t.Fatalf("partial contacts coincide: %s/%s and %s/%s", + iau.PartialStart, split.PartialStart, iau.PartialEnd, split.PartialEnd) + } + if !(iau.PartialStart.Before(split.PartialStart) && iau.PartialEnd.After(split.PartialEnd)) { + t.Fatalf("IAU contacts %s/%s not wider than Split-K %s/%s", + iau.PartialStart, iau.PartialEnd, split.PartialStart, split.PartialEnd) + } + if !(iau.Magnitude > split.Magnitude && iau.Obscuration > split.Obscuration) { + t.Fatalf("IAU magnitude/obscuration %.9f/%.9f not above Split-K %.9f/%.9f", + iau.Magnitude, iau.Obscuration, split.Magnitude, split.Obscuration) + } + if iau.Model != SolarEclipseModelIAUSingleK || split.Model != SolarEclipseModelNASABulletinSplitK { + t.Fatalf("radius provenance = %v/%v", iau.Model, split.Model) + } +} diff --git a/eclipse/solar_local_test.go b/eclipse/solar_local_test.go index 32cb465..4150426 100644 --- a/eclipse/solar_local_test.go +++ b/eclipse/solar_local_test.go @@ -303,8 +303,8 @@ func TestLocalSolarEclipseContactPoints(t *testing.T) { points[point.Label] = point } assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3) - assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.137089, 1e-3) - assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.137089, 1e-3) + assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.133602, 1e-3) + assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.133602, 1e-3) assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3) } diff --git a/eclipse/solar_panel.go b/eclipse/solar_panel.go index 8c048dd..d409ba1 100644 --- a/eclipse/solar_panel.go +++ b/eclipse/solar_panel.go @@ -17,26 +17,20 @@ const ( horizontalParallaxSunRatio = 6378.137 / 696000.0 ) -// SolarEclipseGeocentricPanel 汇总 详细版式面板所需的食甚时刻地心量。 -// SolarEclipseGeocentricPanel carries the geocentric quantities that detailed panels print. +// SolarEclipseGeocentricPanel 详细面板所需的食甚时刻地心量 / geocentric quantities at greatest eclipse for detailed panels. type SolarEclipseGeocentricPanel struct { - // Conjunction 是本次朔,即地心视黄经相等的时刻;与 NASA 全球图上的 Geocentric Conjunction 不是同一个量。 - // Conjunction is the new moon, the instant of equal geocentric apparent ecliptic longitude. It is not - // the same quantity as the Geocentric Conjunction printed on NASA world maps. + // Conjunction 地心视黄经相等的朔时刻 / new moon at equal geocentric apparent ecliptic longitude. Conjunction time.Time ConjunctionJDE float64 - // RightAscensionConjunction 是地心视赤经相等的时刻,也就是 NASA 全球图上 Geocentric Conjunction 的口径; - // 2009-07-22 两者相差约 90 s(视黄经相等在 02:34:34 UT,视赤经相等在 02:33:04 UT)。 - // RightAscensionConjunction is the instant of equal geocentric apparent right ascension, the quantity NASA - // world maps print as Geocentric Conjunction. For 2009-07-22 the two differ by about 90 s. + // RightAscensionConjunction 地心视赤经相等的时刻,与朔的时差不固定 / equal geocentric apparent right ascension, with a variable gap from new moon. RightAscensionConjunction time.Time RightAscensionConjunctionJDE float64 - // RightAscensionConjunctionJD 是同一时刻的世界时儒略日,NASA 全球图上印的就是它。 - // RightAscensionConjunctionJD is the universal-time Julian day of that instant, the value NASA maps print. + // RightAscensionConjunctionJD 同一合时刻的 UT1 儒略日 / UT1 Julian day of the same conjunction. RightAscensionConjunctionJD float64 - // DeltaTSeconds 是食甚时刻实际使用的 ΔT。 - // DeltaTSeconds is the ΔT used at greatest eclipse. + // DeltaTSeconds 食甚时刻实际使用的 ΔT / ΔT used at greatest eclipse. DeltaTSeconds float64 + // SunRadiusModel SunSemidiameterArcsec 所用的太阳半径口径 / solar radius convention behind SunSemidiameterArcsec. + SunRadiusModel SolarEclipseSunRadiusModel SunRightAscensionDeg float64 SunDeclinationDeg float64 @@ -54,14 +48,19 @@ type SolarEclipseGeocentricPanel struct { BrownLunationNumber int // PenumbralK 与 UmbralK 是月地半径比 k1/k2。 + // PenumbralK and UmbralK are the Moon-to-Earth radius ratios k1 and k2. PenumbralK float64 UmbralK float64 // BodyShiftLongitudeArcsec 与 BodyShiftLatitudeArcsec 是星历表里的 Δl/Δb;本库模型不做月面位置平移,恒为零。 + // BodyShiftLongitudeArcsec and BodyShiftLatitudeArcsec are the Δl/Δb of published element tables; + // this model shifts nothing on the lunar surface, so both stay zero. BodyShiftLongitudeArcsec float64 BodyShiftLatitudeArcsec float64 // Ephemeris 是所用模型名称。 + // Ephemeris names the model in use. Ephemeris string // SingleK 表示使用的是 IAU Single-K(k1 同时用于半影与本影)。 + // SingleK reports the IAU Single-K convention, where k1 serves both the penumbra and the umbra. SingleK bool } @@ -74,29 +73,33 @@ func horizontalParallaxArcsec(semidiameterArcsec, radiusRatio float64) float64 { // SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板。 // SolarEclipseGeocentricPanelAt computes the geocentric panel of one eclipse at greatest eclipse. func SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) { - info, ok := SolarEclipseOnDateNASABulletinSplitK(date) - if !ok { - return SolarEclipseGeocentricPanel{}, false - } - panel := solarEclipseGeocentricPanelAt(info) - panel.Ephemeris = "NASA bulletin Split-K" - panel.PenumbralK = basic.SolarEclipsePenumbralK - panel.UmbralK = basic.SolarEclipseUmbralK - return panel, true + return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK}) } // SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板。 // SolarEclipseGeocentricPanelIAUSingleK computes the geocentric panel with the IAU Single-K model. func SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) { - info, ok := SolarEclipseOnDateIAUSingleK(date) + return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}) +} + +// SolarEclipseGeocentricPanelWithOptions 使用自定义半径口径计算食甚时刻的地心量面板。 +// SolarEclipseGeocentricPanelWithOptions computes the geocentric panel at greatest eclipse with custom radius conventions. +func SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool) { + info, ok := SolarEclipseOnDateWithOptions(date, options) if !ok { return SolarEclipseGeocentricPanel{}, false } panel := solarEclipseGeocentricPanelAt(info) - panel.Ephemeris = "IAU Single-K" + if info.Model == SolarEclipseModelIAUSingleK { + panel.Ephemeris = "IAU Single-K" + panel.PenumbralK = basic.SolarEclipseIAUSingleRadiusK + panel.UmbralK = basic.SolarEclipseIAUSingleRadiusK + panel.SingleK = true + return panel, true + } + panel.Ephemeris = "NASA bulletin Split-K" panel.PenumbralK = basic.SolarEclipsePenumbralK - panel.UmbralK = basic.SolarEclipsePenumbralK - panel.SingleK = true + panel.UmbralK = basic.SolarEclipseUmbralK return panel, true } @@ -114,7 +117,8 @@ func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentric } panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(tt) panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(tt) - panel.SunSemidiameterArcsec = basic.SunSemidiameter(tt) + panel.SunRadiusModel = info.SunRadiusModel + panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(tt, info.SunRadiusModel) panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(tt) panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio) panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio) diff --git a/eclipse/solar_panel_test.go b/eclipse/solar_panel_test.go index ec22559..314916f 100644 --- a/eclipse/solar_panel_test.go +++ b/eclipse/solar_panel_test.go @@ -62,8 +62,43 @@ func TestSolarEclipseGeocentricPanelMatchesNASABulletin(t *testing.T) { t.Fatalf("right-ascension conjunction %s differs from NASA by %.1f s", panel.RightAscensionConjunction.Format("15:04:05.0"), delta) } - // 朔(视黄经相等)比它晚约 90 s,两者不是同一个量。 + // 该时差只约束本事件,不代表两种合时刻的普遍关系。 if delta := panel.Conjunction.Sub(panel.RightAscensionConjunction).Seconds(); delta < 60 || delta > 120 { - t.Fatalf("new moon is %.1f s after the right-ascension conjunction, want about 90 s", delta) + t.Fatalf("for 2009-07-22 the new moon is %.1f s after the right-ascension conjunction (event-specific, want 60..120 s)", delta) + } +} + +// 两种合时刻的差值随几何变化,符号也会改变。 +func TestGeocentricConjunctionGapIsNotConstant(t *testing.T) { + cases := []struct { + date string + want float64 + }{ + {"2004-04-19", -3090.6}, + {"2007-09-11", 3529.6}, + {"2009-07-22", -90.2}, + {"2025-09-21", 3368.4}, + } + minGap, maxGap := math.Inf(1), math.Inf(-1) + for _, tc := range cases { + date, err := time.Parse("2006-01-02", tc.date) + if err != nil { + t.Fatal(err) + } + panel, ok := SolarEclipseGeocentricPanelAt(date.Add(12 * time.Hour)) + if !ok { + t.Fatalf("%s 不是日食日", tc.date) + } + gap := panel.RightAscensionConjunction.Sub(panel.Conjunction).Seconds() + if math.Abs(gap-tc.want) > 30 { + t.Errorf("%s 两者相差 %.1f s, want %.1f±30 s", tc.date, gap, tc.want) + } + minGap, maxGap = math.Min(minGap, gap), math.Max(maxGap, gap) + } + if minGap > -1200 || maxGap < 1200 { + t.Errorf("样本应同时出现提前与推后超过 20 分钟的事件,got %.1f … %.1f s", minGap, maxGap) + } + if maxGap-minGap < 3000 { + t.Errorf("样本跨度 %.1f s,不足以说明该差值不是常数", maxGap-minGap) } } diff --git a/eclipse/solar_path.go b/eclipse/solar_path.go index 19a0ce5..56dad4d 100644 --- a/eclipse/solar_path.go +++ b/eclipse/solar_path.go @@ -44,6 +44,12 @@ type SolarEclipsePathPoint struct { type SolarEclipsePath struct { // Eclipse 是对应的全局日食信息, related global solar eclipse information. Eclipse SolarEclipseInfo + // PathWidthDefined 表示本结果的带宽是否有定义:两限存在且上下两条限界线都非空时才为 true; + // 为 false 时 Eclipse.PathWidthKM 与 Greatest.WidthKM 都是 0。 + // PathWidthDefined reports whether this result has a defined band width: both + // limits must exist and both limit lines must be non-empty. When it is false, + // Eclipse.PathWidthKM and Greatest.WidthKM are 0. + PathWidthDefined bool // Greatest 是食甚点/最佳观测点, greatest eclipse point. Greatest SolarEclipsePathPoint // CenterLine 是中心线, central line. @@ -333,6 +339,7 @@ func solarEclipseCentralPath( path := SolarEclipsePath{ Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location), + PathWidthDefined: result.PathWidthDefined, Greatest: solarEclipsePathPointFromBasic(result.Greatest, location), CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location), NorthernLimit: solarEclipsePathPointsFromBasic(result.NorthernLimit, location), diff --git a/eclipse/solar_path_width_contract_test.go b/eclipse/solar_path_width_contract_test.go new file mode 100644 index 0000000..fc10267 --- /dev/null +++ b/eclipse/solar_path_width_contract_test.go @@ -0,0 +1,55 @@ +package eclipse + +import ( + "math" + "testing" + "time" +) + +// basic 与 eclipse 两层必须给出同一个带宽口径标志:单侧极限事件带宽无定义且值恒为 0。 + +func TestSolarEclipsePathCarriesPathWidthDefinition(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, 1, 7, 0, 0, 0, 0, time.UTC)}, + {name: "2003-05-31 single-sided limit with limit lines", date: time.Date(2003, 5, 31, 0, 0, 0, 0, time.UTC)}, + {name: "2024-04-08 total", date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), defined: true, widthKM: 198.6161}, + {name: "2010-01-15 annular", date: time.Date(2010, 1, 15, 0, 0, 0, 0, time.UTC), defined: true, widthKM: 335.0298}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info := ClosestSolarEclipse(tc.date) + if info.PathWidthDefined != tc.defined { + t.Fatalf("info.PathWidthDefined=%v want %v", info.PathWidthDefined, tc.defined) + } + if !tc.defined && info.PathWidthKM != 0 { + t.Fatalf("undefined info width %.4f", info.PathWidthKM) + } + path, ok := SolarEclipseCentralPath(tc.date, SolarEclipsePathOptions{}) + if !ok { + t.Fatalf("no central path") + } + if path.PathWidthDefined != tc.defined || path.Eclipse.PathWidthDefined != tc.defined { + t.Fatalf("defined=%v eclipse=%v want %v", path.PathWidthDefined, path.Eclipse.PathWidthDefined, tc.defined) + } + if !tc.defined { + if path.Eclipse.PathWidthKM != 0 || path.Greatest.WidthKM != 0 { + t.Fatalf("undefined width leaks: eclipse=%.4f greatest=%.4f", + path.Eclipse.PathWidthKM, path.Greatest.WidthKM) + } + return + } + if math.Abs(path.Eclipse.PathWidthKM-tc.widthKM) > 0.01 { + t.Fatalf("path width %.4f want %.4f", path.Eclipse.PathWidthKM, tc.widthKM) + } + if path.Eclipse.PathWidthKM > 0 && (len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0) { + t.Fatalf("width %.4f km with limits %d/%d", path.Eclipse.PathWidthKM, + len(path.NorthernLimit), len(path.SouthernLimit)) + } + }) + } +} diff --git a/eclipse/solar_radius_convention_test.go b/eclipse/solar_radius_convention_test.go new file mode 100644 index 0000000..e5f4dc9 --- /dev/null +++ b/eclipse/solar_radius_convention_test.go @@ -0,0 +1,71 @@ +package eclipse + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +// 面板与选项契约:面板回报的太阳视半径必须与它声明的口径、以及求解器实际使用的半径一致。 + +func TestSolarEclipsePanelSunRadiusMatchesConvention(t *testing.T) { + date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC) + info, ok := SolarEclipseOnDate(date) + if !ok { + t.Fatal("2024-04-08 eclipse missing") + } + panel, ok := SolarEclipseGeocentricPanelAt(date) + if !ok { + t.Fatal("panel missing") + } + if panel.SunRadiusModel != SolarEclipseSunRadiusStandard { + t.Fatalf("panel sun radius model = %q", panel.SunRadiusModel) + } + tt := solarEclipseTimeToTTJDE(info.GreatestEclipse) + want := basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusStandard) + if math.Abs(panel.SunSemidiameterArcsec-want) > 1e-9 { + t.Fatalf("panel sun S.D. = %.6f, want %.6f", panel.SunSemidiameterArcsec, want) + } + // 通用名义半径(IAU 2015,695700 km)比食几何标准档小约 0.4″,面板不应再跟着它走。 + if nominal := basic.SunSemidiameter(tt); math.Abs(panel.SunSemidiameterArcsec-nominal) < 0.3 { + t.Fatalf("panel sun S.D. %.6f still tracks the nominal radius %.6f", panel.SunSemidiameterArcsec, nominal) + } +} + +func TestSolarEclipseOptionsSelectSunRadius(t *testing.T) { + date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC) + standard, ok := SolarEclipseOnDateWithOptions(date, SolarEclipseOptions{}) + if !ok { + t.Fatal("standard eclipse missing") + } + measured, ok := SolarEclipseOnDateWithOptions(date, SolarEclipseOptions{SunRadiusModel: SolarEclipseSunRadiusMeasured}) + if !ok { + t.Fatal("measured eclipse missing") + } + if standard.SunRadiusModel != SolarEclipseSunRadiusStandard || measured.SunRadiusModel != SolarEclipseSunRadiusMeasured { + t.Fatalf("provenance = %q / %q", standard.SunRadiusModel, measured.SunRadiusModel) + } + if standard.Model != SolarEclipseModelNASABulletinSplitK { + t.Fatalf("zero options resolved the k model to %q", standard.Model) + } + if !(measured.PathWidthKM < standard.PathWidthKM) { + t.Fatalf("path width %.3f >= %.3f", measured.PathWidthKM, standard.PathWidthKM) + } + panel, ok := SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{SunRadiusModel: SolarEclipseSunRadiusMeasured}) + if !ok { + t.Fatal("measured panel missing") + } + if panel.SunRadiusModel != SolarEclipseSunRadiusMeasured { + t.Fatalf("panel sun radius model = %q", panel.SunRadiusModel) + } + if panel.PenumbralK != basic.SolarEclipsePenumbralK || panel.UmbralK != basic.SolarEclipseUmbralK { + t.Fatalf("split panel k = %.7f/%.7f", panel.PenumbralK, panel.UmbralK) + } + if tt := solarEclipseTimeToTTJDE(measured.GreatestEclipse); math.Abs( + panel.SunSemidiameterArcsec-basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusMeasured), + ) > 1e-9 { + t.Fatalf("measured panel sun S.D. = %.6f", panel.SunSemidiameterArcsec) + } +} diff --git a/eclipse/solar_shadow.go b/eclipse/solar_shadow.go index d4e621e..96a08fd 100644 --- a/eclipse/solar_shadow.go +++ b/eclipse/solar_shadow.go @@ -56,7 +56,9 @@ func (topology SolarEclipseShadowTopology) Signature() string { // SolarEclipseShadowInstant 某瞬时的全球本影或半影足迹 / instantaneous shadow footprint. type SolarEclipseShadowInstant struct { - // Time 记录对应的时刻:UTC 入口为调用方所给,TT 入口按句柄 ΔT 换算 / instant this record describes. + // Time 记录对应的时刻:UTC 入口为调用方所给,TT 入口回填民用时刻(TT2UTC),与句柄 ΔT 无关。 + // Time is the instant this record describes: the caller's value for the UTC entry, and the civil + // time recovered with TT2UTC for the TT entry, independently of the handle's ΔT. Time time.Time // JDE 几何使用的力学时儒略日,ΔT 不改变它 / TT instant used by the geometry. JDE float64 @@ -82,6 +84,8 @@ func (instant SolarEclipseShadowInstant) Empty() bool { // SolarEclipseStationState 某瞬时的站心日月几何 / topocentric geometry at one instant. type SolarEclipseStationState struct { + // Time 是该状态对应的时刻。 + // Time is the instant this state describes. Time time.Time // JDE 本次计算使用的力学时儒略日 / TT instant used. JDE float64 @@ -106,7 +110,7 @@ type SolarEclipseStationState struct { HasAnnularPhase bool // CentralPhaseType 中心食类型,非中心食为 SolarEclipseNone / central phase kind. CentralPhaseType SolarEclipseType - // Visible 太阳中心在地平线上,海拔用俯仰角修正 / Sun center above the horizon. + // Visible 太阳中心在地平线上,高度用俯仰角修正 / Sun center above the horizon. Visible bool } @@ -131,11 +135,9 @@ func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *Solar } func (solver *SolarEclipseShadowSolver) ttJDE(value time.Time) float64 { - utJDE := basic.Date2JDE(value.UTC()) - if solver.options.DeltaTSeconds > 0 { - return utJDE + solver.options.DeltaTSeconds/86400 - } - return basic.TD2UT(utJDE, true) + // 入参是民用时刻:TT = UTC + (TT−UTC),与闰秒表一致。显式 ΔT 是 TT−UT1, + // 只改自转相位,不能掺进这条换算,否则报告值与实用偏移会差一个 DUT1。 + return basic.UTC2TT(basic.Date2JD(value.UTC())) } // ShadowAt 给定 UTC 时刻的阴影足迹;不在地球上时返回 (零值, false) / footprint at one UTC instant. @@ -164,7 +166,7 @@ func solarEclipseShadowInstantFromBasic( ) SolarEclipseShadowInstant { location := value.Location() if value.IsZero() { - value = solarEclipseTTJDEToTimeWithDeltaT(instant.JDE, instant.DeltaTSeconds, time.UTC) + value = solarEclipseTTJDEToTime(instant.JDE, time.UTC) location = time.UTC } return SolarEclipseShadowInstant{ @@ -202,7 +204,7 @@ func (solver *SolarEclipseShadowSolver) StationStateAtJDE( } state := solver.inner.StationStateAtJDE(jdeTT, lon, lat, height) result := solarEclipseStationStateFromBasic(time.Time{}, state) - result.Time = solarEclipseTTJDEToTimeWithDeltaT(jdeTT, state.DeltaTSeconds, time.UTC) + result.Time = solarEclipseTTJDEToTime(jdeTT, time.UTC) return result } diff --git a/eclipse/solar_shadow_test.go b/eclipse/solar_shadow_test.go index ff4b119..843cb5e 100644 --- a/eclipse/solar_shadow_test.go +++ b/eclipse/solar_shadow_test.go @@ -140,8 +140,9 @@ func (solver *SolarEclipseShadowSolver) stationDeltaT(value time.Time) float64 { return state.DeltaTSeconds } -func TestSolarEclipseShadowTTEntryUsesScopedDeltaTForItsTime(t *testing.T) { - // 显式 ΔT 下:TT 入口回填的 UT 必须是 TT − 句柄 ΔT,闭合弧与 closure 取自同一时刻。 +func TestSolarEclipseShadowTTEntryReportsCivilTime(t *testing.T) { + // 句柄 ΔT 只改地球自转相位:TT 入口回填的必须是民用时刻(TT2UTC), + // 而不是 TT − ΔT —— 后者是 UT1,与民用时刻差一个 DUT1。 override := 3666.18 value := time.Date(2009, time.July, 22, 0, 53, 0, 0, time.UTC) model := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) @@ -151,13 +152,20 @@ func TestSolarEclipseShadowTTEntryUsesScopedDeltaTForItsTime(t *testing.T) { if !ok { t.Fatal("expected a footprint at the shifted instant") } - want := solarEclipseTTJDEToTimeWithDeltaT(jde, override, time.UTC) + want := solarEclipseTTJDEToTime(jde, time.UTC) if !instant.Time.Equal(want) { - t.Fatalf("TT entry time=%v, want %v (TT − %.2f s)", instant.Time.UTC(), want.UTC(), override) + t.Fatalf("TT entry time=%v, want the civil time %v", instant.Time.UTC(), want.UTC()) } if instant.DeltaTSeconds != override { t.Fatalf("reported ΔT=%.6f, want %.2f", instant.DeltaTSeconds, override) } + if shift := instant.Time.Sub(value); shift > time.Millisecond || shift < -time.Millisecond { + t.Fatalf("TT entry time=%v, want the civil instant %v", instant.Time.UTC(), value) + } + state := solver.StationStateAtJDE(jde, -96.8, 32.8, 0) + if !state.Time.Equal(want) { + t.Fatalf("station TT entry time=%v, want the civil time %v", state.Time.UTC(), want.UTC()) + } } func TestSolarEclipseShadowBetweenSurvivesLongWindows(t *testing.T) { diff --git a/eclipse/stateless_exports_contract_test.go b/eclipse/stateless_exports_contract_test.go index 492b84b..0de84a1 100644 --- a/eclipse/stateless_exports_contract_test.go +++ b/eclipse/stateless_exports_contract_test.go @@ -19,9 +19,9 @@ func TestSolarEclipseGeocentricPanelModelsContract(t *testing.T) { if !singleK.SingleK || singleK.Ephemeris != "IAU Single-K" { t.Fatalf("IAU panel SingleK=%v ephemeris=%q", singleK.SingleK, singleK.Ephemeris) } - if singleK.PenumbralK != singleK.UmbralK || singleK.PenumbralK != basic.SolarEclipsePenumbralK { + if singleK.PenumbralK != singleK.UmbralK || singleK.PenumbralK != basic.SolarEclipseIAUSingleRadiusK { t.Fatalf("IAU panel k1=%.7f k2=%.7f, want the single k %.7f", - singleK.PenumbralK, singleK.UmbralK, basic.SolarEclipsePenumbralK) + singleK.PenumbralK, singleK.UmbralK, basic.SolarEclipseIAUSingleRadiusK) } splitK, ok := SolarEclipseGeocentricPanelAt(date) if !ok { diff --git a/eclipse/svg/footer_margin_contract_test.go b/eclipse/svg/footer_margin_contract_test.go new file mode 100644 index 0000000..2f19ded --- /dev/null +++ b/eclipse/svg/footer_margin_contract_test.go @@ -0,0 +1,232 @@ +package svg + +import ( + "encoding/xml" + "fmt" + "io" + "math" + "sort" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。 +// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。 + +const chartBottomMarginMinimum = 6.0 + +type chartTextElement struct { + x float64 + y float64 + fontSize float64 + fill string + value string +} + +func chartTextElements(t *testing.T, document string) []chartTextElement { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(document)) + elements := []chartTextElement{} + stack := []string{} + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("chart is not valid XML: %v", err) + } + switch typed := token.(type) { + case xml.StartElement: + stack = append(stack, typed.Name.Local) + if typed.Name.Local != "text" { + continue + } + element := chartTextElement{} + for _, attribute := range typed.Attr { + switch attribute.Name.Local { + case "x": + element.x, _ = strconv.ParseFloat(attribute.Value, 64) + case "y": + element.y, _ = strconv.ParseFloat(attribute.Value, 64) + case "font-size": + element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) + case "fill": + element.fill = attribute.Value + } + } + elements = append(elements, element) + case xml.EndElement: + stack = stack[:len(stack)-1] + case xml.CharData: + if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 { + elements[len(elements)-1].value += string(typed) + } + } + } + return elements +} + +func chartBottomMarginViolations(t *testing.T, document string, height int) []string { + t.Helper() + violations := []string{} + for _, element := range chartTextElements(t, document) { + if strings.TrimSpace(element.value) == "" { + continue + } + _, below := svgchart.EstimatedTextExtents(element.fontSize) + if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum { + violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum)) + } + } + return violations +} + +func chartFooterCases(t *testing.T) []struct { + name string + height int + document string +} { + t.Helper() + cst := time.FixedZone("CST", 8*3600) + lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst) + long := footerFitLongText("footer ") + cases := []struct { + name string + height int + document string + }{} + add := func(name string, height int, document string, ok bool) { + if !ok { + t.Fatalf("%s: chart missing", name) + } + cases = append(cases, struct { + name string + height int + document string + }{name, height, document}) + } + solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0, + LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst}) + add("站心日食", 692, solar, ok) + solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0, + LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long}) + add("站心日食 窄画布长说明", 520, solarLong, ok) + lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst}) + add("站心月食", 640, lunar, ok) + lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long}) + add("站心月食 窄画布长说明", 520, lunarLong, ok) + detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long}) + add("月食详图 长说明", 1000, detailed, ok) + mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long}) + add("月食地图 长说明", 640, mapChart, ok) + solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), + SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long}) + add("日食地图 长说明", 640, solarMap, ok) + return cases +} + +func TestChartTextKeepsBottomMargin(t *testing.T) { + for _, tc := range chartFooterCases(t) { + if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 { + t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0]) + } + } +} + +func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + cases := []struct { + name string + height int + document string + }{} + solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0, + LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst}) + if !ok { + t.Fatal("站心日食:缺图") + } + cases = append(cases, struct { + name string + height int + document string + }{"站心日食", 692, solar}) + lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst}) + if !ok { + t.Fatal("站心月食:缺图") + } + cases = append(cases, struct { + name string + height int + document string + }{"站心月食", 640, lunar}) + + for _, tc := range cases { + baselines := []float64{} + for _, element := range chartTextElements(t, tc.document) { + if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") { + continue + } + if strings.TrimSpace(element.value) == "" { + continue + } + baselines = append(baselines, element.y) + } + if len(baselines) < 3 { + t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines)) + } + sort.Float64s(baselines) + step := svgchart.FooterLineHeight(12) + for index := 1; index < len(baselines); index++ { + if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 { + t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines) + } + } + if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 { + t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding) + } + } +} + +// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。 +func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) { + long := footerFitLongText("scale ") + cst := time.FixedZone("CST", 8*3600) + lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst) + cases := []struct { + name string + document string + ok bool + }{} + solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0, + LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long}) + cases = append(cases, struct { + name string + document string + ok bool + }{"站心日食", solar, ok}) + lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long}) + cases = append(cases, struct { + name string + document string + ok bool + }{"站心月食", lunar, ok}) + mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long}) + cases = append(cases, struct { + name string + document string + ok bool + }{"月食地图", mapChart, ok}) + for _, tc := range cases { + if !tc.ok { + t.Fatalf("%s:缺图", tc.name) + } + if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") { + t.Errorf("%s:超长说明把时标声明挤掉了", tc.name) + } + } +} diff --git a/eclipse/svg/lunar.go b/eclipse/svg/lunar.go index 930363f..555d27a 100644 --- a/eclipse/svg/lunar.go +++ b/eclipse/svg/lunar.go @@ -1,6 +1,7 @@ package svg import ( + "b612.me/astro/internal/timenote" "fmt" "html" "math" @@ -8,6 +9,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgasset" @@ -66,6 +68,11 @@ type LunarEclipseSVGOptions struct { // Location 是图中显示时刻的时区;nil 时使用 UTC+8。 // Location is the display timezone for chart times; nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires + // Location to be nil or UTC (otherwise the renderer returns false). + TimeScale astro.TimeScale } type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult @@ -149,6 +156,12 @@ func lunarEclipseSVG( calculator lunarEclipseSVGCalculator, finder lunarEclipseSVGFinder, ) (string, bool) { + if options.TimeScale == astro.TimeScaleUT1 { + if options.Location != nil && options.Location != time.UTC { + return "", false + } + options.Location = time.UTC + } options = normalizeLunarEclipseSVGOptions(options) diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{ StepDays: durationToDays(options.Step), @@ -162,7 +175,7 @@ func lunarEclipseSVG( info.HasSaros = coreInfo.HasSaros info.Saros = coreInfo.Saros } - return renderLunarEclipseSVG(info, diagram, options), true + return renderLunarEclipseSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, diagram, options), true } func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions { @@ -319,11 +332,21 @@ func writeLunarEclipseEventLabels( func renderLunarEclipseSVG( info LunarEclipseInfo, + geometry LunarEclipseInfo, diagram basic.LunarEclipseDiagramResult, options LunarEclipseSVGOptions, ) string { - headerTexts := lunarEclipseSVGHeaderTexts(info, options) - layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts)) + headerTexts := lunarEclipseSVGHeaderTexts(info, geometry, options) + headerBottom := lunarEclipseSVGHeaderBottom(headerTexts) + direction := lunarEclipseSVGDirectionTextValue(options) + if options.DirectionText != "" { + direction = svgchart.EllipsizeText(direction, float64(options.Width)-80, 12) + } + footerLines := lunarEclipseSVGFooterLines(direction, info, options, headerBottom) + footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), float64(options.Height), 12, + svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding) + // 页脚高度按实际行数算,再留一点与图区的空,说明文字不会顶到画布底边。 + layout := lunarEclipseSVGLayoutFor(diagram, options, headerBottom, footerOccupied+10) title := lunarEclipseSVGTitleText(info, options) var b strings.Builder @@ -351,7 +374,7 @@ func renderLunarEclipseSVG( writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels) writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY) - writeLunarEclipseFooter(&b, info, options, layout) + writeLunarEclipseFooter(&b, footerLines, footerBaselines) b.WriteString(``) return b.String() @@ -360,7 +383,7 @@ func renderLunarEclipseSVG( func lunarEclipseSVGLayoutFor( diagram basic.LunarEclipseDiagramResult, options LunarEclipseSVGOptions, - headerBottom float64, + headerBottom, footerHeight float64, ) lunarEclipseSVGLayout { width := float64(options.Width) height := float64(options.Height) @@ -372,7 +395,7 @@ func lunarEclipseSVGLayoutFor( diagramRight = width - margin } topReserved := math.Max(166, headerBottom+24) - bottomReserved := 82.0 + bottomReserved := footerHeight extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72 scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent)) if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { @@ -413,11 +436,15 @@ func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOpti return lunarEclipseSVGTitle(info, options.Language) } -func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string { +func lunarEclipseSVGHeaderTexts( + info LunarEclipseInfo, + geometry LunarEclipseInfo, + options LunarEclipseSVGOptions, +) []string { lines := []string{ lunarEclipseSVGSummaryText(info, options), lunarEclipseSVGMaximumTextValue(info, options), - lunarEclipseSVGCoordinatesTextValue(info, options), + lunarEclipseSVGCoordinatesTextValue(geometry, options), lunarEclipseSVGDurationTextValue(info, options), lunarEclipseSVGMetaTextValue(info, options), } @@ -484,6 +511,10 @@ func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string { } func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string { + return lunarEclipseSVGFooterNoteTextBase(options) +} + +func lunarEclipseSVGFooterNoteTextBase(options LunarEclipseSVGOptions) string { if options.FooterNote != "" { return options.FooterNote } @@ -997,29 +1028,40 @@ func writeLunarEclipseContacts( } } -func writeLunarEclipseFooter( - b *strings.Builder, - info LunarEclipseInfo, - options LunarEclipseSVGOptions, - layout lunarEclipseSVGLayout, -) { - _ = info - direction := lunarEclipseSVGDirectionTextValue(options) - if options.DirectionText != "" { - direction = svgchart.EllipsizeText(direction, layout.width-80, 12) +// lunarEclipseSVGFooterLines 返回方向行在前的页脚各行:行数上限由图区下限与画布高度决定。 +func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options LunarEclipseSVGOptions, headerBottom float64) []string { + const ( + fontSize = 12.0 + diagramFloor = 160.0 + stageGap = 10.0 + ) + height := float64(options.Height) + lineHeight := svgchart.FooterLineHeight(fontSize) + padding := localDiagramFooterBottomPadding + maxLines := svgchart.TextLineLimit(fontSize, lineHeight, height-headerBottom-diagramFloor-stageGap-padding-lineHeight) + if cap := int(height * 0.20 / lineHeight); maxLines > cap { + maxLines = cap } - fmt.Fprintf(b, `%s`, - 40.0, layout.height-54, html.EscapeString(direction)) - // 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。 + if maxLines < 1 { + maxLines = 1 + } + note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language) lines := []string{lunarEclipseSVGFooterNoteText(options)} if options.FooterNote != "" { - maxWidth := layout.width - 80 - lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12, - svgchart.BaselineLineLimit(12, 15, layout.height-34, layout.height-4)) + lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize) } + lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-80, fontSize, maxLines) + return append([]string{direction}, lines...) +} + +func writeLunarEclipseFooter(b *strings.Builder, lines []string, baselines []float64) { for index, line := range lines { - fmt.Fprintf(b, `%s`, - 40.0, layout.height-34+float64(index)*15, html.EscapeString(line)) + fill := "#555" + if index == 0 { + fill = "#333" + } + fmt.Fprintf(b, `%s`, + 40.0, baselines[index], fill, html.EscapeString(line)) } } diff --git a/eclipse/svg/lunar_detailed.go b/eclipse/svg/lunar_detailed.go index a69912e..55d3836 100644 --- a/eclipse/svg/lunar_detailed.go +++ b/eclipse/svg/lunar_detailed.go @@ -1,12 +1,14 @@ package svg import ( + "b612.me/astro/internal/timenote" "fmt" "html" "math" "strings" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgasset" @@ -33,9 +35,19 @@ type LunarEclipseDetailedSVGOptions struct { // Location 控制显示时刻的时区;nil 使用 UTC+8。 // Location controls the display timezone; nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires + // Location to be nil or UTC (otherwise the renderer returns false). + TimeScale astro.TimeScale // Step 是月心路径采样步长;<=0 时使用 5 分钟。 // Step is the Moon-center path sampling step; values <= 0 use five minutes. Step time.Duration + // DisablePenumbralPhase 关闭底图可见性分区里的半影阶段,含义与 LunarEclipseMapSVGOptions 的同名字段一致; + // 零值即画半影。 + // DisablePenumbralPhase turns the penumbral phase off in the base map partition, with the same + // meaning as the LunarEclipseMapSVGOptions field of that name; the zero value draws it. + DisablePenumbralPhase bool // Title 与 FooterNote 为空时自动生成。 // Empty Title and FooterNote use automatic text. Title string @@ -98,6 +110,12 @@ func lunarEclipseDetailedSVG( finder lunarEclipseSVGFinder, model eclipsecore.LunarEclipseShadowModel, ) (string, bool) { + if options.TimeScale == astro.TimeScaleUT1 { + if options.Location != nil && options.Location != time.UTC { + return "", false + } + options.Location = time.UTC + } options = normalizeLunarEclipseDetailedSVGOptions(options) diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{ StepDays: durationToDays(options.Step), @@ -115,7 +133,8 @@ func lunarEclipseDetailedSVG( return "", false } return renderLunarEclipseDetailedSVG( - info, diagram, geometry(diagram.Eclipse.Maximum), timeToTTJDE(date), options, model, + lunarEclipseDisplayInfo(info, options.TimeScale), info, + diagram, geometry(diagram.Eclipse.Maximum), diagram.Eclipse.Maximum, options, model, ), true } @@ -189,7 +208,7 @@ func validLunarEclipseDetailedSize(width, height int, info LunarEclipseInfo, opt if (w-2*margin-2*columnGap)/3 < lunarEclipseDetailedMinColumnWidth { return false } - view := lunarEclipseDetailedViewFor(options) + view := lunarEclipseDetailedViewFor(info, options) if solarEclipsePanelsOverlap(lunarEclipseDetailedPanelCells(view, info, options)) { return false } @@ -275,7 +294,7 @@ func lunarEclipseDetailedPanelCells( } } -func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView { +func lunarEclipseDetailedViewFor(info LunarEclipseInfo, options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView { width, height := float64(options.Width), float64(options.Height) margin := math.Max(30, math.Min(52, width*0.044)) view := lunarEclipseDetailedView{ @@ -304,7 +323,8 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc Projection: svgmap.ProjectionEquirectangular, } view.legendY = mapTop + mapHeight + lunarEclipseDetailedLegendGap - view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language)) + view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language, + lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase))) if view.legendRows < 1 { view.legendRows = 1 } @@ -319,13 +339,14 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc func renderLunarEclipseDetailedSVG( info LunarEclipseInfo, + geometry LunarEclipseInfo, diagram basic.LunarEclipseDiagramResult, shadow basic.LunarEclipseShadowGeometry, tt float64, options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel, ) string { - view := lunarEclipseDetailedViewFor(options) + view := lunarEclipseDetailedViewFor(info, options) english := options.Language == lunarEclipseSVGLanguageEnglish title := options.Title if title == "" { @@ -353,13 +374,14 @@ func renderLunarEclipseDetailedSVG( diagramTop: view.diagramTop, diagramEnd: view.diagramBottom, diagramLeft: view.margin, diagramRight: view.width - view.margin, } + penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase) // 不动的元素先占位:数据块、底图、图例与页脚;示意图标注只在示意图带内选位置。 labels := &svgchart.LabelTable{} for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) { labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height) } labels.Reserve(view.mapFrame.X, view.mapFrame.Y, view.mapFrame.Width, view.mapFrame.Height) - for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language) { + for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language, penumbralBands) { for _, item := range row { labels.ReserveText(item.x, item.y, lunarEclipseDetailedMapLegendFontSize, item.text, "start") } @@ -372,7 +394,7 @@ func renderLunarEclipseDetailedSVG( options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) b.WriteString(``) b.WriteString(svgasset.MoonFaceSymbol()) - writeLunarEclipseMapDefinitions(&b, view.mapFrame, info) + writeLunarEclipseMapDefinitions(&b, view.mapFrame, geometry, penumbralBands) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, @@ -388,9 +410,9 @@ func renderLunarEclipseDetailedSVG( writeLunarEclipseDetailedGeocentricBlocks(&b, labels, tt, view, options) writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels) writeLunarEclipseDetailedPanels(&b, info, shadow, scale, view, options) - writeLunarEclipseMapRegions(&b, view.mapFrame, info) + writeLunarEclipseMapRegions(&b, view.mapFrame, geometry, penumbralBands) view.mapFrame.WriteFrame(&b) - writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language) + writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language, penumbralBands) writeLunarEclipseDetailedFooter(&b, view, options, model) b.WriteString(``) return b.String() @@ -435,16 +457,7 @@ func writeLunarEclipseDetailedSummary( fmt.Fprintf(b, `%s`, view.width/2, view.metaY+float64(index)*22, html.EscapeString(line)) } - zoneNote := "图中时刻为 UT" - if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 { - zoneNote = fmt.Sprintf("图中时刻为 %s(UT%s)", zone, formatZoneOffset(offset)) - } - if english { - zoneNote = "All times are UT" - if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 { - zoneNote = fmt.Sprintf("All times are %s (UT%s)", zone, formatZoneOffset(offset)) - } - } + zoneNote := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language) labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle") fmt.Fprintf(b, `%s`, view.width/2, view.metaY+4*22, html.EscapeString(zoneNote)) @@ -479,7 +492,7 @@ func writeLunarEclipseDetailedGeocentricBlocks( } sunRa, sunDec := basic.SunApparentRaDec(tt) moonRa, moonDec := basic.HMoonTrueRaDec(tt) - sunSd := basic.SunSemidiameter(tt) + sunSd := basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusStandard) moonSd := basic.MoonSemidiameter(tt) blocks := [2][]svgchart.PanelRow{ { @@ -603,6 +616,10 @@ func writeLunarEclipseArcMinuteScaleBar( // lunarEclipseDetailedFooterText 是页脚说明:默认模型会在极浅半影上回退 Chauvenet,必须写出实际用的那套。 func lunarEclipseDetailedFooterText(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string { + return lunarEclipseDetailedFooterTextBase(options, model) +} + +func lunarEclipseDetailedFooterTextBase(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string { if options.FooterNote != "" { return options.FooterNote } @@ -623,10 +640,12 @@ func writeLunarEclipseDetailedFooter( lines := svgchart.TruncateTextLines( svgchart.WrapText(lunarEclipseDetailedFooterText(options, model), maxWidth, lunarEclipseDetailedFooterFontSize), maxWidth, lunarEclipseDetailedFooterFontSize, - svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, 15, view.footerY, view.height-4)) + svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize), + view.footerY, view.height-svgchart.FooterBottomPadding(lunarEclipseDetailedFooterFontSize))) for index, line := range lines { fmt.Fprintf(b, `%s`, - view.margin, view.footerY+float64(index)*15, lunarEclipseDetailedFooterFontSize, html.EscapeString(line)) + view.margin, view.footerY+float64(index)*svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize), + lunarEclipseDetailedFooterFontSize, html.EscapeString(line)) } } diff --git a/eclipse/svg/lunar_detailed_test.go b/eclipse/svg/lunar_detailed_test.go index 9b8a33b..a14211c 100644 --- a/eclipse/svg/lunar_detailed_test.go +++ b/eclipse/svg/lunar_detailed_test.go @@ -47,7 +47,7 @@ func TestLunarEclipseDetailedSVGCombinesBothCharts(t *testing.T) { // 地影几何要与 NASA 月食图逐项吻合:2009 之外的 2025-03-14 全食,Gamma 0.3481、P./U. Radius 1.1899/0.6537。 func TestLunarEclipseShadowGeometryMatchesNASABulletin(t *testing.T) { date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC) - result := basic.LunarEclipse(basic.TD2UT(basic.Date2JDE(date), true)) + result := basic.LunarEclipse(basic.UTC2TT(basic.Date2JD(date))) geometry := basic.LunarEclipseShadowGeometryAt(result.Maximum) t.Logf("gamma=%.4f P.Radius=%.4f U.Radius=%.4f umbral=%.4f penumbral=%.4f", geometry.Gamma, geometry.PenumbralRadiusDegrees, geometry.UmbralRadiusDegrees, diff --git a/eclipse/svg/lunar_geocentric_maximum_test.go b/eclipse/svg/lunar_geocentric_maximum_test.go new file mode 100644 index 0000000..d4fdc03 --- /dev/null +++ b/eclipse/svg/lunar_geocentric_maximum_test.go @@ -0,0 +1,75 @@ +package svg + +import ( + "html" + "math" + "regexp" + "testing" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" +) + +// 详细版"食甚时的月亮(地心坐标)"块必须用食甚时刻的力学时求值,与 NASA 月食图同一时刻; +// 输入日期的时刻(当天 0 时或 12 时)会让赤经赤纬整体偏掉。 +func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + cases := []struct { + name string + date time.Time + ra string + dec string + }{ + // 对应 NASA 图上的 Moon at Greatest Eclipse:2026-03-03 为 10h56m15.0s / +06°24'05.2", + // 2025-03-14 为 11h38m23.0s / +02°40'54.6",与本库的差在 0.3 角秒内。 + {"2026-03-03", time.Date(2026, 3, 3, 0, 0, 0, 0, cst), "10h56m15.1s", "+06°24'05.2""}, + {"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40'54.3""}, + } + for _, want := range cases { + doc, ok := LunarEclipseDetailedSVG(want.date, LunarEclipseDetailedSVGOptions{ + Language: "zh", Location: cst}) + if !ok { + t.Fatalf("%s: missing chart", want.name) + } + ra, dec := lunarGeocentricMoonBlock(t, doc) + if ra != want.ra || dec != want.dec { + t.Errorf("%s: 食甚坐标 RA=%s Dec=%s, want RA=%s Dec=%s", want.name, ra, dec, want.ra, want.dec) + } + info, ok := eclipsecore.LunarEclipseOnDate(want.date) + if !ok { + t.Fatalf("%s: missing eclipse", want.name) + } + maximumRA, maximumDec := basic.HMoonTrueRaDec(timeToTTJDE(info.Maximum)) + if got, wantValue := ra, formatSolarEclipseRA(maximumRA); got != wantValue { + t.Errorf("%s: RA=%s 不是食甚时刻的 %s", want.name, got, wantValue) + } + if got, wantValue := dec, html.EscapeString(formatSolarEclipseDec(maximumDec)); got != wantValue { + t.Errorf("%s: Dec=%s 不是食甚时刻的 %s", want.name, got, wantValue) + } + inputRA, inputDec := basic.HMoonTrueRaDec(timeToTTJDE(want.date)) + if math.Abs(maximumRA-inputRA) < 1 { + t.Fatalf("%s: 该用例区分不出输入日期与食甚时刻", want.name) + } + if dec == html.EscapeString(formatSolarEclipseDec(inputDec)) { + t.Errorf("%s: Dec 仍等于输入日期时刻的值 %s", want.name, formatSolarEclipseDec(inputDec)) + } + } +} + +func lunarGeocentricMoonBlock(t *testing.T, doc string) (string, string) { + t.Helper() + block := regexp.MustCompile(`食甚时的月亮(地心坐标)(.*?)`).FindStringSubmatch(doc) + if block == nil { + t.Fatal("geocentric Moon block not found") + } + value := func(label string) string { + pattern := regexp.MustCompile(regexp.QuoteMeta(label) + `]*text-anchor="end">([^<]*)`) + match := pattern.FindStringSubmatch(block[1]) + if match == nil { + t.Fatalf("row %s not found in the geocentric Moon block", label) + } + return match[1] + } + return value("赤经 R.A."), value("赤纬 Dec.") +} diff --git a/eclipse/svg/lunar_map.go b/eclipse/svg/lunar_map.go index 64fd95b..71fc09d 100644 --- a/eclipse/svg/lunar_map.go +++ b/eclipse/svg/lunar_map.go @@ -1,12 +1,14 @@ package svg import ( + "b612.me/astro/internal/timenote" "fmt" "html" "math" "strings" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/lunarhorizon" @@ -32,9 +34,20 @@ type LunarEclipseMapSVGOptions struct { // Location 控制显示的事件时刻;nil 使用 date.Location()。 // Location controls displayed event times. Nil uses date.Location(). Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则返回 false),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer. + TimeScale astro.TimeScale // Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。 // Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false. Projection EclipseMapProjection + // DisablePenumbralPhase 关闭半影阶段,回到只按 P1/P4 分区的四类可见区:零值画半影(补画 U1–U4 + // 地平边界、单独着色只看得见半影的月出与月落带「半影月出」「半影月落」,并在时刻行里标出本影接触)。 + // DisablePenumbralPhase falls back to the four-way P1/P4-only partition: the zero value draws the + // penumbral phase, adding the U1-U4 horizons, the penumbra-only moonrise and moonset bands as + // separate legend entries, and the umbral contact times. + DisablePenumbralPhase bool // 空文本字段使用本地化的自动标签。 // Empty text fields use localized automatic labels. Title string @@ -71,12 +84,18 @@ func lunarEclipseMapSVG( if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() { return "", false } + if options.TimeScale == astro.TimeScaleUT1 { + if options.Location != nil && options.Location != time.UTC { + return "", false + } + options.Location = time.UTC + } options = normalizeLunarEclipseMapSVGOptions(date, options) projection := internalEclipseMapProjection(options.Projection) if projection == "" { projection = svgmap.ProjectionEquirectangular } - return renderLunarEclipseMapSVG(info, options, projection), true + return renderLunarEclipseMapSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, options, projection), true } func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions { @@ -99,10 +118,15 @@ func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapS func renderLunarEclipseMapSVG( info eclipsecore.LunarEclipseInfo, + geometry eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions, projection svgmap.Projection, ) string { frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92) + if projection == svgmap.ProjectionOrthographic { + // 正射视点取食甚时刻的月下点:视点固定 (0°,0°) 会把半个可见半球让给与本次月食无关的区域。 + frame.CenterLongitude, frame.CenterLatitude = lunarEclipseOrthographicCenter(geometry.Maximum) + } title := options.Title if title == "" { date := info.Maximum.In(options.Location).Format("2006-01-02") @@ -116,8 +140,9 @@ func renderLunarEclipseMapSVG( var builder strings.Builder fmt.Fprintf(&builder, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) + penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase) builder.WriteString(``) - writeLunarEclipseMapDefinitions(&builder, frame, info) + writeLunarEclipseMapDefinitions(&builder, frame, geometry, penumbralBands) builder.WriteString(``) builder.WriteString(``) fmt.Fprintf(&builder, ``, @@ -130,17 +155,28 @@ func renderLunarEclipseMapSVG( float64(options.Width)/2, html.EscapeString(titleText)) writeLunarEclipseMapSummary(&builder, info, options) - writeLunarEclipseMapRegions(&builder, frame, info) + writeLunarEclipseMapRegions(&builder, frame, geometry, penumbralBands) frame.WriteFrame(&builder) - writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language) - writeLunarEclipseMapFooter(&builder, frame, options, projection) + legendRows := lunarEclipseMapLegendRows(frame, float64(options.Width), options.Language, penumbralBands) + writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language, penumbralBands) + writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, len(legendRows)) builder.WriteString(``) return builder.String() } +// lunarEclipseMapDrawsPenumbralBands 只在该场月食确有本影阶段且开关打开时才区分半影带。 +func lunarEclipseMapDrawsPenumbralBands(info eclipsecore.LunarEclipseInfo, includePenumbral bool) bool { + return includePenumbral && info.HasPartial +} + +// 可见性由 P1、食甚、P4 三刻的月球地平状态共同界定:全程可见要求三刻都在地平上, +// 只在食甚前后露出地平的地点仍看得见本影食,算带食月落;三刻都不见才算不可见。 func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) { writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame, + "lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape") + writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-p4-mask", frame, "lunar-visible-p1-shape", "lunar-visible-p4-shape") + writeLunarEclipseVisibilityMask(builder, "lunar-not-maximum-mask", frame, "lunar-visible-maximum-shape") writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape") writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape") } @@ -180,7 +216,7 @@ func eclipseMapFrame(width, height int, projection svgmap.Projection, top, botto } func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) { - points := basic.MoonHorizon(basic.Date2JDE(value.UTC()), 360) + points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 360) boundary := make([]svgmap.GeoPoint, len(points)) for index, point := range points { boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]} @@ -204,6 +240,12 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, [] } boundary = lunarhorizon.RefineWithin(boundary, value, tolerance) } + closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) + return lunarEclipseGeoPolygonPath(boundary, frame), closedBoundary +} + +// lunarEclipseGeoPolygonPath 把经纬度环投影成 SVG 面路径,反经线与画布边缘按共享片段切开。 +func lunarEclipseGeoPolygonPath(boundary []svgmap.GeoPoint, frame svgmap.Frame) string { var builder strings.Builder for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) { if frame.IsPolar() { @@ -211,8 +253,7 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, [] } appendEclipseMapPolygonPathConsistent(&builder, frame, fragment) } - closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) - return builder.String(), closedBoundary + return builder.String() } func lunarEclipsePolarHorizonClosure(points []svgmap.GeoPoint) []svgmap.GeoPoint { @@ -313,14 +354,44 @@ func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.Luna } fmt.Fprintf(builder, `%s`, float64(options.Width)/2, html.EscapeString(text)) + if line := lunarEclipseMapUmbralContactLine(info, options); line != "" { + fmt.Fprintf(builder, `%s`, + float64(options.Width)/2, html.EscapeString(line)) + } +} + +// lunarEclipseMapUmbralContactLine 给出本影接触时刻行;开关关闭或没有本影阶段时返回空串。 +func lunarEclipseMapUmbralContactLine(info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) string { + if options.DisablePenumbralPhase || !info.HasPartial { + return "" + } + parts := []string{"U1 " + info.PartialStart.In(options.Location).Format("15:04:05")} + if info.HasTotal { + parts = append(parts, + "U2 "+info.TotalStart.In(options.Location).Format("15:04:05"), + "U3 "+info.TotalEnd.In(options.Location).Format("15:04:05")) + } + parts = append(parts, "U4 "+info.PartialEnd.In(options.Location).Format("15:04:05")) + prefix := "本影阶段 " + if options.Language == "en" { + prefix = "Umbral phases " + } + return prefix + strings.Join(parts, " | ") } // lunarEclipseMapLegendRows 给出可见性图例的标注位次:每行按实测文本宽度排布并居中在地图框下方。 -func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string) [][]solarEclipseCardinalLabel { +func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) [][]solarEclipseCardinalLabel { labels := []string{"全程可见", "带食月出", "带食月落", "不可见"} if language == "en" { labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"} } + if penumbralBands { + moonrise, moonset := "半影月出", "半影月落" + if language == "en" { + moonrise, moonset = "Penumbra moonrise", "Penumbra moonset" + } + labels = []string{labels[0], labels[1], moonrise, labels[2], moonset, labels[3]} + } width := func(text string) float64 { return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(text, lunarEclipseDetailedMapLegendFontSize) @@ -378,10 +449,13 @@ func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language return rows } -func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string) { +func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) { colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"} + if penumbralBands { + colors = []string{"#5e846d", "#4e9da0", "#7488cc", "#e2aa4b", "#ab7fb5", "#747b7d"} + } index := 0 - for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language) { + for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language, penumbralBands) { for _, entry := range row { color := colors[0] if index < len(colors) { @@ -401,6 +475,8 @@ func writeLunarEclipseMapFooter( frame svgmap.Frame, options LunarEclipseMapSVGOptions, projection svgmap.Projection, + instant time.Time, + legendRows int, ) { text := options.FooterNote if text == "" { @@ -410,17 +486,28 @@ func writeLunarEclipseMapFooter( text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。" } } - baseline := float64(options.Height) - 38 + // 页脚末行要落在白色图框内:图框在画布内缩 18px,11px 字的末行基线留 26px(框线 + 间隙 + 字下伸部)。 + const footerBottomPadding = 28.0 + // 首行基线再让出一个字下伸部,末行才刚好落在留白之上(BaselineLineLimit 按行下沿计行)。 + baseline := float64(options.Height) - footerBottomPadding - svgchart.FooterLineHeight(11) - 4 + // 图例换成多行时页脚要跟着下移,否则窄画布上两行文字会叠在一起;下行仍留在图框内。 + if legendRows > 1 { + if shifted := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 15; shifted > baseline { + baseline = math.Min(shifted, float64(options.Height)-footerBottomPadding) + } + } // 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。 + note := timenote.Scale(options.TimeScale, instant, options.Location, options.Language) lines := []string{text} if options.FooterNote != "" { - maxWidth := float64(options.Width) - frame.X - 24 - lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 11), maxWidth, 11, - svgchart.BaselineLineLimit(11, 15, baseline, float64(options.Height)-4)) + lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-frame.X-24, 11) } + lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-frame.X-24, 11, + svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, + float64(options.Height)-footerBottomPadding)) for index, line := range lines { fmt.Fprintf(builder, `%s`, - frame.X, baseline+float64(index)*15, html.EscapeString(line)) + frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line)) } } @@ -432,9 +519,17 @@ func normalizeDegree180(value float64) float64 { return value - 180 } +// lunarEclipseOrthographicCenter 给出月食正射图的视点:食甚时刻的月下点(月球位于天顶的地点)。 +func lunarEclipseOrthographicCenter(at time.Time) (float64, float64) { + jd := basic.Date2JD(at.UTC()) + tt := basic.UTC2TT(jd) + ra, dec := basic.HMoonTrueRaDec(tt) + return normalizeDegree180(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec +} + // writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。 // 独立全球图和 详细版式组合图共用,形状按传入的图框重建。 -func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { +func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) { b.WriteString(frame.ClipDefinition("lunar-map-clip")) startPath, _ := lunarEclipseVisibilityPath(info.PenumbralStart, frame) endPath, _ := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) @@ -445,10 +540,96 @@ func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, inf b.WriteString(``) b.WriteString(``) writeLunarEclipseVisibilityMasks(b, frame) + if penumbralBands { + writeLunarEclipseUmbralVisibilityShapes(b, frame, info) + } } -// writeLunarEclipseMapRegions 画底图与四类可见性区域,末尾补两条地平边界线。 -func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { +// writeLunarEclipseUmbralVisibilityShapes 输出 U1、U4 的月球可见半球形状,以及"本影阶段任意时刻 +// 在地平上"的取样半球、裁剪路径与排除掩膜;底带取并集内部,半影带取外部。 +func writeLunarEclipseUmbralVisibilityShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { + u1Path, _ := lunarEclipseVisibilityPath(info.PartialStart, frame) + u4Path, _ := lunarEclipseVisibilityPath(info.PartialEnd, frame) + fmt.Fprintf(b, ``, u1Path) + fmt.Fprintf(b, ``, u4Path) + b.WriteString(``) + b.WriteString(``) + writeLunarEclipseUmbralVisibleShapes(b, frame, info) +} + +// writeLunarEclipseUmbralVisibleShapes 输出"本影阶段任意时刻在地平上"的取样半球:本影区间按固定档位 +// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域—— +// 实测 1932-03-22 西经 75.5° 一带上边界 0.5° 经度内从 +19.9° 掉到 −27.2°,1° 列的弦会切掉十几度)。 +// 这些形状同时用于底带的裁剪(取并集)与半影带的掩膜(取补集),两侧严丝合缝。 +func writeLunarEclipseUmbralVisibleShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { + instants := lunarEclipseUmbralVisibilityInstants(info) + if len(instants) == 0 { + // 兜底:算不出本影可见区时至少挖掉两端接触(宁可少扣,也不让底带无裁剪地铺满)。 + b.WriteString(``) + writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame, + "lunar-visible-u1-shape", "lunar-visible-u4-shape") + return + } + // 两端复用已经写好的精细半球形状,中间的取样用粗轮廓:单个圆盘在球面上的偏差约 0.035°, + // 在 1200 像素宽的世界图上不到 0.1 像素,而每个形状能省下三分之二的字节。 + ids := []string{"lunar-visible-u1-shape"} + for index := 1; index < len(instants)-1; index++ { + id := fmt.Sprintf("lunar-visible-umbral-%d", index) + fmt.Fprintf(b, ``, id, lunarEclipseMaskHorizonPath(instants[index], frame)) + ids = append(ids, id) + } + ids = append(ids, "lunar-visible-u4-shape") + var clip strings.Builder + clip.WriteString(``) + for _, id := range ids { + fmt.Fprintf(&clip, ``, id) + } + clip.WriteString(``) + b.WriteString(clip.String()) + writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame, ids...) +} + +// lunarEclipseMaskHorizonPath 掩膜用的可见半球轮廓:90 点基础圆,等距圆柱投影下再按 0.2° 容差 +// 细化,避免极区一段地平弧投影后横跨近 180° 经度、弦切出地平线以外的假可见帽。 +func lunarEclipseMaskHorizonPath(value time.Time, frame svgmap.Frame) string { + points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 90) + boundary := make([]svgmap.GeoPoint, len(points)) + for index, point := range points { + boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + if frame.Projection == svgmap.ProjectionEquirectangular { + boundary = lunarhorizon.RefineWithin(boundary, value, 0.2) + } + return lunarEclipseGeoPolygonPath(boundary, frame) +} + +// lunarEclipseUmbralVisibilityInstants 本影区间按 15 分钟档位取样,至少两端、至多 12 刻:中间取样用 +// 粗轮廓,圆盘分辨率约 0.035°,合起来残余的掠射窗口深度约 0.05°(约 0.15 像素);要逐点精确判定 +// 请用站点 API 或 GeoJSON。 +func lunarEclipseUmbralVisibilityInstants(info eclipsecore.LunarEclipseInfo) []time.Time { + const ( + step = 15 * time.Minute + limit = 12 + ) + duration := info.PartialEnd.Sub(info.PartialStart) + if duration <= 0 { + return nil + } + count := int(duration/step) + 1 + if count < 2 { + count = 2 + } + if count > limit { + count = limit + } + instants := make([]time.Time, count) + for index := range instants { + instants[index] = info.PartialStart.Add(duration * time.Duration(index) / time.Duration(count-1)) + } + return instants +} + +func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) { _, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame) _, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) frame.WriteOcean(b) @@ -456,10 +637,61 @@ func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info ec frame.WriteLand(b, "lunar-map-clip") fmt.Fprintf(b, ``, eclipseMapSourceLunarVisibilityRegions) writeLunarEclipseUnavailableRegion(b, frame) - b.WriteString(``) - b.WriteString(``) + writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral", + ``) + writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral", + ``) b.WriteString(``) + // 两端可见、食甚却在地平下的高纬月落-月出带:两端可见推不出全程可见,归入带食月落。 + b.WriteString(``) + // 两端都在地平下、只有食甚前后在地平上的极区透镜:月落必然落在食甚之后,与带食月落同类着色。 + b.WriteString(``) + if penumbralBands { + writeLunarEclipsePenumbralRegions(b) + } b.WriteString(``) writeEclipseMapGeoLine(b, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries) writeEclipseMapGeoLine(b, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries) + if penumbralBands { + writeLunarEclipseUmbralHorizonLines(b, frame, info) + } } + +// writeLunarEclipseBaseRegion 画一条月出/月落底带;半影带开启时只保留"本影阶段真的能看见"的部分, +// 半影段交给半影带:两条带用同一区域的正反面裁切,既不叠色也不留缝。 +func writeLunarEclipseBaseRegion(b *strings.Builder, penumbralBands bool, clip, element string) { + if penumbralBands { + fmt.Fprintf(b, `%s`, clip, element) + return + } + b.WriteString(element) +} + +// writeLunarEclipsePenumbralRegions 只着色只看得见半影的两条带(月落侧与月出侧):主端在地平上, +// 本影阶段整段在地平下,再扣掉另一端的可见区(极区下中天会让两端同时可见)。 +func writeLunarEclipsePenumbralRegions(b *strings.Builder) { + b.WriteString(``) + b.WriteString(``) +} + +// writeLunarEclipseUmbralHorizonLines 补画本影接触点的地平边界,边界之间就是各阶段被月出月落切掉的区间。 +func writeLunarEclipseUmbralHorizonLines(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { + type horizonContact struct { + class string + at time.Time + } + contacts := []horizonContact{{"u1-horizon", info.PartialStart}} + if info.HasTotal { + contacts = append(contacts, horizonContact{"u2-horizon", info.TotalStart}, horizonContact{"u3-horizon", info.TotalEnd}) + } + contacts = append(contacts, horizonContact{"u4-horizon", info.PartialEnd}) + for _, contact := range contacts { + if contact.at.IsZero() { + continue + } + _, boundary := lunarEclipseVisibilityPath(contact.at, frame) + writeEclipseMapGeoLine(b, frame, boundary, contact.class, "#5f4b8b", 1.1, "3 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries) + } +} + +// lunarEclipseInfoUT1Labels 把民用时刻换成同一物理时刻的 UT1 时刻。零值 time.Time 表示该阶段 diff --git a/eclipse/svg/lunar_map_orthographic_test.go b/eclipse/svg/lunar_map_orthographic_test.go new file mode 100644 index 0000000..fe249f3 --- /dev/null +++ b/eclipse/svg/lunar_map_orthographic_test.go @@ -0,0 +1,149 @@ +package svg + +import ( + "fmt" + "math" + "regexp" + "strconv" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgmap" +) + +// 月食正射图的两条契约: +// 1. 视点取食甚时刻的月下点("食甚可见半球"的边界就是圆盘边缘),否则视点固定在 (0°,0°), +// 会把半个可见半球让给与本次月食无关的区域; +// 2. 去掉圆盘边缘 2 px 内的固有亚像素带后,四类分区的着色必须与三刻地平几何一致。 +func TestLunarEclipseMapSVGOrthographicCentering(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), + time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), + time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC), + } { + // 本用例核对投影几何与四类可见区的物理判据,半影带另有覆盖性用例。 + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Language: "zh", Location: time.UTC, + Projection: EclipseMapProjectionOrthographic, DisablePenumbralPhase: true, + }) + if !ok { + t.Fatalf("%s: missing orthographic map", date.Format("2006-01-02")) + } + centreX, centreY, radius := lunarOrthographicDisc(t, doc) + partition := lunarMapPartitionOf(t, doc) + // 食甚可见半球的边界是视界大圆:它的投影环应当以圆盘中心为圆心。 + ringCentroidX, ringCentroidY, ok := lunarOrthographicRingCentroid(partition, "lunar-visible-maximum-shape") + if !ok { + t.Fatalf("%s: no maximum-visible ring", date.Format("2006-01-02")) + } + if offset := math.Hypot(ringCentroidX-centreX, ringCentroidY-centreY); offset > 1.5 { + t.Errorf("%s: greatest-visible ring is %.3f px off the disc centre", date.Format("2006-01-02"), offset) + } + if err := lunarOrthographicInteriorMatchesPhysics(t, date, doc, partition, centreX, centreY, radius); err != nil { + t.Errorf("%s: %v", date.Format("2006-01-02"), err) + } + } +} + +// lunarOrthographicDisc 从图内读出圆盘圆心与半径。 +func lunarOrthographicDisc(t *testing.T, doc string) (float64, float64, float64) { + t.Helper() + match := regexp.MustCompile(` radius-2 { + continue + } + h1 := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) + hm := lunarPartitionAltitude(info.Maximum, longitude, latitude) + h4 := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) + if math.Abs(h1) < 0.05 || math.Abs(hm) < 0.05 || math.Abs(h4) < 0.05 { + continue + } + expected := lunarOrthographicExpectedClass(h1, hm, h4) + category, _ := partition.painted(x, y) + counts[expected]++ + if category != expected { + mismatches++ + if first == "" { + first = fmt.Sprintf("lon=%s lat=%s h=(%s,%s,%s) expected=%s got=%s", + lunarPartitionFormat(longitude), lunarPartitionFormat(latitude), + lunarPartitionFormat(h1), lunarPartitionFormat(hm), lunarPartitionFormat(h4), + expected, category) + } + } + } + } + for _, class := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset, lunarPartitionCategoryMoonrise} { + if counts[class] == 0 { + t.Errorf("orthographic disc has no %s cell away from the rim", class) + } + } + if mismatches != 0 { + return fmt.Errorf("%d interior cells carry the wrong class, first %s", mismatches, first) + } + return nil +} + +func lunarOrthographicExpectedClass(h1, hm, h4 float64) string { + switch { + case h1 > 0 && hm > 0 && h4 > 0: + return lunarPartitionCategoryEntire + case h1 > 0 && h4 > 0: + return lunarPartitionCategoryMoonset + case h1 > 0: + return lunarPartitionCategoryMoonset + case h4 > 0: + return lunarPartitionCategoryMoonrise + case hm > 0: + return lunarPartitionCategoryMoonset + } + return lunarPartitionCategoryUnavailable +} diff --git a/eclipse/svg/lunar_map_partition_test.go b/eclipse/svg/lunar_map_partition_test.go new file mode 100644 index 0000000..5c0f619 --- /dev/null +++ b/eclipse/svg/lunar_map_partition_test.go @@ -0,0 +1,457 @@ +package svg + +import ( + "encoding/xml" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" +) + +// 月食全球可见图的分区契约:四类区域对全球网格穷尽且互斥,"不可见"只覆盖 P1、食甚、P4 三刻都在 +// 地平下的地点,"全程可见"恰好等于 P1、食甚、P4 三刻都在地平上的地点。 +// Partition contract: the four regions tile the globe exhaustively and exclusively. + +const ( + lunarPartitionCategoryEntire = "entire" + lunarPartitionCategoryMoonset = "moonset" + lunarPartitionCategoryMoonrise = "moonrise" + lunarPartitionCategoryUnavailable = "unavailable" + lunarPartitionCategoryPenumbra = "penumbra-only" +) + +type lunarPartitionLayer struct { + category string + shape string + masks []string + clips []string +} + +type lunarPartition struct { + frame svgFrameBox + shapes map[string][][][2]float64 + masks map[string][]string + clips map[string][]string + layers []lunarPartitionLayer +} + +func lunarMapPartitionOf(t *testing.T, doc string) lunarPartition { + t.Helper() + partition := lunarPartition{ + frame: lunarPenumbralTestFrame(t, doc), + shapes: map[string][][][2]float64{}, + masks: map[string][]string{}, + clips: map[string][]string{}, + } + type frame struct { + masks []string + clips []string + } + var stack []frame + inRegions := false + var container, containerID string + decoder := xml.NewDecoder(strings.NewReader(doc)) + for { + token, err := decoder.Token() + if err != nil { + break + } + switch typed := token.(type) { + case xml.StartElement: + class := lunarPartitionAttr(typed, "class") + reference := strings.TrimPrefix(lunarPartitionAttr(typed, "href"), "#") + mask := lunarPartitionURLID(lunarPartitionAttr(typed, "mask")) + clip := lunarPartitionURLID(lunarPartitionAttr(typed, "clip-path")) + switch typed.Name.Local { + case "path": + if id := lunarPartitionAttr(typed, "id"); id != "" { + partition.shapes[id] = lunarPenumbralTestRings(t, lunarPartitionAttr(typed, "d")) + } + case "mask", "clipPath": + container, containerID = typed.Name.Local, lunarPartitionAttr(typed, "id") + case "use": + switch container { + case "mask": + if reference != "" { + partition.masks[containerID] = append(partition.masks[containerID], reference) + } + case "clipPath": + if reference != "" { + partition.clips[containerID] = append(partition.clips[containerID], reference) + } + } + case "rect", "circle": + if container == "clipPath" && containerID != "" && partition.clips[containerID] == nil { + partition.clips[containerID] = []string{} + } + case "g": + if strings.Contains(class, "lunar-visibility-regions") { + inRegions = true + } + inherited := frame{} + if len(stack) > 0 { + inherited = stack[len(stack)-1] + } + if mask != "" { + inherited.masks = append(append([]string(nil), inherited.masks...), mask) + } + if clip != "" { + inherited.clips = append(append([]string(nil), inherited.clips...), clip) + } + stack = append(stack, inherited) + } + if inRegions && (typed.Name.Local == "use" || typed.Name.Local == "rect" || typed.Name.Local == "circle") { + current := frame{} + if len(stack) > 0 { + current = stack[len(stack)-1] + } + if mask != "" { + current.masks = append(append([]string(nil), current.masks...), mask) + } + if clip != "" { + current.clips = append(append([]string(nil), current.clips...), clip) + } + partition.layers = append(partition.layers, lunarPartitionLayer{ + category: lunarPartitionCategory(class, typed.Name.Local), + shape: reference, + masks: current.masks, + clips: current.clips, + }) + } + case xml.EndElement: + if typed.Name.Local == "mask" || typed.Name.Local == "clipPath" { + container, containerID = "", "" + } + if typed.Name.Local == "g" { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + if len(stack) == 0 { + inRegions = false + } + } + } + } + if len(partition.layers) == 0 { + t.Fatal("visibility region layers not found") + } + return partition +} + +func lunarPartitionAttr(element xml.StartElement, name string) string { + for _, attribute := range element.Attr { + if attribute.Name.Local == name { + return attribute.Value + } + } + return "" +} + +func lunarPartitionURLID(value string) string { + value = strings.TrimSuffix(strings.TrimPrefix(value, "url(#"), ")") + return value +} + +func lunarPartitionCategory(class, element string) string { + switch { + case strings.Contains(class, "entire-eclipse-region"): + return lunarPartitionCategoryEntire + case strings.Contains(class, "penumbra-only-region"): + return lunarPartitionCategoryPenumbra + case strings.Contains(class, "moonset-region"): + return lunarPartitionCategoryMoonset + case strings.Contains(class, "moonrise-region"): + return lunarPartitionCategoryMoonrise + case strings.Contains(class, "eclipse-unavailable-region"): + return lunarPartitionCategoryUnavailable + } + return element + ":" + class +} + +// covers 逐点复算一层是否落在该点:形状、外层 clip-path(多形状取并)与嵌套 mask 都要通过, +// mask 里的黑色形状按挖空处理。 +func (partition lunarPartition) covers(layer lunarPartitionLayer, x, y float64) bool { + if layer.shape != "" && !partition.inside(layer.shape, x, y) { + return false + } + // clipPath 里的多个形状取并集:落在其中任意一个之内就算通过。 + for _, clip := range layer.clips { + shapes := partition.clips[clip] + if len(shapes) == 0 { + continue + } + inside := false + for _, shape := range shapes { + if partition.inside(shape, x, y) { + inside = true + break + } + } + if !inside { + return false + } + } + for _, maskID := range layer.masks { + for _, excluded := range partition.masks[maskID] { + if partition.inside(excluded, x, y) { + return false + } + } + } + return true +} + +func (partition lunarPartition) inside(shapeID string, x, y float64) bool { + return lunarPenumbralTestInside(partition.shapes[shapeID], x, y) +} + +// painted 返回覆盖该点的最上层区域(SVG 后画的盖住先画的)与覆盖层数。 +func (partition lunarPartition) painted(x, y float64) (string, int) { + category, count := "", 0 + for _, layer := range partition.layers { + if partition.covers(layer, x, y) { + category, count = layer.category, count+1 + } + } + return category, count +} + +// partitionGrid 遍历 1°×1° 全球网格(64800 点,格心取 ±0.5°)。 +func (partition lunarPartition) grid(visit func(longitude, latitude float64, x, y float64, category string, covered int)) { + for longitude := -179.5; longitude < 180; longitude += 1 { + for latitude := -89.5; latitude < 90; latitude += 1 { + x, y, projectable := partition.frame.Project(longitude, latitude) + if !projectable { + continue + } + category, count := partition.painted(x, y) + visit(longitude, latitude, x, y, category, count) + } + } +} + +func lunarPartitionAltitude(at time.Time, longitude, latitude float64) float64 { + return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0) +} + +func TestLunarEclipseMapSVGVisibilityRegionsTileTheGlobe(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), + time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), + time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), + time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC), + time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC), + } { + // 只检查四类底图的分区:半影带另有覆盖性用例。 + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Language: "zh", Location: time.UTC, DisablePenumbralPhase: true}) + if !ok { + t.Fatalf("%s: missing map", date.Format("2006-01-02")) + } + partition := lunarMapPartitionOf(t, doc) + counts := map[string]int{} + uncovered, covered, multiple := 0, 64800, 0 + var firstUncovered string + partition.grid(func(longitude, latitude, x, y float64, category string, layers int) { + counts[category]++ + if category == "" { + uncovered++ + if firstUncovered == "" { + firstUncovered = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")" + } + return + } + if layers > 1 { + multiple++ + } + }) + t.Logf("%s %s: %v", date.Format("2006-01-02"), countsString(counts), "uncovered="+lunarPartitionFormat(float64(uncovered))) + if uncovered != 0 { + t.Errorf("%s: %d of %d grid points carry no region colour, first at %s", + date.Format("2006-01-02"), uncovered, covered, firstUncovered) + } + if multiple != 0 { + t.Errorf("%s: %d grid points are painted by more than one region layer", date.Format("2006-01-02"), multiple) + } + for _, category := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset, + lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable} { + if counts[category] == 0 { + t.Errorf("%s: region %s covers no grid point", date.Format("2006-01-02"), category) + } + } + } +} + +// 打开半影阶段后,"仅见半影"带与带食月出/带食月落必须互斥:两种半透明底色叠在一起会混出图例里 +// 没有的颜色,所以底带要被同源的 U1/U4 形状裁掉;同时裁切不能切出没有着色的缝。 +func TestLunarEclipseMapSVGPenumbralPhaseKeepsCoverage(t *testing.T) { + date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC) + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Language: "zh", Location: time.UTC}) + if !ok { + t.Fatal("missing penumbral-phase map") + } + partition := lunarMapPartitionOf(t, doc) + uncovered, penumbraOnly, doubleTinted := 0, 0, 0 + partition.grid(func(longitude, latitude, x, y float64, category string, layers int) { + if category == "" { + uncovered++ + return + } + if category != lunarPartitionCategoryPenumbra { + return + } + penumbraOnly++ + for _, layer := range partition.layers { + if layer.category == lunarPartitionCategoryPenumbra || !partition.covers(layer, x, y) { + continue + } + if layer.category == lunarPartitionCategoryMoonset || layer.category == lunarPartitionCategoryMoonrise { + doubleTinted++ + return + } + } + }) + if uncovered != 0 { + t.Errorf("penumbral-phase map leaves %d grid points without a region colour", uncovered) + } + if penumbraOnly == 0 || doubleTinted != 0 { + t.Errorf("penumbra-only bands cover %d points, %d of them still tinted by a moonrise/moonset band", penumbraOnly, doubleTinted) + } +} + +// 与 NASA/EclipseWise 世界图同口径:月球在食甚时刻已在地平上的地点,无论月出月落落在食内哪一段, +// 都属于可见区,绝不能画成"不可见";反之不可见区必须三刻都在地平下。 +func TestLunarEclipseMapSVGNotVisibleFollowsHorizon(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), + time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), + time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), + time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC), + } { + info, ok := eclipsecore.LunarEclipseOnDate(date) + if !ok { + t.Fatalf("%s: missing eclipse", date.Format("2006-01-02")) + } + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Language: "zh", Location: time.UTC}) + if !ok { + t.Fatalf("%s: missing map", date.Format("2006-01-02")) + } + partition := lunarMapPartitionOf(t, doc) + // 0.02° 容差内的地点落在边界上,任何一类都不算错。 + wronglyInvisible, upAtMaximumInvisible, wronglyVisible := 0, 0, 0 + var sample string + partition.grid(func(longitude, latitude, x, y float64, category string, layers int) { + start := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) + maximum := lunarPartitionAltitude(info.Maximum, longitude, latitude) + end := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) + if math.Abs(start) < 0.02 || math.Abs(maximum) < 0.02 || math.Abs(end) < 0.02 { + return + } + invisible := category == lunarPartitionCategoryUnavailable + wantInvisible := start < 0 && maximum < 0 && end < 0 + if invisible && maximum > 0 { + upAtMaximumInvisible++ + if sample == "" { + sample = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")" + } + } + if invisible != wantInvisible { + wronglyInvisible++ + } + if !invisible && start > 0 && maximum > 0 && end > 0 && category != lunarPartitionCategoryEntire { + wronglyVisible++ + } + }) + t.Logf("%s: 食甚在地平上却画成不可见 %d 点%s;与三刻地平判据不符 %d 点;全程在地平上却不在全程可见区 %d 点", + date.Format("2006-01-02"), upAtMaximumInvisible, sample, wronglyInvisible, wronglyVisible) + if upAtMaximumInvisible != 0 { + t.Errorf("%s: %d grid points whose Moon is above the horizon at greatest eclipse %s are painted as not visible", + date.Format("2006-01-02"), upAtMaximumInvisible, sample) + } + if wronglyInvisible != 0 || wronglyVisible != 0 { + t.Errorf("%s: visibility regions disagree with the horizon at P1/greatest/P4 for %d + %d grid points", + date.Format("2006-01-02"), wronglyInvisible, wronglyVisible) + } + } +} + +// "全程可见"必须同时满足 P1、食甚、P4 三刻月亮都在地平上:两端可见推不出中途可见, +// 高纬下中天会让月亮在食甚前后落到地平下,这类点归入带食月落。 +// Visible throughout requires the Moon above the horizon at P1, greatest and P4 alike: +// visible at both contacts does not imply visible in between. +func TestLunarEclipseMapSVGEntireRegionRequiresGreatestVisibility(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), + time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), + time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), + time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC), + } { + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Language: "zh", Location: time.UTC}) + if !ok { + t.Fatalf("%s: missing map", date.Format("2006-01-02")) + } + info, ok := eclipsecore.LunarEclipseOnDate(date) + if !ok { + t.Fatalf("%s: missing eclipse", date.Format("2006-01-02")) + } + partition := lunarMapPartitionOf(t, doc) + holes, holeArea, missing, mislabelled, partialBand := 0, 0.0, 0, 0, 0 + partition.grid(func(longitude, latitude, x, y float64, category string, layers int) { + inP1 := partition.inside("lunar-visible-p1-shape", x, y) + inP4 := partition.inside("lunar-visible-p4-shape", x, y) + inMaximum := partition.inside("lunar-visible-maximum-shape", x, y) + cell := math.Cos(latitude * math.Pi / 180) + switch { + case inP1 && inP4 && inMaximum && category != lunarPartitionCategoryEntire: + holes++ + holeArea += cell + case !(inP1 && inP4 && inMaximum) && category == lunarPartitionCategoryEntire: + missing++ + case inP1 && inP4 && !inMaximum && category == lunarPartitionCategoryMoonset: + partialBand++ + } + // 几何复核:三刻中任一刻月亮在地平下(留 0.05° 余量)就不得涂成全程可见。 + if category == lunarPartitionCategoryEntire { + if lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) < -0.05 || + lunarPartitionAltitude(info.Maximum, longitude, latitude) < -0.05 || + lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) < -0.05 { + mislabelled++ + } + } + }) + t.Logf("%s: p1∩p4∩max 内未着全程可见色 %d 点(%.1f 平方度),全程可见区越界 %d 点,中间不可见带 %d 点,误标 %d 点", + date.Format("2006-01-02"), holes, holeArea, missing, partialBand, mislabelled) + if holes != 0 || missing != 0 || mislabelled != 0 { + t.Errorf("%s: entire-eclipse region is not exactly p1∩p4∩max (holes=%d, extra=%d, mislabelled=%d)", + date.Format("2006-01-02"), holes, missing, mislabelled) + } + } +} + +func lunarPartitionFormat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func countsString(counts map[string]int) string { + keys := []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset, + lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable, "", lunarPartitionCategoryPenumbra} + parts := make([]string, 0, len(keys)) + for _, key := range keys { + if counts[key] == 0 { + continue + } + name := key + if name == "" { + name = "uncovered" + } + parts = append(parts, name+"="+lunarPartitionFormat(float64(counts[key]))) + } + return strings.Join(parts, " ") +} diff --git a/eclipse/svg/lunar_map_polar_test.go b/eclipse/svg/lunar_map_polar_test.go index 460a865..fec209c 100644 --- a/eclipse/svg/lunar_map_polar_test.go +++ b/eclipse/svg/lunar_map_polar_test.go @@ -35,7 +35,7 @@ func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t * if len(rings) == 0 { t.Fatal("visibility path has no rings") } - jd := basic.Date2JDE(info.PenumbralStart) + jd := basic.Date2JD(info.PenumbralStart) // 只取足够深入地平线以下的见证点:−89.9° 处月心高度约 −0.054°,而更靠近极点的 // −89.99° 只有约 −0.004°,落在可见性判定本身的数值容差里,不能作为回归依据。 // Only a witness clearly below the horizon is used: at -89.9 degrees the Moon centre sits diff --git a/eclipse/svg/lunar_map_test.go b/eclipse/svg/lunar_map_test.go index 622954d..da3f8d3 100644 --- a/eclipse/svg/lunar_map_test.go +++ b/eclipse/svg/lunar_map_test.go @@ -47,11 +47,13 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) { for _, want := range []string{ `mask id="lunar-not-visible-mask"`, + `mask id="lunar-not-p1-p4-mask"`, `mask id="lunar-not-p4-mask"`, `mask id="lunar-not-p1-mask"`, `class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`, `class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`, `class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`, + `class="eclipse-at-maximum moonset-region" href="#lunar-visible-maximum-shape"`, } { if !strings.Contains(diagram, want) { t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want) @@ -60,8 +62,18 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) { if strings.Contains(diagram, `class="entire-eclipse-region"`) && strings.Contains(diagram, `fill-opacity="0.70"`) { t.Fatal("entire-eclipse region still uses the opaque stacked-overlay style") } - if !strings.Contains(diagram, `clipPath id="lunar-visible-maximum"`) { - t.Fatal("entire-eclipse region is not constrained by greatest-eclipse visibility") + // 全程可见区必须同时受 P1、P4 与食甚可见区裁剪:两端可见推不出中途可见, + // 中间落到地平下的高纬带另画带食月落,不能再被算成全程可见。 + if !strings.Contains(diagram, ``) || + !strings.Contains(diagram, ``) { + t.Fatal("not-visible region is not bounded by greatest-eclipse visibility") } } @@ -99,7 +111,7 @@ func TestLunarEclipseVisibilityPathMatchesTopocentricHorizon(t *testing.T) { if len(rings) == 0 || len(rings) > 3 { t.Fatalf("projection=%s subpaths=%d", projection, len(rings)) } - jd := basic.Date2JDE(at) + jd := basic.Date2JD(at) for _, point := range boundary { if altitude := basic.HMoonHeight(jd, point.Longitude, point.Latitude, 0); math.Abs(altitude) > 1e-9 { t.Fatalf("horizon altitude=%g", altitude) diff --git a/eclipse/svg/lunar_model.go b/eclipse/svg/lunar_model.go index 8d6ca1f..0db3433 100644 --- a/eclipse/svg/lunar_model.go +++ b/eclipse/svg/lunar_model.go @@ -4,6 +4,7 @@ import ( "math" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" ) @@ -21,8 +22,11 @@ const ( LunarEclipseTotal = eclipsecore.LunarEclipseTotal ) -func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo { - return LunarEclipseInfo{ +func lunarEclipseInfoFromBasic( + result basic.LunarEclipseResult, + location *time.Location, +) LunarEclipseInfo { + info := LunarEclipseInfo{ Type: mapBasicLunarEclipseType(result.Type), PenumbralMagnitude: result.PenumbralMagnitude, UmbralMagnitude: result.Magnitude, @@ -38,6 +42,16 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L HasPartial: result.HasPartial, HasTotal: result.HasTotal, } + return info +} + +// lunarEclipseDisplayInfo 给出写图用的时刻副本;几何必须用未换算的 UTC 时刻, +// 否则把 UT1 读数当民用时刻会平移地平线、月下点与地心坐标块。 +func lunarEclipseDisplayInfo(info LunarEclipseInfo, scale astro.TimeScale) LunarEclipseInfo { + if scale == astro.TimeScaleUT1 { + return eclipsecore.LunarEclipseInfoInUT1(info) + } + return info } func lunarEclipseContactPointsFromBasic( @@ -128,13 +142,13 @@ func ttJDEToTime(ttJDE float64, location *time.Location) time.Time { if ttJDE == 0 { return time.Time{} } - utcJDE := basic.TD2UT(ttJDE, false) - return basic.JDE2DateByZone(utcJDE, location, false) + utcJD := basic.TT2UTC(ttJDE) + return basic.JD2DateByZone(utcJD, location, false) } func timeToTTJDE(date time.Time) float64 { - utcJDE := basic.Date2JDE(date.UTC()) - return basic.TD2UT(utcJDE, true) + utcJD := basic.Date2JD(date.UTC()) + return basic.UTC2TT(utcJD) } func normalizeDegree360(angle float64) float64 { diff --git a/eclipse/svg/lunar_penumbral_phase_test.go b/eclipse/svg/lunar_penumbral_phase_test.go new file mode 100644 index 0000000..f9d8a53 --- /dev/null +++ b/eclipse/svg/lunar_penumbral_phase_test.go @@ -0,0 +1,419 @@ +package svg + +import ( + "crypto/md5" + "encoding/hex" + "math" + "regexp" + "sort" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" +) + +// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、 +// 单独着色只看得见半影的月出月落带,且每条边界仍落在它自己的接触时刻上。 +// Contract of the penumbral-phase switch: DisablePenumbralPhase reproduces the penumbral-agnostic baseline +// byte for byte, while the enabled value adds the U1-U4 horizons and the penumbra-only bands, with +// every boundary still sitting on its own contact instant. + +var lunarPenumbralPhaseBaseline = []struct { + name string + digest string + length int +}{ + {"2026-03-03 全食 960x640 zh CST", "4e83ea42b18c703bdde6715f572169d5", 243483}, + {"2026-03-03 全食 960x640 en CST", "21696a72ec3bc77a21752e2d675a230b", 243544}, + {"2026-03-03 全食 1200x800 zh UTC", "e5a72a8f0b73449f85b4e433f6054069", 247413}, + {"2026-03-03 全食 1200x800 en UTC", "1a0386abae194bdbee0bd5c0cb7a5a15", 247482}, + {"2025-03-14 全食 960x640 zh CST", "84c39bb9f893678c1cdf354ba8acf850", 250237}, + {"2025-03-14 全食 1200x800 zh UTC", "dad36f434ba2bfd7369a430b6f00e567", 255213}, + {"2026-03-03 北极投影 zh", "8ee2f75f0f7c617b136576635d908cb4", 159000}, + {"2026-03-03 南极投影 zh", "931e502692cdc5b0d09e8f91629f1b2f", 84934}, + {"2016-08-18 半影 1200x800 zh UTC", "6d9c60787cc1b703abdd13c4b75a2a92", 241387}, + {"2029-01-01 全食 960x640 en CST", "95c0a9d618bda4c0510fa827e104ceaa", 231567}, + {"2026-03-03 详细版 zh", "31795334a05621f3a5a0beff1b63b5bd", 270844}, + {"2026-03-03 详细版 en", "c5ee0c76f875c3c5fe4a7ff29a90a653", 270992}, + {"2029-01-01 详细版 zh", "b0521aa165454262df4344168c9100e9", 257156}, + {"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488}, +} + +func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) { + render := lunarPenumbralPhaseBaselineRenderers() + if len(render) != len(lunarPenumbralPhaseBaseline) { + t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render)) + } + for _, want := range lunarPenumbralPhaseBaseline { + draw, ok := render[want.name] + if !ok { + t.Fatalf("baseline case %q has no renderer", want.name) + } + doc, rendered := draw() + if !rendered { + t.Fatalf("%s: chart missing", want.name) + } + sum := md5.Sum([]byte(doc)) + if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length { + t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d", + want.name, got, len(doc), want.digest, want.length) + } + } +} + +func lunarPenumbralPhaseBaselineRenderers() map[string]func() (string, bool) { + cst := time.FixedZone("CST", 8*3600) + utc := time.UTC + return map[string]func() (string, bool){ + "2026-03-03 全食 960x640 zh CST": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true}) + }, + "2026-03-03 全食 960x640 en CST": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true}) + }, + "2026-03-03 全食 1200x800 zh UTC": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true}) + }, + "2026-03-03 全食 1200x800 en UTC": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: utc, DisablePenumbralPhase: true}) + }, + "2025-03-14 全食 960x640 zh CST": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, cst), + LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true}) + }, + "2025-03-14 全食 1200x800 zh UTC": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true}) + }, + "2026-03-03 北极投影 zh": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionNorthPolar, DisablePenumbralPhase: true}) + }, + "2026-03-03 南极投影 zh": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionSouthPolar, DisablePenumbralPhase: true}) + }, + "2016-08-18 半影 1200x800 zh UTC": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, utc), + LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true}) + }, + "2029-01-01 全食 960x640 en CST": func() (string, bool) { + return LunarEclipseMapSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true}) + }, + "2026-03-03 详细版 zh": func() (string, bool) { + return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true}) + }, + "2026-03-03 详细版 en": func() (string, bool) { + return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), + LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true}) + }, + "2029-01-01 详细版 zh": func() (string, bool) { + return LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true}) + }, + "2016-08-18 详细版 en": func() (string, bool) { + return LunarEclipseDetailedSVG(time.Date(2016, 8, 18, 12, 0, 0, 0, utc), + LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true}) + }, + } +} + +func TestLunarEclipseMapSVGPenumbralPhaseAddsUmbralContacts(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + date := time.Date(2026, 3, 3, 0, 0, 0, 0, cst) + options := LunarEclipseMapSVGOptions{Language: "zh", Location: cst} + on, ok := LunarEclipseMapSVG(date, options) + if !ok { + t.Fatal("missing penumbral-phase lunar-eclipse map") + } + options.DisablePenumbralPhase = true + off, ok := LunarEclipseMapSVG(date, options) + if !ok { + t.Fatal("missing lunar-eclipse map") + } + for _, want := range []string{ + `class="u1-horizon"`, `class="u2-horizon"`, `class="u3-horizon"`, `class="u4-horizon"`, + `class="penumbra-only-region penumbra-moonset-region"`, `class="penumbra-only-region penumbra-moonrise-region"`, + `mask id="lunar-not-umbral-visible-mask"`, `clip-path="url(#lunar-visible-umbral)"`, + `id="lunar-visible-umbral-1"`, + `id="lunar-visible-u1-shape"`, `id="lunar-visible-u4-shape"`, + "半影月出", "半影月落", "本影阶段 U1", + } { + if !strings.Contains(on, want) { + t.Fatalf("penumbral-phase map missing %q", want) + } + if strings.Contains(off, want) { + t.Fatalf("default map must not contain %q", want) + } + } + if err := validateEclipseMapXML(on); err != nil { + t.Fatalf("penumbral-phase map is not valid XML: %v", err) + } + for _, id := range []string{"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape"} { + shape := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`) + a, b := shape.FindStringSubmatch(off), shape.FindStringSubmatch(on) + if a == nil || b == nil || a[1] != b[1] { + t.Fatalf("shape %s changed when the penumbral phase is enabled", id) + } + } + english, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Language: "en", Location: cst}) + if !ok || !strings.Contains(english, "Penumbra moonrise") || !strings.Contains(english, "Penumbra moonset") || + !strings.Contains(english, "Umbral phases U1") { + t.Fatal("English penumbral-phase map misses its legend entry or contact row") + } + // 纯半影月食没有本影接触,按 NASA 口径不该凭空生成本影边界。 + onlyPenumbral, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC), + LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC}) + if !ok { + t.Fatal("missing penumbral-only lunar-eclipse map") + } + for _, unwanted := range []string{"u1-horizon", "penumbra-only-region", "半影月出", "半影月落"} { + if strings.Contains(onlyPenumbral, unwanted) { + t.Fatalf("penumbral-only eclipse must not gain %q", unwanted) + } + } + onlyPenumbralDefault, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC), + LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC, DisablePenumbralPhase: true}) + if !ok || onlyPenumbralDefault != onlyPenumbral { + t.Fatal("penumbral-only eclipse must render identically with and without the switch") + } +} + +// 同判:U1/U4 形状等价于「该接触时刻月球中心在地平线上」,半影带等价于对应的三段高度判据, +// 每条地平边界线都落在自己的接触时刻上(P1 线不能落在 U1 时刻)。 +func TestLunarEclipseMapSVGPenumbraBandsMatchHorizonCriterion(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), + time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), + } { + info, ok := eclipsecore.LunarEclipseOnDate(date) + if !ok || !info.HasPartial { + t.Fatalf("%s: missing umbral lunar eclipse", date.Format("2006-01-02")) + } + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Language: "zh", Location: time.UTC}) + if !ok { + t.Fatalf("%s: missing penumbral-phase map", date.Format("2006-01-02")) + } + frame := lunarPenumbralTestFrame(t, doc) + p1 := lunarPenumbralTestShape(t, doc, "lunar-visible-p1-shape") + p4 := lunarPenumbralTestShape(t, doc, "lunar-visible-p4-shape") + u1 := lunarPenumbralTestShape(t, doc, "lunar-visible-u1-shape") + u4 := lunarPenumbralTestShape(t, doc, "lunar-visible-u4-shape") + + for _, line := range []struct { + class string + at time.Time + other time.Time + }{ + {"p1-horizon", info.PenumbralStart, info.PartialStart}, + {"p4-horizon", info.PenumbralEnd, info.PartialEnd}, + {"u1-horizon", info.PartialStart, info.PenumbralStart}, + {"u4-horizon", info.PartialEnd, info.PenumbralEnd}, + } { + maxOnContact := 0.0 + var otherAltitudes []float64 + vertices := lunarPenumbralTestLine(t, doc, line.class) + if len(vertices) < 30 { + t.Fatalf("%s: %s has %d vertices", date.Format("2006-01-02"), line.class, len(vertices)) + } + for _, vertex := range vertices { + longitude, latitude := lunarPenumbralTestUnproject(frame, vertex[0], vertex[1]) + maxOnContact = math.Max(maxOnContact, math.Abs(lunarPenumbralTestAltitude(line.at, longitude, latitude))) + otherAltitudes = append(otherAltitudes, math.Abs(lunarPenumbralTestAltitude(line.other, longitude, latitude))) + } + if maxOnContact > 0.01 { + t.Fatalf("%s: %s deviates %.4f deg from its own contact", date.Format("2006-01-02"), line.class, maxOnContact) + } + // 两条地平线必然相交于两点,交点是整条线唯一贴近另一组接触的地方;中位数说明其余部分分得很开。 + sort.Float64s(otherAltitudes) + median := otherAltitudes[len(otherAltitudes)/2] + if median < 2 { + t.Fatalf("%s: %s sits only %.3f deg (median) from the other contact", date.Format("2006-01-02"), line.class, median) + } + t.Logf("%s %s: 与自身接触最大偏差 %.5f 度,与另一组接触 |alt| 中位数 %.3f 度(最小 %.3f)", + date.Format("2006-01-02"), line.class, maxOnContact, median, otherAltitudes[0]) + } + + judged, mismatches := 0, 0 + for longitude := -179.0; longitude < 180; longitude += 2 { + for latitude := -89.0; latitude < 90; latitude += 2 { + x, y, projectable := frame.Project(longitude, latitude) + if !projectable { + continue + } + altitudes := [4]float64{ + lunarPenumbralTestAltitude(info.PenumbralStart, longitude, latitude), + lunarPenumbralTestAltitude(info.PartialStart, longitude, latitude), + lunarPenumbralTestAltitude(info.PartialEnd, longitude, latitude), + lunarPenumbralTestAltitude(info.PenumbralEnd, longitude, latitude), + } + nearHorizon := false + for _, altitude := range altitudes { + if math.Abs(altitude) < 0.05 { + nearHorizon = true + } + } + if nearHorizon { + continue + } + judged++ + if lunarPenumbralTestInside(u1, x, y) != (altitudes[1] > 0) { + t.Errorf("%s: U1 shape disagrees with the U1 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude) + } + if lunarPenumbralTestInside(u4, x, y) != (altitudes[2] > 0) { + t.Errorf("%s: U4 shape disagrees with the U4 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude) + } + // SVG 组合规则:月落带 = P1 可见、P4 与 U1 不可见;月出带 = P4 可见、P1 与 U4 不可见。 + svgMoonset := lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(u1, x, y) + svgMoonrise := lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(u4, x, y) + wantMoonset := altitudes[0] > 0 && altitudes[1] <= 0 && altitudes[3] <= 0 + wantMoonrise := altitudes[3] > 0 && altitudes[2] <= 0 && altitudes[0] <= 0 + if svgMoonset != wantMoonset { + mismatches++ + } + if svgMoonrise != wantMoonrise { + mismatches++ + } + } + } + if judged < 5000 { + t.Fatalf("%s: too few judged samples (%d)", date.Format("2006-01-02"), judged) + } + if mismatches != 0 { + t.Fatalf("%s: penumbra band boundaries disagree with the horizon criterion at %d of %d judged sites", + date.Format("2006-01-02"), mismatches, judged) + } + } +} + +func lunarPenumbralTestAltitude(at time.Time, longitude, latitude float64) float64 { + return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0) +} + +func lunarPenumbralTestFrame(t *testing.T, doc string) svgFrameBox { + t.Helper() + match := regexp.MustCompile(`= 3 { + rings = append(rings, ring) + } + } + return rings +} + +func lunarPenumbralTestInside(rings [][][2]float64, x, y float64) bool { + for _, ring := range rings { + if pointInLunarVisibilityPolygon(x, y, ring) { + return true + } + } + return false +} + +// 仅见半影的两条带必须排除"本影区间里月亮曾在地平上"的站点:只挖 U1/U4 两刻会漏掉两刻之间的窗口, +// 按经度插值又会把近乎子午线方向的边界切进真实区域(1932-03-22 西经 75.5° 一带 0.5° 经度内上边界 +// 从 +19.9° 掉到 −27.2°)。掩膜按 15 分钟档位取样整球可见半球,残余的掠射窗口深度约 0.05° +// (约 0.15 像素);深度更浅、只持续几分钟的窗口只由站点 API 与 GeoJSON 精确判定。 +func TestLunarEclipseMapSVGPenumbralBandsExcludeUmbralWindow(t *testing.T) { + date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC) + doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Language: "zh", Location: time.UTC}) + if !ok { + t.Fatal("missing lunar-eclipse map") + } + partition := lunarMapPartitionOf(t, doc) + for _, witness := range []struct { + name string + lon, lat float64 + want string + }{ + {"U1 已在地平上(+0.0041 度)", -91.75, -88.75, lunarPartitionCategoryMoonrise}, + {"U1 高度 +0.21 度(陡边界,1° 列会切进去)", -75.5, 6.5, lunarPartitionCategoryMoonset}, + {"本影极值 -2.25 度(宽余量)", -68.75, -70.15, lunarPartitionCategoryPenumbra}, + } { + x, y, projectable := partition.frame.Project(witness.lon, witness.lat) + if !projectable { + t.Fatalf("%s: 投影失败", witness.name) + } + category, _ := partition.painted(x, y) + if category != witness.want { + t.Fatalf("%s (%.4f,%.4f) category=%q want %q", witness.name, witness.lon, witness.lat, category, witness.want) + } + } +} diff --git a/eclipse/svg/lunar_timescale_test.go b/eclipse/svg/lunar_timescale_test.go new file mode 100644 index 0000000..4750719 --- /dev/null +++ b/eclipse/svg/lunar_timescale_test.go @@ -0,0 +1,191 @@ +package svg + +import ( + "regexp" + "strings" + "testing" + "time" + + "b612.me/astro" + eclipsecore "b612.me/astro/eclipse" +) + +// UT1 出图:换算不能碰零值(零值表示"该阶段不存在"),且非 UTC 时区应明确失败而不是画错图。 +func TestLunarEclipseUT1LabelsPreserveZeroPhases(t *testing.T) { + info := eclipsecore.LunarEclipseInfo{ + Maximum: time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC), + } + got := eclipsecore.LunarEclipseInfoInUT1(info) + if !got.TotalStart.IsZero() { + t.Fatalf("不存在的阶段应保持零值, got %v", got.TotalStart) + } + if delta := got.Maximum.Sub(info.Maximum).Seconds(); delta <= 0 { + t.Fatalf("UT1 时刻应领先民用时刻, got %g 秒", delta) + } +} + +// assertUT1Declaration 钉住 UT1 声明必须落在 元素内,并写出 DUT1 差值。 +func assertUT1Declaration(t *testing.T, name, svg string) { + t.Helper() + declaration := "DUT1 = UT1−UTC = " + idx := strings.Index(svg, declaration) + if idx < 0 { + t.Errorf("%s 应在图内声明 UT1 并写出 DUT1 差值", name) + return + } + rest := svg[idx:] + end := strings.Index(rest, "") + if end < 0 || strings.Contains(rest[:end], "<") { + t.Errorf("%s 的 UT1 声明必须落在 元素内", name) + } +} + +func TestLunarEclipseMapSVGTimeScaleOption(t *testing.T) { + date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC) + utcSVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{}) + if !ok { + t.Fatal("UTC 渲染应成功") + } + ut1SVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1}) + if !ok { + t.Fatal("UT1 渲染应成功") + } + assertUT1Declaration(t, "月食全球图", ut1SVG) + if strings.Contains(utcSVG, "世界时") { + t.Fatal("UTC 图不应带 UT1 图注") + } + cst := time.FixedZone("CST", 8*3600) + if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok { + t.Fatal("UT1 配非 UTC 时区应返回 false") + } + if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.UTC}); !ok { + t.Fatal("UT1 配 UTC 时区应成功") + } +} + +// 三个月食入口共用同一个展示副本:不存在的阶段(零值)不得被换算成真实时刻, +// 且展示副本只影响文字,几何仍走原 UTC 数据。 +func TestLunarEclipseUT1LabelHelperPreservesZero(t *testing.T) { + real := time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC) + info := LunarEclipseInfo{Maximum: real, PenumbralStart: real} + display := lunarEclipseDisplayInfo(info, astro.TimeScaleUT1) + if !display.TotalStart.IsZero() { + t.Fatalf("零值阶段应原样保留, got %v", display.TotalStart) + } + if !display.Maximum.After(real) { + t.Fatalf("UT1 时刻应领先民用时刻: %v vs %v", display.Maximum, real) + } + if utc := lunarEclipseDisplayInfo(info, astro.TimeScaleUTC); !utc.Maximum.Equal(real) { + t.Fatalf("UTC 口径不应换算: %v vs %v", utc.Maximum, real) + } +} + +func TestLunarEclipseChartTimeScaleGuard(t *testing.T) { + date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC) + cst := time.FixedZone("CST", 8*3600) + renderers := map[string]func(time.Time, *time.Location) (string, bool){ + "diagram": func(d time.Time, loc *time.Location) (string, bool) { + return LunarEclipseSVG(d, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc}) + }, + "detailed": func(d time.Time, loc *time.Location) (string, bool) { + return LunarEclipseDetailedSVG(d, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc}) + }, + "map": func(d time.Time, loc *time.Location) (string, bool) { + return LunarEclipseMapSVG(d, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc}) + }, + } + for name, render := range renderers { + if _, ok := render(date, cst); ok { + t.Errorf("%s: UT1 配非 UTC 时区应返回 false", name) + } + if _, ok := render(date, time.UTC); !ok { + t.Errorf("%s: UT1 配 UTC 时区应成功", name) + } + if _, ok := render(date, nil); !ok { + t.Errorf("%s: UT1 下 nil Location 应被强制为 UTC 并成功", name) + } + } +} + +// 日食两族:UT1 选项必须成功、在图注里声明尺度,并且拒绝非 UTC 时区。 +func TestSolarEclipseChartTimeScaleOption(t *testing.T) { + date := time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC) + cst := time.FixedZone("CST", 8*3600) + + mapSVG, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1}) + if !ok { + t.Fatal("日食地图 UT1 渲染应成功") + } + assertUT1Declaration(t, "日食地图", mapSVG) + if _, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok { + t.Error("日食地图 UT1 配非 UTC 时区应返回 false") + } + + localSVG, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1}) + if !ok { + t.Fatal("站心日食 UT1 渲染应成功") + } + assertUT1Declaration(t, "站心日食图", localSVG) + if _, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok { + t.Error("站心日食 UT1 配非 UTC 时区应返回 false") + } +} + +// 月食组合图与详细版的图注也要声明尺度。 +func TestLunarEclipseChartsDeclareUT1(t *testing.T) { + date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC) + for name, svg := range map[string]string{ + "diagram": func() string { + text, _ := LunarEclipseSVG(date, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1}) + return text + }(), + "detailed": func() string { + text, _ := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1}) + return text + }(), + } { + if svg == "" { + t.Fatalf("%s: UT1 渲染应成功", name) + } + assertUT1Declaration(t, name, svg) + } +} + +// UT1 输出只允许改文字:把 元素整体去掉后,UTC 与 UT1 的同名图必须逐字节相同。 +// 几何一旦用了 UT1 读数(把 UT1 当民用时刻),地平线、月下点与地心坐标块都会平移。 +func TestLunarEclipseUT1ChartsKeepGeometry(t *testing.T) { + date := time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC) + renderers := map[string]func(time.Time, astro.TimeScale) (string, bool){ + "map": func(at time.Time, scale astro.TimeScale) (string, bool) { + return LunarEclipseMapSVG(at, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Location: time.UTC, TimeScale: scale}) + }, + "detailed": func(at time.Time, scale astro.TimeScale) (string, bool) { + return LunarEclipseDetailedSVG(at, LunarEclipseDetailedSVGOptions{ + Width: 1000, Height: 1414, Location: time.UTC, TimeScale: scale}) + }, + "diagram": func(at time.Time, scale astro.TimeScale) (string, bool) { + return LunarEclipseSVG(at, LunarEclipseSVGOptions{ + Width: 920, Height: 720, Location: time.UTC, TimeScale: scale}) + }, + } + textElements := regexp.MustCompile(`(?s)]*>.*?`) + for name, render := range renderers { + utc, ok := render(date, astro.TimeScaleUTC) + if !ok { + t.Fatalf("%s: UTC render failed", name) + } + ut1, ok := render(date, astro.TimeScaleUT1) + if !ok { + t.Fatalf("%s: UT1 render failed", name) + } + if strings.Contains(ut1, "UT1") == false { + t.Errorf("%s: UT1 chart does not declare the scale", name) + } + utcGeometry := textElements.ReplaceAllString(utc, "") + ut1Geometry := textElements.ReplaceAllString(ut1, "") + if utcGeometry != ut1Geometry { + t.Errorf("%s: switching to UT1 changed chart geometry", name) + } + } +} diff --git a/eclipse/svg/solar.go b/eclipse/svg/solar.go index 8a55f67..4ac780a 100644 --- a/eclipse/svg/solar.go +++ b/eclipse/svg/solar.go @@ -1,12 +1,14 @@ package svg import ( + "b612.me/astro/internal/timenote" "fmt" "html" "math" "strings" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgchart" @@ -64,6 +66,11 @@ type LocalSolarEclipseSVGOptions struct { // Location 是图中显示时刻的时区;nil 时使用 UTC+8。 // Location is the display timezone for chart times; nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则返回 false),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer. + TimeScale astro.TimeScale } type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult @@ -106,6 +113,12 @@ func localSolarEclipseSVG( calculator localSolarEclipseSVGCalculator, finder localSolarEclipseSVGFinder, ) (string, bool) { + if options.TimeScale == astro.TimeScaleUT1 { + if options.Location != nil && options.Location != time.UTC { + return "", false + } + options.Location = time.UTC + } options = normalizeLocalSolarEclipseSVGOptions(options) diagram := calculator( solarEclipseTimeToTTJDE(date), @@ -117,7 +130,7 @@ func localSolarEclipseSVG( if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 { return "", false } - info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location) + info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location, options.TimeScale) if finder != nil { coreInfo := finder(info.GreatestEclipse, lon, lat, height) info.HasSaros = coreInfo.HasSaros @@ -153,6 +166,14 @@ func renderLocalSolarEclipseSVG( headerBottom := localSolarEclipseSVGHeaderBottom(headerTexts) width := float64(options.Width) height := float64(options.Height) + direction := localSolarEclipseSVGDirectionTextValue(options) + if options.DirectionText != "" { + direction = svgchart.EllipsizeText(direction, width-80, 12) + } + scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language) + footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom) + footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12, + svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding) margin := math.Max(30, math.Min(46, width*0.05)) panelWidth := math.Max(230, math.Min(280, width*0.28)) diagramLeft := margin @@ -160,7 +181,8 @@ func renderLocalSolarEclipseSVG( if diagramRight-diagramLeft < width*0.48 { diagramRight = width - margin } - footerHeight := 72.0 + // 页脚与图区之间留一点空,页脚高度按实际行数算出来,说明文字再多也不会顶到画布底边。 + footerHeight := footerOccupied + 10 stageHeight := math.Max(160, math.Min(210, height*0.27)) stageTop := height - stageHeight - footerHeight if stageTop < headerBottom+160 { @@ -250,22 +272,13 @@ func renderLocalSolarEclipseSVG( } writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight) - direction := localSolarEclipseSVGDirectionTextValue(options) - if options.DirectionText != "" { - direction = svgchart.EllipsizeText(direction, width-80, 12) - } - fmt.Fprintf(&b, `%s`, - 40.0, height-54, html.EscapeString(direction)) - // 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。 - lines := []string{localSolarEclipseSVGFooterNoteText(options)} - if options.FooterNote != "" { - maxWidth := width - 80 - lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12, - svgchart.BaselineLineLimit(12, 15, height-34, height-4)) - } - for index, line := range lines { - fmt.Fprintf(&b, `%s`, - 40.0, height-34+float64(index)*15, html.EscapeString(line)) + for index, line := range footerLines { + fill := "#555" + if index == 0 { + fill = "#333" + } + fmt.Fprintf(&b, `%s`, + 40.0, footerBaselines[index], fill, html.EscapeString(line)) } writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92)) b.WriteString(``) @@ -359,6 +372,10 @@ func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions) } func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string { + return localSolarEclipseSVGFooterNoteTextBase(options) +} + +func localSolarEclipseSVGFooterNoteTextBase(options LocalSolarEclipseSVGOptions) string { if options.FooterNote != "" { return options.FooterNote } @@ -368,6 +385,62 @@ func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) str return "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。" } +// localSolarEclipseSVGFooterLines 折行页脚说明并按图区下限与画布高度截断行数,返回方向行在前的页脚各行。 +// localDiagramFooterBottomPadding 是站心图页脚末行基线距画布底边的留白:图框在画布内缩 18px, +// 留白取 18px 框线 + 4px 间隙 + 12px 字的字下伸部,末行因此落在框线之内而不是压在框线上。 +const localDiagramFooterBottomPadding = 26.0 + +func localSolarEclipseSVGFooterLines(direction, noteText, scaleNote string, width, height, headerBottom float64) []string { + const ( + fontSize = 12.0 + stageFloor = 160.0 + stageGap = 10.0 + ) + lineHeight := svgchart.FooterLineHeight(fontSize) + padding := localDiagramFooterBottomPadding + // 先扣掉底边留白、方向行与图区下限,剩下的才是说明行能用的高度;再按画布高度两成封顶。 + maxLines := svgchart.TextLineLimit(fontSize, lineHeight, + height-headerBottom-stageFloor-stageGap-padding-lineHeight) + if cap := int(height * 0.20 / lineHeight); maxLines > cap { + maxLines = cap + } + if maxLines < 1 { + maxLines = 1 + } + maxWidth := width - 80 + // 默认把时标声明并进说明一起均衡折行(行宽最接近方向说明),在放得下的行数里挑最均衡的一种。 + mergedText := strings.TrimSpace(noteText + " " + scaleNote) + lines := svgchart.WrapTextBalanced(mergedText, maxWidth, fontSize) + for slots := 2; slots <= maxLines; slots++ { + candidate := svgchart.WrapTextBalancedLines(mergedText, maxWidth, fontSize, slots) + if len(candidate) > maxLines { + break + } + if solarEclipseLocalFooterSpread(direction, candidate) < solarEclipseLocalFooterSpread(direction, lines) { + lines = candidate + } + } + if len(lines) > maxLines { + // 说明太长折不下:截断说明,把时标声明单独留成最后一行。 + notesOnly := svgchart.WrapTextBalanced(noteText, maxWidth, fontSize) + lines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, fontSize, maxLines) + } + return append([]string{direction}, lines...) +} + +// solarEclipseLocalFooterSpread 返回页脚各行估算宽度的最大最小比,用于在两种折行方案里取更整齐的一种。 +func solarEclipseLocalFooterSpread(direction string, lines []string) float64 { + minimum, maximum := svgchart.EstimatedTextWidth(direction, 12), svgchart.EstimatedTextWidth(direction, 12) + for _, line := range lines { + width := svgchart.EstimatedTextWidth(line, 12) + minimum, maximum = math.Min(minimum, width), math.Max(maximum, width) + } + if minimum <= 0 { + return math.Inf(1) + } + return maximum / minimum +} + func localSolarEclipseSVGHeaderLineY(index int) float64 { return 86 + float64(index)*23 } diff --git a/eclipse/svg/solar_local_footer_balance_test.go b/eclipse/svg/solar_local_footer_balance_test.go new file mode 100644 index 0000000..5249397 --- /dev/null +++ b/eclipse/svg/solar_local_footer_balance_test.go @@ -0,0 +1,129 @@ +package svg + +import ( + "encoding/xml" + "io" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 站心日食页脚契约:方向说明与补充说明(含时标声明)同字号排成三行,行宽接近、不超可用宽度, +// 且显示时区的偏移只写一次。 + +const ( + solarEclipseLocalFooterDirectionFill = "#333" + solarEclipseLocalFooterNoteFill = "#555" + // 页脚与页眉同色,靠字号区分。 + solarEclipseLocalFooterFontSize = 12 +) + +type solarEclipseLocalFooterText struct { + fill string + fontSize float64 + value string +} + +func solarEclipseLocalFooterTexts(t *testing.T, diagram string) []solarEclipseLocalFooterText { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + texts := []solarEclipseLocalFooterText{} + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("decode SVG: %v", err) + } + element, ok := token.(xml.StartElement) + if !ok || element.Name.Local != "text" { + continue + } + text := solarEclipseLocalFooterText{} + for _, attribute := range element.Attr { + switch attribute.Name.Local { + case "fill": + text.fill = attribute.Value + case "font-size": + text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) + } + } + if text.fontSize != solarEclipseLocalFooterFontSize { + continue + } + if text.fill != solarEclipseLocalFooterDirectionFill && text.fill != solarEclipseLocalFooterNoteFill { + continue + } + var content string + if err := decoder.DecodeElement(&content, &element); err != nil { + t.Fatalf("decode text element: %v", err) + } + text.value = content + texts = append(texts, text) + } + return texts +} + +func solarEclipseLocalFooterDiagram(t *testing.T, language string, location *time.Location) string { + t.Helper() + diagram, ok := LocalSolarEclipseSVG( + time.Date(2012, time.May, 21, 6, 10, 0, 0, time.UTC), + 118.0894, 24.4798, 0, + LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Language: language, Location: location}, + ) + if !ok { + t.Fatalf("%s %s: no local solar eclipse diagram", language, location) + } + return diagram +} + +func TestLocalSolarEclipseFooterIsEvenAndNamesTheZoneOnce(t *testing.T) { + const width = 920 + for _, location := range []*time.Location{ + time.FixedZone("UTC+08:00", 8*3600), + time.FixedZone("CST", 8*3600), + } { + for _, language := range []string{"zh", "en"} { + diagram := solarEclipseLocalFooterDiagram(t, language, location) + lines := solarEclipseLocalFooterTexts(t, diagram) + if len(lines) != 3 { + t.Fatalf("%s %s: footer lines = %d, want the direction line plus two note lines (%#v)", + language, location, len(lines), lines) + } + minimum, maximum := math.Inf(1), 0.0 + note := []string{} + for _, line := range lines { + lineWidth := svgchart.EstimatedTextWidth(line.value, line.fontSize) + minimum, maximum = math.Min(minimum, lineWidth), math.Max(maximum, lineWidth) + if lineWidth > width-80 { + t.Fatalf("%s %s: footer line %q width %.1f exceeds %d", language, location, line.value, lineWidth, width-80) + } + if strings.Contains(line.value, "…") { + t.Fatalf("%s %s: default footer text is truncated: %q", language, location, line.value) + } + if line.fill == solarEclipseLocalFooterNoteFill { + note = append(note, line.value) + } + } + if spread := maximum / minimum; spread > 1.15 { + t.Fatalf("%s %s: footer spread = %.3f, want <= 1.15 (%#v)", language, location, spread, lines) + } + noteText := strings.Join(note, " ") + if language == "en" { + if !strings.Contains(noteText, "shown in") { + t.Fatalf("en %s: footer note lost the time zone declaration: %q", location, noteText) + } + } else if !strings.Contains(noteText, "显示时区") { + t.Fatalf("zh %s: footer note lost the time zone declaration: %q", location, noteText) + } + if got := strings.Count(noteText, "UTC+08:00"); got != 1 { + t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", language, location, got, noteText) + } + } + } +} diff --git a/eclipse/svg/solar_map.go b/eclipse/svg/solar_map.go index 5618287..f40ea10 100644 --- a/eclipse/svg/solar_map.go +++ b/eclipse/svg/solar_map.go @@ -1,12 +1,14 @@ package svg import ( + "b612.me/astro/internal/timenote" "fmt" "html" "math" "strings" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/geodata" @@ -50,6 +52,11 @@ type SolarEclipseMapSVGOptions struct { // Location 控制显示的事件时刻;nil 使用 date.Location()。 // Location controls displayed event times. Nil uses date.Location(). Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则返回 false),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer. + TimeScale astro.TimeScale // Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。 // Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false. Projection EclipseMapProjection @@ -159,6 +166,12 @@ func solarEclipseMapSVG( if !validEclipseMapProjection(options.Projection) { return "", false } + if options.TimeScale == astro.TimeScaleUT1 { + if options.Location != nil && options.Location != time.UTC { + return "", false + } + options.Location = time.UTC + } options = normalizeSolarEclipseMapSVGOptions(date, options) // 日期门与核心一致:当天没有日食就不出图。偏食足迹是“取最近一次”语义, // 缺这道门会把邻近日期的图当成当天的图交出去。 @@ -198,14 +211,25 @@ func solarEclipseMapSVG( if calculators.panel != nil { geocentric, hasGeocentric = calculators.panel(date) } + // 几何(直射点、闭合弧、等时线取值)必须用民用时刻算;只有写出的文字换时标。 + // 时刻标记按输出时标的整点取点(那里本就是另一个物理时刻),位置随整点标注移动。 + civilPartial := partial + if options.TimeScale == astro.TimeScaleUT1 { + global = eclipsecore.SolarEclipseInfoInUT1(global) + greatestTimes = eclipsecore.TimeLabelsInUT1(greatestTimes) + partial = eclipsecore.SolarEclipsePartialFootprintsInUT1(partial) + central = eclipsecore.SolarEclipsePathInUT1(central) + local = eclipsecore.LocalSolarEclipseInfoInUT1(local) + geocentric = eclipsecore.SolarEclipseGeocentricPanelInUT1(geocentric) + } plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric, options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial)) // 数据块行距或图例带放不下时拒绝该画布,而不是把压叠的文字画出来。 if !plan.fits() { return "", false } - return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, - options, projection), true + return plan.render(civilPartial, partial, central, hasCentral, + local, hasLocal, geocentric, hasGeocentric, options, projection), true } // solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。 @@ -414,10 +438,11 @@ func renderSolarEclipseMapSVG( ) string { plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric, options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial)) - return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection) + return plan.render(partial, partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection) } func (plan solarEclipseMapPlan) render( + civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo, partial eclipsecore.SolarEclipsePartialFootprintsInfo, central eclipsecore.SolarEclipsePath, hasCentral bool, @@ -436,7 +461,7 @@ func (plan solarEclipseMapPlan) render( if options.Title != "" { titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24) } - partialPath, partialSource := solarEclipsePartialSweepPath(partial, frame) + partialPath, partialSource := solarEclipsePartialSweepPath(civilPartial, frame) // 一张已放置矩形表:固定元素先占位,地图标注再按候选偏移避让。 labels := &svgchart.LabelTable{} @@ -465,7 +490,7 @@ func (plan solarEclipseMapPlan) render( writeSolarEclipseRiseSetCurves(&builder, partial.RiseSetCurves, frame) writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options, labels) // 点标注比等值线标注重要:先把食甚、接触、直射点与中心线时刻占到位置,等值线标注再让开。 - pointLabels := solarEclipseMapPlacePointLabels(partial, central, hasCentral, frame, options, labels) + pointLabels := solarEclipseMapPlacePointLabels(civilPartial, partial, central, hasCentral, frame, options, labels) writeSolarEclipseGreatestTimeContours(&builder, partial.GreatestTimeContours, frame, options, labels) magnitudeAxis, magnitudeNormal, hasMagnitudeAxis := solarEclipseMagnitudeLabelAxis(central) if !hasMagnitudeAxis { @@ -769,8 +794,8 @@ func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector { func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint { ttJDE := solarEclipseTimeToTTJDE(value) ra, dec := basic.HSunApparentRaDec(ttJDE) - utJDE := basic.TD2UT(ttJDE, false) - longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15) + ut1JDE := basic.TT2UT1(ttJDE) + longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(ut1JDE)*15) return svgmap.GeoPoint{Longitude: longitude, Latitude: dec} } @@ -896,7 +921,7 @@ func solarEclipseCentralEnvelopeCoversPath( return false } limitToleranceKM := solarEclipseCentralEnvelopeAxisMissToleranceKM - if width := path.Eclipse.PathWidthKM; width > 0 && !math.IsInf(width, 1) { + if width := path.Eclipse.PathWidthKM; path.Eclipse.PathWidthDefined && width > 0 && !math.IsInf(width, 1) { limitToleranceKM = math.Max(limitToleranceKM, solarEclipseCentralEnvelopeLimitMissFraction*width) } for _, series := range [][]eclipsecore.SolarEclipsePathPoint{path.NorthernLimit, path.SouthernLimit} { @@ -1107,7 +1132,7 @@ func writeSolarEclipseMapSummary( summaryX, html.EscapeString(text)) details := make([]string, 0, 4) - if info.HasCentral { + if info.PathWidthDefined { if options.Language == "en" { details = append(details, fmt.Sprintf("central path width %.1f km", info.PathWidthKM)) } else { @@ -1167,33 +1192,12 @@ func writeSolarEclipseMapSummary( summaryX, conjunctionY, html.EscapeString(conjunctionText)) } // 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。 - zoneNote := solarEclipseTimeZoneNote(maximum, options.Language) + zoneNote := timenote.Scale(options.TimeScale, maximum, options.Location, options.Language) labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle") fmt.Fprintf(builder, `%s`, summaryX, conjunctionY+20, html.EscapeString(zoneNote)) } -func solarEclipseTimeZoneNote(maximum time.Time, language string) string { - name, offsetSeconds := maximum.Zone() - if offsetSeconds == 0 { - if language == "en" { - return "All times are UT" - } - return "图中时刻为 UT" - } - sign := "+" - if offsetSeconds < 0 { - sign = "-" - offsetSeconds = -offsetSeconds - } - hours := offsetSeconds / 3600 - minutes := (offsetSeconds % 3600) / 60 - if language == "en" { - return fmt.Sprintf("All times are %s (UT%s%02d:%02d)", name, sign, hours, minutes) - } - return fmt.Sprintf("图中时刻为 %s(UT%s%02d:%02d)", name, sign, hours, minutes) -} - func formatSolarEclipseMapDuration(value time.Duration) string { seconds := int(math.Round(value.Seconds())) if seconds < 0 { @@ -1274,6 +1278,10 @@ const ( ) func solarEclipseMapFooterText(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string { + return solarEclipseMapFooterTextBase(options, projection) +} + +func solarEclipseMapFooterTextBase(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string { if options.FooterNote != "" { return options.FooterNote } @@ -1301,11 +1309,13 @@ func writeSolarEclipseMapFooter( maxWidth := float64(options.Width) - footerX - layout.margin lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, solarEclipseMapFooterFontSize), maxWidth, solarEclipseMapFooterFontSize, - svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, 15, baseline, float64(options.Height)-4)) + svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, svgchart.FooterLineHeight(solarEclipseMapFooterFontSize), + baseline, float64(options.Height)-svgchart.FooterBottomPadding(solarEclipseMapFooterFontSize))) } for index, line := range lines { fmt.Fprintf(builder, `%s`, - footerX, baseline+float64(index)*15, solarEclipseMapFooterFontSize, html.EscapeString(line)) + footerX, baseline+float64(index)*svgchart.FooterLineHeight(solarEclipseMapFooterFontSize), + solarEclipseMapFooterFontSize, html.EscapeString(line)) } } diff --git a/eclipse/svg/solar_map_globe_test.go b/eclipse/svg/solar_map_globe_test.go index b371875..a482d65 100644 --- a/eclipse/svg/solar_map_globe_test.go +++ b/eclipse/svg/solar_map_globe_test.go @@ -114,16 +114,16 @@ func TestSolarEclipseMapOrthographicIsExplicitOnly(t *testing.T) { } } -// 图上时刻都按展示时区,必须显式写出与 UT 的偏差,且 详细版式的所有内容都不得溢出图框。 +// 图上时刻必须同时写明时标(UTC)与展示时区相对它的偏差,且详细版式的所有内容都不得溢出图框。 func TestSolarEclipseMapStatesTimeZoneOffset(t *testing.T) { date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) cases := []struct { location *time.Location want string }{ - {location: time.UTC, want: "图中时刻为 UT"}, - {location: time.FixedZone("CST", 8*3600), want: "UT+08:00"}, - {location: time.FixedZone("EST", -5*3600), want: "UT-05:00"}, + {location: time.UTC, want: "图中时刻为 UTC"}, + {location: time.FixedZone("CST", 8*3600), want: "UTC+08:00"}, + {location: time.FixedZone("EST", -5*3600), want: "UTC-05:00"}, } for _, test := range cases { options := SolarEclipseMapSVGOptions{ diff --git a/eclipse/svg/solar_map_isochrone_levels_contract_test.go b/eclipse/svg/solar_map_isochrone_levels_contract_test.go index 64d5151..93ffc0c 100644 --- a/eclipse/svg/solar_map_isochrone_levels_contract_test.go +++ b/eclipse/svg/solar_map_isochrone_levels_contract_test.go @@ -58,7 +58,7 @@ func solarEclipseContractLevelDiff(got, want []string) string { // 导出 TT/UTC 换算把力学时儒略日换回 UTC 时刻,口径与核心写进 PartialBegin/EndOnEarth 的一致。 func solarEclipseContractTimeFromTT(ttJDE float64) time.Time { - return basic.JDE2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false) + return basic.JD2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false) } func TestSolarEclipseGreatestTimeLevelsContract(t *testing.T) { @@ -160,8 +160,8 @@ func TestSolarEclipseGreatestTimeLevelsOnRealEclipseContract(t *testing.T) { t.Fatal("missing 2009-07-22 eclipse") } // 同一力学时时刻经导出换算回 UTC,不依赖 eclipse 包内部的 TT→UTC 实现。 - beginTT := basic.TD2UT(basic.Date2JDE(reported.PartialBeginOnEarth.UTC()), true) - endTT := basic.TD2UT(basic.Date2JDE(reported.PartialEndOnEarth.UTC()), true) + beginTT := basic.UTC2TT(basic.Date2JD(reported.PartialBeginOnEarth.UTC())) + endTT := basic.UTC2TT(basic.Date2JD(reported.PartialEndOnEarth.UTC())) info := eclipsecore.SolarEclipseInfo{ PartialBeginOnEarth: solarEclipseContractTimeFromTT(beginTT), PartialEndOnEarth: solarEclipseContractTimeFromTT(endTT), diff --git a/eclipse/svg/solar_map_labels.go b/eclipse/svg/solar_map_labels.go index 1b81da5..9414dee 100644 --- a/eclipse/svg/solar_map_labels.go +++ b/eclipse/svg/solar_map_labels.go @@ -350,6 +350,7 @@ func solarEclipseContactMarkerSpecs(info eclipsecore.SolarEclipsePartialFootprin // solarEclipseMapPlacePointLabels 按重要性放置点标注:食甚最先占位,其余标注再让开。 func solarEclipseMapPlacePointLabels( + civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo, partial eclipsecore.SolarEclipsePartialFootprintsInfo, central eclipsecore.SolarEclipsePath, hasCentral bool, @@ -376,7 +377,7 @@ func solarEclipseMapPlacePointLabels( }, frame) } result.contacts = solarEclipseMapPlaceContactLabels(partial, frame, labels) - result.subsolar = solarEclipseMapPlaceSubsolarLabel(partial.Eclipse, frame, options.Language, labels) + result.subsolar = solarEclipseMapPlaceSubsolarLabel(civilPartial.Eclipse, frame, options.Language, labels) if hasCentral { result.times = solarEclipseMapPlaceTimeLabels(central, frame, options, labels) } diff --git a/eclipse/svg/solar_map_panel_rows.go b/eclipse/svg/solar_map_panel_rows.go index 5e844e8..4a564e9 100644 --- a/eclipse/svg/solar_map_panel_rows.go +++ b/eclipse/svg/solar_map_panel_rows.go @@ -69,11 +69,14 @@ func solarEclipseContactRows( contact("U4 "+name("本影外切", "umbra ends"), partial.U4), } info := partial.Eclipse - // 非中心食没有中心线,带宽与中心食时长无从谈起,留空而不是写 0。 + // 非中心食没有中心线,单侧极限的中心带只擦到地球边缘:两者带宽都无定义,留空而不是写 0 或发散值。 + // 中心食时长不受影响,单侧极限事件同样有目录口径的时长。 pathWidth, centralDuration := "—", "—" if info.HasCentral { - pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM) centralDuration = formatSolarEclipseMapDuration(info.CentralDuration) + if info.PathWidthDefined { + pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM) + } } circumstances = []svgchart.PanelRow{ {Label: name("食甚", "Greatest"), Value: info.GreatestEclipse.In(options.Location).Format("15:04:05")}, diff --git a/eclipse/svg/solar_map_projected_fill_test.go b/eclipse/svg/solar_map_projected_fill_test.go new file mode 100644 index 0000000..9c1bf67 --- /dev/null +++ b/eclipse/svg/solar_map_projected_fill_test.go @@ -0,0 +1,198 @@ +package svg + +import ( + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/svgmap" +) + +type projectedFillCase struct { + name string + date time.Time + pole float64 + projection geodata.Projection +} + +// 偏食可见域的填充必须与球面并集同域:极冠环在等经纬图上沿地图上、下边缘闭合、 +// 接缝两侧各贴自己那一侧的边缘,在正射球面图上沿视界圆盘闭合; +// 把窗口两端折到同侧会让闭合边横穿整幅图并丢掉极冠。 +func TestSolarEclipseProjectedPartialFillMatchesUnion(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + cases := make([]projectedFillCase, 0, 6) + for _, item := range []projectedFillCase{ + {"2012-05-21 北极冠", time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 90, ""}, + {"2021-12-04 南极冠", time.Date(2021, 12, 4, 12, 0, 0, 0, cst), -90, ""}, + {"2035-09-02 无冠", time.Date(2035, 9, 2, 12, 0, 0, 0, cst), 0, ""}, + } { + for _, projection := range []geodata.Projection{ + geodata.ProjectionEquirectangular, geodata.ProjectionOrthographic, + } { + item := item + item.projection = projection + cases = append(cases, item) + } + } + for _, item := range cases { + item := item + t.Run(item.name+"/"+string(item.projection), func(t *testing.T) { + info, ok := eclipse.SolarEclipsePartialFootprints(item.date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, BoundaryPoints: 180, + }) + if !ok { + t.Fatal("missing partial footprints") + } + polygons, ok := solarEclipsePartialBandPolygons(info) + if !ok || len(polygons) == 0 { + t.Fatal("missing partial-band union") + } + options := SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst} + layout := solarEclipseMapLayoutFor(options, item.projection, + geodata.GeoPoint{Longitude: info.Eclipse.GreatestLongitude, Latitude: info.Eclipse.GreatestLatitude}) + frame := layout.frame + rings := projectedPartialRings(t, polygons, frame) + + if item.projection == geodata.ProjectionEquirectangular { + for _, ring := range rings { + for index := range ring { + point, next := ring[index], ring[(index+1)%len(ring)] + if math.Abs(next[0]-point[0]) <= frame.Width/2 || math.Abs(next[1]-point[1]) >= 1 { + continue + } + if frame.Y-point[1] > 1 && point[1]-(frame.Y+frame.Height) > 1 { + t.Fatalf("填充边横穿整幅图:y=%.3f x=%.3f→%.3f", point[1], point[0], next[0]) + } + } + } + } + + projected := func(longitude, latitude float64) (float64, float64, bool) { + x, y, ok := frame.Project(longitude, latitude) + if !ok { + return 0, 0, false + } + if item.projection != geodata.ProjectionOrthographic { + return x, y, true + } + radius := math.Min(frame.Width, frame.Height) / 2 + if math.Hypot(x-(frame.X+frame.Width/2), y-(frame.Y+frame.Height/2)) > radius+1 { + return 0, 0, false + } + return x, y, true + } + + polarSamples := 0 + if item.pole != 0 { + latitude := math.Copysign(89.5, item.pole) + for longitude := -180.0; longitude < 180; longitude += 5 { + point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude} + if !geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0] { + continue + } + x, y, ok := projected(point.Longitude, point.Latitude) + if !ok { + continue + } + if !planeFillContains(rings, x, y) { + t.Fatalf("纬度 %.1f 经度 %.1f 在球面并集内但未被填充", latitude, longitude) + } + polarSamples++ + } + if polarSamples < 48 { + t.Fatalf("极冠样本只有 %d 个", polarSamples) + } + } + + checked, skipped := 0, 0 + for latitude := -85.0; latitude <= 85; latitude += 5 { + for longitude := -180.0; longitude < 180; longitude += 10 { + point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude} + x, y, ok := projected(point.Longitude, point.Latitude) + if !ok { + continue + } + if planeFillBoundaryDistance(rings, x, y) < 3 { + skipped++ + continue + } + spherical := geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0] + if plane := planeFillContains(rings, x, y); plane != spherical { + t.Fatalf("%.1f %.1f 填充=%v 球面并集=%v", longitude, latitude, plane, spherical) + } + checked++ + } + } + if checked < 400 || skipped > checked/4 { + t.Fatalf("一致性样本 checked=%d skipped=%d", checked, skipped) + } + }) + } +} + +func projectedPartialRings(t *testing.T, polygons [][]geodata.GeoPoint, frame svgmap.Frame) [][][2]float64 { + t.Helper() + var rings [][][2]float64 + for _, polygon := range polygons { + points := make([]geodata.GeoPoint, len(polygon)) + copy(points, polygon) + for _, fragment := range svgmap.PolygonFragments(points, frame.Clip()) { + ring := make([][2]float64, 0, len(fragment)) + for _, point := range fragment { + x, y, ok := frame.Project(point.Longitude, point.Latitude) + if !ok { + ring = nil + break + } + ring = append(ring, [2]float64{x, y}) + } + if len(ring) >= 3 { + rings = append(rings, ring) + } + } + } + if len(rings) == 0 { + t.Fatal("no projected fragment") + } + return rings +} + +func planeFillContains(rings [][][2]float64, x, y float64) bool { + winding := 0 + for _, ring := range rings { + for index := range ring { + first, second := ring[index], ring[(index+1)%len(ring)] + if first[1] <= y { + if second[1] > y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) > 0 { + winding++ + } + } else if second[1] <= y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) < 0 { + winding-- + } + } + } + return winding != 0 +} + +func planeFillBoundaryDistance(rings [][][2]float64, x, y float64) float64 { + distance := math.Inf(1) + for _, ring := range rings { + for index := range ring { + first, second := ring[index], ring[(index+1)%len(ring)] + distance = math.Min(distance, planeSegmentDistance(x, y, first, second)) + } + } + return distance +} + +func planeSegmentDistance(x, y float64, first, second [2]float64) float64 { + dx, dy := second[0]-first[0], second[1]-first[1] + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, ((x-first[0])*dx+(y-first[1])*dy)/length)) + } + return math.Hypot(x-(first[0]+fraction*dx), y-(first[1]+fraction*dy)) +} diff --git a/eclipse/svg/solar_map_timescale_test.go b/eclipse/svg/solar_map_timescale_test.go new file mode 100644 index 0000000..97cf7dd --- /dev/null +++ b/eclipse/svg/solar_map_timescale_test.go @@ -0,0 +1,55 @@ +package svg + +import ( + "regexp" + "strings" + "testing" + "time" + + "b612.me/astro" +) + +// 太阳能图的 UT1 输出只改时刻文字:直射点、闭合弧与等时线几何必须逐字节相同。 +// 时刻标记按输出时标的整点取点(UT1 整点是另一个物理时刻),与文字一起从比较里剔除。 +func TestSolarEclipseMapUT1KeepsGeometry(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2021, time.June, 10, 12, 0, 0, 0, time.UTC), // DUT1 较大,直射点位移可测 + time.Date(1995, time.October, 24, 12, 0, 0, 0, time.UTC), // 双向极限事件 + time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC), + } { + options := SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, TimeLabelStep: 30 * time.Minute, + } + utc, ok := SolarEclipseMapSVG(date, options) + if !ok { + t.Fatalf("%s: UTC render failed", date.Format("2006-01-02")) + } + ut1Options := options + ut1Options.TimeScale = astro.TimeScaleUT1 + ut1, ok := SolarEclipseMapSVG(date, ut1Options) + if !ok { + t.Fatalf("%s: UT1 render failed", date.Format("2006-01-02")) + } + if !strings.Contains(ut1, "DUT1 = UT1−UTC") { + t.Errorf("%s: UT1 chart does not declare the DUT1 offset", date.Format("2006-01-02")) + } + cst := time.FixedZone("CST", 8*3600) + badOptions := ut1Options + badOptions.Location = cst + if _, ok := SolarEclipseMapSVG(date, badOptions); ok { + t.Errorf("%s: UT1 with a non-UTC location must fail", date.Format("2006-01-02")) + } + if stripSolarTimescaleText(utc) != stripSolarTimescaleText(ut1) { + t.Errorf("%s: switching to UT1 changed chart geometry", date.Format("2006-01-02")) + } + } +} + +var ( + solarTimescaleText = regexp.MustCompile(`(?s)]*>.*?`) + solarTimescaleMarkers = regexp.MustCompile(`(?s).*?`) +) + +func stripSolarTimescaleText(doc string) string { + return solarTimescaleText.ReplaceAllString(solarTimescaleMarkers.ReplaceAllString(doc, ""), "") +} diff --git a/eclipse/svg/solar_map_width_contract_test.go b/eclipse/svg/solar_map_width_contract_test.go new file mode 100644 index 0000000..4b98077 --- /dev/null +++ b/eclipse/svg/solar_map_width_contract_test.go @@ -0,0 +1,90 @@ +package svg + +import ( + "strings" + "testing" + "time" +) + +// 带宽无定义时地图面板与摘要行都按目录口径留空,不得打印发散的解析值。 + +func TestSolarEclipseMapHidesUndefinedPathWidth(t *testing.T) { + testCases := []struct { + name string + date time.Time + panelValue string + summary string + forbidden []string + }{ + { + name: "-1404-01-07 single-sided limit", + date: time.Date(-1404, 1, 7, 0, 0, 0, 0, time.UTC), + panelValue: "—", + forbidden: []string{"20692", "中心食带宽 "}, + }, + { + name: "2003-05-31 single-sided limit with limit lines", + date: time.Date(2003, 5, 31, 0, 0, 0, 0, time.UTC), + panelValue: "—", + forbidden: []string{"4454", "中心食带宽 "}, + }, + { + name: "2024-04-08 total", + date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), + panelValue: "198.6 km", + summary: "中心食带宽 198.6 km", + }, + { + name: "2010-01-15 annular", + date: time.Date(2010, 1, 15, 0, 0, 0, 0, time.UTC), + panelValue: "335.0 km", + summary: "中心食带宽 335.0 km", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + diagram, ok := SolarEclipseMapSVG(tc.date, SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute, + }) + if !ok { + t.Fatalf("expected map") + } + value, found := solarEclipsePanelRowValue(diagram, "中心食带宽") + if !found { + t.Fatalf("panel row missing") + } + if value != tc.panelValue { + t.Fatalf("panel row %q want %q", value, tc.panelValue) + } + if tc.summary != "" && !strings.Contains(diagram, tc.summary) { + t.Fatalf("summary missing %q", tc.summary) + } + for _, unwanted := range tc.forbidden { + if strings.Contains(diagram, unwanted) { + t.Fatalf("diagram contains %q", unwanted) + } + } + if err := validateEclipseMapXML(diagram); err != nil { + t.Fatalf("map is not valid XML: %v", err) + } + }) + } +} + +func solarEclipsePanelRowValue(diagram, label string) (string, bool) { + index := strings.Index(diagram, ">"+label+"") + if index < 0 { + return "", false + } + rest := diagram[index+len(label)+len(">"):] + start := strings.Index(rest, ">") + if start < 0 { + return "", false + } + rest = rest[start+1:] + end := strings.Index(rest, "") + if end < 0 { + return "", false + } + return rest[:end], true +} diff --git a/eclipse/svg/solar_model.go b/eclipse/svg/solar_model.go index e310993..4537fb1 100644 --- a/eclipse/svg/solar_model.go +++ b/eclipse/svg/solar_model.go @@ -4,6 +4,7 @@ import ( "math" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" ) @@ -31,9 +32,13 @@ func localSolarEclipseInfoFromDiagram( diagram basic.LocalSolarEclipseDiagramResult, lon, lat, height float64, location *time.Location, + scale astro.TimeScale, ) LocalSolarEclipseInfo { info := localSolarEclipseInfoFieldsFromBasic(diagram.Eclipse, lon, lat, height, location) info.ContactPoints = localSolarEclipseContactPointsFromFrames(diagram.Frames, location) + if scale == astro.TimeScaleUT1 { + info = eclipsecore.LocalSolarEclipseInfoInUT1(info) + } return info } @@ -131,13 +136,13 @@ func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time { if ttJDE == 0 { return time.Time{} } - utcJDE := basic.TD2UT(ttJDE, false) - return basic.JDE2DateByZone(utcJDE, location, false) + utcJD := basic.TT2UTC(ttJDE) + return basic.JD2DateByZone(utcJD, location, false) } func solarEclipseTimeToTTJDE(date time.Time) float64 { - utcJDE := basic.Date2JDE(date.UTC()) - return basic.TD2UT(utcJDE, true) + utcJD := basic.Date2JD(date.UTC()) + return basic.UTC2TT(utcJD) } func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 { diff --git a/event_boundary_public_test.go b/event_boundary_public_test.go index 539ae89..f286814 100644 --- a/event_boundary_public_test.go +++ b/event_boundary_public_test.go @@ -82,7 +82,7 @@ func TestPublicPlanetEventBoundaryIncludesCurrent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - eventTime := basic.JDE2DateByZone(tc.eventUT, time.UTC, false) + eventTime := basic.JD2DateByZone(tc.eventUT, time.UTC, false) assertSameEventTime(t, "last", tc.funcs.last(eventTime), eventTime) assertSameEventTime(t, "next", tc.funcs.next(eventTime), eventTime) }) @@ -132,7 +132,7 @@ func TestPublicMoonPlanetConjunctionBoundaryIncludesCurrent(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - eventTime := basic.JDE2DateByZone(tc.eventUT, time.UTC, false) + eventTime := basic.JD2DateByZone(tc.eventUT, time.UTC, false) assertSameEventTime(t, "last", tc.funcs.last(eventTime), eventTime) assertSameEventTime(t, "next", tc.funcs.next(eventTime), eventTime) }) @@ -140,7 +140,7 @@ func TestPublicMoonPlanetConjunctionBoundaryIncludesCurrent(t *testing.T) { } func eventBoundaryTT(year int) float64 { - return basic.TD2UT(basic.Date2JDE(time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)), true) + return basic.UTC2TT(basic.Date2JD(time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC))) } func assertSameEventTime(t *testing.T, name string, got, want time.Time) { diff --git a/formula/distance.go b/formula/distance.go new file mode 100644 index 0000000..5857bc5 --- /dev/null +++ b/formula/distance.go @@ -0,0 +1,22 @@ +package formula + +import "b612.me/astro/tools" + +// DistanceUnit 距离输入单位,与 tools 共用同一套枚举 / a unit accepted for distance input, shared with tools. +type DistanceUnit = tools.DistanceUnit + +const ( + // DistanceParsec 秒差距 / parsec. + DistanceParsec = tools.DistanceParsec + // DistanceLightYear 光年 / light-year. + DistanceLightYear = tools.DistanceLightYear + // DistanceAU 天文单位 / astronomical unit. + DistanceAU = tools.DistanceAU +) + +// Distance 把给定单位的距离换算为秒差距 / converts a distance in the given unit to parsecs. +// +// 换算基元位于 tools,本函数只是对外转发;非正数、NaN 与未知单位返回 NaN。 +func Distance(value float64, unit DistanceUnit) float64 { + return tools.DistanceToParsecs(value, unit) +} diff --git a/formula/distance_test.go b/formula/distance_test.go new file mode 100644 index 0000000..fda259c --- /dev/null +++ b/formula/distance_test.go @@ -0,0 +1,19 @@ +package formula + +import ( + "math" + "testing" +) + +// formula 只做对外转发,换算基元在 tools,这里只锁别名与转发不脱钩。 +func TestDistanceDelegatesToTools(t *testing.T) { + assertFormulaClose(t, "1 ly in pc", Distance(1, DistanceLightYear), 0.306601393786, 1e-12) + assertFormulaClose(t, "1 pc identity", Distance(2.5, DistanceParsec), 2.5, 1e-15) + assertFormulaClose(t, "aliases agree", float64(DistanceParsec), 0, 1e-15) + if got := Distance(0, DistanceParsec); !math.IsNaN(got) { + t.Fatalf("Distance(0) = %v, want NaN", got) + } + if got := Distance(1, DistanceUnit(200)); !math.IsNaN(got) { + t.Fatalf("Distance(1, unknown unit) = %v, want NaN", got) + } +} diff --git a/geojson/eclipse.go b/geojson/eclipse.go index c21e283..b675294 100644 --- a/geojson/eclipse.go +++ b/geojson/eclipse.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "b612.me/astro" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/geodata" @@ -452,7 +453,32 @@ func MarshalSolarEclipse( partial eclipsecore.SolarEclipsePartialFootprintsInfo, central *eclipsecore.SolarEclipsePath, ) ([]byte, error) { - return marshalSolarEclipse(partial, central, nil) + return marshalSolarEclipse(partial, central, SolarEclipseOptions{}) +} + +// SolarEclipseOptions 控制日食 GeoJSON 的输出内容。 +// SolarEclipseOptions controls the solar-eclipse GeoJSON content. +type SolarEclipseOptions struct { + // TimeMarkers 非空时沿中心线追加时间标记 Point 要素,等价于 MarshalSolarEclipseWithTimeMarkers。 + // TimeMarkers adds time-marker Point Features along the center line when non-nil. + TimeMarkers *TimeMarkerOptions + // SkipRoles 列出不写进输出的 role,例如 partial-footprint(瞬时半影轮廓)、partial-band、 + // magnitude-line、visibility-boundary。只丢要素,不改变几何:偏食域包络仍用完整采样闭合, + // 因此跳过 partial-footprint 不会降低 partial-band 的精度。全部要素都被丢掉时返回错误。 + // SkipRoles lists roles to leave out of the output, such as partial-footprint, partial-band, + // magnitude-line or visibility-boundary. It drops Features only and does not change geometry: + // the partial band is still closed from the complete sampling, so skipping the instantaneous + // penumbral outlines costs no accuracy. Returns an error when every Feature is dropped. + SkipRoles []string +} + +// MarshalSolarEclipseWithOptions 编码日食,输出内容由 options 选择 / encodes a solar eclipse with the content selected by options. +func MarshalSolarEclipseWithOptions( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + central *eclipsecore.SolarEclipsePath, + options SolarEclipseOptions, +) ([]byte, error) { + return marshalSolarEclipse(partial, central, options) } // MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。 @@ -462,14 +488,15 @@ func MarshalSolarEclipseWithTimeMarkers( central *eclipsecore.SolarEclipsePath, options TimeMarkerOptions, ) ([]byte, error) { - return marshalSolarEclipse(partial, central, &options) + return marshalSolarEclipse(partial, central, SolarEclipseOptions{TimeMarkers: &options}) } func marshalSolarEclipse( partial eclipsecore.SolarEclipsePartialFootprintsInfo, central *eclipsecore.SolarEclipsePath, - markerOptions *TimeMarkerOptions, + options SolarEclipseOptions, ) ([]byte, error) { + markerOptions := options.TimeMarkers if markerOptions != nil { if err := validateTimeMarkerOptions(*markerOptions); err != nil { return nil, err @@ -481,14 +508,29 @@ func marshalSolarEclipse( if err := validateSolarEclipseInput(partial, central); err != nil { return nil, err } + timeScale, scaleErr := timeScaleForMarkers(markerOptions) + if scaleErr != nil { + return nil, scaleErr + } + // 几何(月下点、地平闭合弧、带宽限界)必须用民用时刻算:只有写进属性的时刻换时标。 + geometryPartial := partial + civilCentral := central + if timeScale == astro.TimeScaleUT1 { + partial = eclipsecore.SolarEclipsePartialFootprintsInUT1(partial) + if central != nil { + converted := eclipsecore.SolarEclipsePathInUT1(*central) + central = &converted + } + } properties := map[string]interface{}{ "eclipse_type": string(partial.Eclipse.Type), "model": string(partial.Eclipse.Model), } features := make([]feature, 0, len(partial.Footprints)+9) - for _, footprint := range partial.Footprints { - polygon, err := solarPartialFootprintPolygon(footprint, true) + for footprintIndex, footprint := range partial.Footprints { + // 闭合弧由月下点决定,必须用未换时标的同一足迹算几何。 + polygon, err := solarPartialFootprintPolygon(geometryPartial.Footprints[footprintIndex], true) if err != nil { return nil, err } @@ -509,7 +551,8 @@ func marshalSolarEclipse( if !footprint.Closed { footprintProperties["geometry_role"] = "horizon-closed-region" footprintProperties["closure"] = solarHorizonClosureProperties( - footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), + geometryPartial.Footprints[footprintIndex].Time, footprint.Time, + solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), ) } if len(polygon) == 1 { @@ -531,7 +574,7 @@ func marshalSolarEclipse( )) } - if value, source, ok, err := solarPartialBandGeometry(partial); err != nil { + if value, source, ok, err := solarPartialBandGeometry(geometryPartial); err != nil { return nil, fmt.Errorf("geojson: solar partial band: %w", err) } else if ok { bandProperties := cloneProperties(properties) @@ -630,8 +673,13 @@ func marshalSolarEclipse( // (NASA's path table lists its limits from U1 to U4), so a band built or // validated only against the trimmed subset silently loses the flared // ends of the real annular/total region. - bandFootprints = solarCentralBandFootprintsForPath(partial, central) - sweepFootprints := solarCentralBandFootprints(partial) + // 带宽与限界几何按民用时刻构造;写出的时刻仍取换过时标的 central。 + geometryCentral := central + if timeScale == astro.TimeScaleUT1 && civilCentral != nil { + geometryCentral = civilCentral + } + bandFootprints = solarCentralBandFootprintsForPath(geometryPartial, geometryCentral) + sweepFootprints := solarCentralBandFootprints(geometryPartial) // The exported limit lines are trimmed to the center-line interval for // ordinary maps, but the static band must be built from the complete // U1/U4 paired limits: for a shallow two-limit event the axis interval is @@ -639,15 +687,18 @@ func marshalSolarEclipse( // limits drops hundreds of kilometres of real annular area. presentationNorthernLimit := central.NorthernLimit presentationSouthernLimit := central.SouthernLimit + geometryNorthernLimit := geometryCentral.NorthernLimit + geometrySouthernLimit := geometryCentral.SouthernLimit if partial.Eclipse.Centrality == eclipsecore.SolarEclipseCentralTwoLimits { if north, south, ok := solarCentralTwoLimitPresentationLimits( - central.NorthernLimit, central.SouthernLimit, central.CenterLine, + geometryNorthernLimit, geometrySouthernLimit, geometryCentral.CenterLine, ); ok { presentationNorthernLimit, presentationSouthernLimit = north, south + geometryNorthernLimit, geometrySouthernLimit = north, south } } - bandNorthernLimit := central.NorthernLimit - bandSouthernLimit := central.SouthernLimit + bandNorthernLimit := geometryNorthernLimit + bandSouthernLimit := geometrySouthernLimit // A grazing band is not bounded by the instantaneous cross-section // limits: those stop describing the region and can sit hundreds of // kilometres inside it (1136-06-01: 456 km for the northern limit). @@ -655,21 +706,21 @@ func marshalSolarEclipse( // exported lines are taken from the band ring itself, so the dashed // limits and the filled band describe the same region. var derivedNorthernLimit, derivedSouthernLimit []eclipsecore.SolarEclipsePathPoint - if len(central.NorthernLimit) > 0 { + if len(geometryNorthernLimit) > 0 { var value geometry var source string var usedMagnitudeOne bool - centralEnvelope := partial.CentralBandSegments - if len(central.CentralBandSegments) > 0 { - centralEnvelope = central.CentralBandSegments + centralEnvelope := geometryPartial.CentralBandSegments + if len(geometryCentral.CentralBandSegments) > 0 { + centralEnvelope = geometryCentral.CentralBandSegments } useCriticalEnvelope := len(centralEnvelope) > 0 // Check the shadow axis, not the instantaneous cross-section limits: // near the horizon those samples can have their local greatest below // the horizon and need not belong to the visible central band. - coveragePath := *central - coveragePath.NorthernLimit = presentationNorthernLimit - coveragePath.SouthernLimit = presentationSouthernLimit + coveragePath := *geometryCentral + coveragePath.NorthernLimit = geometryNorthernLimit + coveragePath.SouthernLimit = geometrySouthernLimit if useCriticalEnvelope && !solarCentralBandEnvelopeCoversPath( centralEnvelope, &coveragePath, ) { @@ -689,23 +740,23 @@ func marshalSolarEclipse( value, source, usedMagnitudeOne, err = solarCentralMagnitudeOneBandGeometry( partial.Eclipse.Type, partial.MagnitudeContours, - central.CenterLine, - partial.CentralBandHorizonClosures, + geometryCentral.CenterLine, + geometryPartial.CentralBandHorizonClosures, ) } if !useCriticalEnvelope && !usedMagnitudeOne { value, source, err = solarCentralBandGeometry( bandNorthernLimit, bandSouthernLimit, - central.CenterLine, + geometryCentral.CenterLine, partial.Eclipse.Type, partial.Eclipse.Centrality, sweepFootprints, - partial.CentralBandHorizonClosures, + geometryPartial.CentralBandHorizonClosures, ) } - if err != nil && len(partial.CentralBandFootprints) > 0 && - !sameSolarFootprintSlice(sweepFootprints, partial.CentralBandFootprints) { + if err != nil && len(geometryPartial.CentralBandFootprints) > 0 && + !sameSolarFootprintSlice(sweepFootprints, geometryPartial.CentralBandFootprints) { // A caller may request dense central-shadow samples. Near // grazing contacts, the planar sweep can become numerically // open; the always-available end-cap samples provide a stable @@ -713,10 +764,10 @@ func marshalSolarEclipse( value, source, err = solarCentralBandGeometry( bandNorthernLimit, bandSouthernLimit, - central.CenterLine, + geometryCentral.CenterLine, partial.Eclipse.Type, partial.Eclipse.Centrality, - partial.CentralBandFootprints, + geometryPartial.CentralBandFootprints, partial.CentralBandHorizonClosures, ) } @@ -743,7 +794,7 @@ func marshalSolarEclipse( return nil, fmt.Errorf("geojson: solar central band: %w", sweepErr) } value, geometryErr := multiPolygonGeometry( - solarCentralBandWithCenterlineCorridor(polygons, central.CenterLine), + solarCentralBandWithCenterlineCorridor(polygons, geometryCentral.CenterLine), ) if geometryErr != nil { return nil, fmt.Errorf("geojson: solar central band: %w", geometryErr) @@ -833,7 +884,7 @@ func marshalSolarEclipse( if err != nil { return nil, err } - return marshalFeatureCollection(features) + return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), timeScale) } // solarCentralBandSeamEpsilonKM is the seam tolerance used when rejoining the @@ -1723,12 +1774,50 @@ func solarCentralBandFootprintsForPath( return footprints } -// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区和几何地平线编码为 GeoJSON,不含折射。 -// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions and geometric horizons, without refraction. +// LunarEclipseOptions 月食 GeoJSON 导出选项;全部要素都被丢掉时导出返回错误。 +// LunarEclipseOptions are the lunar-eclipse GeoJSON export options; the export fails when every +// Feature is dropped. +type LunarEclipseOptions struct { + // TimeMarkers 非空时沿月下点轨迹追加时间标记 Point 要素,等价于 MarshalLunarEclipseWithTimeMarkers。 + // TimeMarkers adds time-marker Point Features along the sublunar track when non-nil. + TimeMarkers *TimeMarkerOptions + // SkipRoles 列出不写进输出的 role。写包络的 role 被跳过时不再为它成环,两块都跳过则连整段 + // 扫掠都不做——时间包络是本入口的主要开销,其余要素只占很小一部分。 + // SkipRoles lists roles to leave out. A skipped envelope role is not polygonized, and skipping + // both skips the whole sweep, which dominates this entry point. + SkipRoles []string + // EnvelopeSweepSamples 是时间包络在 P1-P4 上的采样段数;0 或负值用默认 48,正值收敛到 [2, 192]。 + // 段数越少越快,包络边界处的采样误差量级见手册。 + // EnvelopeSweepSamples is the number of P1-P4 sampling steps for the time envelopes: 0 or a + // negative value keeps the default of 48, positive values are clamped to [2, 192]. Fewer steps + // are faster; the manual lists the sampling error at the envelope boundary. + EnvelopeSweepSamples int + // EnvelopeLongitudePoints 是时间包络的经度列数;0 或负值取 max(360, boundaryPoints),正值收敛到 [12, 720]。 + // 列距就是区域边缘的固有误差量级,减小它同时变快、变粗;实际列数由包络要素的 longitude_points 给出, + // 瞬时半球的 boundary_points 不受它影响。 + // EnvelopeLongitudePoints is the time-envelope longitude column count: 0 or a negative value uses + // max(360, boundaryPoints), positive values are clamped to [12, 720]. The column spacing sets the + // inherent edge error, so lowering it is faster and coarser; the effective count is reported as the + // envelope's own longitude_points and the instantaneous hemispheres' boundary_points is unaffected. + EnvelopeLongitudePoints int +} + +// MarshalLunarEclipseWithOptions 编码月食,输出内容与采样精度由 options 选择。 +// MarshalLunarEclipseWithOptions encodes a lunar eclipse with the content and sampling selected by options. +func MarshalLunarEclipseWithOptions( + info eclipsecore.LunarEclipseInfo, + boundaryPoints int, + options LunarEclipseOptions, +) ([]byte, error) { + return marshalLunarEclipse(info, boundaryPoints, options) +} + +// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区、几何地平线以及 P1-P4 的时间包络编码为 GeoJSON,不含折射。 +// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions, their geometric horizons and the P1-P4 time envelopes, without refraction. // boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。 // boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440]. func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) { - return marshalLunarEclipse(info, boundaryPoints, nil) + return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{}) } // MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。 @@ -1740,14 +1829,577 @@ func MarshalLunarEclipseWithTimeMarkers( boundaryPoints int, options TimeMarkerOptions, ) ([]byte, error) { - return marshalLunarEclipse(info, boundaryPoints, &options) + return marshalLunarEclipse(info, boundaryPoints, LunarEclipseOptions{TimeMarkers: &options}) +} + +type lunarLatitudeInterval struct { + low float64 + high float64 +} + +type lunarHorizonSeries struct { + points []geodata.GeoPoint + longitudes []float64 +} + +const ( + lunarVisibilitySweepSamples = 48 + lunarVisibilitySweepSamplesMin = 2 + lunarVisibilitySweepSamplesMax = 192 + lunarVisibilityLongitudeMin = 360 + // 包络按 1° 经度分列,交点纬度只需比列距小两个数量级;用 GeoJSON 默认的 0.002° 会多插一倍以上顶点。 + lunarVisibilityHorizonToleranceDegrees = 0.02 +) + +func lunarVisibilityLongitudePoints(boundaryPoints int, options LunarEclipseOptions) int { + if options.EnvelopeLongitudePoints > 0 { + points := options.EnvelopeLongitudePoints + if points < 12 { + points = 12 + } + if points > 720 { + points = 720 + } + return points + } + points := lunarVisibilityLongitudeMin + if boundaryPoints > points { + points = boundaryPoints + } + if points > 720 { + points = 720 + } + return points +} + +func lunarVisibilitySamples(options LunarEclipseOptions) int { + if options.EnvelopeSweepSamples <= 0 { + return lunarVisibilitySweepSamples + } + samples := options.EnvelopeSweepSamples + if samples < lunarVisibilitySweepSamplesMin { + samples = lunarVisibilitySweepSamplesMin + } + if samples > lunarVisibilitySweepSamplesMax { + samples = lunarVisibilitySweepSamplesMax + } + return samples +} + +func skippedLunarRole(skip []string, role string) bool { + for _, value := range skip { + if value == role { + return true + } + } + return false +} + +func lunarVisibilityEnvelopeProperties(info eclipsecore.LunarEclipseInfo, aggregation string, longitudePoints int) map[string]interface{} { + return map[string]interface{}{ + "eclipse_type": string(info.Type), + "longitude_points": longitudePoints, + "time_start": formatTime(info.PenumbralStart), + "time_end": formatTime(info.PenumbralEnd), + "aggregation": aggregation, + } +} + +// lunarSubtractInterval 从一段纬度区间里扣掉另一段。 +func lunarSubtractInterval(piece, second lunarLatitudeInterval) []lunarLatitudeInterval { + if second.high <= piece.low || second.low >= piece.high { + return []lunarLatitudeInterval{piece} + } + pieces := make([]lunarLatitudeInterval, 0, 2) + if second.low > piece.low { + pieces = append(pieces, lunarLatitudeInterval{low: piece.low, high: second.low}) + } + if second.high < piece.high { + pieces = append(pieces, lunarLatitudeInterval{low: second.high, high: piece.high}) + } + return pieces +} + +// lunarSubtractLatitudeIntervals 从 minuend 里扣掉 subtrahend 覆盖的纬度区间。 +func lunarSubtractLatitudeIntervals(minuend, subtrahend []lunarLatitudeInterval) []lunarLatitudeInterval { + if len(minuend) == 0 || len(subtrahend) == 0 { + return minuend + } + result := make([]lunarLatitudeInterval, 0, len(minuend)) + for _, first := range minuend { + pieces := []lunarLatitudeInterval{first} + for _, second := range subtrahend { + next := make([]lunarLatitudeInterval, 0, len(pieces)+1) + for _, piece := range pieces { + next = append(next, lunarSubtractInterval(piece, second)...) + } + pieces = next + } + result = append(result, pieces...) + } + return lunarUnionLatitudeIntervals(result...) +} + +// lunarHorizonColumns 是某时刻地平线在每个经度列上的纬度交点,与那一刻的月面状态。 +type lunarHorizonColumns struct { + state basic.MoonState + roots [][]float64 +} + +func lunarHorizonColumnsAt(at time.Time, longitudePoints int, longitudes []float64) (lunarHorizonColumns, error) { + state := basic.MoonStateAt(basic.Date2JD(at.UTC())) + points := state.MoonHorizon(longitudePoints) + if len(points) < 3 { + return lunarHorizonColumns{}, fmt.Errorf("geojson: lunar visibility horizon is unavailable") + } + return lunarHorizonColumns{ + state: state, + roots: lunarHorizonSeriesFor(lunarHorizonRefinedPoints(points, at)).rootsByLongitude(longitudes), + }, nil +} + +func lunarVisibilityLongitudes(boundaryPoints int, options LunarEclipseOptions) []float64 { + longitudePoints := lunarVisibilityLongitudePoints(boundaryPoints, options) + longitudes := make([]float64, longitudePoints+1) + for index := range longitudes { + longitudes[index] = -180 + 360*float64(index)/float64(longitudePoints) + } + return longitudes +} + +// lunarBandColumn 取一列上"主端可见区依次扣掉若干可见区"后的纬度区间。 +func lunarBandColumn(primary lunarHorizonColumns, longitude float64, index int, exclude ...[]lunarLatitudeInterval) []lunarLatitudeInterval { + intervals := lunarVisibleLatitudeIntervals(primary.state, longitude, primary.roots[index]) + for _, other := range exclude { + intervals = lunarSubtractLatitudeIntervals(intervals, other) + } + return intervals +} + +// lunarVisibilityUnionColumns 逐经度取 [start, end] 上"月亮在地平上"可见区的并集: +// 只比区间端点会漏掉极区掠射时两刻之间短暂露出的窗口,必须按与包络同一档位采样后求并。 +func lunarVisibilityUnionColumns( + start, end time.Time, samples, longitudePoints int, longitudes []float64, +) ([][]lunarLatitudeInterval, error) { + columns := make([][]lunarLatitudeInterval, len(longitudes)) + if samples < 1 { + samples = 1 + } + for index := 0; index <= samples; index++ { + at := start.Add(end.Sub(start) * time.Duration(index) / time.Duration(samples)) + instant, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes) + if err != nil { + return nil, err + } + for longitudeIndex, longitude := range longitudes { + intervals := lunarVisibleLatitudeIntervals(instant.state, longitude, instant.roots[longitudeIndex]) + columns[longitudeIndex] = lunarUnionLatitudeIntervals(append(columns[longitudeIndex], intervals...)...) + } + } + return columns, nil +} + +// lunarUmbralBandSamples 让本影区间的采样步长与整场包络一致:区间更短就按比例少采,下限 2 段。 +func lunarUmbralBandSamples(info eclipsecore.LunarEclipseInfo, options LunarEclipseOptions) int { + total := info.PenumbralEnd.Sub(info.PenumbralStart) + umbral := info.PartialEnd.Sub(info.PartialStart) + if total <= 0 || umbral <= 0 { + return lunarVisibilitySamples(options) + } + samples := int(math.Round(float64(lunarVisibilitySamples(options)) * float64(umbral) / float64(total))) + if samples < 2 { + return 2 + } + if limit := lunarVisibilitySamples(options); samples > limit { + return limit + } + return samples +} + +// lunarPenumbraBandGeometries 生成"仅见半影"的两条带:月落侧是食始在地平上、本影阶段整段在地平下, +// 月出侧是食终在地平上、本影阶段整段在地平下;各自再扣掉另一端的可见区(极区下中天会让两端同时可见)。 +func lunarPenumbraBandGeometries( + info eclipsecore.LunarEclipseInfo, + boundaryPoints int, + options LunarEclipseOptions, + wantMoonset, wantMoonrise bool, +) ([2]geometry, error) { + var result [2]geometry + longitudes := lunarVisibilityLongitudes(boundaryPoints, options) + longitudePoints := len(longitudes) - 1 + columns := make([]lunarHorizonColumns, 2) + for index, at := range []time.Time{info.PenumbralStart, info.PenumbralEnd} { + value, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes) + if err != nil { + return result, err + } + columns[index] = value + } + umbralColumns, err := lunarVisibilityUnionColumns( + info.PartialStart, info.PartialEnd, lunarUmbralBandSamples(info, options), longitudePoints, longitudes, + ) + if err != nil { + return result, err + } + bandColumns := [2][][]lunarLatitudeInterval{ + make([][]lunarLatitudeInterval, len(longitudes)), + make([][]lunarLatitudeInterval, len(longitudes)), + } + for longitudeIndex, longitude := range longitudes { + p1 := lunarVisibleLatitudeIntervals(columns[0].state, longitude, columns[0].roots[longitudeIndex]) + p4 := lunarVisibleLatitudeIntervals(columns[1].state, longitude, columns[1].roots[longitudeIndex]) + if wantMoonset { + bandColumns[0][longitudeIndex] = lunarBandColumn(columns[0], longitude, longitudeIndex, umbralColumns[longitudeIndex], p4) + } + if wantMoonrise { + bandColumns[1][longitudeIndex] = lunarBandColumn(columns[1], longitude, longitudeIndex, umbralColumns[longitudeIndex], p1) + } + } + for index, role := range []string{"penumbra-moonset", "penumbra-moonrise"} { + if (index == 0 && !wantMoonset) || (index == 1 && !wantMoonrise) { + continue + } + value, err := lunarVisibilityEnvelopeGeometry(longitudes, bandColumns[index]) + if err != nil { + return result, fmt.Errorf("geojson: %s: %w", role, err) + } + result[index] = dropDegenerateMultiPolygonRings(value) + } + return result, nil +} + +func lunarPenumbraBandProperties(info eclipsecore.LunarEclipseInfo, start, end time.Time, boundaryPoints int) map[string]interface{} { + return map[string]interface{}{ + "eclipse_type": string(info.Type), + "time_start": formatTime(start), + "time_end": formatTime(end), + "phase": "penumbral-only", + } +} + +// lunarVisibilityEnvelopeGeometries 只对 wantUnion / wantIntersection 指定的包络成环;被跳过的 +// 那一块连逐列并/交都不做。 +func lunarVisibilityEnvelopeGeometries( + info eclipsecore.LunarEclipseInfo, + boundaryPoints int, + options LunarEclipseOptions, + wantUnion, wantIntersection bool, +) (geometry, geometry, error) { + start, end := info.PenumbralStart, info.PenumbralEnd + if !start.Before(end) { + return geometry{}, geometry{}, fmt.Errorf("geojson: lunar eclipse penumbral interval is invalid") + } + longitudePoints := lunarVisibilityLongitudePoints(boundaryPoints, options) + sweepSamples := lunarVisibilitySamples(options) + times := make([]time.Time, sweepSamples+1) + for index := range times { + times[index] = start.Add(end.Sub(start) * time.Duration(index) / time.Duration(sweepSamples)) + } + longitudes := lunarVisibilityLongitudes(boundaryPoints, options) + states := make([]basic.MoonState, len(times)) + roots := make([][][]float64, len(times)) + for index, at := range times { + columns, err := lunarHorizonColumnsAt(at, longitudePoints, longitudes) + if err != nil { + return geometry{}, geometry{}, err + } + states[index], roots[index] = columns.state, columns.roots + } + unionColumns := make([][]lunarLatitudeInterval, len(longitudes)) + intersectionColumns := make([][]lunarLatitudeInterval, len(longitudes)) + for longitudeIndex, longitude := range longitudes { + var union, intersection []lunarLatitudeInterval + for timeIndex := range times { + intervals := lunarVisibleLatitudeIntervals( + states[timeIndex], longitude, roots[timeIndex][longitudeIndex], + ) + if wantIntersection { + if timeIndex == 0 { + intersection = append(intersection, intervals...) + } else { + intersection = lunarIntersectLatitudeIntervals(intersection, intervals) + } + } + if wantUnion { + union = lunarUnionLatitudeIntervals(append(union, intervals...)...) + } + } + if wantUnion { + unionColumns[longitudeIndex] = union + } + if wantIntersection { + intersectionColumns[longitudeIndex] = intersection + } + } + var unionGeometry, intersectionGeometry geometry + if wantUnion { + value, err := lunarVisibilityEnvelopeGeometry(longitudes, unionColumns) + if err != nil { + return geometry{}, geometry{}, fmt.Errorf("geojson: visible-during-eclipse: %w", err) + } + unionGeometry = dropDegenerateMultiPolygonRings(value) + } + if wantIntersection { + value, err := lunarVisibilityEnvelopeGeometry(longitudes, intersectionColumns) + if err != nil { + return geometry{}, geometry{}, fmt.Errorf("geojson: visible-throughout-eclipse: %w", err) + } + intersectionGeometry = dropDegenerateMultiPolygonRings(value) + } + return unionGeometry, intersectionGeometry, nil +} + +func lunarHorizonRefinedPoints(points [][2]float64, at time.Time) [][2]float64 { + horizon := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + horizon = lunarhorizon.RefineWithin(horizon, at, lunarVisibilityHorizonToleranceDegrees) + refined := make([][2]float64, len(horizon)) + for index, point := range horizon { + refined[index] = [2]float64{point.Longitude, point.Latitude} + } + return refined +} + +func lunarHorizonSeriesFor(points [][2]float64) lunarHorizonSeries { + series := lunarHorizonSeries{ + points: make([]geodata.GeoPoint, len(points)), + longitudes: make([]float64, len(points)), + } + for index, point := range points { + series.points[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + longitude := point[0] + if index > 0 { + previous := series.longitudes[index-1] + for longitude-previous > 180 { + longitude -= 360 + } + for longitude-previous < -180 { + longitude += 360 + } + } + series.longitudes[index] = longitude + } + return series +} + +// rootsByLongitude 一次遍历地平圈,给出每个经度列上的交点纬度。 +func (series lunarHorizonSeries) rootsByLongitude(longitudes []float64) [][]float64 { + roots := make([][]float64, len(longitudes)) + if len(series.points) < 3 || len(longitudes) < 2 { + return roots + } + origin := longitudes[0] + step := (longitudes[len(longitudes)-1] - origin) / float64(len(longitudes)-1) + if !(step > 0) { + return roots + } + for index := range series.points { + next := (index + 1) % len(series.points) + first, second := series.longitudes[index], series.longitudes[next] + for second-first > 180 { + second -= 360 + } + for second-first < -180 { + second += 360 + } + span := second - first + if math.Abs(span) < 1e-12 { + continue + } + minimum, maximum := math.Min(first, second), math.Max(first, second) + firstWorld := int(math.Floor((minimum - origin) / 360)) + lastWorld := int(math.Floor((maximum - origin) / 360)) + for world := firstWorld; world <= lastWorld; world++ { + offset := 360 * float64(world) + lowIndex := int(math.Ceil((minimum - offset - origin) / step)) + highIndex := int(math.Floor((maximum - offset - origin) / step)) + if lowIndex < 0 { + lowIndex = 0 + } + if highIndex >= len(longitudes) { + highIndex = len(longitudes) - 1 + } + for column := lowIndex; column <= highIndex; column++ { + target := origin + step*float64(column) + offset + fraction := (target - first) / span + if fraction < 0 || fraction > 1 { + continue + } + latitude := series.points[index].Latitude + + (series.points[next].Latitude-series.points[index].Latitude)*fraction + duplicate := false + for _, root := range roots[column] { + if math.Abs(root-latitude) < 1e-9 { + duplicate = true + break + } + } + if !duplicate { + roots[column] = append(roots[column], latitude) + } + } + } + } + for column := range roots { + sort.Float64s(roots[column]) + } + return roots +} + +func lunarVisibleLatitudeIntervals(state basic.MoonState, longitude float64, roots []float64) []lunarLatitudeInterval { + boundaries := make([]float64, 0, len(roots)+2) + boundaries = append(boundaries, -90) + for _, root := range roots { + if root > -90 && root < 90 { + boundaries = append(boundaries, root) + } + } + boundaries = append(boundaries, 90) + intervals := make([]lunarLatitudeInterval, 0, len(boundaries)-1) + for index := 0; index+1 < len(boundaries); index++ { + low, high := boundaries[index], boundaries[index+1] + if high-low <= 1e-9 { + continue + } + if state.HMoonHeight(longitude, (low+high)/2) > 0 { + intervals = append(intervals, lunarLatitudeInterval{low: low, high: high}) + } + } + return intervals +} + +func lunarUnionLatitudeIntervals(intervals ...lunarLatitudeInterval) []lunarLatitudeInterval { + if len(intervals) == 0 { + return nil + } + sort.Slice(intervals, func(left, right int) bool { return intervals[left].low < intervals[right].low }) + merged := make([]lunarLatitudeInterval, 0, len(intervals)) + for _, interval := range intervals { + if interval.high <= interval.low { + continue + } + if len(merged) == 0 || interval.low > merged[len(merged)-1].high+1e-9 { + merged = append(merged, interval) + continue + } + if interval.high > merged[len(merged)-1].high { + merged[len(merged)-1].high = interval.high + } + } + return merged +} + +func lunarIntersectLatitudeIntervals(left, right []lunarLatitudeInterval) []lunarLatitudeInterval { + if len(left) == 0 || len(right) == 0 { + return nil + } + result := make([]lunarLatitudeInterval, 0, len(left)) + for _, first := range left { + for _, second := range right { + low := math.Max(first.low, second.low) + high := math.Min(first.high, second.high) + if high > low { + result = append(result, lunarLatitudeInterval{low: low, high: high}) + } + } + } + return lunarUnionLatitudeIntervals(result...) +} + +// lunarEnvelopeBand 是一条沿经度连续延伸的可见带,收口时下边界正向、上边界反向拼成环。 +type lunarEnvelopeBand struct { + low []geodata.GeoPoint + high []geodata.GeoPoint + last lunarLatitudeInterval +} + +// lunarVisibilityEnvelopeGeometry 按纬度重叠把每列的区间串成带,同一列的主带与极冠各走各的。 +func lunarVisibilityEnvelopeGeometry(longitudes []float64, columns [][]lunarLatitudeInterval) (geometry, error) { + polygons := make([][]geodata.GeoPoint, 0, 2) + bands := make([]lunarEnvelopeBand, 0, 2) + closeBand := func(band lunarEnvelopeBand) { + if len(band.low) < 2 { + return + } + ring := make([]geodata.GeoPoint, 0, len(band.low)+len(band.high)) + ring = append(ring, band.low...) + for index := len(band.high) - 1; index >= 0; index-- { + ring = append(ring, band.high[index]) + } + if len(ring) >= 3 { + polygons = append(polygons, ring) + } + } + for columnIndex, longitude := range longitudes { + column := columns[columnIndex] + assigned := make([]int, len(bands)) + used := make([]bool, len(column)) + for index := range assigned { + assigned[index] = -1 + } + for { + bestBand, bestInterval, bestOverlap := -1, -1, 0.0 + for bandIndex := range bands { + if assigned[bandIndex] >= 0 { + continue + } + for intervalIndex, interval := range column { + if used[intervalIndex] { + continue + } + overlap := math.Min(interval.high, bands[bandIndex].last.high) - + math.Max(interval.low, bands[bandIndex].last.low) + if overlap > bestOverlap { + bestBand, bestInterval, bestOverlap = bandIndex, intervalIndex, overlap + } + } + } + if bestBand < 0 { + break + } + assigned[bestBand] = bestInterval + used[bestInterval] = true + bands[bestBand].low = append(bands[bestBand].low, geodata.GeoPoint{Longitude: longitude, Latitude: column[bestInterval].low}) + bands[bestBand].high = append(bands[bestBand].high, geodata.GeoPoint{Longitude: longitude, Latitude: column[bestInterval].high}) + bands[bestBand].last = column[bestInterval] + } + alive := bands[:0] + for bandIndex := range bands { + if assigned[bandIndex] < 0 { + closeBand(bands[bandIndex]) + continue + } + alive = append(alive, bands[bandIndex]) + } + bands = alive + for intervalIndex, interval := range column { + if used[intervalIndex] { + continue + } + bands = append(bands, lunarEnvelopeBand{ + low: []geodata.GeoPoint{{Longitude: longitude, Latitude: interval.low}}, + high: []geodata.GeoPoint{{Longitude: longitude, Latitude: interval.high}}, + last: interval, + }) + } + } + for _, band := range bands { + closeBand(band) + } + if len(polygons) == 0 { + return geometry{Type: "MultiPolygon", Coordinates: [][][][]float64{}}, nil + } + return multiPolygonGeometry(polygons) } func marshalLunarEclipse( info eclipsecore.LunarEclipseInfo, boundaryPoints int, - markerOptions *TimeMarkerOptions, + options LunarEclipseOptions, ) ([]byte, error) { + markerOptions := options.TimeMarkers if markerOptions != nil { if err := validateTimeMarkerOptions(*markerOptions); err != nil { return nil, err @@ -1756,28 +2408,38 @@ func marshalLunarEclipse( if err := validateLunarEclipseInfo(info); err != nil { return nil, err } + timeScale, scaleErr := timeScaleForMarkers(markerOptions) + if scaleErr != nil { + return nil, scaleErr + } + // 几何一律用民用时刻,只有写进属性的时刻换时标,否则把 UT1 读数当民用时刻会平移月下点。 + geometry := info + if timeScale == astro.TimeScaleUT1 { + info = eclipsecore.LunarEclipseInfoInUT1(info) + } boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints) properties := map[string]interface{}{ "eclipse_type": string(info.Type), "boundary_points": boundaryPoints, } - features := make([]feature, 0, 5) + features := make([]feature, 0, 7) contacts := []struct { - role string - horizonRole string - time time.Time + role string + horizonRole string + geometryTime time.Time + labelTime time.Time }{ - {role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart}, - {role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd}, + {role: "visible-at-p1", horizonRole: "p1-horizon", geometryTime: geometry.PenumbralStart, labelTime: info.PenumbralStart}, + {role: "visible-at-p4", horizonRole: "p4-horizon", geometryTime: geometry.PenumbralEnd, labelTime: info.PenumbralEnd}, } for _, contact := range contacts { - points := basic.MoonHorizon(basic.Date2JDE(contact.time.UTC()), boundaryPoints) + points := basic.MoonHorizon(basic.Date2JD(contact.geometryTime.UTC()), boundaryPoints) horizon := make([]geodata.GeoPoint, len(points)) for index, point := range points { horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} } - horizon = lunarhorizon.Refine(horizon, contact.time) + horizon = lunarhorizon.Refine(horizon, contact.geometryTime) value, err := multiPolygonGeometry([][]geodata.GeoPoint{horizon}) if err != nil { return nil, fmt.Errorf("geojson: %s: %w", contact.role, err) @@ -1792,7 +2454,7 @@ func marshalLunarEclipse( // not be filtered. value = dropDegenerateMultiPolygonRings(value) contactProperties := cloneProperties(properties) - contactProperties["time"] = formatTime(contact.time) + contactProperties["time"] = formatTime(contact.labelTime) features = append(features, newFeature( lunarEclipseEvent, contact.role, value, contactProperties, )) @@ -1807,12 +2469,51 @@ func marshalLunarEclipse( horizonValue, map[string]interface{}{ "eclipse_type": string(info.Type), - "time": formatTime(contact.time), + "time": formatTime(contact.labelTime), }, )) } - maximum := lunarSubpoint(info.Maximum) + wantPenumbraMoonset := !skippedLunarRole(options.SkipRoles, "penumbra-moonset") + wantPenumbraMoonrise := !skippedLunarRole(options.SkipRoles, "penumbra-moonrise") + if info.HasPartial && (wantPenumbraMoonset || wantPenumbraMoonrise) { + bandGeometries, bandErr := lunarPenumbraBandGeometries( + geometry, boundaryPoints, options, wantPenumbraMoonset, wantPenumbraMoonrise, + ) + if bandErr != nil { + return nil, bandErr + } + if wantPenumbraMoonset { + features = append(features, newFeature(lunarEclipseEvent, "penumbra-moonset", bandGeometries[0], + lunarPenumbraBandProperties(info, info.PenumbralStart, info.PartialStart, boundaryPoints))) + } + if wantPenumbraMoonrise { + features = append(features, newFeature(lunarEclipseEvent, "penumbra-moonrise", bandGeometries[1], + lunarPenumbraBandProperties(info, info.PartialEnd, info.PenumbralEnd, boundaryPoints))) + } + } + + wantDuring := !skippedLunarRole(options.SkipRoles, "visible-during-eclipse") + wantThroughout := !skippedLunarRole(options.SkipRoles, "visible-throughout-eclipse") + if wantDuring || wantThroughout { + unionGeometry, intersectionGeometry, envelopeErr := lunarVisibilityEnvelopeGeometries( + geometry, boundaryPoints, options, wantDuring, wantThroughout, + ) + if envelopeErr != nil { + return nil, envelopeErr + } + longitudePoints := lunarVisibilityLongitudePoints(boundaryPoints, options) + if wantDuring { + features = append(features, newFeature(lunarEclipseEvent, "visible-during-eclipse", unionGeometry, + lunarVisibilityEnvelopeProperties(info, "union", longitudePoints))) + } + if wantThroughout { + features = append(features, newFeature(lunarEclipseEvent, "visible-throughout-eclipse", intersectionGeometry, + lunarVisibilityEnvelopeProperties(info, "intersection", longitudePoints))) + } + } + + maximum := lunarSubpoint(geometry.Maximum) features, err := appendPointFeature( features, lunarEclipseEvent, @@ -1824,7 +2525,7 @@ func marshalLunarEclipse( return nil, err } if markerOptions != nil { - markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions) + markers, markerErr := lunarEclipseTimeMarkerSamples(geometry, timeScale, *markerOptions) if markerErr != nil { return nil, markerErr } @@ -1839,7 +2540,7 @@ func marshalLunarEclipse( return nil, err } } - return marshalFeatureCollection(features) + return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), timeScale) } // horizonExact 为 true 时把开放边界补到地平圈擦地点(导出用);为 false 时沿用旧封口, @@ -3688,6 +4389,7 @@ func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interfac "magnitude": info.Magnitude, "gamma": info.Gamma, "path_width_km": info.PathWidthKM, + "path_width_defined": info.PathWidthDefined, "partial_begin_on_earth": formatTime(info.PartialBeginOnEarth), "partial_end_on_earth": formatTime(info.PartialEndOnEarth), "central_begin_on_earth": formatTime(info.CentralBeginOnEarth), @@ -3752,12 +4454,20 @@ func degenerateGeoJSONRing(ring [][]float64) bool { return true } area := 0.0 + minimumLongitude, maximumLongitude := math.Inf(1), math.Inf(-1) for index := range ring { next := ring[(index+1)%len(ring)] if len(ring[index]) < 2 || len(next) < 2 { return false } area += ring[index][0]*next[1] - next[0]*ring[index][1] + minimumLongitude = math.Min(minimumLongitude, ring[index][0]) + maximumLongitude = math.Max(maximumLongitude, ring[index][0]) + } + // 顶点全落在同一条子午线上时面积只剩求和噪声,量级随顶点数增长(实测 9e-12,越过 1e-12 阈值), + // 必须先按经度跨度判死,不能只靠面积。 + if maximumLongitude-minimumLongitude < 1e-9 { + return true } return math.Abs(area/2) < 1e-12 } @@ -3787,23 +4497,24 @@ func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interfac } func solarSubsolarPoint(value time.Time) geodata.GeoPoint { - 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) - longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15) + ut1JDE := basic.TT2UT1(ttJDE) + longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15) return geodata.GeoPoint{Longitude: longitude, Latitude: dec} } func lunarSubpoint(value time.Time) geodata.GeoPoint { - ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true) + ttJDE := basic.UTC2TT(basic.Date2JD(value.UTC())) ra, dec := basic.HMoonTrueRaDec(ttJDE) - utJDE := basic.TD2UT(ttJDE, false) - longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15) + ut1JDE := basic.TT2UT1(ttJDE) + longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15) return geodata.GeoPoint{Longitude: longitude, Latitude: dec} } func lunarEclipseTimeMarkerSamples( info eclipsecore.LunarEclipseInfo, + scale astro.TimeScale, options TimeMarkerOptions, ) ([]pathSample, error) { step, err := normalizeTimeMarkerStep(options.Step) @@ -3820,8 +4531,12 @@ func lunarEclipseTimeMarkerSamples( markers := make([]pathSample, 0, capacity) for current.Before(end) { point := lunarSubpoint(current) + label := current + if scale == astro.TimeScaleUT1 { + label = astro.LabelIn(astro.TimeScaleUT1, current) + } markers = append(markers, pathSample{ - Time: current, + Time: label, Longitude: point.Longitude, Latitude: point.Latitude, }) @@ -3842,3 +4557,14 @@ func normalizeLunarBoundaryPoints(value int) int { } return value } + +// timeScaleForMarkers 校验导出时标选项:UT1 时刻没有时区语义,配非 UTC 时区时明确失败。 +func timeScaleForMarkers(options *TimeMarkerOptions) (astro.TimeScale, error) { + if options == nil || options.TimeScale != astro.TimeScaleUT1 { + return astro.TimeScaleUTC, nil + } + if options.Location != nil && options.Location != time.UTC { + return astro.TimeScaleUTC, fmt.Errorf("geojson: UT1 time properties do not take a non-UTC location") + } + return astro.TimeScaleUT1, nil +} diff --git a/geojson/eclipse_path_width_defined_test.go b/geojson/eclipse_path_width_defined_test.go new file mode 100644 index 0000000..9b03a3a --- /dev/null +++ b/geojson/eclipse_path_width_defined_test.go @@ -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) + } +} diff --git a/geojson/geojson.go b/geojson/geojson.go index 1dd7791..b152515 100644 --- a/geojson/geojson.go +++ b/geojson/geojson.go @@ -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 { diff --git a/geojson/geojson_test.go b/geojson/geojson_test.go index ba1aa49..5b070a9 100644 --- a/geojson/geojson_test.go +++ b/geojson/geojson_test.go @@ -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 时区应报错") + } +} diff --git a/geojson/lunar_horizon_test.go b/geojson/lunar_horizon_test.go index c674843..39d8d53 100644 --- a/geojson/lunar_horizon_test.go +++ b/geojson/lunar_horizon_test.go @@ -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 +} diff --git a/geojson/lunar_penumbra_band_test.go b/geojson/lunar_penumbra_band_test.go new file mode 100644 index 0000000..4924977 --- /dev/null +++ b/geojson/lunar_penumbra_band_test.go @@ -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) + } + } + } +} diff --git a/geojson/lunar_visibility_ring_test.go b/geojson/lunar_visibility_ring_test.go index 76a1c45..080256d 100644 --- a/geojson/lunar_visibility_ring_test.go +++ b/geojson/lunar_visibility_ring_test.go @@ -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 { diff --git a/geojson/occultation.go b/geojson/occultation.go index c7606ea..fd17679 100644 --- a/geojson/occultation.go +++ b/geojson/occultation.go @@ -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 diff --git a/geojson/occultation_timescale_test.go b/geojson/occultation_timescale_test.go new file mode 100644 index 0000000..42423b0 --- /dev/null +++ b/geojson/occultation_timescale_test.go @@ -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{} +} diff --git a/geojson/path_regression_p2_test.go b/geojson/path_regression_p2_test.go index 3bc4548..ff456da 100644 --- a/geojson/path_regression_p2_test.go +++ b/geojson/path_regression_p2_test.go @@ -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, diff --git a/geojson/solar_eclipse_20120521_regression_test.go b/geojson/solar_eclipse_20120521_regression_test.go index 98af0a3..98929ca 100644 --- a/geojson/solar_eclipse_20120521_regression_test.go +++ b/geojson/solar_eclipse_20120521_regression_test.go @@ -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{ diff --git a/geojson/solar_eclipse_central_shadow_region_test.go b/geojson/solar_eclipse_central_shadow_region_test.go index 158c80d..cf2ecda 100644 --- a/geojson/solar_eclipse_central_shadow_region_test.go +++ b/geojson/solar_eclipse_central_shadow_region_test.go @@ -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 diff --git a/geojson/solar_eclipse_grazing_band_regression_test.go b/geojson/solar_eclipse_grazing_band_regression_test.go index 576ec6d..7732f5b 100644 --- a/geojson/solar_eclipse_grazing_band_regression_test.go +++ b/geojson/solar_eclipse_grazing_band_regression_test.go @@ -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) diff --git a/geojson/solar_eclipse_options_test.go b/geojson/solar_eclipse_options_test.go new file mode 100644 index 0000000..c1e23eb --- /dev/null +++ b/geojson/solar_eclipse_options_test.go @@ -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) + } +} diff --git a/geojson/solar_isochrone_seam_test.go b/geojson/solar_isochrone_seam_test.go new file mode 100644 index 0000000..a44dd5d --- /dev/null +++ b/geojson/solar_isochrone_seam_test.go @@ -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]) + } + } + } +} diff --git a/geojson/solar_shadow_instant.go b/geojson/solar_shadow_instant.go index 87ef5be..79f5fd8 100644 --- a/geojson/solar_shadow_instant.go +++ b/geojson/solar_shadow_instant.go @@ -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, diff --git a/geojson/solar_shadow_region.go b/geojson/solar_shadow_region.go index 53f145a..efd497e 100644 --- a/geojson/solar_shadow_region.go +++ b/geojson/solar_shadow_region.go @@ -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}, } } diff --git a/geojson/ut1_geometry_test.go b/geojson/ut1_geometry_test.go new file mode 100644 index 0000000..0fa35d1 --- /dev/null +++ b/geojson/ut1_geometry_test.go @@ -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 +} diff --git a/internal/civiltime/event.go b/internal/civiltime/event.go new file mode 100644 index 0000000..e3009d9 --- /dev/null +++ b/internal/civiltime/event.go @@ -0,0 +1,36 @@ +package civiltime + +import "time" + +// Event 合并变长民用日的固定偏移候选,handled=false 表示普通日期 / merges fixed-offset candidates on a civil day with an offset change. +func Event(date time.Time, missing error, calculate func(time.Time) (time.Time, error)) (result time.Time, err error, handled bool) { + year, month, day := date.Date() + start := time.Date(year, month, day, 0, 0, 0, 0, date.Location()) + end := time.Date(year, month, day+1, 0, 0, 0, 0, date.Location()) + _, firstOffset := start.Zone() + _, lastOffset := end.Add(-time.Nanosecond).Zone() + if firstOffset == lastOffset { + return time.Time{}, nil, false + } + var firstErr error + for _, offset := range []int{firstOffset, lastOffset} { + fixed := time.Date(year, month, day, 0, 0, 0, 0, time.FixedZone("", offset)) + candidate, candidateErr := calculate(fixed) + if candidateErr != nil { + if firstErr == nil { + firstErr = candidateErr + } + continue + } + if !candidate.Before(start) && candidate.Before(end) && (result.IsZero() || candidate.Before(result)) { + result = candidate.In(date.Location()) + } + } + if !result.IsZero() { + return result, nil, true + } + if firstErr != nil { + return time.Time{}, firstErr, true + } + return time.Time{}, missing, true +} diff --git a/internal/civiltime/event_test.go b/internal/civiltime/event_test.go new file mode 100644 index 0000000..40df0cb --- /dev/null +++ b/internal/civiltime/event_test.go @@ -0,0 +1,55 @@ +package civiltime + +import ( + "errors" + "testing" + "time" +) + +func TestEventFiltersByActualCivilDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + missing := errors.New("no event on requested day") + cases := []struct { + month, day, offset, hour int + wantFound bool + }{ + {3, 8, -5, 23, false}, + {11, 1, -5, 23, true}, + } + for _, tc := range cases { + date := time.Date(2026, time.Month(tc.month), tc.day, 12, 0, 0, 0, loc) + candidate := time.Date(2026, time.Month(tc.month), tc.day, tc.hour, 30, 0, 0, time.FixedZone("", tc.offset*3600)) + calls := 0 + got, err, handled := Event(date, missing, func(fixed time.Time) (time.Time, error) { + calls++ + if fixed.Location() == loc { + t.Fatal("calculation must use a fixed offset") + } + return candidate, nil + }) + if !handled || calls != 2 { + t.Fatalf("handled=%t calls=%d", handled, calls) + } + if tc.wantFound { + if err != nil || !got.Equal(candidate) || got.Location() != loc { + t.Errorf("got %s, %v; want %s", got, err, candidate) + } + } else if !got.IsZero() || !errors.Is(err, missing) { + t.Errorf("outside-day result %s, %v", got, err) + } + } +} + +func TestEventLeavesFixedOffsetToCaller(t *testing.T) { + date := time.Date(2026, 11, 1, 12, 0, 0, 0, time.FixedZone("EST", -5*3600)) + _, _, handled := Event(date, nil, func(time.Time) (time.Time, error) { + t.Fatal("ordinary day invoked fallback") + return time.Time{}, nil + }) + if handled { + t.Fatal("ordinary day reported handled") + } +} diff --git a/internal/geodata/seam_test.go b/internal/geodata/seam_test.go index d8b976f..7153429 100644 --- a/internal/geodata/seam_test.go +++ b/internal/geodata/seam_test.go @@ -32,8 +32,9 @@ func TestPolygonFragmentsCentredSeamKeepsGeometry(t *testing.T) { t.Fatalf("fragment has %d points", len(fragment)) } for _, point := range fragment { - if point.Longitude < -60-1e-6 || point.Longitude > 60+1e-6 { - t.Fatalf("fragment longitude %.3f escaped the ring", point.Longitude) + // 分段用地图窗口坐标表示(居中经线 ±180),折回 -180…180 后再比对。 + if longitude := normalizeLongitude180(point.Longitude); longitude < -60-1e-6 || longitude > 60+1e-6 { + t.Fatalf("fragment longitude %.3f escaped the ring", longitude) } if math.Abs(point.Latitude) > 30+0.5 { t.Fatalf("fragment latitude %.3f escaped the ring", point.Latitude) diff --git a/internal/geodata/topology.go b/internal/geodata/topology.go index 1c872a9..b9427e1 100644 --- a/internal/geodata/topology.go +++ b/internal/geodata/topology.go @@ -455,7 +455,7 @@ func equirectangularSeamShift(view ClipView) float64 { func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { segments := splitPolylineAntimeridian(rotateLongitudes(points, -shift)) for _, segment := range segments { - rotateLongitudesInPlace(segment, shift) + unshiftLongitudesInPlace(segment, shift) } return segments } @@ -464,7 +464,7 @@ func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { fragments := splitPolygonAntimeridian(rotateLongitudes(points, -shift)) for _, fragment := range fragments { - rotateLongitudesInPlace(fragment, shift) + unshiftLongitudesInPlace(fragment, shift) } return fragments } @@ -472,13 +472,25 @@ func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { func rotateLongitudes(points []GeoPoint, shift float64) []GeoPoint { rotated := make([]GeoPoint, len(points)) copy(rotated, points) - rotateLongitudesInPlace(rotated, shift) + for index := range rotated { + rotated[index].Longitude = normalizeLongitude180(rotated[index].Longitude + shift) + } return rotated } -func rotateLongitudesInPlace(points []GeoPoint, shift float64) { +// unshiftLongitudesInPlace 把接缝坐标系的分段转回地图窗口,结果落在 [shift-180, shift+180]。 +// 窗口两端是同一条接缝经线:分段落在 +180 的点贴的是右边缘,折回 -180 会让闭合边横穿整幅图。 +func unshiftLongitudesInPlace(points []GeoPoint, shift float64) { + left := shift - 180 for index := range points { - points[index].Longitude = normalizeLongitude180(points[index].Longitude + shift) + value := points[index].Longitude + shift + for value < left { + value += 360 + } + for value > left+360 { + value -= 360 + } + points[index].Longitude = value } } diff --git a/internal/lunarhorizon/lunarhorizon.go b/internal/lunarhorizon/lunarhorizon.go index fa782fe..01fe78e 100644 --- a/internal/lunarhorizon/lunarhorizon.go +++ b/internal/lunarhorizon/lunarhorizon.go @@ -64,11 +64,11 @@ func RefineWithin(points []geodata.GeoPoint, at time.Time, toleranceDegrees floa if len(points) < 3 { return points } - jd := basic.Date2JDE(at.UTC()) - tt := basic.TD2UT(jd, true) + jd := basic.Date2JD(at.UTC()) + tt := basic.UTC2TT(jd) ra, dec := basic.HMoonTrueRaDec(tt) distanceAU := basic.HMoonAway(tt) / 149597870.7 - sidereal := basic.ApparentSiderealTime(jd) * 15 + sidereal := basic.ApparentSiderealTime(basic.UTC2UT1(jd)) * 15 center := geodata.GeoPoint{Longitude: normalizeLongitude(ra - sidereal), Latitude: dec} // Every correction is at the same instant. Reuse its full ephemeris while // retaining the ellipsoid and topocentric transform used by HMoonHeight. diff --git a/internal/svgchart/text.go b/internal/svgchart/text.go index 0412c1d..7b38809 100644 --- a/internal/svgchart/text.go +++ b/internal/svgchart/text.go @@ -4,6 +4,7 @@ import ( "math" "strings" "unicode" + "unicode/utf8" ) // ellipsisMark 是截断标记;用单个全角省略号,避免与文本里的三个半角点混淆。 @@ -43,6 +44,12 @@ func truncateWithMark(value string, maxWidth, fontSize float64) string { // WrapText 按 EstimatedTextWidth 保守折行:优先在空白处断开,单个词或全角文本按字符断开,空行不返回。 // WrapText wraps conservatively by EstimatedTextWidth, preferring whitespace breaks; empty lines are dropped. func WrapText(value string, maxWidth, fontSize float64) []string { + return wrapText(value, maxWidth, fontSize, false) +} + +// wrapText 是 WrapText 与 WrapTextBalanced 共用的折行核心;fullWidthBreak 为真时允许在全角字符前断开, +// 只有落在 ASCII 词内部才回退到空白,避免中文夹英文的行被空白回退压成短行。 +func wrapText(value string, maxWidth, fontSize float64, fullWidthBreak bool) []string { value = strings.TrimSpace(value) if value == "" { return nil @@ -67,7 +74,7 @@ func WrapText(value string, maxWidth, fontSize float64) []string { } end++ } - if end < len(runes) && lastSpace > 0 { + if end < len(runes) && lastSpace > 0 && !(fullWidthBreak && runes[end] >= utf8.RuneSelf) { end = lastSpace } if end == 0 { @@ -84,6 +91,48 @@ func WrapText(value string, maxWidth, fontSize float64) []string { return lines } +// WrapTextBalanced 均衡折行:行数取 maxWidth 下 WrapText 的行数 n,再二分出仍能压到 n 行的最小宽度折行,使各行宽度接近; +// 折行宽度不比 maxWidth 宽,逐行都不超过 maxWidth。全角字符前允许硬断,因此结果可能少于 n 行(中文夹空格时段落尾部会只剩一个短桩)。 +// 只影响断行位置,不改 WrapText 本身的口径。 +// WrapTextBalanced keeps the greedy line count for maxWidth and narrows the break width until the lines are close in width. +// It may return fewer lines than WrapText because it may break before a full-width rune. +func WrapTextBalanced(value string, maxWidth, fontSize float64) []string { + if maxWidth <= 0 { + return WrapText(value, maxWidth, fontSize) + } + return balancedTextLines(value, maxWidth, fontSize, len(wrapText(value, maxWidth, fontSize, true))) +} + +// WrapTextBalancedLines 均衡折行到 maxLines 行(maxLines 非正时等同 WrapTextBalanced),供只有固定行数槽位的文本块使用; +// maxWidth 下压不到 maxLines 行时返回放得下的最少行数,由调用方截断。 +// WrapTextBalancedLines balances the text into maxLines slots when maxWidth allows it. +func WrapTextBalancedLines(value string, maxWidth, fontSize float64, maxLines int) []string { + if maxWidth <= 0 { + return WrapText(value, maxWidth, fontSize) + } + if maxLines <= 0 { + return WrapTextBalanced(value, maxWidth, fontSize) + } + return balancedTextLines(value, maxWidth, fontSize, maxLines) +} + +// balancedTextLines 二分出仍能压到 targetLines 行的最小折行宽度并折行;行数随折行宽度单调不增,故二分成立。 +func balancedTextLines(value string, maxWidth, fontSize float64, targetLines int) []string { + if targetLines < 2 { + return wrapText(value, maxWidth, fontSize, true) + } + low, high := 0.0, maxWidth + for iteration := 0; iteration < 40; iteration++ { + middle := (low + high) / 2 + if len(wrapText(value, middle, fontSize, true)) > targetLines { + low = middle + continue + } + high = middle + } + return wrapText(value, high, fontSize, true) +} + // TextLineLimit 返回高度预算 maxHeight 能容纳的行数,至少一行;口径与 EstimatedTextExtents 一致。 // TextLineLimit reports how many lines fit in maxHeight, never less than one. func TextLineLimit(fontSize, lineHeight, maxHeight float64) int { @@ -105,6 +154,50 @@ func BaselineLineLimit(fontSize, lineHeight, firstBaseline, bottomLimit float64) return TextLineLimit(fontSize, lineHeight, bottomLimit-firstBaseline+above) } +// FooterLineHeight 返回页脚行距:按 1.5 倍字号取整,12px 字得 18px、11px 字得 17px。 +// FooterLineHeight returns the footer line step, 1.5x the font size, rounded. +func FooterLineHeight(fontSize float64) float64 { + return math.Round(fontSize * 1.5) +} + +// FooterBottomPadding 返回页脚末行基线距画布底边的留白:按 1.35 倍字号取整,12px 字得 16px、11px 字得 15px。 +// FooterBottomPadding returns the gap between the last footer baseline and the canvas bottom. +func FooterBottomPadding(fontSize float64) float64 { + return math.Round(fontSize * 1.35) +} + +// FooterBlock 把页脚各行按统一行距自画布底边往上排:末行基线距底边 padding,其上每行递增 lineHeight; +// 返回各行基线与整块自底边起占用的高度(含首行字高),供上方内容留位。 +// FooterBlock lays footer lines bottom-up with one uniform step and returns their baselines +// plus the height the block occupies above the canvas bottom. +func FooterBlock(lineCount int, height, fontSize, lineHeight, padding float64) (baselines []float64, occupied float64) { + if lineCount < 1 { + lineCount = 1 + } + above, _ := EstimatedTextExtents(fontSize) + baselines = make([]float64, lineCount) + for index := range baselines { + baselines[index] = height - padding - float64(lineCount-1-index)*lineHeight + } + return baselines, padding + float64(lineCount-1)*lineHeight + above +} + +// FooterLinesWithScale 在说明行之后接上时标声明:说明最多占 maxLines-1 行,声明独占最后一行、不参与截断; +// 说明行数超过 maxLines 时先截断说明,再长的自定义说明也挤不掉时标声明。 +// FooterLinesWithScale keeps the time-scale declaration on its own final line, outside truncation. +func FooterLinesWithScale(notes []string, scaleNote string, maxWidth, fontSize float64, maxLines int) []string { + if maxLines <= 0 { + return nil + } + if scaleNote == "" { + return TruncateTextLines(notes, maxWidth, fontSize, maxLines) + } + if maxLines == 1 { + return []string{scaleNote} + } + return append(TruncateTextLines(notes, maxWidth, fontSize, maxLines-1), scaleNote) +} + // TruncateTextLines 按行数上限截断折行结果:截断时末行以省略号结尾并重新裁到 maxWidth 内,未截断时原样返回。 // TruncateTextLines cuts lines to maxLines; a cut last line is ellipsized to maxWidth. func TruncateTextLines(lines []string, maxWidth, fontSize float64, maxLines int) []string { diff --git a/internal/svgchart/text_test.go b/internal/svgchart/text_test.go index 43547e6..6a78128 100644 --- a/internal/svgchart/text_test.go +++ b/internal/svgchart/text_test.go @@ -1,6 +1,7 @@ package svgchart import ( + "reflect" "strings" "testing" ) @@ -72,6 +73,33 @@ func TestTruncateTextLinesEllipsizesTheLastLineToWidth(t *testing.T) { textFits(t, kept, 60, 10) } +func TestFooterLinesWithScaleRespectsLineLimit(t *testing.T) { + notes := []string{"说明一"} + scale := "图中时刻为 UTC" + for _, test := range []struct { + name string + maxLines int + scaleNote string + want []string + }{ + {name: "no lines", maxLines: 0, scaleNote: scale}, + {name: "negative lines", maxLines: -1, scaleNote: scale}, + {name: "scale only", maxLines: 1, scaleNote: scale, want: []string{scale}}, + {name: "notes and scale", maxLines: 2, scaleNote: scale, want: []string{"说明一", scale}}, + {name: "notes without scale", maxLines: 1, want: []string{"说明一"}}, + } { + t.Run(test.name, func(t *testing.T) { + got := FooterLinesWithScale(notes, test.scaleNote, 400, 11, test.maxLines) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("lines = %#v, want %#v", got, test.want) + } + if test.maxLines > 0 && len(got) > test.maxLines { + t.Fatalf("lines = %d, want no more than %d", len(got), test.maxLines) + } + }) + } +} + func TestTextLineLimitCountsExtents(t *testing.T) { // 3 行 11 号字、行距 15 的总高约 42.65,预算再少一点就只放得下 2 行。 if limit := TextLineLimit(11, 15, 42.7); limit != 3 { @@ -106,3 +134,113 @@ func TestEllipsizeTextStaysInsideWidth(t *testing.T) { t.Fatalf("clamped = %q, want an empty string when even the mark does not fit", empty) } } + +// 均衡折行只改断行位置:行数不超过同宽度下的 WrapText,行宽互相接近,且逐行仍在 maxWidth 内。 +// textRunes 拼掉断行处被吃掉的空白,用于比较两种折行的字符内容。 +func textRunes(lines []string) string { + return strings.Join(strings.Fields(strings.Join(lines, " ")), "") +} + +func textSpread(lines []string, fontSize float64) float64 { + minimum, maximum := 0.0, 0.0 + for index, line := range lines { + width := EstimatedTextWidth(line, fontSize) + if index == 0 || width < minimum { + minimum = width + } + if index == 0 || width > maximum { + maximum = width + } + } + if minimum <= 0 { + return 0 + } + return maximum / minimum +} + +func TestWrapTextBalancedKeepsLineCountAndWidth(t *testing.T) { + for _, test := range []struct { + name string + value string + maxWidth float64 + fontSize float64 + sameCount bool + wantSpread bool + }{ + {name: "english prose", maxWidth: 500, fontSize: 10, sameCount: true, wantSpread: true, + value: "Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track."}, + {name: "chinese with one space", maxWidth: 420, fontSize: 12, wantSpread: true, + value: "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)"}, + {name: "mixed page", maxWidth: 300, fontSize: 9, + value: strings.Repeat("月球掩星 occultation 说明;", 8)}, + } { + t.Run(test.name, func(t *testing.T) { + greedy := WrapText(test.value, test.maxWidth, test.fontSize) + balanced := WrapTextBalanced(test.value, test.maxWidth, test.fontSize) + if len(balanced) > len(greedy) { + t.Fatalf("balanced lines = %d, want no more than the greedy %d", len(balanced), len(greedy)) + } + if test.sameCount && len(balanced) != len(greedy) { + t.Fatalf("balanced lines = %d, want the greedy %d", len(balanced), len(greedy)) + } + if len(balanced) < 2 { + t.Fatalf("balanced lines = %d, want the text to be wrapped", len(balanced)) + } + textFits(t, balanced, test.maxWidth, test.fontSize) + if got, want := textRunes(balanced), textRunes(greedy); got != want { + t.Fatalf("balanced text = %q, want the same runes as %q", got, want) + } + spread := textSpread(balanced, test.fontSize) + if test.sameCount && spread > textSpread(greedy, test.fontSize) { + t.Fatalf("balanced spread = %.3f, want no worse than the greedy %.3f (%#v)", + spread, textSpread(greedy, test.fontSize), balanced) + } + if test.wantSpread && spread > 1.15 { + t.Fatalf("balanced spread = %.3f, want <= 1.15 (%#v)", spread, balanced) + } + }) + } +} + +// 中文里只有一个空格时,WrapText 会为躲开西文词把首行回退到该空格,WrapTextBalanced 允许在全角字符前断开,不再留下短行。 +func TestWrapTextBalancedBreaksFullWidthTextWithoutShortTail(t *testing.T) { + value := "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)" + greedy := WrapText(value, 420, 12) + if len(greedy) < 3 { + t.Fatalf("greedy lines = %#v, want the whitespace backoff to cost extra lines", greedy) + } + if width := EstimatedTextWidth(greedy[0], 12); width > 0.5*420 { + t.Fatalf("greedy first line = %q (%.1f), want the short backoff line", greedy[0], width) + } + balanced := WrapTextBalanced(value, 420, 12) + if len(balanced) >= len(greedy) { + t.Fatalf("balanced lines = %d, want fewer than the greedy %d", len(balanced), len(greedy)) + } + textFits(t, balanced, 420, 12) + if len(balanced) != 2 || textSpread(balanced, 12) > 1.15 { + t.Fatalf("balanced = %#v, want two lines of close width", balanced) + } +} + +func TestWrapTextBalancedLinesFillsSlots(t *testing.T) { + value := strings.Repeat("月球掩星 occultation 说明;", 2) + filled := WrapTextBalancedLines(value, 400, 9, 2) + if len(filled) != 2 { + t.Fatalf("filled lines = %d, want 2", len(filled)) + } + textFits(t, filled, 400, 9) + if spread := textSpread(filled, 9); spread > 1.15 { + t.Fatalf("filled spread = %.3f, want <= 1.15 (%#v)", spread, filled) + } + if natural := WrapTextBalanced(value, 400, 9); len(natural) != 1 { + t.Fatalf("natural lines = %d, want the natural wrap to keep one line", len(natural)) + } + if lines := WrapTextBalancedLines(value, 400, 9, 0); len(lines) != 1 { + t.Fatalf("lines = %d, want maxLines 0 to fall back to the natural wrap", len(lines)) + } + // 槽位放不下时不强行行数:返回可用宽度下的折行结果,由调用方截断。 + long := strings.Repeat("月球掩星 occultation 说明;", 12) + if lines := WrapTextBalancedLines(long, 400, 9, 2); len(lines) < 2 { + t.Fatalf("lines = %d, want the width-limited wrap", len(lines)) + } +} diff --git a/internal/svgmap/map.go b/internal/svgmap/map.go index b2f4be0..dd6008b 100644 --- a/internal/svgmap/map.go +++ b/internal/svgmap/map.go @@ -163,11 +163,13 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) { } fmt.Fprintf(builder, ``, clipID) if !frame.IsPolar() { - first, last := -150.0, 150.0 + // 居中视图循环到窗口另一端会同一条接缝经线,只画一次。 + first, count := -150.0, 11 if frame.CenterLongitude != 0 { - first, last = frame.CenterLongitude-180, frame.CenterLongitude+180 + first, count = frame.CenterLongitude-180, 12 } - for longitude := first; longitude <= last; longitude += 30 { + for index := 0; index < count; index++ { + longitude := first + 30*float64(index) x, _, _ := frame.Project(longitude, 0) if frame.CenterLongitude != 0 && (x < frame.X-0.5 || x > frame.X+frame.Width+0.5) { continue @@ -269,17 +271,18 @@ func (frame Frame) hemisphere() float64 { return 1 } -// equirectangularLongitudeOffset 返回经度相对居中经线的偏移,换算成 0…360 的剂量。 -// 居中经线落在画面正中,其对面的经线落在左右任一边界上。 +// equirectangularLongitudeOffset 返回经度相对画面左边缘的偏移,换算成 0…360 的剂量。 +// 居中经线落在画面正中,其对面的经线同时是左右边缘:已经在窗口内的经度按原值返回, +// 让接缝两侧的点各自贴住自己那一侧的边缘,窗口外的经度再按 360 折回。 func equirectangularLongitudeOffset(longitude, center float64) float64 { - offset := math.Mod(longitude-center, 360) + offset := longitude - (center - 180) + if offset >= 0 && offset <= 360 { + return offset + } + offset = math.Mod(offset, 360) if offset < 0 { offset += 360 } - offset += 180 - if offset >= 360 { - offset -= 360 - } return offset } diff --git a/internal/svgmap/seam_test.go b/internal/svgmap/seam_test.go new file mode 100644 index 0000000..6ad0147 --- /dev/null +++ b/internal/svgmap/seam_test.go @@ -0,0 +1,92 @@ +package svgmap + +import ( + "math" + "testing" +) + +// 居中经线对面的接缝同时是画面左右边缘:贴右边缘的分段必须落在窗口右端, +// 折回左端会让闭合边横穿整幅图。 +func TestEquirectangularSeamKeepsBothWindowEdges(t *testing.T) { + frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4, + Projection: ProjectionEquirectangular, CenterLongitude: 176.271} + left := frame.CenterLongitude - 180 + right := frame.CenterLongitude + 180 + if x, _, _ := frame.Project(left, 0); math.Abs(x-frame.X) > 1e-9 { + t.Fatalf("seam at the left edge maps to x=%.6f, want %.6f", x, frame.X) + } + if x, _, _ := frame.Project(right, 0); math.Abs(x-(frame.X+frame.Width)) > 1e-9 { + t.Fatalf("the same seam in the adjacent world maps to x=%.6f, want %.6f", x, frame.X+frame.Width) + } + if x, _, _ := frame.Project(right-0.5, 0); x >= frame.X+frame.Width { + t.Fatalf("a point just west of the seam maps to x=%.6f, want inside the frame", x) + } + + ring := make([]GeoPoint, 0, 40) + for longitude := -10.0; longitude <= 5; longitude += 5 { + ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 0}) + } + for latitude := 5.0; latitude <= 20; latitude += 5 { + ring = append(ring, GeoPoint{Longitude: 5, Latitude: latitude}) + } + for longitude := 0.0; longitude >= -10; longitude -= 5 { + ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 20}) + } + for latitude := 15.0; latitude >= 5; latitude -= 5 { + ring = append(ring, GeoPoint{Longitude: -10, Latitude: latitude}) + } + fragments := PolygonFragments(ring, frame.Clip()) + if len(fragments) != 2 { + t.Fatalf("straddling ring fragments = %d, want 2", len(fragments)) + } + for index, fragment := range fragments { + minX, maxX := math.Inf(1), math.Inf(-1) + for _, point := range fragment { + x, _, ok := frame.Project(point.Longitude, point.Latitude) + if !ok { + t.Fatalf("fragment %d dropped a point", index) + } + minX, maxX = math.Min(minX, x), math.Max(maxX, x) + } + if maxX-minX > frame.Width/2 { + t.Fatalf("fragment %d spans %.3f px, want one side of the seam only", index, maxX-minX) + } + } +} + +// 绕极点一圈的环靠地图上边缘闭合,闭合边两端必须分贴左右边缘。 +func TestEquirectangularPoleClosureUsesMapEdges(t *testing.T) { + frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4, + Projection: ProjectionEquirectangular, CenterLongitude: 176.271} + ring := make([]GeoPoint, 0, 36) + for longitude := -180.0; longitude < 180; longitude += 10 { + ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 70}) + } + fragments := PolygonFragments(ring, frame.Clip()) + if len(fragments) != 1 { + t.Fatalf("cap ring fragments = %d, want 1", len(fragments)) + } + left, right, top := false, false, false + for _, point := range fragments[0] { + if math.Abs(point.Latitude) < 89.999 { + continue + } + x, y, _ := frame.Project(point.Longitude, point.Latitude) + if math.Abs(y-frame.Y) > 1e-6 { + t.Fatalf("pole closure at y=%.3f, want the map top edge %.3f", y, frame.Y) + } + switch { + case math.Abs(x-frame.X) < 1e-6: + left = true + case math.Abs(x-(frame.X+frame.Width)) < 1e-6: + right = true + } + top = true + } + if !top || !left || !right { + t.Fatalf("pole closure left=%v right=%v top=%v, want the cap closed along both edges", left, right, top) + } + if fragments := PolygonFragments(ring, ClipView{Projection: ProjectionEquirectangular}); len(fragments) != 1 { + t.Fatalf("uncentred cap fragments = %d, want 1", len(fragments)) + } +} diff --git a/internal/timenote/timenote.go b/internal/timenote/timenote.go new file mode 100644 index 0000000..18c7119 --- /dev/null +++ b/internal/timenote/timenote.go @@ -0,0 +1,120 @@ +// Package timenote 生成出图时写入图内或图注的时标声明。 +package timenote + +import ( + "fmt" + "strconv" + "strings" + "time" + + "b612.me/astro" +) + +const english = "en" + +// Scale 生成图内时标声明:UTC 口径写显示时区与 UTC 偏移,UT1 口径补 DUT1 差值 / builds the in-figure time-scale declaration. +func Scale(scale astro.TimeScale, instant time.Time, location *time.Location, language string) string { + if scale == astro.TimeScaleUT1 { + dut1 := astro.DUT1(instant) + if language == english { + return fmt.Sprintf("Times are UT1 (Universal Time 1); DUT1 = UT1−UTC = %+.2f s.", dut1) + } + return fmt.Sprintf("图中时刻为 UT1(世界时),DUT1 = UT1−UTC = %+.2f s。", dut1) + } + zone, offset := displayZone(instant, location) + if offset == 0 { + if language == english { + return "All times are UTC" + } + return "图中时刻为 UTC" + } + if named, ok := namedZoneOffset(zone); ok && named == offset { + // 时区名本身就是偏移标签(FixedZone("UTC+08:00")、"Etc/GMT+8" 之类)时再写一遍偏移就是重复文案。 + if language == english { + return fmt.Sprintf("All times are UTC (shown in %s)", zone) + } + return fmt.Sprintf("图中时刻为 UTC(显示时区 %s)", zone) + } + sign := "+" + if offset < 0 { + sign, offset = "-", -offset + } + hours, minutes := offset/3600, offset%3600/60 + if language == english { + return fmt.Sprintf("All times are UTC (shown in %s, UTC%s%02d:%02d)", zone, sign, hours, minutes) + } + return fmt.Sprintf("图中时刻为 UTC(显示时区 %s,UTC%s%02d:%02d)", zone, sign, hours, minutes) +} + +// Merge 把时标声明并进图注,已含声明时不重复 / appends the declaration to a footer note once. +func Merge(text, note string) string { + if note == "" { + return text + } + if text == "" { + return note + } + if strings.Contains(text, note) { + return text + } + return text + " " + note +} + +// namedZoneOffset 解析时区名里写死的 UTC 偏移,秒为单位;名字不含偏移时返回 false。 +func namedZoneOffset(name string) (int, bool) { + rest := strings.ToUpper(name) + for _, prefix := range []string{"UTC", "GMT", "UT"} { + if strings.HasPrefix(rest, prefix) { + rest = rest[len(prefix):] + break + } + } + if rest == "" { + return 0, false + } + sign := 1 + switch rest[0] { + case '+': + rest = rest[1:] + case '-': + sign, rest = -1, rest[1:] + default: + return 0, false + } + digits := strings.ReplaceAll(rest, ":", "") + if digits == "" || len(digits) > 6 { + return 0, false + } + for _, symbol := range digits { + if symbol < '0' || symbol > '9' { + return 0, false + } + } + hours, minutes, seconds := 0, 0, 0 + switch len(digits) { + case 1, 2: + hours, _ = strconv.Atoi(digits) + case 3, 4: + hours, _ = strconv.Atoi(digits[:len(digits)-2]) + minutes, _ = strconv.Atoi(digits[len(digits)-2:]) + case 5, 6: + hours, _ = strconv.Atoi(digits[:len(digits)-4]) + minutes, _ = strconv.Atoi(digits[len(digits)-4 : len(digits)-2]) + seconds, _ = strconv.Atoi(digits[len(digits)-2:]) + } + if minutes > 59 || seconds > 59 { + return 0, false + } + return sign * (hours*3600 + minutes*60 + seconds), true +} + +func displayZone(instant time.Time, location *time.Location) (string, int) { + if location != nil { + instant = instant.In(location) + } + zone, offset := instant.Zone() + if zone == "" { + zone = "UTC" + } + return zone, offset +} diff --git a/internal/timenote/timenote_test.go b/internal/timenote/timenote_test.go new file mode 100644 index 0000000..9ac7a47 --- /dev/null +++ b/internal/timenote/timenote_test.go @@ -0,0 +1,64 @@ +package timenote + +import ( + "strings" + "testing" + "time" + + "b612.me/astro" +) + +// 时标声明里同一个 UTC 偏移只写一次:时区名本身是偏移标签(FixedZone("UTC+08:00") 之类)时不再追加偏移。 +func TestScaleWritesTheOffsetOnce(t *testing.T) { + instant := time.Date(2025, time.June, 5, 20, 2, 6, 0, time.UTC) + for _, test := range []struct { + name string + location *time.Location + language string + want string + }{ + {name: "偏移型时区名", location: time.FixedZone("UTC+08:00", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+08:00)"}, + {name: "偏移型时区名英文", location: time.FixedZone("UTC+08:00", 8*3600), language: "en", want: "All times are UTC (shown in UTC+08:00)"}, + {name: "整点时区名", location: time.FixedZone("UTC+8", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+8)"}, + {name: "半小时偏移名", location: time.FixedZone("+05:30", 5*3600+1800), want: "图中时刻为 UTC(显示时区 +05:30)"}, + {name: "地名缩写中文", location: time.FixedZone("CST", 8*3600), want: "图中时刻为 UTC(显示时区 CST,UTC+08:00)"}, + {name: "地名缩写英文", location: time.FixedZone("EST", -5*3600), language: "en", want: "All times are UTC (shown in EST, UTC-05:00)"}, + {name: "偏移与名字不符", location: time.FixedZone("UTC+3", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+3,UTC+08:00)"}, + {name: "UTC", location: time.UTC, want: "图中时刻为 UTC"}, + } { + t.Run(test.name, func(t *testing.T) { + got := Scale(astro.TimeScaleUTC, instant, test.location, test.language) + if got != test.want { + t.Fatalf("Scale() = %q, want %q", got, test.want) + } + if strings.Count(got, "UTC+08:00") > 1 || strings.Count(got, "UTC+8") > 1 { + t.Fatalf("Scale() = %q, want the offset written once", got) + } + }) + } +} + +func TestNamedZoneOffset(t *testing.T) { + for _, test := range []struct { + name string + value string + offset int + ok bool + }{ + {name: "UTC 前缀", value: "UTC+08:00", offset: 8 * 3600, ok: true}, + {name: "GMT 前缀", value: "GMT-5", offset: -5 * 3600, ok: true}, + {name: "无前缀", value: "+0800", offset: 8 * 3600, ok: true}, + {name: "带秒", value: "UTC+05:30:30", offset: 5*3600 + 30*60 + 30, ok: true}, + {name: "地名", value: "CST", ok: false}, + {name: "UTC 本身", value: "UTC", ok: false}, + {name: "小数偏移", value: "UTC+8.5", ok: false}, + {name: "分钟越界", value: "UTC+0870", ok: false}, + } { + t.Run(test.name, func(t *testing.T) { + offset, ok := namedZoneOffset(test.value) + if ok != test.ok || offset != test.offset { + t.Fatalf("namedZoneOffset(%q) = (%d, %v), want (%d, %v)", test.value, offset, ok, test.offset, test.ok) + } + }) + } +} diff --git a/jupiter/diameter.go b/jupiter/diameter.go index 39d7454..c94f370 100644 --- a/jupiter/diameter.go +++ b/jupiter/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 木星视半径(截断版),单位角秒 / truncated apparent Jupiter semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 木星视直径,单位角秒 / apparent Jupiter diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 木星视直径(截断版),单位角秒 / truncated apparent Jupiter diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterDiameterN(basic.UTC2TT(jd), n) } diff --git a/jupiter/jupiter.go b/jupiter/jupiter.go index 6b7e193..7fb739c 100644 --- a/jupiter/jupiter.go +++ b/jupiter/jupiter.go @@ -15,7 +15,7 @@ var ( ERR_JUPITER_NEVER_DOWN = ERR_JUPITER_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回木星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Jupiter at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回木星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Jupiter at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回木星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Jupiter at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回木星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Jupiter at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回木星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Jupiter at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回木星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Jupiter at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回木星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Jupiter to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthJupiterAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthJupiterAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回木星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Jupiter to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(4, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(4, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterHeight(jde, lon, lat, timezone) + return basic.JupiterHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterAzimuth(jde, lon, lat, timezone) + return basic.JupiterAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterHourAngle(jde, lon, timezone) + return basic.JupiterHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.JupiterCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.JupiterCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.JupiterRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.JupiterRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.JupiterSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.JupiterSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterConjunction(jde), date.Location(), false) } // LastOpposition 上一次冲日 / previous opposition. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or before date, keeping date's time zone. func LastOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterOpposition(jde), date.Location(), false) } // NextOpposition 下一次冲日 / next opposition. @@ -237,8 +232,8 @@ func LastOpposition(date time.Time) time.Time { // 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or after date, keeping date's time zone. func NextOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterOpposition(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -246,8 +241,8 @@ func NextOpposition(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterProgradeToRetrograde(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -255,8 +250,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterProgradeToRetrograde(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -264,8 +259,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterRetrogradeToPrograde(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -273,8 +268,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterRetrogradeToPrograde(jde), date.Location(), false) } // LastEasternQuadrature 上一次东方照 / previous eastern quadrature. @@ -282,8 +277,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or before date, keeping date's time zone. func LastEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterEasternQuadrature(jde), date.Location(), false) } // NextEasternQuadrature 下一次东方照 / next eastern quadrature. @@ -291,8 +286,8 @@ func LastEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or after date, keeping date's time zone. func NextEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterEasternQuadrature(jde), date.Location(), false) } // LastWesternQuadrature 上一次西方照 / previous western quadrature. @@ -300,8 +295,8 @@ func NextEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or before date, keeping date's time zone. func LastWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastJupiterWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastJupiterWesternQuadrature(jde), date.Location(), false) } // NextWesternQuadrature 下一次西方照 / next western quadrature. @@ -309,6 +304,6 @@ func LastWesternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or after date, keeping date's time zone. func NextWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextJupiterWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextJupiterWesternQuadrature(jde), date.Location(), false) } diff --git a/jupiter/nodes.go b/jupiter/nodes.go index 9e46507..e75d517 100644 --- a/jupiter/nodes.go +++ b/jupiter/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 木星升交点黄经(截断版) / truncated ascending node longitude of Jupiter. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 木星降交点黄经 / descending node longitude of Jupiter. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 木星降交点黄经(截断版) / truncated descending node longitude of Jupiter. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/jupiter/phase.go b/jupiter/phase.go index f857427..30590ef 100644 --- a/jupiter/phase.go +++ b/jupiter/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/jupiter/phenomena.go b/jupiter/phenomena.go index 9627712..504894b 100644 --- a/jupiter/phenomena.go +++ b/jupiter/phenomena.go @@ -38,7 +38,7 @@ type GalileanPhenomenaInfo struct { // date 表示观测绝对时刻(UTC),内部换算为该时刻对应的 TT/TDB 历元求值。 // date is the observing instant in UTC; it is converted to the corresponding TT/TDB epoch for evaluation. func SatellitePhenomena(date time.Time) GalileanPhenomenaInfo { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) phenomena := basic.JupiterGalileanSatellitePhenomena(jde) return GalileanPhenomenaInfo{ Io: galileanPhenomenonFromBasic(phenomena[0]), diff --git a/jupiter/phenomena_contact_events.go b/jupiter/phenomena_contact_events.go index d01b2ff..fffb2bf 100644 --- a/jupiter/phenomena_contact_events.go +++ b/jupiter/phenomena_contact_events.go @@ -59,7 +59,7 @@ type GalileanPhenomenonContactEvent struct { func LastGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent { return galileanPhenomenonContactEventFromBasic( basic.LastJupiterGalileanPhenomenonContactEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -71,7 +71,7 @@ func LastGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomeno func NextGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent { return galileanPhenomenonContactEventFromBasic( basic.NextJupiterGalileanPhenomenonContactEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -83,7 +83,7 @@ func NextGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomeno func ClosestGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent { return galileanPhenomenonContactEventFromBasic( basic.ClosestJupiterGalileanPhenomenonContactEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -95,7 +95,7 @@ func galileanPhenomenonContactEventFromBasic(event basic.JupiterGalileanPhenomen if !event.Valid { return GalileanPhenomenonContactEvent{} } - greatest := basic.JDE2DateByZone(event.Greatest, loc, false) + greatest := basic.JD2DateByZone(event.Greatest, loc, false) return GalileanPhenomenonContactEvent{ Valid: true, Satellite: event.Satellite, @@ -111,9 +111,9 @@ func galileanPhenomenonContactFromBasic(contact basic.JupiterGalileanPhenomenonC if !contact.Valid { return GalileanPhenomenonContact{} } - start := basic.JDE2DateByZone(contact.Start, loc, false) - modelCrossing := basic.JDE2DateByZone(contact.ModelCrossing, loc, false) - end := basic.JDE2DateByZone(contact.End, loc, false) + start := basic.JD2DateByZone(contact.Start, loc, false) + modelCrossing := basic.JD2DateByZone(contact.ModelCrossing, loc, false) + end := basic.JD2DateByZone(contact.End, loc, false) return GalileanPhenomenonContact{ Valid: true, Phase: GalileanPhenomenonContactPhase(contact.Phase), diff --git a/jupiter/phenomena_contact_events_test.go b/jupiter/phenomena_contact_events_test.go index 8f1a522..1f1289b 100644 --- a/jupiter/phenomena_contact_events_test.go +++ b/jupiter/phenomena_contact_events_test.go @@ -18,7 +18,7 @@ func TestGalileanPhenomenonContactEventWrappersMatchBasic(t *testing.T) { got := ClosestGalileanPhenomenonContactEvent(queryMid, record.Satellite, phenomenonType) want := basic.ClosestJupiterGalileanPhenomenonContactEvent( - basic.Date2JDE(queryMid.UTC()), + basic.Date2JD(queryMid.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ) @@ -43,7 +43,7 @@ func assertGalileanContactWrapperMatchesBasic( if got.Greatest.Location() != loc { t.Fatalf("%s greatest timezone mismatch", name) } - wantGreatest := basic.JDE2DateByZone(want.Greatest, loc, false) + wantGreatest := basic.JD2DateByZone(want.Greatest, loc, false) if !got.Greatest.Equal(wantGreatest) { t.Fatalf("%s greatest mismatch: got %s want %s", name, got.Greatest.Format(time.RFC3339Nano), wantGreatest.Format(time.RFC3339Nano)) } @@ -69,9 +69,9 @@ func assertGalileanContactMatchesBasic( if !got.Valid { return } - wantStart := basic.JDE2DateByZone(want.Start, loc, false) - wantModel := basic.JDE2DateByZone(want.ModelCrossing, loc, false) - wantEnd := basic.JDE2DateByZone(want.End, loc, false) + wantStart := basic.JD2DateByZone(want.Start, loc, false) + wantModel := basic.JD2DateByZone(want.ModelCrossing, loc, false) + wantEnd := basic.JD2DateByZone(want.End, loc, false) if got.Start.Location() != loc || got.ModelCrossing.Location() != loc || got.End.Location() != loc { t.Fatalf("%s timezone mismatch", name) } diff --git a/jupiter/phenomena_events.go b/jupiter/phenomena_events.go index 28a6e22..a31324e 100644 --- a/jupiter/phenomena_events.go +++ b/jupiter/phenomena_events.go @@ -63,7 +63,7 @@ type GalileanPhenomenonEvent struct { func LastGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent { return galileanPhenomenonEventFromBasic( basic.LastJupiterGalileanPhenomenonEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -75,7 +75,7 @@ func LastGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType G func NextGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent { return galileanPhenomenonEventFromBasic( basic.NextJupiterGalileanPhenomenonEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -87,7 +87,7 @@ func NextGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType G func ClosestGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent { return galileanPhenomenonEventFromBasic( basic.ClosestJupiterGalileanPhenomenonEvent( - basic.Date2JDE(date.UTC()), + basic.Date2JD(date.UTC()), satellite, basic.JupiterGalileanPhenomenonType(phenomenonType), ), @@ -99,9 +99,9 @@ func galileanPhenomenonEventFromBasic(event basic.JupiterGalileanPhenomenonEvent if !event.Valid { return GalileanPhenomenonEvent{} } - start := basic.JDE2DateByZone(event.Start, loc, false) - greatest := basic.JDE2DateByZone(event.Greatest, loc, false) - end := basic.JDE2DateByZone(event.End, loc, false) + start := basic.JD2DateByZone(event.Start, loc, false) + greatest := basic.JD2DateByZone(event.Greatest, loc, false) + end := basic.JD2DateByZone(event.End, loc, false) return GalileanPhenomenonEvent{ Valid: true, Satellite: event.Satellite, diff --git a/jupiter/phenomena_events_test.go b/jupiter/phenomena_events_test.go index 14de11a..4eb7cda 100644 --- a/jupiter/phenomena_events_test.go +++ b/jupiter/phenomena_events_test.go @@ -35,21 +35,21 @@ func TestGalileanPhenomenonEventWrappersMatchBasic(t *testing.T) { t, record.Label+" next", NextGalileanPhenomenonEvent(queryBefore, record.Satellite, phenomenonType), - basic.NextJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryBefore.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), + basic.NextJupiterGalileanPhenomenonEvent(basic.Date2JD(queryBefore.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), loc, ) assertGalileanWrapperMatchesBasic( t, record.Label+" last", LastGalileanPhenomenonEvent(queryAfter, record.Satellite, phenomenonType), - basic.LastJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryAfter.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), + basic.LastJupiterGalileanPhenomenonEvent(basic.Date2JD(queryAfter.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), loc, ) assertGalileanWrapperMatchesBasic( t, record.Label+" closest", ClosestGalileanPhenomenonEvent(queryMid, record.Satellite, phenomenonType), - basic.ClosestJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryMid.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), + basic.ClosestJupiterGalileanPhenomenonEvent(basic.Date2JD(queryMid.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)), loc, ) } @@ -72,9 +72,9 @@ func assertGalileanWrapperMatchesBasic( if got.Start.Location() != loc || got.Greatest.Location() != loc || got.End.Location() != loc { t.Fatalf("%s timezone mismatch", name) } - wantStart := basic.JDE2DateByZone(want.Start, loc, false) - wantGreatest := basic.JDE2DateByZone(want.Greatest, loc, false) - wantEnd := basic.JDE2DateByZone(want.End, loc, false) + wantStart := basic.JD2DateByZone(want.Start, loc, false) + wantGreatest := basic.JD2DateByZone(want.Greatest, loc, false) + wantEnd := basic.JD2DateByZone(want.End, loc, false) if !got.Start.Equal(wantStart) || !got.Greatest.Equal(wantGreatest) || !got.End.Equal(wantEnd) { t.Fatalf( "%s time mismatch: got [%s %s %s] want [%s %s %s]", diff --git a/jupiter/phenomena_test.go b/jupiter/phenomena_test.go index 5c623c6..15e6e26 100644 --- a/jupiter/phenomena_test.go +++ b/jupiter/phenomena_test.go @@ -116,7 +116,7 @@ func TestGalileanPhenomenaUseTTFrame(t *testing.T) { time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC), } for _, date := range dates { - want := basic.JupiterGalileanSatellitePhenomena(basic.TD2UT(basic.Date2JDE(date.UTC()), true)) + want := basic.JupiterGalileanSatellitePhenomena(basic.UTC2TT(basic.Date2JD(date.UTC()))) got := SatellitePhenomena(date) phenomena := []GalileanSatellitePhenomenon{got.Io, got.Europa, got.Ganymede, got.Callisto} for i, phenomenon := range phenomena { diff --git a/jupiter/physical.go b/jupiter/physical.go index 6ae7c34..8f8d32f 100644 --- a/jupiter/physical.go +++ b/jupiter/physical.go @@ -47,11 +47,11 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 木星物理观测参数(截断版) / truncated physical observing parameters of Jupiter. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - jd := basic.TD2UT(jde, true) - info := basic.JupiterPhysicalN(jd, n) - meridians := basic.JupiterCentralMeridiansN(jd, n) - ds, de := basic.JupiterDSDEN(jd, n) + jd := basic.Date2JD(date.UTC()) + jde := basic.UTC2TT(jd) + info := basic.JupiterPhysicalN(jde, n) + meridians := basic.JupiterCentralMeridiansN(jde, n) + ds, de := basic.JupiterDSDEN(jde, n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, @@ -73,8 +73,8 @@ func CentralMeridians(date time.Time) CentralMeridianInfo { // CentralMeridiansN 木星 System I/II/III 中央经线(截断版) / truncated Jupiter System I/II/III central meridians. func CentralMeridiansN(date time.Time, n int) CentralMeridianInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.JupiterCentralMeridiansN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.JupiterCentralMeridiansN(basic.UTC2TT(jd), n) return CentralMeridianInfo{ SystemI: info.SystemI, SystemII: info.SystemII, diff --git a/jupiter/physical_test.go b/jupiter/physical_test.go index 56ff839..0ff1038 100644 --- a/jupiter/physical_test.go +++ b/jupiter/physical_test.go @@ -10,12 +10,12 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.JupiterPhysicalN(basic.TD2UT(jde, true), -1) - wantMeridians := basic.JupiterCentralMeridiansN(basic.TD2UT(jde, true), -1) + want := basic.JupiterPhysicalN(basic.UTC2TT(jde), -1) + wantMeridians := basic.JupiterCentralMeridiansN(basic.UTC2TT(jde), -1) gotMeridians := CentralMeridians(date) gotMeridiansN := CentralMeridiansN(date, -1) @@ -24,7 +24,7 @@ func TestPhysicalWrapperMatchesBasic(t *testing.T) { assertSamePhysicalFloat(t, "SubSolarLongitude", got.SubSolarLongitude, want.SubSolarLongitude) assertSamePhysicalFloat(t, "SubSolarLatitude", got.SubSolarLatitude, want.SubSolarLatitude) assertSamePhysicalFloat(t, "NorthPolePositionAngle", got.NorthPolePositionAngle, want.NorthPolePositionAngle) - wantDS, wantDE := basic.JupiterDSDEN(basic.TD2UT(jde, true), -1) + wantDS, wantDE := basic.JupiterDSDEN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "DS", got.DS, wantDS) assertSamePhysicalFloat(t, "DE", got.DE, wantDE) assertSamePhysicalFloat(t, "CentralMeridianSystemI", got.CentralMeridianSystemI, wantMeridians.SystemI) diff --git a/jupiter/satellites.go b/jupiter/satellites.go index 681749a..0ccae7c 100644 --- a/jupiter/satellites.go +++ b/jupiter/satellites.go @@ -46,7 +46,7 @@ type GalileanSatellitesInfo struct { // date 表示观测绝对时刻(UTC),内部换算为该时刻对应的 TT/TDB 历元做 L1 星历求值。 // date is the observing instant in UTC; it is converted to the corresponding TT/TDB epoch for the L1 ephemeris evaluation. func Satellites(date time.Time) GalileanSatellitesInfo { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) observations := basic.JupiterGalileanSatelliteObservations(jde) return GalileanSatellitesInfo{ Io: galileanSatellitePositionFromBasic(observations[0]), diff --git a/jupiter/satellites_test.go b/jupiter/satellites_test.go index f847cf5..2a2c539 100644 --- a/jupiter/satellites_test.go +++ b/jupiter/satellites_test.go @@ -106,7 +106,7 @@ func TestGalileanSatellitesUseTTFrame(t *testing.T) { time.Date(2026, 4, 2, 18, 0, 0, 0, time.UTC), } for _, date := range dates { - want := basic.JupiterGalileanSatelliteObservations(basic.TD2UT(basic.Date2JDE(date.UTC()), true)) + want := basic.JupiterGalileanSatelliteObservations(basic.UTC2TT(basic.Date2JD(date.UTC()))) got := Satellites(date) positions := []GalileanSatellitePosition{got.Io, got.Europa, got.Ganymede, got.Callisto} for i, position := range positions { diff --git a/jupiter/truncated.go b/jupiter/truncated.go index b222e6a..8b1ab68 100644 --- a/jupiter/truncated.go +++ b/jupiter/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.JupiterMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.JupiterMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthJupiterAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthJupiterAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(4, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(4, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterHeightN(jde, lon, lat, timezone, n) + return basic.JupiterHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterAzimuthN(jde, lon, lat, timezone, n) + return basic.JupiterAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.JupiterHourAngleN(jde, lon, timezone, n) + return basic.JupiterHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.JupiterCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.JupiterCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.JupiterRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.JupiterRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.JupiterSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.JupiterSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/kml/kml.go b/kml/kml.go new file mode 100644 index 0000000..cc75a0f --- /dev/null +++ b/kml/kml.go @@ -0,0 +1,1477 @@ +// Package kml 把 RFC 7946 GeoJSON 转成 KML 2.2,供 Google Earth 等客户端交互查看;除本库根包的 +// 时标门面外只依赖标准库。输入可以是本库 geojson 包的输出,也可以是任何第三方 GeoJSON 文件。 +// Package kml converts RFC 7946 GeoJSON into KML 2.2 for interactive clients such as Google Earth, +// using only the standard library apart from the root package's time-scale facade. The input may come +// from this library's geojson package or from any third-party GeoJSON file. +// +// 分层与配色都按要素的 role 属性派发:role 作 Folder,样式取下面的默认调色板,可用 Options.Styles 覆盖。 +// 默认只有高亮中心带与掩星带填充,其余面只描轮廓,跨半球且互相重叠的重图层可用 Options.SkipRoles 整层丢弃。 +// 时间:带 times 的 MultiLineString 拆成逐段 Placemark 并各带 TimeStamp,逐点的 time 属性同理; +// 集合带 time_scale=UT1 时先把标签折回 UTC 再写 (KML 时间轴必须是 UTC),原口径记进 ExtendedData。 +// Layers and colors are dispatched by each feature's role property, and times become KML TimeStamps. +package kml + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "b612.me/astro" +) + +const namespace = "http://www.opengis.net/kml/2.2" + +// 默认调色板,KML 的 aabbggrr 十六进制(与 CSS 的 rrggbb 顺序不同):日食中心带红、中心线黑、 +// 食甚等时线绿、食分线与掩星全掩/半掩带黄、日出日落阶段线与偏食区边界橙,其余中性灰。 +const ( + // ColorRed 不透明红,日食中心带 / opaque red for the solar central band. + ColorRed = "ff0000ff" + // ColorBlack 不透明黑,中心线 / opaque black for the center line. + ColorBlack = "ff000000" + // ColorGreen 不透明绿,日食食甚等时线 / opaque green for solar greatest-time isochrones. + ColorGreen = "ff008000" + // ColorYellow 不透明黄,食分线与掩星全掩/半掩带 / opaque yellow for magnitude contours and occultation bands. + ColorYellow = "ff00ffff" + // ColorDarkBlue 深蓝,导出常量保留但默认调色板已不用它 / kept exported, unused by the default palette. + ColorDarkBlue = "ff8b0000" + // ColorOrange 不透明橙,日出日落阶段线与偏食区边界 / opaque orange for rise/set phase lines and the partial-visibility edge. + ColorOrange = "ff008cff" + // ColorGray 灰,未指定家族的中性图层 / gray for unclassified layers. + ColorGray = "ff808080" + + redFill = "590000ff" + yellowFill = "4d00ffff" + grayFill = "33808080" + // 仅见半影的两条带用与 SVG 同一族色相:月出偏蓝、月落偏紫红。 + penumbraMoonriseColor = "ffcc8874" + penumbraMoonsetColor = "ffb57fab" + penumbraMoonriseFill = "4dcc8874" + penumbraMoonsetFill = "4db57fab" +) + +const ( + eventSolarEclipse = "solar-eclipse" + eventLunarEclipse = "lunar-eclipse" + eventLunarOccultation = "lunar-occultation" +) + +// Style 是一个 KML 样式;零值字段使用内置调色板。颜色为 KML 的 aabbggrr 十六进制(ff0000ff 是不透明红)。 +// Style describes one KML style; zero fields fall back to the built-in palette. Colors use KML's +// aabbggrr hex order (ff0000ff is opaque red). +type Style struct { + // LineColor 是线色;空值用调色板 / line color, empty for the palette default. + LineColor string + // FillColor 是面填充色;空值用调色板(只有中心带、掩星带这类高亮面才有填充),点与线忽略该字段。 + // FillColor is the polygon fill; empty uses the palette, where only highlight bands carry a fill. + FillColor string + // NoFill 为 true 时强制不填充,压过调色板与 FillColor / forces no fill, overriding the palette and FillColor. + NoFill bool + // LineWidth 是线宽(像素);<=0 用调色板默认 / line width in pixels. + LineWidth float64 +} + +// Options 控制输出 / Options controls the output. +type Options struct { + // Name 是 Document 名;空值按事件类型与集合里最早的时刻推导。 + // Name is the Document name; empty derives it from the event type and the earliest instant. + Name string + // Language 选择图层名语言:"zh"(默认)或 "en";未收录的角色保留原始 role id。 + // Language selects layer names: "zh" (default) or "en"; unknown roles keep their raw id. + Language string + // Styles 覆盖默认样式,键为 role,或更具体的 "event/role"(后者优先)。 + // Styles overrides the palette, keyed by role or by the more specific "event/role". + Styles map[string]Style + // NoLookAt 为 true 时不输出 Document/LookAt / omits the Document LookAt. + NoLookAt bool + // SkipRoles 列出要整层丢弃的 role。日食的 partial-band 与 partial-footprint 覆盖大半个地球, + // 光栅化很重且互相重叠,移动端客户端容易卡顿,按需丢弃即可 / roles to drop entirely. + SkipRoles []string + // FillContext 为 true 时中性面图层也填充;默认只填高亮中心带与掩星带,避免大面积半透明面拖慢客户端。 + // FillContext also fills neutral context polygons; by default only highlight bands are filled. + FillContext bool + // NoTimes 为 true 时不输出任何时间元素,得到一份静态叠加;客户端的时间轴停在事件窗口之外时会 + // 隐藏全部带时间的要素(只剩没有时间戳的面),静态查看时用它绕开该行为。 + // NoTimes omits every time primitive, producing a static overlay; clients whose time slider sits + // outside the event window hide all timed features, and this option sidesteps that behaviour. + NoTimes bool +} + +type document struct { + Name string `xml:"name,omitempty"` + Description string `xml:"description,omitempty"` + LookAt *lookAt `xml:"LookAt,omitempty"` + TimeSpan *timeSpan `xml:"TimeSpan,omitempty"` + Styles []kmlStyle `xml:"Style"` + ExtendedData *extendedData `xml:"ExtendedData,omitempty"` + Folders []folder `xml:"Folder"` +} + +type folder struct { + Name string `xml:"name,omitempty"` + Placemarks []placemark `xml:"Placemark"` +} + +type placemark struct { + Name string `xml:"name,omitempty"` + TimeStamp *timeStamp `xml:"TimeStamp,omitempty"` + StyleURL string `xml:"styleUrl,omitempty"` + ExtendedData *extendedData `xml:"ExtendedData,omitempty"` + geometry +} + +type timeStamp struct { + When string `xml:"when"` +} + +type timeSpan struct { + Begin string `xml:"begin"` + End string `xml:"end"` +} + +// 字段顺序就是 KML 2.2 的 LookAt sequence(heading、tilt、range),顺序错了严格客户端与 XSD 校验会拒绝整个 LookAt。 +type lookAt struct { + Longitude float64 `xml:"longitude"` + Latitude float64 `xml:"latitude"` + Heading float64 `xml:"heading"` + Tilt float64 `xml:"tilt"` + Range float64 `xml:"range"` +} + +// 字段顺序就是 KML 2.2 的 Style sequence(IconStyle、LabelStyle、LineStyle、PolyStyle), +// 顺序错了严格客户端会整段丢弃样式。 +type kmlStyle struct { + ID string `xml:"id,attr"` + IconStyle *iconStyle `xml:"IconStyle,omitempty"` + LineStyle *lineStyle `xml:"LineStyle,omitempty"` + PolyStyle *polyStyle `xml:"PolyStyle,omitempty"` +} + +type lineStyle struct { + Color string `xml:"color"` + Width float64 `xml:"width"` +} + +type polyStyle struct { + Color string `xml:"color"` + Fill int `xml:"fill"` + Outline int `xml:"outline"` +} + +type iconStyle struct { + Color string `xml:"color"` + Scale float64 `xml:"scale"` +} + +type dataEntry struct { + Name string `xml:"name,attr"` + Value string `xml:"value"` +} + +type extendedData struct { + Data []dataEntry `xml:"Data"` +} + +type point struct { + AltitudeMode string `xml:"altitudeMode"` + Coordinates string `xml:"coordinates"` +} + +type lineString struct { + Tessellate int `xml:"tessellate"` + AltitudeMode string `xml:"altitudeMode"` + Coordinates string `xml:"coordinates"` +} + +type linearRing struct { + Coordinates string `xml:"coordinates"` +} + +type boundary struct { + Ring linearRing `xml:"LinearRing"` +} + +type polygon struct { + Tessellate int `xml:"tessellate"` + AltitudeMode string `xml:"altitudeMode"` + OuterBoundaryIs *boundary `xml:"outerBoundaryIs,omitempty"` + InnerBoundaries []boundary `xml:"innerBoundaryIs,omitempty"` +} + +type geometry struct { + Point *point `xml:"Point,omitempty"` + LineString *lineString `xml:"LineString,omitempty"` + Polygon *polygon `xml:"Polygon,omitempty"` + MultiGeometry *multiGeometry `xml:"MultiGeometry,omitempty"` +} + +type multiGeometry struct { + Points []point `xml:"Point,omitempty"` + Lines []lineString `xml:"LineString,omitempty"` + Polygons []polygon `xml:"Polygon,omitempty"` +} + +// empty 报告几何在去重与接缝切除之后是否还有可画的部分。 +func (g geometry) empty() bool { + switch { + case g.Point != nil, g.LineString != nil, g.Polygon != nil: + return false + case g.MultiGeometry != nil: + multi := g.MultiGeometry + return len(multi.Points) == 0 && len(multi.Lines) == 0 && len(multi.Polygons) == 0 + } + return true +} + +// rawCollection 是解析 RFC 7946 的最小结构;coordinates 按几何类型分别解码。 +type rawCollection struct { + Type string `json:"type"` + TimeScale string `json:"time_scale"` + Features []rawFeature `json:"features"` +} + +type rawFeature struct { + Type string `json:"type"` + Properties map[string]interface{} `json:"properties"` + Geometry rawGeometry `json:"geometry"` +} + +type rawGeometry struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + Geometries []rawGeometry `json:"geometries"` +} + +// element 是规范化的几何片段:三种类型互斥,when 是该片段的时间戳(可空)。 +type element struct { + kind string + point []float64 + line [][]float64 + rings [][][]float64 + when string +} + +type kmlRoot struct { + XMLName xml.Name `xml:"kml"` + Xmlns string `xml:"xmlns,attr"` + Document document `xml:"Document"` +} + +// FromGeoJSON 把一份 RFC 7946 FeatureCollection 转成 KML 2.2 字节 / converts one RFC 7946 FeatureCollection into KML 2.2 bytes. +func FromGeoJSON(data []byte, options Options) ([]byte, error) { + var collection rawCollection + if err := json.Unmarshal(data, &collection); err != nil { + return nil, fmt.Errorf("kml: decode GeoJSON: %w", err) + } + if collection.Type != "FeatureCollection" { + return nil, fmt.Errorf("kml: GeoJSON type %q is not a FeatureCollection", collection.Type) + } + built, err := buildDocument(&collection, options) + if err != nil { + return nil, err + } + body, err := xml.Marshal(kmlRoot{Xmlns: namespace, Document: *built}) + if err != nil { + return nil, fmt.Errorf("kml: encode: %w", err) + } + return append([]byte(xml.Header), body...), nil +} + +type preparedFeature struct { + event string + role string + props map[string]interface{} + groups [][]element + polygon bool + timed bool + earliest time.Time + latest time.Time +} + +func buildDocument(collection *rawCollection, options Options) (*document, error) { + language := options.Language + if language == "" { + language = "zh" + } + features := make([]preparedFeature, 0, len(collection.Features)) + for index := range collection.Features { + prepared, err := prepareFeature(collection.Features[index], collection.TimeScale) + if err != nil { + return nil, fmt.Errorf("kml: feature %d: %w", index, err) + } + features = append(features, *prepared) + } + + skipRoles := map[string]bool{} + for _, role := range options.SkipRoles { + skipRoles[role] = true + } + kept := make([]preparedFeature, 0, len(features)) + for _, feature := range features { + if !skipRoles[feature.role] { + kept = append(kept, feature) + } + } + if len(collection.Features) > 0 && len(kept) == 0 { + return nil, fmt.Errorf("kml: every feature was dropped by SkipRoles") + } + documentEntries := commonProperties(kept) + if collection.TimeScale != "" { + documentEntries["time_scale"] = collection.TimeScale + } + + // 样式与图层都用 (event, role) 结构体做键:两者都可能含 '-',拼成字符串后 + // (foo-bar, baz) 与 (foo, bar-baz) 会撞成同一个键,把两个图层和样式并成一个。 + styles := map[styleRef]bool{} + polygonal := map[styleRef]bool{} + folderKeys := map[styleRef][]placemark{} + folderOrder := []styleRef{} + eventCount := map[string]bool{} + // 样式 id 必须同时合法且唯一:role 来自任意输入,不同键也可能清洗成同一个 id。 + styleIDs := map[styleRef]string{} + usedStyleIDs := map[string]bool{} + styleIDFor := func(ref styleRef) string { + if id, ok := styleIDs[ref]; ok { + return id + } + base := sanitizeStyleID(ref.id()) + id := base + for suffix := 2; usedStyleIDs[id]; suffix++ { + id = base + "-" + strconv.Itoa(suffix) + } + usedStyleIDs[id] = true + styleIDs[ref] = id + return id + } + var lookAtGroups []roleGeometry + var earliest, latest time.Time + drawn := 0 + for _, feature := range kept { + ref := styleRef{event: feature.event, role: feature.role} + featureStyle := resolveStyle(feature.event, feature.role, feature.polygon, options) + placemarks, err := placemarksFor(feature, styleIDFor(ref), documentEntries, options.NoTimes, featureStyle.FillColor != "") + if err != nil { + return nil, err + } + if len(placemarks) == 0 { + continue + } + drawn++ + eventCount[feature.event] = true + styles[ref] = true + if feature.polygon { + polygonal[ref] = true + } + if !feature.earliest.IsZero() && (earliest.IsZero() || feature.earliest.Before(earliest)) { + earliest = feature.earliest + } + if !feature.latest.IsZero() && (latest.IsZero() || feature.latest.After(latest)) { + latest = feature.latest + } + if _, ok := folderKeys[ref]; !ok { + folderOrder = append(folderOrder, ref) + } + folderKeys[ref] = append(folderKeys[ref], placemarks...) + lookAtGroups = append(lookAtGroups, roleGeometry{role: feature.role, groups: feature.groups}) + } + sort.Slice(folderOrder, func(left, right int) bool { return styleLess(folderOrder[left], folderOrder[right]) }) + if drawn == 0 && len(collection.Features) > 0 { + return nil, fmt.Errorf("kml: no feature produced drawable geometry") + } + + built := &document{ + Folders: make([]folder, 0, len(folderOrder)), + } + for _, ref := range folderOrder { + name := localizedRole(ref.event, ref.role, language) + if len(eventCount) > 1 && ref.event != "" { + name = localizedEvent(ref.event, language) + " · " + name + } + built.Folders = append(built.Folders, folder{Name: name, Placemarks: folderKeys[ref]}) + } + + refs := make([]styleRef, 0, len(styles)) + for ref := range styles { + refs = append(refs, ref) + } + sort.Slice(refs, func(left, right int) bool { return styleLess(refs[left], refs[right]) }) + built.Styles = make([]kmlStyle, 0, len(refs)) + for _, ref := range refs { + resolved := resolveStyle(ref.event, ref.role, polygonal[ref], options) + style := kmlStyle{ + ID: styleIDFor(ref), + IconStyle: &iconStyle{Color: resolved.LineColor, Scale: 0.8}, + LineStyle: &lineStyle{Color: resolved.LineColor, Width: resolved.LineWidth}, + } + if resolved.FillColor != "" { + // 面的描边交给同一 Placemark 里的折线:折线能在反经线接缝处断开,而面必须闭合, + // 闭合边会在换日线上留下一条染色直线。 + style.PolyStyle = &polyStyle{Color: resolved.FillColor, Fill: 1, Outline: 0} + } else if polygonal[ref] { + // 面要素必须显式写 fill=0:缺 PolyStyle 时客户端会套用自己的默认面样式(Google Earth 是白填充), + // 把整个可见域盖成一片白,底下的中心带与等值线全部看不见。 + style.PolyStyle = &polyStyle{Color: resolved.LineColor, Fill: 0, Outline: 1} + } + built.Styles = append(built.Styles, style) + } + + if len(documentEntries) > 0 { + built.ExtendedData = &extendedData{Data: sortedDataEntries(documentEntries)} + } + built.Name = documentName(options, kept, earliest) + built.Description = descriptionText(options, language, collection.TimeScale) + if !options.NoLookAt { + built.LookAt = lookAtFor(lookAtElements(lookAtGroups)) + } + if !options.NoTimes && !earliest.IsZero() && !latest.IsZero() { + // Document 级 TimeSpan 声明整个集合的时间窗口,免得客户端的时间轴默认停在窗口之外把带时间的要素全藏起来。 + built.TimeSpan = &timeSpan{ + Begin: earliest.UTC().Format(time.RFC3339Nano), + End: latest.UTC().Format(time.RFC3339Nano), + } + } + return built, nil +} + +func prepareFeature(feature rawFeature, timeScale string) (*preparedFeature, error) { + props := feature.Properties + if props == nil { + props = map[string]interface{}{} + } + event, _ := props["event"].(string) + role, _ := props["role"].(string) + if role == "" { + role = "unclassified" + } + elements, err := decodeGeometry(feature.Geometry) + if err != nil { + return nil, err + } + polygon := false + for _, item := range elements { + if item.kind == "Polygon" { + polygon = true + } + } + + prepared := &preparedFeature{event: event, role: role, props: props, polygon: polygon} + times, hasTimes, err := decodeTimes(props["times"]) + if err != nil { + return nil, err + } + single, _ := props["time"].(string) + switch { + case hasTimes: + lineOnly := true + for _, item := range elements { + if item.kind != "LineString" { + lineOnly = false + } + } + if !lineOnly || len(times) != len(elements) { + return nil, fmt.Errorf("times array (%d) does not align with %d line segments", len(times), len(elements)) + } + for index := range elements { + when, err := normalizeWhen(times[index][0], timeScale) + if err != nil { + return nil, err + } + elements[index].when = when + prepared.timed = true + prepared.earliest = minTime(prepared.earliest, when) + prepared.latest = maxTime(prepared.latest, when) + prepared.groups = append(prepared.groups, []element{elements[index]}) + } + case single != "": + when, err := normalizeWhen(single, timeScale) + if err != nil { + return nil, err + } + // 一个标量 time 覆盖整组几何:它们进同一个 Placemark,时刻写在它的 TimeStamp 上。 + for index := range elements { + elements[index].when = when + } + prepared.timed = true + prepared.earliest = minTime(prepared.earliest, when) + prepared.latest = maxTime(prepared.latest, when) + prepared.groups = append(prepared.groups, elements) + default: + prepared.groups = append(prepared.groups, elements) + } + return prepared, nil +} + +func placemarksFor(feature preparedFeature, styleID string, documentEntries map[string]string, noTimes, filled bool) ([]placemark, error) { + entries := encodeProperties(feature.props, true) + // times 是逐段时刻的嵌套数组:它随 TimeStamp 走,NoTimes 下也不该把坐标级数据抄一遍。 + delete(entries, "times") + if feature.timed && !noTimes { + // time 已经变成 TimeStamp,不再重复写进 ExtendedData。 + delete(entries, "time") + } + placemarks := make([]placemark, 0, len(feature.groups)) + for _, group := range feature.groups { + value, err := geometryFor(group, filled) + if err != nil { + return nil, err + } + if value.empty() { + continue + } + current := placemark{StyleURL: "#" + styleID, geometry: value} + if when := uniformWhen(group); when != "" { + if !noTimes { + current.TimeStamp = &timeStamp{When: when} + current.Name = placemarkName(feature.props, group[0].when) + } else if label, ok := feature.props["label"].(string); ok && label != "" { + current.Name = label + } + } + if len(entries) > 0 { + local := map[string]string{} + for name, encoded := range entries { + if shared, ok := documentEntries[name]; ok && shared == encoded { + continue + } + local[name] = encoded + } + if len(local) > 0 { + current.ExtendedData = &extendedData{Data: sortedDataEntries(local)} + } + } + placemarks = append(placemarks, current) + } + return placemarks, nil +} + +func placemarkName(props map[string]interface{}, when string) string { + if label, ok := props["label"].(string); ok && label != "" { + return label + } + return compactWhen(when) +} + +func compactWhen(when string) string { + return strings.Replace(truncateToSecond(when), "T", " ", 1) +} + +// truncateToSecond 去掉小数秒,仅用于 Placemark 名字;TimeStamp 仍写完整精度。 +func truncateToSecond(when string) string { + dot := strings.IndexByte(when, '.') + if dot < 0 { + return when + } + end := len(when) + for index := dot + 1; index < len(when); index++ { + if when[index] == 'Z' || when[index] == '+' || when[index] == '-' { + end = index + break + } + } + return when[:dot] + when[end:] +} + +func normalizeWhen(when, timeScale string) (string, error) { + parsed, err := time.Parse(time.RFC3339Nano, when) + if err != nil { + return "", fmt.Errorf("time %q is not RFC 3339: %w", when, err) + } + if timeScale == "UT1" { + // GeoJSON 里的 UT1 标签带 Z 后缀但不等于 UTC,KML 的时间轴必须是 UTC,按同一 ΔT 模型折回。 + parsed = astro.UTCFromUT1(parsed) + } + // KML 的 when 是 xsd:dateTime,允许小数秒;截断到整秒会把每个时刻整体提前最多 1 秒。 + return parsed.UTC().Format(time.RFC3339Nano), nil +} + +func maxTime(current time.Time, when string) time.Time { + parsed, err := time.Parse(time.RFC3339, when) + if err != nil { + return current + } + if current.IsZero() || parsed.After(current) { + return parsed + } + return current +} + +func minTime(current time.Time, when string) time.Time { + parsed, err := time.Parse(time.RFC3339, when) + if err != nil { + return current + } + if current.IsZero() || parsed.Before(current) { + return parsed + } + return current +} + +func decodeTimes(value interface{}) ([][]string, bool, error) { + if value == nil { + return nil, false, nil + } + raw, ok := value.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("times property has type %T", value) + } + times := make([][]string, 0, len(raw)) + for _, segment := range raw { + list, ok := segment.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("times segment has type %T", segment) + } + entries := make([]string, 0, len(list)) + for _, entry := range list { + text, ok := entry.(string) + if !ok { + return nil, false, fmt.Errorf("times entry has type %T", entry) + } + entries = append(entries, text) + } + if len(entries) == 0 { + return nil, false, fmt.Errorf("times segment is empty") + } + times = append(times, entries) + } + if len(times) == 0 { + return nil, false, fmt.Errorf("times array is empty") + } + return times, true, nil +} + +func decodeGeometry(raw rawGeometry) ([]element, error) { + switch raw.Type { + case "Point": + var position []float64 + if err := json.Unmarshal(raw.Coordinates, &position); err != nil { + return nil, fmt.Errorf("Point coordinates: %w", err) + } + return []element{{kind: "Point", point: position}}, nil + case "MultiPoint": + var positions [][]float64 + if err := json.Unmarshal(raw.Coordinates, &positions); err != nil { + return nil, fmt.Errorf("MultiPoint coordinates: %w", err) + } + elements := make([]element, 0, len(positions)) + for _, position := range positions { + elements = append(elements, element{kind: "Point", point: position}) + } + return elements, nil + case "LineString": + var line [][]float64 + if err := json.Unmarshal(raw.Coordinates, &line); err != nil { + return nil, fmt.Errorf("LineString coordinates: %w", err) + } + return []element{{kind: "LineString", line: line}}, nil + case "MultiLineString": + var lines [][][]float64 + if err := json.Unmarshal(raw.Coordinates, &lines); err != nil { + return nil, fmt.Errorf("MultiLineString coordinates: %w", err) + } + elements := make([]element, 0, len(lines)) + for _, line := range lines { + elements = append(elements, element{kind: "LineString", line: line}) + } + return elements, nil + case "Polygon": + var rings [][][]float64 + if err := json.Unmarshal(raw.Coordinates, &rings); err != nil { + return nil, fmt.Errorf("Polygon coordinates: %w", err) + } + return []element{{kind: "Polygon", rings: rings}}, nil + case "MultiPolygon": + var polygons [][][][]float64 + if err := json.Unmarshal(raw.Coordinates, &polygons); err != nil { + return nil, fmt.Errorf("MultiPolygon coordinates: %w", err) + } + elements := make([]element, 0, len(polygons)) + for _, rings := range polygons { + elements = append(elements, element{kind: "Polygon", rings: rings}) + } + return elements, nil + case "GeometryCollection": + elements := []element{} + for _, child := range raw.Geometries { + decoded, err := decodeGeometry(child) + if err != nil { + return nil, err + } + elements = append(elements, decoded...) + } + if len(elements) == 0 { + return nil, fmt.Errorf("GeometryCollection is empty") + } + return elements, nil + case "": + return nil, fmt.Errorf("geometry is missing") + default: + return nil, fmt.Errorf("unsupported geometry type %q", raw.Type) + } +} + +func geometryFor(elements []element, filled bool) (geometry, error) { + if len(elements) == 1 { + return singleGeometry(elements[0], filled) + } + multi := &multiGeometry{} + for _, item := range elements { + switch item.kind { + case "Point": + text, err := pointText(item.point) + if err != nil { + return geometry{}, err + } + multi.Points = append(multi.Points, point{AltitudeMode: "clampToGround", Coordinates: text}) + case "LineString": + parts, err := seamCutLine(item.line) + if err != nil { + return geometry{}, err + } + for _, part := range parts { + text, err := lineText(part) + if err != nil { + return geometry{}, err + } + multi.Lines = append(multi.Lines, lineString{Tessellate: 1, AltitudeMode: "clampToGround", Coordinates: text}) + } + case "Polygon": + outline, err := outlineGeometry(item.rings) + if err != nil { + return geometry{}, err + } + switch { + case outline.LineString != nil: + multi.Lines = append(multi.Lines, *outline.LineString) + case outline.MultiGeometry != nil: + multi.Lines = append(multi.Lines, outline.MultiGeometry.Lines...) + } + if !filled { + continue + } + value, err := polygonFor(item.rings) + if err != nil { + return geometry{}, err + } + multi.Polygons = append(multi.Polygons, value) + } + } + return geometry{MultiGeometry: multi}, nil +} + +func singleGeometry(item element, filled bool) (geometry, error) { + switch item.kind { + case "Point": + text, err := pointText(item.point) + if err != nil { + return geometry{}, err + } + return geometry{Point: &point{AltitudeMode: "clampToGround", Coordinates: text}}, nil + case "LineString": + parts, err := seamCutLine(item.line) + if err != nil { + return geometry{}, err + } + lines := make([]lineString, 0, len(parts)) + for _, part := range parts { + text, err := lineText(part) + if err != nil { + return geometry{}, err + } + lines = append(lines, lineString{Tessellate: 1, AltitudeMode: "clampToGround", Coordinates: text}) + } + switch len(lines) { + case 0: + return geometry{}, nil + case 1: + return geometry{LineString: &lines[0]}, nil + } + return geometry{MultiGeometry: &multiGeometry{Lines: lines}}, nil + case "Polygon": + outline, err := outlineGeometry(item.rings) + if err != nil { + return geometry{}, err + } + if !filled { + return outline, nil + } + value, err := polygonFor(item.rings) + if err != nil { + return geometry{}, err + } + multi := &multiGeometry{Polygons: []polygon{value}} + switch { + case outline.LineString != nil: + multi.Lines = append(multi.Lines, *outline.LineString) + case outline.MultiGeometry != nil: + multi.Lines = append(multi.Lines, outline.MultiGeometry.Lines...) + } + return geometry{MultiGeometry: multi}, nil + } + return geometry{}, fmt.Errorf("unsupported geometry element %q", item.kind) +} + +// outlineGeometry 把只描轮廓的面画成折线。反经线接缝(两端都在 ±180 的经线段)是拆环的产物、不是真实边界, +// 必须在它处断开,否则图上会多出一条沿换日线的直线。 +func outlineGeometry(rings [][][]float64) (geometry, error) { + lines := []lineString{} + for _, ring := range rings { + parts, err := outlineLines(ring) + if err != nil { + return geometry{}, err + } + for _, part := range parts { + text, err := lineText(part) + if err != nil { + return geometry{}, err + } + lines = append(lines, lineString{Tessellate: 1, AltitudeMode: "clampToGround", Coordinates: text}) + } + } + switch len(lines) { + case 0: + return geometry{}, nil + case 1: + return geometry{LineString: &lines[0]}, nil + } + return geometry{MultiGeometry: &multiGeometry{Lines: lines}}, nil +} + +func outlineLines(ring [][]float64) ([][][]float64, error) { + normalized, err := normalizeRing(ring, true) + if err != nil { + return nil, err + } + points := normalized + if len(points) > 1 && equalPosition(points[0], points[len(points)-1]) { + points = points[:len(points)-1] + } + // 从某条接缝边的后一个顶点起绕环:顺序扫描时"环尾回到环首"那一段真实边不会被检查到, + // 接缝在别处时它就被整段漏掉。旋转后每条真实边都在一圈之内,末尾那条正好是接缝本身。 + start := -1 + for index := range points { + if isSeamSegment(points[index], points[(index+1)%len(points)]) { + start = (index + 1) % len(points) + break + } + } + if start < 0 { + // 不碰接缝的环按闭合折线画,保持原来的形状。 + closed := append([][]float64{}, points...) + return [][][]float64{append(closed, append([]float64(nil), points[0]...))}, nil + } + ordered := append(append([][]float64{}, points[start:]...), points[:start]...) + parts := [][][]float64{} + current := [][]float64{} + for index := range ordered { + current = append(current, ordered[index]) + if index == len(ordered)-1 { + break + } + if isSeamSegment(ordered[index], ordered[index+1]) { + if len(current) > 1 { + parts = append(parts, current) + } + current = nil + } + } + if len(current) > 1 { + parts = append(parts, current) + } + // 整个环都落在 ±180 上时没有可画的真实边,交给调用方按"空几何"跳过。 + return parts, nil +} + +// seamCutLine 把折线在反经线接缝处断开:两端都落在 ±180 的段是拆环产物、不是真实边界, +// 描出来就是一条沿换日线的直线(本库 geojson 的 band-outline 正是这种闭合环线)。 +// 不碰接缝的折线原样返回一段,保持既有输出。 +func seamCutLine(line [][]float64) ([][][]float64, error) { + points := make([][]float64, 0, len(line)) + for _, position := range line { + normalized, err := normalizePosition(position) + if err != nil { + return nil, err + } + if len(points) > 0 && equalPosition(points[len(points)-1], normalized) { + continue + } + points = append(points, normalized) + } + if len(points) < 2 { + // 只剩一个位置的路径片段画不出线(本库 geojson 用两个相同坐标表示它),跳过而不是让整份转换失败。 + return nil, nil + } + closed := equalPosition(points[0], points[len(points)-1]) + parts := [][][]float64{} + current := [][]float64{} + for index := range points { + current = append(current, points[index]) + if index == len(points)-1 && !closed { + break + } + if !isSeamSegment(points[index], points[(index+1)%len(points)]) { + continue + } + if len(current) > 1 { + parts = append(parts, current) + } + current = nil + } + if len(current) > 1 { + parts = append(parts, current) + } + if len(parts) == 0 { + // 整条线都落在 ±180 上:那是真实边界(例如极区被反经线裁出的边),不是拆环残留, + // 原样保留比让整次转换失败更合理。 + return [][][]float64{points}, nil + } + return parts, nil +} + +func isSeamSegment(from, to []float64) bool { + const tolerance = 1e-9 + return math.Abs(math.Abs(from[0])-180) < tolerance && math.Abs(math.Abs(to[0])-180) < tolerance +} + +func polygonFor(rings [][][]float64) (polygon, error) { + if len(rings) == 0 { + return polygon{}, fmt.Errorf("polygon has no rings") + } + outer, err := normalizeRing(rings[0], true) + if err != nil { + return polygon{}, err + } + value := polygon{ + Tessellate: 1, + AltitudeMode: "clampToGround", + OuterBoundaryIs: &boundary{Ring: linearRing{Coordinates: textForPositions(outer)}}, + } + for _, ring := range rings[1:] { + hole, err := normalizeRing(ring, false) + if err != nil { + return polygon{}, err + } + value.InnerBoundaries = append(value.InnerBoundaries, boundary{Ring: linearRing{Coordinates: textForPositions(hole)}}) + } + return value, nil +} + +// normalizeRing 归一化环:位置必须有限、经度回绕到 (−180,180]、纬度在 ±90 内;闭合,并按 KML 要求 +// 把外环摆成逆时针、洞摆成顺时针。 +func normalizeRing(ring [][]float64, outer bool) ([][]float64, error) { + positions := make([][]float64, 0, len(ring)+1) + for _, position := range ring { + normalized, err := normalizePosition(position) + if err != nil { + return nil, err + } + if len(positions) > 0 && equalPosition(positions[len(positions)-1], normalized) { + continue + } + positions = append(positions, normalized) + } + if len(positions) > 1 && equalPosition(positions[0], positions[len(positions)-1]) { + positions = positions[:len(positions)-1] + } + if len(positions) < 3 { + return nil, fmt.Errorf("polygon ring requires at least three distinct positions") + } + area := signedArea(positions) + if math.Abs(area) < 1e-12 { + return nil, fmt.Errorf("polygon ring has zero area") + } + if (outer && area < 0) || (!outer && area > 0) { + for left, right := 0, len(positions)-1; left < right; left, right = left+1, right-1 { + positions[left], positions[right] = positions[right], positions[left] + } + } + return append(positions, append([]float64(nil), positions[0]...)), nil +} + +func normalizePosition(position []float64) ([]float64, error) { + if len(position) < 2 { + return nil, fmt.Errorf("position requires longitude and latitude") + } + longitude, latitude := position[0], position[1] + if math.IsNaN(longitude) || math.IsInf(longitude, 0) || math.IsNaN(latitude) || math.IsInf(latitude, 0) { + return nil, fmt.Errorf("position is not finite") + } + if latitude < -90 || latitude > 90 { + return nil, fmt.Errorf("latitude %.6f is outside ±90", latitude) + } + // ±180 必须原样保留:把 +180 折成 −180,会让已经按反经线拆好的环在经度上多出 360° 的边, + // 客户端会把这种环填成一条环绕全球的假带。 + if longitude > 180 || longitude < -180 { + longitude = math.Mod(longitude+180, 360) + if longitude < 0 { + longitude += 360 + } + longitude -= 180 + } + return []float64{longitude, latitude}, nil +} + +func equalPosition(a, b []float64) bool { + return len(a) >= 2 && len(b) >= 2 && a[0] == b[0] && a[1] == b[1] +} + +func signedArea(positions [][]float64) float64 { + area := 0.0 + for index, position := range positions { + next := positions[(index+1)%len(positions)] + area += position[0]*next[1] - next[0]*position[1] + } + return area / 2 +} + +func pointText(position []float64) (string, error) { + normalized, err := normalizePosition(position) + if err != nil { + return "", err + } + return textForPositions([][]float64{normalized}), nil +} + +func lineText(line [][]float64) (string, error) { + if len(line) < 2 { + return "", fmt.Errorf("line requires at least two positions") + } + positions := make([][]float64, 0, len(line)) + for _, position := range line { + normalized, err := normalizePosition(position) + if err != nil { + return "", err + } + positions = append(positions, normalized) + } + return textForPositions(positions), nil +} + +func textForPositions(positions [][]float64) string { + var builder strings.Builder + for index, position := range positions { + if index > 0 { + builder.WriteByte(' ') + } + builder.WriteString(strconv.FormatFloat(position[0], 'f', -1, 64)) + builder.WriteByte(',') + builder.WriteString(strconv.FormatFloat(position[1], 'f', -1, 64)) + } + return builder.String() +} + +type roleGeometry struct { + role string + groups [][]element +} + +// lookAtPriority 是取景优先的路径类角色:存在这些图层时只按它们取景,免得被跨半球的大区域拉成全球视角。 +var lookAtPriority = map[string]bool{ + "center-line": true, + "central-band": true, + "central-shadow": true, + "central-shadow-sweep": true, + "central-shadow-footprint": true, + "north-limit": true, + "south-limit": true, + "north-total-limit": true, + "south-total-limit": true, + "band-outline": true, + "total-band-outline": true, + "occultation-band": true, + "total-footprint": true, + "sublunar-track": true, +} + +func lookAtElements(groups []roleGeometry) [][]element { + preferred := [][]element{} + for _, item := range groups { + if lookAtPriority[item.role] { + preferred = append(preferred, item.groups...) + } + } + if len(preferred) > 0 { + return preferred + } + all := [][]element{} + for _, item := range groups { + all = append(all, item.groups...) + } + return all +} + +func lookAtFor(groups [][]element) *lookAt { + latitudes := []float64{} + longitudes := []float64{} + for _, group := range groups { + for _, item := range group { + switch item.kind { + case "Point": + if normalized, err := normalizePosition(item.point); err == nil { + latitudes = append(latitudes, normalized[1]) + longitudes = append(longitudes, normalized[0]) + } + case "LineString": + for _, position := range item.line { + if normalized, err := normalizePosition(position); err == nil { + latitudes = append(latitudes, normalized[1]) + longitudes = append(longitudes, normalized[0]) + } + } + case "Polygon": + for _, ring := range item.rings { + for _, position := range ring { + if normalized, err := normalizePosition(position); err == nil { + latitudes = append(latitudes, normalized[1]) + longitudes = append(longitudes, normalized[0]) + } + } + } + } + } + } + if len(latitudes) == 0 { + return nil + } + sort.Float64s(latitudes) + sort.Float64s(longitudes) + latitude := (latitudes[0] + latitudes[len(latitudes)-1]) / 2 + // 经度用"最大间隙"法取跨反经线也正确的包络。 + span := 0.0 + gapStart := 0.0 + for index := range longitudes { + next := longitudes[(index+1)%len(longitudes)] + 360*float64((index+1)/len(longitudes)) + gap := next - longitudes[index] + if gap > span { + span = gap + gapStart = longitudes[(index+1)%len(longitudes)] + } + } + longitudeSpan := 360 - span + longitude := gapStart + longitudeSpan/2 + if longitude > 180 { + longitude -= 360 + } + rangeKM := math.Max(latitudes[len(latitudes)-1]-latitudes[0], longitudeSpan*math.Cos(latitude*math.Pi/180)) * 111.32 + // KML 的 LookAt/range 单位是米(不是千米),1.8 倍留边、下限 200 km。 + return &lookAt{Longitude: longitude, Latitude: latitude, Range: math.Max(rangeKM*1.8, 200) * 1000} +} + +func resolveStyle(event, role string, polygonal bool, options Options) Style { + resolved := paletteStyle(event, role) + if polygonal && options.FillContext && resolved.FillColor == "" { + resolved.FillColor = grayFill + } + for _, key := range []string{event + "/" + role, role} { + custom, ok := options.Styles[key] + if !ok { + continue + } + if custom.LineColor != "" { + resolved.LineColor = custom.LineColor + } + if custom.FillColor != "" { + resolved.FillColor = custom.FillColor + } + if custom.LineWidth > 0 { + resolved.LineWidth = custom.LineWidth + } + if custom.NoFill { + resolved.FillColor = "" + } + break + } + return resolved +} + +func paletteStyle(event, role string) Style { + switch role { + case "center-line": + return Style{LineColor: ColorBlack, LineWidth: 3} + case "central-band", "total-footprint", "central-shadow-footprint", "central-shadow-sweep", "central-shadow": + if event == eventSolarEclipse { + return Style{LineColor: ColorRed, FillColor: redFill, LineWidth: 1.5} + } + case "greatest-time-line": + if event == eventSolarEclipse { + return Style{LineColor: ColorGreen, LineWidth: 2} + } + case "magnitude-line", "magnitude-one-envelope": + return Style{LineColor: ColorYellow, LineWidth: 2} + case "total-band", "partial-band", "total-band-outline", "occultation-band": + if event == eventLunarOccultation { + return Style{LineColor: ColorYellow, FillColor: yellowFill, LineWidth: 2} + } + if role == "partial-band" { + // 日食的 partial-band 是地方最大食分等于零的可见域边界;它有一大段与日出日落线重合, + // 同色才不会看起来"一半橙一半黄"。 + return Style{LineColor: ColorOrange, LineWidth: 2} + } + case "visibility-boundary", "p1-horizon", "p4-horizon", + "visible-at-p1", "visible-at-p4", + "visible-during-eclipse", "visible-throughout-eclipse": + return Style{LineColor: ColorOrange, LineWidth: 2} + case "penumbra-moonrise": + return Style{LineColor: penumbraMoonriseColor, FillColor: penumbraMoonriseFill, LineWidth: 2} + case "penumbra-moonset": + return Style{LineColor: penumbraMoonsetColor, FillColor: penumbraMoonsetFill, LineWidth: 2} + } + return Style{LineColor: ColorGray, LineWidth: 1} +} + +// styleRef 是样式与图层的身份:event 与 role 都可能含 '-',拼成字符串再当键会让 +// (foo-bar, baz) 与 (foo, bar-baz) 撞成同一个图层和样式。 +type styleRef struct { + event string + role string +} + +// id 返回样式 id 的基础串,合法性与唯一性分别由 sanitizeStyleID 与调用方处理。 +func (ref styleRef) id() string { + if ref.event == "" { + return "style-" + ref.role + } + return "style-" + ref.event + "-" + ref.role +} + +// styleLess 按 id 字典序排序;id 相同时再比 event 与 role,保证碰撞时输出仍逐字节稳定。 +func styleLess(left, right styleRef) bool { + if left.id() != right.id() { + return left.id() < right.id() + } + if left.event != right.event { + return left.event < right.event + } + return left.role < right.role +} + +// commonProperties 取所有要素共有的属性(名字与编码后的值都相同)——只有它们真正属于集合层, +// 首个要素独有的属性(瞬时足迹的 closure、interp_signature 之类)不能冒充集合属性。 +func commonProperties(features []preparedFeature) map[string]string { + if len(features) == 0 { + return map[string]string{} + } + common := encodeProperties(features[0].props, true) + for _, feature := range features[1:] { + entries := encodeProperties(feature.props, true) + for name, value := range common { + if other, ok := entries[name]; !ok || other != value { + delete(common, name) + } + } + } + // 角色与时间只对单个要素有意义,留在要素层。 + delete(common, "role") + delete(common, "time") + delete(common, "times") + return common +} + +// sanitizeStyleID 把逻辑键转成合法的 XML ID(NCName):非法字符换成 '-',首字符不能是数字、'.' 或 '-'。 +// Google Earth 容忍非法 id,但 KML 2.2 的 id 是 xsd:ID,严格客户端与 XSD 校验会直接拒绝。 +func sanitizeStyleID(key string) string { + var builder strings.Builder + builder.Grow(len(key) + 2) + for _, symbol := range key { + switch { + case symbol >= 'a' && symbol <= 'z', symbol >= 'A' && symbol <= 'Z', + symbol >= '0' && symbol <= '9', symbol == '_', symbol == '-', symbol == '.': + builder.WriteRune(symbol) + default: + builder.WriteByte('-') + } + } + id := builder.String() + if id == "" { + return "style" + } + first := id[0] + if !(first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z' || first == '_') { + id = "style-" + id + } + return id +} + +// uniformWhen 返回整组几何共用的时刻;时刻不一致的组(times 数组那种)返回空串。 +func uniformWhen(group []element) string { + if len(group) == 0 || group[0].when == "" { + return "" + } + for _, item := range group[1:] { + if item.when != group[0].when { + return "" + } + } + return group[0].when +} + +func encodeProperties(props map[string]interface{}, includeNested bool) map[string]string { + entries := map[string]string{} + for name, value := range props { + switch typed := value.(type) { + case nil: + continue + case string: + entries[name] = typed + case bool: + entries[name] = strconv.FormatBool(typed) + case float64: + entries[name] = strconv.FormatFloat(typed, 'g', -1, 64) + default: + if !includeNested { + continue + } + encoded, err := json.Marshal(typed) + if err != nil { + continue + } + entries[name] = string(encoded) + } + } + return entries +} + +func sortedDataEntries(entries map[string]string) []dataEntry { + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + data := make([]dataEntry, 0, len(names)) + for _, name := range names { + data = append(data, dataEntry{Name: name, Value: entries[name]}) + } + return data +} + +func documentName(options Options, features []preparedFeature, earliest time.Time) string { + if options.Name != "" { + return options.Name + } + // 事件类型只在整个集合一致时才代表整份文档;混了多种事件就退回通用名, + // 免得标题只写了首个要素的事件。 + event := "" + for _, feature := range features { + if feature.event == "" { + continue + } + if event == "" { + event = feature.event + continue + } + if feature.event != event { + event = "" + break + } + } + name := localizedEvent(event, options.Language) + if !earliest.IsZero() { + name = name + " " + earliest.UTC().Format("2006-01-02") + } + return name +} + +func descriptionText(options Options, language, timeScale string) string { + if language == "en" { + text := "Converted from GeoJSON. Layers are grouped by role; red: solar central band; black: center line; green: solar greatest-time isochrone; yellow: magnitude contours and occultation total/partial bands; orange: rise/set phase lines." + if timeScale != "" { + text += " Times were written in UTC (original scale: " + timeScale + ")." + } + return text + } + text := "由 GeoJSON 转换而来。图层按 role 分组;红:日食中心带;黑:中心线;绿:日食食甚等时线;黄:食分线与掩星全掩/半掩带;橙:日出日落阶段线(初亏/食甚/复圆)。" + if timeScale != "" { + text += "时间已按 UTC 写入(原口径:" + timeScale + ")。" + } + return text +} + +var roleNames = map[string][2]string{ + "central-band": {"中心带", "Central band"}, + "central-shadow": {"中心影", "Central shadow"}, + "central-shadow-footprint": {"中心影轮廓", "Central shadow footprint"}, + "central-shadow-sweep": {"中心影扫掠", "Central shadow sweep"}, + "central-shadow-boundary": {"中心影边界", "Central shadow boundary"}, + "center-line": {"中心线", "Center line"}, + "north-limit": {"北限", "Northern limit"}, + "south-limit": {"南限", "Southern limit"}, + "north-total-limit": {"全带北限", "Northern total limit"}, + "south-total-limit": {"全带南限", "Southern total limit"}, + "band-outline": {"带边界", "Band outline"}, + "total-band-outline": {"全带边界", "Total band outline"}, + "partial-footprint": {"偏食足迹", "Partial footprint"}, + "total-footprint": {"全食足迹", "Total footprint"}, + "occultation-band": {"掩带", "Occultation band"}, + "occultation-footprint": {"掩星足迹", "Occultation footprint"}, + "visible-footprint-sweep": {"可见足迹扫掠", "Visible footprint sweep"}, + "greatest": {"食甚", "Greatest eclipse"}, + "greatest-time-line": {"食甚等时线", "Greatest-time isochrone"}, + "magnitude-line": {"食分线", "Magnitude contour"}, + "magnitude-one-envelope": {"食分 1 包络", "Magnitude-1 envelope"}, + "visibility-boundary": {"日出日落阶段线", "Rise/set phase lines"}, + "p1-horizon": {"P1 地平线", "P1 horizon"}, + "p4-horizon": {"P4 地平线", "P4 horizon"}, + "visible-at-p1": {"P1 可见点", "Visible at P1"}, + "visible-at-p4": {"P4 可见点", "Visible at P4"}, + "visible-during-eclipse": {"食内任意时刻可见区", "Visible at some time during eclipse"}, + "visible-throughout-eclipse": {"食内全程可见区", "Visible throughout eclipse"}, + "penumbra-moonrise": {"半影月出", "Penumbra moonrise"}, + "penumbra-moonset": {"半影月落", "Penumbra moonset"}, + "horizon-connector": {"地平连接线", "Horizon connector"}, + "target-horizon": {"目标地平线", "Target horizon"}, + "time-marker": {"时间标记", "Time markers"}, + "besselian-critical-envelope": {"贝塞尔临界包络", "Besselian critical envelope"}, + "sublunar-track": {"月下点轨迹", "Sublunar track"}, +} + +func localizedRole(event, role, language string) string { + if language == "en" { + if name, ok := roleNames[role]; ok { + return name[1] + } + return strings.ReplaceAll(role, "-", " ") + } + if name, ok := roleNames[role]; ok { + return name[0] + } + if role == "partial-band" && event == eventLunarOccultation { + return "半掩带" + } + if role == "total-band" && event == eventLunarOccultation { + return "全掩带" + } + if role == "partial-band" { + return "偏食带" + } + return role +} + +func localizedEvent(event, language string) string { + switch event { + case eventSolarEclipse: + if language == "en" { + return "Solar eclipse" + } + return "日食" + case eventLunarEclipse: + if language == "en" { + return "Lunar eclipse" + } + return "月食" + case eventLunarOccultation: + if language == "en" { + return "Lunar occultation" + } + return "月掩" + } + if event == "" { + if language == "en" { + return "GeoJSON" + } + return "GeoJSON" + } + return event +} diff --git a/kml/kml_test.go b/kml/kml_test.go new file mode 100644 index 0000000..793faee --- /dev/null +++ b/kml/kml_test.go @@ -0,0 +1,1221 @@ +package kml + +import ( + "encoding/xml" + "math" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro" + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +// 契约:调色板按 (event, role) 派发、role 分层、时间变 TimeStamp、环闭合与朝向、UT1 折回 UTC、LookAt 跨反经线。 + +func convert(t *testing.T, input string, options Options) kmlRoot { + t.Helper() + out, err := FromGeoJSON([]byte(input), options) + if err != nil { + t.Fatalf("FromGeoJSON: %v", err) + } + if !strings.HasPrefix(string(out), " 0 +} + +// 契约:集合的时间窗口写成 Document/TimeSpan;NoTimes 去掉全部时间元素,得到不受客户端时间轴影响的静态叠加。 +func TestDocumentTimeSpanAndNoTimes(t *testing.T) { + sweep := feature("solar-eclipse", "visible-footprint-sweep", + `{"type":"MultiLineString","coordinates":[[[100,20],[101,20]],[[102,21],[103,21]]]}`, + `"times":[["2024-04-08T18:00:00Z","2024-04-08T18:05:00Z"],["2024-04-08T18:10:00Z","2024-04-08T18:15:00Z"]]`) + marker := feature("solar-eclipse", "time-marker", + `{"type":"Point","coordinates":[121.5,31.2]}`, `"time":"2024-04-08T18:17:20Z","label":"02:17"`) + input := collection(sweep, marker) + + root := convert(t, input, Options{}) + if root.Document.TimeSpan == nil { + t.Fatal("缺少 Document/TimeSpan") + } + if root.Document.TimeSpan.Begin != "2024-04-08T18:00:00Z" || root.Document.TimeSpan.End != "2024-04-08T18:17:20Z" { + t.Fatalf("TimeSpan = %+v", root.Document.TimeSpan) + } + if len(placemarksOfRole(root, "visible-footprint-sweep")) != 2 { + t.Fatal("带时间时应逐段拆 Placemark") + } + + staticOut, err := FromGeoJSON([]byte(input), Options{NoTimes: true}) + if err != nil { + t.Fatalf("FromGeoJSON: %v", err) + } + if text := string(staticOut); strings.Contains(text, "") || strings.Contains(text, "") || strings.Contains(text, "") { + t.Fatalf("NoTimes 仍写出时间元素:%s", text) + } + var static kmlRoot + if err := xml.Unmarshal(staticOut, &static); err != nil { + t.Fatalf("解析输出的 KML 失败:%v", err) + } + if static.Document.TimeSpan != nil { + t.Fatalf("NoTimes 不该有 TimeSpan:%+v", static.Document.TimeSpan) + } + items := placemarksOfRole(static, "visible-footprint-sweep") + if len(items) != 2 { + t.Fatalf("NoTimes 仍应保留几何:%d", len(items)) + } + if items[0].Name != "" { + t.Fatalf("NoTimes 下没有 label 的段不该再拿时刻当名字:%q", items[0].Name) + } + // 集合里没有时间时不写 TimeSpan。 + plain := convert(t, collection(feature("solar-eclipse", "center-line", lineGeometry, "")), Options{}) + if plain.Document.TimeSpan != nil { + t.Fatalf("无时间集合不该有 TimeSpan:%+v", plain.Document.TimeSpan) + } +} + +func TestUT1LabelsAreConvertedToUTC(t *testing.T) { + label := time.Date(2024, 4, 8, 18, 17, 20, 0, time.UTC) + // 2024 年的 DUT1 只有十几毫秒:秒以下精度必须保留,否则这次折算在输出里完全看不见。 + want := astro.UTCFromUT1(label).Format(time.RFC3339Nano) + if !strings.Contains(want, ".") { + t.Fatalf("样例应带小数秒以覆盖精度保留:%s", want) + } + input := `{"type":"FeatureCollection","time_scale":"UT1","features":[` + + feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[121.5,31.2]}`, + `"time":"`+label.Format(time.RFC3339)+`"`) + `]}` + root := convert(t, input, Options{}) + items := placemarksOfRole(root, "time-marker") + if len(items) != 1 || items[0].TimeStamp == nil || items[0].TimeStamp.When != want { + t.Fatalf("UT1 折回 UTC 后 = %+v, want %s", items, want) + } + if !strings.Contains(root.Document.Description, "UT1") { + t.Fatalf("说明里应记录原口径:%q", root.Document.Description) + } + found := false + for _, entry := range root.Document.ExtendedData.Data { + if entry.Name == "time_scale" && entry.Value == "UT1" { + found = true + } + } + if !found { + t.Fatal("ExtendedData 缺少 time_scale") + } +} + +// 契约:中性面图层默认只出轮廓,FillContext 才填充,NoFill 压过两者。 +func TestFillPolicy(t *testing.T) { + band := feature("solar-eclipse", "partial-band", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "") + input := collection(band, feature("solar-eclipse", "central-band", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "")) + + plain := convert(t, input, Options{}) + if got := styleByID(t, plain, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Fill != 0 { + t.Fatalf("中性面默认必须显式 fill=0:%+v", got) + } + if got := styleByID(t, plain, "style-solar-eclipse-central-band").PolyStyle; got == nil || got.Color != redFill { + t.Fatalf("中心带应有红色填充:%+v", got) + } + + filled := convert(t, input, Options{FillContext: true}) + if got := styleByID(t, filled, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Color != grayFill { + t.Fatalf("FillContext 应给中性面灰色填充:%+v", got) + } + + custom := convert(t, input, Options{ + FillContext: true, + Styles: map[string]Style{ + "partial-band": {FillColor: "8000ff00"}, + "central-band": {NoFill: true}, + }, + }) + if got := styleByID(t, custom, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Color != "8000ff00" { + t.Fatalf("自定义填充未生效:%+v", got) + } + if got := styleByID(t, custom, "style-solar-eclipse-central-band").PolyStyle; got == nil || got.Fill != 0 { + t.Fatalf("NoFill 应压成 fill=0:%+v", got) + } +} + +// 契约:SkipRoles 整层丢弃,图层、样式与 LookAt 都不再包含该 role。 +func TestSkipRoles(t *testing.T) { + input := collection( + feature("solar-eclipse", "center-line", lineGeometry, ""), + feature("solar-eclipse", "partial-footprint", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, ""), + ) + root := convert(t, input, Options{SkipRoles: []string{"partial-footprint"}}) + for _, folder := range root.Document.Folders { + if strings.Contains(folder.Name, "偏食足迹") { + t.Fatalf("被跳过的 role 仍出图层:%q", folder.Name) + } + } + for _, style := range root.Document.Styles { + if strings.HasSuffix(style.ID, "partial-footprint") { + t.Fatalf("被跳过的 role 仍出样式:%s", style.ID) + } + } + if got := len(placemarksOfRole(root, "center-line")); got != 1 { + t.Fatalf("保留图层被误删:%d", got) + } +} + +// 契约:存在中心线等路径图层时,LookAt 只按路径取景,不被跨半球的大区域拉成全球视角。 +func TestLookAtPrefersPathRoles(t *testing.T) { + globe := feature("solar-eclipse", "partial-band", + `{"type":"Polygon","coordinates":[[[-170,-40],[170,-40],[170,40],[-170,40],[-170,-40]]]}`, "") + path := feature("solar-eclipse", "center-line", `{"type":"LineString","coordinates":[[120,30],[140,40]]}`, "") + root := convert(t, collection(globe, path), Options{}) + if root.Document.LookAt == nil { + t.Fatal("缺少 LookAt") + } + if lat := root.Document.LookAt.Latitude; lat < 34 || lat > 36 { + t.Fatalf("纬度 = %v,应按中心线取景", lat) + } + if lon := root.Document.LookAt.Longitude; lon < 129 || lon > 131 { + t.Fatalf("经度 = %v,应按中心线取景", lon) + } + single := feature("solar-eclipse", "magnitude-line", `{"type":"LineString","coordinates":[[100,10],[140,20]]}`, "") + only := convert(t, collection(single), Options{}) + if only.Document.LookAt == nil || math.Abs(only.Document.LookAt.Longitude-120) > 1 || math.Abs(only.Document.LookAt.Latitude-15) > 1 { + t.Fatalf("无路径图层时应退回全部要素:%+v", only.Document.LookAt) + } +} + +func TestLookAtHandlesAntimeridian(t *testing.T) { + input := collection(feature("lunar-occultation", "occultation-band", + `{"type":"Polygon","coordinates":[[[179,10],[-179,10],[-179,12],[179,12],[179,10]]]}`, "")) + root := convert(t, input, Options{}) + if root.Document.LookAt == nil { + t.Fatal("缺少 LookAt") + } + if math.Abs(root.Document.LookAt.Longitude) < 179 || math.Abs(root.Document.LookAt.Longitude) > 180 { + t.Fatalf("跨反经线的中心经度 = %v,应贴近 ±180", root.Document.LookAt.Longitude) + } + if root.Document.LookAt.Latitude < 10 || root.Document.LookAt.Latitude > 12 { + t.Fatalf("中心纬度 = %v", root.Document.LookAt.Latitude) + } + plain := convert(t, input, Options{NoLookAt: true}) + if plain.Document.LookAt != nil { + t.Fatal("NoLookAt 时不应输出 LookAt") + } +} + +func TestFromGeoJSONRejectsBadInput(t *testing.T) { + cases := []struct { + name string + input string + }{ + {"非 JSON", `{`}, + {"非 FeatureCollection", `{"type":"Feature","geometry":null,"properties":{}}`}, + {"缺少几何", collection(`{"type":"Feature","properties":{"role":"center-line"}}`)}, + {"未知几何", collection(`{"type":"Feature","properties":{"role":"center-line"},"geometry":{"type":"CircularString","coordinates":[]}}`)}, + {"times 与段数不齐", collection(feature("solar-eclipse", "visible-footprint-sweep", + `{"type":"MultiLineString","coordinates":[[[100,20],[101,20]],[[102,21],[103,21]]]}`, + `"times":[["2024-04-08T18:00:00Z","2024-04-08T18:05:00Z"]]`))}, + {"纬度越界", collection(feature("solar-eclipse", "center-line", `{"type":"LineString","coordinates":[[100,95],[101,20]]}`, ""))}, + {"非 UTC 时间", collection(feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[100,20]}`, `"time":"not-a-time"`))}, + } + for _, tc := range cases { + if _, err := FromGeoJSON([]byte(tc.input), Options{}); err == nil { + t.Errorf("%s:应当报错", tc.name) + } + } +} + +func TestGeometryCollectionBecomesMultiGeometry(t *testing.T) { + input := collection(feature("lunar-occultation", "occultation-band", + `{"type":"GeometryCollection","geometries":[`+ + `{"type":"MultiPolygon","coordinates":[[[[100,20],[101,20],[101,21],[100,20]]]]},`+ + `{"type":"MultiLineString","coordinates":[[[102,22],[103,22]]]}]}`, "")) + root := convert(t, input, Options{}) + items := placemarksOfRole(root, "occultation-band") + if len(items) != 1 || items[0].MultiGeometry == nil { + t.Fatalf("GeometryCollection 应转成 MultiGeometry:%+v", items) + } + multi := items[0].MultiGeometry + // 面自己另带一条描边折线(面只填充、不描边),加上集合里原有的线。 + if len(multi.Polygons) != 1 || len(multi.Lines) != 2 { + t.Fatalf("MultiGeometry 内容 = %d 面 / %d 线", len(multi.Polygons), len(multi.Lines)) + } +} + +// ringLongitudes 取某个 role 的所有外环经度序列。 +func ringLongitudes(t *testing.T, root kmlRoot, role string) [][]float64 { + t.Helper() + rings := [][]float64{} + for _, item := range placemarksOfRole(root, role) { + polygons := []polygon{} + if item.Polygon != nil { + polygons = append(polygons, *item.Polygon) + } + if item.MultiGeometry != nil { + polygons = append(polygons, item.MultiGeometry.Polygons...) + } + for _, shape := range polygons { + if shape.OuterBoundaryIs == nil { + continue + } + ring := []float64{} + for _, token := range strings.Fields(shape.OuterBoundaryIs.Ring.Coordinates) { + value, err := strconv.ParseFloat(strings.Split(token, ",")[0], 64) + if err != nil { + t.Fatalf("解析经度 %q:%v", token, err) + } + ring = append(ring, value) + } + rings = append(rings, ring) + } + } + if len(rings) == 0 { + t.Fatalf("%s 没有外环", role) + } + return rings +} + +// 契约:本库 geojson 的输出已经按反经线拆好,转出的每条线与每个环都不得出现跨 180° 的经度边。 +func TestLibraryOutputHasNoGlobeSpanningEdge(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 20 * time.Minute, BoundaryPoints: 24, GreatestTimeStep: time.Hour, + }) + 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) + } + out, err := FromGeoJSON(data, Options{}) + if err != nil { + t.Fatalf("转换本库 GeoJSON 失败:%v", err) + } + var root kmlRoot + if err := xml.Unmarshal(out, &root); err != nil { + t.Fatalf("输出不是合法 KML:%v", err) + } + for _, folder := range root.Document.Folders { + for _, item := range folder.Placemarks { + for _, coordinates := range coordinatesOf(item.geometry) { + previous := math.NaN() + for _, token := range strings.Fields(coordinates) { + longitude, parseErr := strconv.ParseFloat(strings.Split(token, ",")[0], 64) + if parseErr != nil { + t.Fatalf("解析经度 %q:%v", token, parseErr) + } + if !math.IsNaN(previous) && math.Abs(longitude-previous) > 180 { + t.Fatalf("%s 出现跨度 %.1f° 的边(%v → %v)", folder.Name, math.Abs(longitude-previous), previous, longitude) + } + previous = longitude + } + } + } + } +} + +func coordinatesOf(shape geometry) []string { + result := []string{} + switch { + case shape.Point != nil: + result = append(result, shape.Point.Coordinates) + case shape.LineString != nil: + result = append(result, shape.LineString.Coordinates) + case shape.Polygon != nil: + result = append(result, ringCoordinates(*shape.Polygon)...) + case shape.MultiGeometry != nil: + for _, item := range shape.MultiGeometry.Points { + result = append(result, item.Coordinates) + } + for _, item := range shape.MultiGeometry.Lines { + result = append(result, item.Coordinates) + } + for _, item := range shape.MultiGeometry.Polygons { + result = append(result, ringCoordinates(item)...) + } + } + return result +} + +// lineCoordinatesOf 只取折线坐标:面的环允许含 ±180 接缝段(那是填充边界), +// 而面的样式写了 outline=0,客户端不会描它。 +func lineCoordinatesOf(shape geometry) []string { + result := []string{} + switch { + case shape.LineString != nil: + result = append(result, shape.LineString.Coordinates) + case shape.MultiGeometry != nil: + for _, item := range shape.MultiGeometry.Lines { + result = append(result, item.Coordinates) + } + } + return result +} + +func ringCoordinates(shape polygon) []string { + result := []string{} + if shape.OuterBoundaryIs != nil { + result = append(result, shape.OuterBoundaryIs.Ring.Coordinates) + } + for _, inner := range shape.InnerBoundaries { + result = append(result, inner.Ring.Coordinates) + } + return result +} + +func TestFromGeoJSONAcceptsLibraryOutput(t *testing.T) { + info := eclipse.ClosestLunarEclipse(time.Date(2029, time.January, 1, 0, 0, 0, 0, time.UTC)) + data, err := geojson.MarshalLunarEclipse(info, 48) + if err != nil { + t.Fatalf("MarshalLunarEclipse: %v", err) + } + out, err := FromGeoJSON(data, Options{}) + if err != nil { + t.Fatalf("转换本库 GeoJSON 失败:%v", err) + } + var root kmlRoot + if err := xml.Unmarshal(out, &root); err != nil { + t.Fatalf("输出不是合法 KML:%v", err) + } + if len(root.Document.Folders) == 0 { + t.Fatal("本库月食 GeoJSON 应至少产出一个图层") + } + if root.Document.LookAt == nil { + t.Fatal("应输出 LookAt") + } +} + +// 契约补充(review 修复):LookAt.range 用米;Style 子元素按 KML sequence;样式 id 合法; +// when 保留小数秒;SkipRoles 裁光全部要素时报错。 +func TestLookAtRangeIsMeters(t *testing.T) { + wide := feature("solar-eclipse", "center-line", + `{"type":"LineString","coordinates":[[-60,10],[60,20]]}`, "") + root := convert(t, collection(wide), Options{}) + if root.Document.LookAt == nil { + t.Fatal("缺少 LookAt") + } + // 120° 经度跨度约 1.2e7 m;若仍按千米写入会是 1e4 量级。 + if rangeMeters := root.Document.LookAt.Range; rangeMeters < 1e7 { + t.Fatalf("LookAt range = %v, 应使用米(≥1e7)", rangeMeters) + } + narrow := feature("solar-eclipse", "time-marker", + `{"type":"Point","coordinates":[121.5,31.2]}`, "") + small := convert(t, collection(narrow), Options{}) + if small.Document.LookAt == nil || small.Document.LookAt.Range < 2e5 { + t.Fatalf("小要素的 LookAt range 下限应为 200 km:%+v", small.Document.LookAt) + } +} + +func TestStyleElementsFollowKMLSequence(t *testing.T) { + input := collection(feature("solar-eclipse", "central-band", + `{"type":"Polygon","coordinates":[[[100,20],[101,20],[101,21],[100,20]]]}`, "")) + out, err := FromGeoJSON([]byte(input), Options{}) + if err != nil { + t.Fatal(err) + } + text := string(out) + icon := strings.Index(text, "") + line := strings.Index(text, "") + poly := strings.Index(text, "") + if icon < 0 || line < 0 || poly < 0 || !(icon < line && line < poly) { + t.Fatalf("Style 子元素顺序应为 IconStyle", `{"type":"LineString","coordinates":[[0,0],[1,1]]}`, ""), + feature("", "third-party-_-role-", `{"type":"LineString","coordinates":[[0,0],[1,1]]}`, ""), + ) + out, err := FromGeoJSON([]byte(input), Options{}) + if err != nil { + t.Fatal(err) + } + var root kmlRoot + if err := xml.Unmarshal(out, &root); err != nil { + t.Fatal(err) + } + valid := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`) + seen := map[string]bool{} + for _, style := range root.Document.Styles { + if !valid.MatchString(style.ID) { + t.Errorf("样式 id 不是合法 NCName:%q", style.ID) + } + if seen[style.ID] { + t.Errorf("样式 id 重复:%q", style.ID) + } + seen[style.ID] = true + } + if len(seen) != 2 { + t.Fatalf("样式数 = %d, want 2", len(seen)) + } + if !strings.Contains(string(out), "#"+firstStyleID(t, root)) { + t.Fatal("styleUrl 未引用清洗后的 id") + } + // 图层名仍应是原始 role,而不是清洗后的 id。 + foundRaw := false + for _, folder := range root.Document.Folders { + if folder.Name == "third-party & " { + foundRaw = true + } + } + if !foundRaw { + t.Fatalf("图层名未保留原始 role:%+v", root.Document.Folders) + } +} + +func firstStyleID(t *testing.T, root kmlRoot) string { + t.Helper() + if len(root.Document.Styles) == 0 { + t.Fatal("缺少样式") + } + return root.Document.Styles[0].ID +} + +func TestTimeStampsKeepSubSecondPrecision(t *testing.T) { + input := collection(feature("solar-eclipse", "time-marker", + `{"type":"Point","coordinates":[121.5,31.2]}`, + `"time":"2024-04-08T18:17:20.123456789Z","label":"02:17"`)) + root := convert(t, input, Options{}) + items := placemarksOfRole(root, "time-marker") + if len(items) != 1 || items[0].TimeStamp == nil { + t.Fatalf("时间标记 = %+v", items) + } + if items[0].TimeStamp.When != "2024-04-08T18:17:20.123456789Z" { + t.Fatalf("when = %q, 应保留小数秒", items[0].TimeStamp.When) + } +} + +func TestSkipRolesRejectsDroppingEverything(t *testing.T) { + input := collection(feature("solar-eclipse", "partial-footprint", + `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "")) + if _, err := FromGeoJSON([]byte(input), Options{SkipRoles: []string{"partial-footprint"}}); err == nil { + t.Fatal("裁光全部要素时应报错,而不是输出空 Document") + } +} + +func TestMixedEventsPrefixFolderNames(t *testing.T) { + input := collection( + feature("solar-eclipse", "center-line", lineGeometry, ""), + feature("lunar-occultation", "center-line", lineGeometry, ""), + ) + root := convert(t, input, Options{}) + if len(root.Document.Folders) != 2 { + t.Fatalf("图层数 = %d, want 2", len(root.Document.Folders)) + } + for _, folder := range root.Document.Folders { + if !strings.Contains(folder.Name, " · ") { + t.Fatalf("多事件集合的图层名应带事件前缀:%q", folder.Name) + } + } + if root.Document.Name != "GeoJSON" { + t.Fatalf("多事件集合的文档名不该只写首个事件:%q", root.Document.Name) + } + single := convert(t, collection(feature("solar-eclipse", "center-line", lineGeometry, "")), Options{}) + if single.Document.Folders[0].Name != "中心线" { + t.Fatalf("单事件集合不应加前缀:%q", single.Document.Folders[0].Name) + } + if single.Document.Name != "日食" { + t.Fatalf("单事件集合的文档名应按事件类型:%q", single.Document.Name) + } +} + +func TestEmptyCollectionProducesDocumentWithoutLayers(t *testing.T) { + out, err := FromGeoJSON([]byte(`{"type":"FeatureCollection","features":[]}`), Options{}) + if err != nil { + t.Fatalf("空集合应合法:%v", err) + } + var root kmlRoot + if err := xml.Unmarshal(out, &root); err != nil { + t.Fatal(err) + } + if len(root.Document.Folders) != 0 || len(root.Document.Styles) != 0 { + t.Fatalf("空集合不该产生图层或样式:%+v", root.Document) + } +} + +// 契约:折线同样要在反经线接缝处断开。本库 geojson 的 band-outline 是"闭合环线", +// 环上带 ±180 接缝段;只处理面的轮廓会让这条折线在换日线上画出一条直线。 +func TestClosedLineSeamIsNotDrawn(t *testing.T) { + input := collection(feature("lunar-occultation", "band-outline", + `{"type":"MultiLineString","coordinates":[[[170,10],[180,10],[180,-10],[170,-10],[170,10]],[[-180,-10],[-170,-10],[-170,10],[-180,10],[-180,-10]]]}`, "")) + root := convert(t, input, Options{}) + items := placemarksOfRole(root, "band-outline") + if len(items) != 1 { + t.Fatalf("Placemark 数 = %d, want 1", len(items)) + } + texts := coordinatesOf(items[0].geometry) + // 东侧环的接缝边在环中段,切成 2 段;西侧环的接缝边在收尾处,只切成 1 段:共 3 段。 + if len(texts) != 3 { + t.Fatalf("接缝切分后应有 3 段折线,得到 %d:%v", len(texts), texts) + } + for _, text := range texts { + previous := math.NaN() + previousLatitude := math.NaN() + for _, token := range strings.Fields(text) { + parts := strings.Split(token, ",") + longitude, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + t.Fatalf("解析经度 %q:%v", token, err) + } + latitude, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + t.Fatalf("解析纬度 %q:%v", token, err) + } + if !math.IsNaN(previous) && math.Abs(math.Abs(previous)-180) < 1e-9 && + math.Abs(math.Abs(longitude)-180) < 1e-9 && math.Abs(latitude-previousLatitude) > 1e-12 { + t.Fatalf("折线上仍残留日界线段:%s", text) + } + previous, previousLatitude = longitude, latitude + } + } +} + +// 端到端:真实星掩的 band-outline 跨反经线,转换后的 KML 不得出现沿 ±180 的直线段。 +func TestLibraryOccultationOutputDrawsNoSeamLine(t *testing.T) { + antares := moon.StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, + ProperMotionDecMasPerYear: -20, + ParallaxMas: 24, + } + paths, err := moon.FindStarOccultationPaths( + time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC), + time.Date(2026, time.February, 12, 0, 0, 0, 0, time.UTC), antares, + moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}) + if err != nil || len(paths) == 0 { + t.Fatalf("FindStarOccultationPaths: paths=%d err=%v", len(paths), err) + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + out, err := FromGeoJSON(data, Options{}) + if err != nil { + t.Fatalf("FromGeoJSON: %v", err) + } + var root kmlRoot + if err := xml.Unmarshal(out, &root); err != nil { + t.Fatalf("输出不是合法 KML:%v", err) + } + seams := 0 + for _, folder := range root.Document.Folders { + for _, item := range folder.Placemarks { + for _, coordinates := range lineCoordinatesOf(item.geometry) { + previousLongitude, previousLatitude := math.NaN(), math.NaN() + for _, token := range strings.Fields(coordinates) { + parts := strings.Split(token, ",") + longitude, parseErr := strconv.ParseFloat(parts[0], 64) + if parseErr != nil { + t.Fatalf("解析经度 %q:%v", token, parseErr) + } + latitude, parseErr := strconv.ParseFloat(parts[1], 64) + if parseErr != nil { + t.Fatalf("解析纬度 %q:%v", token, parseErr) + } + if !math.IsNaN(previousLongitude) && math.Abs(math.Abs(previousLongitude)-180) < 1e-9 && + math.Abs(math.Abs(longitude)-180) < 1e-9 && math.Abs(latitude-previousLatitude) > 1e-12 { + seams++ + } + previousLongitude, previousLatitude = longitude, latitude + } + } + } + } + if seams != 0 { + t.Fatalf("KML 里仍画出 %d 条日界线直线段", seams) + } +} + +// 契约:整条都落在 ±180 上的线是真实边界(例如极区被反经线裁出的边),原样保留而不是报错。 +func TestLineOnTheSeamIsPreserved(t *testing.T) { + input := collection(feature("lunar-occultation", "visibility-boundary", + `{"type":"LineString","coordinates":[[180,-80],[180,-60],[180,-40]]}`, "")) + root := convert(t, input, Options{}) + items := placemarksOfRole(root, "visibility-boundary") + if len(items) != 1 { + t.Fatalf("Placemark 数 = %d, want 1", len(items)) + } + texts := coordinatesOf(items[0].geometry) + if len(texts) != 1 { + t.Fatalf("接缝上的线应原样保留 1 段,得到 %d", len(texts)) + } + if !strings.Contains(texts[0], "180,-80") || !strings.Contains(texts[0], "180,-40") { + t.Fatalf("坐标未保留:%s", texts[0]) + } +} + +// 契约补充(review 修复):LookAt 子元素按 KML 2.2 sequence;单点路径片段跳过而不是让整份转换失败; +// 多几何要素的标量 time 也变成 TimeStamp;环上除接缝外的真实边一条不少;任意 event 都支持样式覆盖。 + +func splitPositions(text string) [][2]float64 { + result := [][2]float64{} + for _, chunk := range strings.Fields(text) { + parts := strings.Split(chunk, ",") + if len(parts) < 2 { + continue + } + longitude, firstErr := strconv.ParseFloat(parts[0], 64) + latitude, secondErr := strconv.ParseFloat(parts[1], 64) + if firstErr != nil || secondErr != nil { + continue + } + result = append(result, [2]float64{longitude, latitude}) + } + return result +} + +func TestLookAtElementsFollowKMLSequence(t *testing.T) { + out, err := FromGeoJSON([]byte(collection(feature("solar-eclipse", "center-line", lineGeometry, ""))), Options{}) + if err != nil { + t.Fatal(err) + } + text := string(out) + start := strings.Index(text, "") + end := strings.Index(text, "") + if start < 0 || end < start { + t.Fatalf("缺少 LookAt:%s", text) + } + block := text[start:end] + order := []int{} + for _, name := range []string{"longitude", "latitude", "heading", "tilt", "range"} { + order = append(order, strings.Index(block, "<"+name+">")) + } + for index, position := range order { + if position < 0 { + t.Fatalf("LookAt 缺少第 %d 个子元素:%s", index, block) + } + if index > 0 && position < order[index-1] { + t.Fatalf("LookAt 子元素顺序应为 longitude"+name+"") { + t.Fatalf("language %q: KML 缺少图层名 %q", tc.language, name) + } + } + } +} diff --git a/lite/internal/common.go b/lite/internal/common.go index 895567c..6678c30 100644 --- a/lite/internal/common.go +++ b/lite/internal/common.go @@ -5,6 +5,8 @@ import ( "math" . "b612.me/astro/tools" + + "b612.me/astro/basic" ) const ( @@ -17,8 +19,8 @@ var ( ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date") ) -func MeanObliquity(jd float64) float64 { - t := (jd - 2451545.0) / 36525.0 +func MeanObliquity(jde float64) float64 { + t := (jde - 2451545.0) / 36525.0 return 23.4392911111 - (46.8150*t+0.00059*t*t-0.001813*t*t*t)/3600.0 } @@ -27,8 +29,8 @@ func MeanSiderealTime(jd float64) float64 { return Limit360(280.46061837 + 360.98564736629*(jd-2451545.0) + 0.000387933*t*t - t*t*t/38710000.0) } -func EclipticToEquatorial(jd, lo, bo float64) (float64, float64) { - eps := MeanObliquity(jd) +func EclipticToEquatorial(jde, lo, bo float64) (float64, float64) { + eps := MeanObliquity(jde) ra := math.Atan2(Sin(lo)*Cos(eps)-Tan(bo)*Sin(eps), Cos(lo)) * 180.0 / math.Pi if ra < 0 { ra += 360 @@ -38,7 +40,7 @@ func EclipticToEquatorial(jd, lo, bo float64) (float64, float64) { } func HorizontalCoordinates(ra, dec, jd, lon, lat float64) (float64, float64, float64) { - lst := Limit360(MeanSiderealTime(jd) + lon) + lst := Limit360(MeanSiderealTime(basic.UTC2UT1(jd)) + lon) hourAngle := Limit360(lst - ra) altitude := ArcSin(clampUnit(Sin(lat)*Sin(dec) + Cos(lat)*Cos(dec)*Cos(hourAngle))) @@ -63,7 +65,7 @@ func TopocentricRaDec(ra, dec, observerLat, observerLon, jd, distanceEarthRadii, rhoCos := math.Cos(u) + heightMeters/6378140.0*Cos(observerLat) parallax := math.Asin(1.0 / distanceEarthRadii) - hourAngle := (Limit360(MeanSiderealTime(jd) + observerLon - ra)) * math.Pi / 180.0 + hourAngle := (Limit360(MeanSiderealTime(basic.UTC2UT1(jd)) + observerLon - ra)) * math.Pi / 180.0 decRad := dec * math.Pi / 180.0 numerator := -rhoCos * math.Sin(parallax) * math.Sin(hourAngle) diff --git a/lite/internal/sun.go b/lite/internal/sun.go index 8686425..e11cdd9 100644 --- a/lite/internal/sun.go +++ b/lite/internal/sun.go @@ -2,47 +2,48 @@ package internal import . "b612.me/astro/tools" -func SunLo(jd float64) float64 { - t := (jd - 2451545.0) / 365250.0 +func SunLo(jde float64) float64 { + t := (jde - 2451545.0) / 365250.0 return Limit360(280.4664567 + 360007.6982779*t + 0.03032028*t*t + t*t*t/49931.0 - t*t*t*t/15299.0 - t*t*t*t*t/1988000.0) } -func SunMeanAnomaly(jd float64) float64 { - t := (jd - 2451545.0) / 36525.0 +func SunMeanAnomaly(jde float64) float64 { + t := (jde - 2451545.0) / 36525.0 return Limit360(357.5291092 + 35999.0502909*t - 0.0001559*t*t - 0.00000048*t*t*t) } -func EarthEccentricity(jd float64) float64 { - t := (jd - 2451545.0) / 36525.0 +func EarthEccentricity(jde float64) float64 { + t := (jde - 2451545.0) / 36525.0 return 0.016708617 - 0.000042037*t - 0.0000001236*t*t } -func SunCenter(jd float64) float64 { - t := (jd - 2451545.0) / 36525.0 - m := SunMeanAnomaly(jd) +func SunCenter(jde float64) float64 { + t := (jde - 2451545.0) / 36525.0 + m := SunMeanAnomaly(jde) return (1.9146-0.004817*t-0.000014*t*t)*Sin(m) + (0.019993-0.000101*t)*Sin(2*m) + 0.00029*Sin(3*m) } -func SunTrueLo(jd float64) float64 { - return Limit360(SunLo(jd) + SunCenter(jd)) +// 本文件以 jde(力学时儒略日)键控;lite 包装层的实参是 UTC 民用 JD,69 s 的历元差远低于轻量公式精度。 +func SunTrueLo(jde float64) float64 { + return Limit360(SunLo(jde) + SunCenter(jde)) } -func SunApparentLo(jd float64) float64 { - t := (jd - 2451545.0) / 36525.0 - return Limit360(SunTrueLo(jd) - 0.00569 - 0.00478*Sin(125.04-1934.136*t)) +func SunApparentLo(jde float64) float64 { + t := (jde - 2451545.0) / 36525.0 + return Limit360(SunTrueLo(jde) - 0.00569 - 0.00478*Sin(125.04-1934.136*t)) } -func SunDistanceAU(jd float64) float64 { - c := SunCenter(jd) - m := SunMeanAnomaly(jd) - e := EarthEccentricity(jd) +func SunDistanceAU(jde float64) float64 { + c := SunCenter(jde) + m := SunMeanAnomaly(jde) + e := EarthEccentricity(jde) return 1.000001018 * (1 - e*e) / (1 + e*Cos(m+c)) } -func SunTrueRaDec(jd float64) (float64, float64) { - return EclipticToEquatorial(jd, SunTrueLo(jd), 0) +func SunTrueRaDec(jde float64) (float64, float64) { + return EclipticToEquatorial(jde, SunTrueLo(jde), 0) } -func SunApparentRaDec(jd float64) (float64, float64) { - return EclipticToEquatorial(jd, SunApparentLo(jd), 0) +func SunApparentRaDec(jde float64) (float64, float64) { + return EclipticToEquatorial(jde, SunApparentLo(jde), 0) } diff --git a/lite/moon/moon.go b/lite/moon/moon.go index 28a8100..d625c0f 100644 --- a/lite/moon/moon.go +++ b/lite/moon/moon.go @@ -1,6 +1,7 @@ package moon import ( + "b612.me/astro/internal/civiltime" "errors" "math" "time" @@ -18,50 +19,50 @@ var ( // TrueLo 轻量真黄经 / lightweight true ecliptic longitude. func TrueLo(date time.Time) float64 { - return lite.MoonGeocentric(basic.Date2JDE(date.UTC())).Longitude + return lite.MoonGeocentric(basic.Date2JD(date.UTC())).Longitude } // TrueBo 轻量真黄纬 / lightweight true ecliptic latitude. func TrueBo(date time.Time) float64 { - return lite.MoonGeocentric(basic.Date2JDE(date.UTC())).Latitude + return lite.MoonGeocentric(basic.Date2JD(date.UTC())).Latitude } // TrueRa 轻量真赤经 / lightweight true right ascension. func TrueRa(date time.Time) float64 { - return lite.MoonGeocentric(basic.Date2JDE(date.UTC())).RightAscension + return lite.MoonGeocentric(basic.Date2JD(date.UTC())).RightAscension } // TrueDec 轻量真赤纬 / lightweight true declination. func TrueDec(date time.Time) float64 { - return lite.MoonGeocentric(basic.Date2JDE(date.UTC())).Declination + return lite.MoonGeocentric(basic.Date2JD(date.UTC())).Declination } // TrueRaDec 轻量真赤经、真赤纬 / lightweight true right ascension and declination. func TrueRaDec(date time.Time) (float64, float64) { - state := lite.MoonGeocentric(basic.Date2JDE(date.UTC())) + state := lite.MoonGeocentric(basic.Date2JD(date.UTC())) return state.RightAscension, state.Declination } // ApparentRa 轻量站心视赤经 / lightweight topocentric apparent right ascension. func ApparentRa(date time.Time, lon, lat float64) float64 { - state := lite.MoonTopocentric(basic.Date2JDE(date.UTC()), lon, lat, 0) + state := lite.MoonTopocentric(basic.Date2JD(date.UTC()), lon, lat, 0) return state.RightAscension } // ApparentDec 轻量站心视赤纬 / lightweight topocentric apparent declination. func ApparentDec(date time.Time, lon, lat float64) float64 { - state := lite.MoonTopocentric(basic.Date2JDE(date.UTC()), lon, lat, 0) + state := lite.MoonTopocentric(basic.Date2JD(date.UTC()), lon, lat, 0) return state.Declination } // ApparentRaDec 轻量站心视赤经、视赤纬 / lightweight topocentric apparent right ascension and declination. func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) { - state := lite.MoonTopocentric(basic.Date2JDE(date.UTC()), lon, lat, 0) + state := lite.MoonTopocentric(basic.Date2JD(date.UTC()), lon, lat, 0) return state.RightAscension, state.Declination } func topocentricHorizontal(date time.Time, lon, lat float64) (altitude, azimuth, hourAngle float64) { - jd := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) state := lite.MoonTopocentric(jd, lon, lat, 0) return lite.HorizontalCoordinates(state.RightAscension, state.Declination, jd, lon, lat) } @@ -92,8 +93,8 @@ func Zenith(date time.Time, lon, lat float64) float64 { // SunMoonLoDiff 轻量日月黄经差 / lightweight Moon-Sun ecliptic-longitude difference. func SunMoonLoDiff(date time.Time) float64 { - jd := basic.Date2JDE(date.UTC()) - return Limit360(lite.MoonGeocentric(jd).Longitude - lite.SunApparentLo(jd)) + jdUTC := basic.Date2JD(date.UTC()) + return Limit360(lite.MoonGeocentric(jdUTC).Longitude - lite.SunApparentLo(jdUTC)) } // PhaseAge 轻量月龄 / lightweight lunar age in days. @@ -108,26 +109,36 @@ func Phase(date time.Time) float64 { // RiseTime 轻量月出时刻 / lightweight moonrise time. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) { + return RiseTime(d, lon, lat, height, aero) + }); handled { + return result, err + } return riseSetTime(date, lon, lat, height, aero, true) } // SetTime 轻量月落时刻 / lightweight moonset time. func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) { + return SetTime(d, lon, lat, height, aero) + }); handled { + return result, err + } return riseSetTime(date, lon, lat, height, aero, false) } func riseSetTime(date time.Time, lon, lat, height float64, aero, isRise bool) (time.Time, error) { localMidnight := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - localJD := basic.Date2JDE(localMidnight) + localJD := basic.Date2JD(localMidnight) _, offset := localMidnight.Zone() timezone := float64(offset) / 3600.0 horizonDip := basic.HeightDegreeByLat(height, lat) altitudeFn := func(localJD float64) float64 { - utJD := localJD - timezone/24.0 - state := lite.MoonTopocentric(utJD, lon, lat, height) - altitude, _, _ := lite.HorizontalCoordinates(state.RightAscension, state.Declination, utJD, lon, lat) + utcJD := localJD - timezone/24.0 + state := lite.MoonTopocentric(utcJD, lon, lat, height) + altitude, _, _ := lite.HorizontalCoordinates(state.RightAscension, state.Declination, utcJD, lon, lat) residual := altitude + horizonDip if aero { residual += basic.RefractionFromTrueAltitude(altitude, 1010, 10) @@ -149,7 +160,7 @@ func riseSetTime(date time.Time, lon, lat, height float64, aero, isRise bool) (t return time.Time{}, err } } - return basic.JDE2DateByZone(eventJD, date.Location(), true), nil + return basic.JD2DateByZone(eventJD-timezone/24, date.Location(), false), nil } func liteMoonSemidiameterDegrees(distanceEarthRadii float64) float64 { diff --git a/lite/moon/moon_test.go b/lite/moon/moon_test.go index f310a74..6c61c02 100644 --- a/lite/moon/moon_test.go +++ b/lite/moon/moon_test.go @@ -156,7 +156,7 @@ func TestHorizontalEntriesMatchDuplicatedEvaluation(t *testing.T) { } for _, s := range samples { - jd := basic.Date2JDE(s.date.UTC()) + jd := basic.Date2JD(s.date.UTC()) altitude, azimuth, hourAngle := lite.HorizontalCoordinates(ApparentRa(s.date, s.lon, s.lat), ApparentDec(s.date, s.lon, s.lat), jd, s.lon, s.lat) checks := []struct { name string diff --git a/lite/moon/perf_bench_test.go b/lite/moon/perf_bench_test.go index 8b88073..01c1bc1 100644 --- a/lite/moon/perf_bench_test.go +++ b/lite/moon/perf_bench_test.go @@ -60,7 +60,7 @@ func BenchmarkMoonZenith(b *testing.B) { func BenchmarkMoonHorizontalLegacy(b *testing.B) { date, lon, lat := benchmarkHorizontalInputs() - jd := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) entries := []struct { name string pick func(altitude, azimuth, hourAngle float64) float64 diff --git a/lite/sun/sun.go b/lite/sun/sun.go index b5b2d37..6a50cb5 100644 --- a/lite/sun/sun.go +++ b/lite/sun/sun.go @@ -1,6 +1,7 @@ package sun import ( + "b612.me/astro/internal/civiltime" "errors" "math" "time" @@ -16,68 +17,68 @@ var ( // TrueLo 轻量真黄经 / lightweight true ecliptic longitude. func TrueLo(date time.Time) float64 { - return lite.SunTrueLo(basic.Date2JDE(date.UTC())) + return lite.SunTrueLo(basic.Date2JD(date.UTC())) } // ApparentLo 轻量视黄经 / lightweight apparent ecliptic longitude. func ApparentLo(date time.Time) float64 { - return lite.SunApparentLo(basic.Date2JDE(date.UTC())) + return lite.SunApparentLo(basic.Date2JD(date.UTC())) } // Distance 轻量日地距离 / lightweight Sun-Earth distance in AU. func Distance(date time.Time) float64 { - return lite.SunDistanceAU(basic.Date2JDE(date.UTC())) + return lite.SunDistanceAU(basic.Date2JD(date.UTC())) } // TrueRa 轻量真赤经 / lightweight true right ascension. func TrueRa(date time.Time) float64 { - ra, _ := lite.SunTrueRaDec(basic.Date2JDE(date.UTC())) + ra, _ := lite.SunTrueRaDec(basic.Date2JD(date.UTC())) return ra } // TrueDec 轻量真赤纬 / lightweight true declination. func TrueDec(date time.Time) float64 { - _, dec := lite.SunTrueRaDec(basic.Date2JDE(date.UTC())) + _, dec := lite.SunTrueRaDec(basic.Date2JD(date.UTC())) return dec } // TrueRaDec 轻量真赤经、真赤纬 / lightweight true right ascension and declination. func TrueRaDec(date time.Time) (float64, float64) { - return lite.SunTrueRaDec(basic.Date2JDE(date.UTC())) + return lite.SunTrueRaDec(basic.Date2JD(date.UTC())) } // ApparentRa 轻量视赤经 / lightweight apparent right ascension. func ApparentRa(date time.Time) float64 { - ra, _ := lite.SunApparentRaDec(basic.Date2JDE(date.UTC())) + ra, _ := lite.SunApparentRaDec(basic.Date2JD(date.UTC())) return ra } // ApparentDec 轻量视赤纬 / lightweight apparent declination. func ApparentDec(date time.Time) float64 { - _, dec := lite.SunApparentRaDec(basic.Date2JDE(date.UTC())) + _, dec := lite.SunApparentRaDec(basic.Date2JD(date.UTC())) return dec } // ApparentRaDec 轻量视赤经、视赤纬 / lightweight apparent right ascension and declination. func ApparentRaDec(date time.Time) (float64, float64) { - return lite.SunApparentRaDec(basic.Date2JDE(date.UTC())) + return lite.SunApparentRaDec(basic.Date2JD(date.UTC())) } // HourAngle 轻量时角 / lightweight hour angle. func HourAngle(date time.Time, lon, lat float64) float64 { - _, _, hourAngle := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JDE(date.UTC()), lon, lat) + _, _, hourAngle := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JD(date.UTC()), lon, lat) return hourAngle } // Azimuth 轻量方位角 / lightweight azimuth. func Azimuth(date time.Time, lon, lat float64) float64 { - _, azimuth, _ := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JDE(date.UTC()), lon, lat) + _, azimuth, _ := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JD(date.UTC()), lon, lat) return azimuth } // Altitude 轻量高度角 / lightweight altitude. func Altitude(date time.Time, lon, lat float64) float64 { - altitude, _, _ := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JDE(date.UTC()), lon, lat) + altitude, _, _ := lite.HorizontalCoordinates(ApparentRa(date), ApparentDec(date), basic.Date2JD(date.UTC()), lon, lat) return altitude } @@ -88,30 +89,40 @@ func Zenith(date time.Time, lon, lat float64) float64 { // RiseTime 轻量日出时刻 / lightweight sunrise time. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return RiseTime(d, lon, lat, height, aero) + }); handled { + return result, err + } return riseSetTime(date, lon, lat, height, aero, true) } // SetTime 轻量日落时刻 / lightweight sunset time. func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return SetTime(d, lon, lat, height, aero) + }); handled { + return result, err + } return riseSetTime(date, lon, lat, height, aero, false) } func riseSetTime(date time.Time, lon, lat, height float64, aero, isRise bool) (time.Time, error) { localMidnight := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - localJD := basic.Date2JDE(localMidnight) + localJD := basic.Date2JD(localMidnight) _, offset := localMidnight.Zone() timezone := float64(offset) / 3600.0 horizonDip := basic.HeightDegreeByLat(height, lat) altitudeFn := func(localJD float64) float64 { - utJD := localJD - timezone/24.0 - ra, dec := lite.SunApparentRaDec(utJD) - altitude, _, _ := lite.HorizontalCoordinates(ra, dec, utJD, lon, lat) + utcJD := localJD - timezone/24.0 + ra, dec := lite.SunApparentRaDec(utcJD) + altitude, _, _ := lite.HorizontalCoordinates(ra, dec, utcJD, lon, lat) residual := altitude + horizonDip if aero { residual += basic.RefractionFromTrueAltitude(altitude, 1010, 10) - residual += liteSunSemidiameterDegrees(lite.SunDistanceAU(utJD)) + residual += liteSunSemidiameterDegrees(lite.SunDistanceAU(utcJD)) } return residual } @@ -127,7 +138,7 @@ func riseSetTime(date time.Time, lon, lat, height float64, aero, isRise bool) (t return time.Time{}, err } } - return basic.JDE2DateByZone(eventJD, date.Location(), true), nil + return basic.JD2DateByZone(eventJD-timezone/24, date.Location(), false), nil } func liteSunSemidiameterDegrees(distanceAU float64) float64 { diff --git a/mars/diameter.go b/mars/diameter.go index 51980ad..196dc1a 100644 --- a/mars/diameter.go +++ b/mars/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 火星视半径(截断版),单位角秒 / truncated apparent Mars semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 火星视直径,单位角秒 / apparent Mars diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 火星视直径(截断版),单位角秒 / truncated apparent Mars diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsDiameterN(basic.UTC2TT(jd), n) } diff --git a/mars/mars.go b/mars/mars.go index 699460d..6c9e6c2 100644 --- a/mars/mars.go +++ b/mars/mars.go @@ -15,7 +15,7 @@ var ( ERR_MARS_NEVER_DOWN = ERR_MARS_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回火星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Mars at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回火星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Mars at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回火星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Mars at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回火星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Mars at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回火星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Mars at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回火星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Mars at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回火星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Mars to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthMarsAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthMarsAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回火星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Mars to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(3, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(3, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsHeight(jde, lon, lat, timezone) + return basic.MarsHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsAzimuth(jde, lon, lat, timezone) + return basic.MarsAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsHourAngle(jde, lon, timezone) + return basic.MarsHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.MarsCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.MarsCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MarsRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MarsRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MarsSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MarsSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsConjunction(jde), date.Location(), false) } // LastOpposition 上一次冲日 / previous opposition. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or before date, keeping date's time zone. func LastOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsOpposition(jde), date.Location(), false) } // NextOpposition 下一次冲日 / next opposition. @@ -237,8 +232,8 @@ func LastOpposition(date time.Time) time.Time { // 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or after date, keeping date's time zone. func NextOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsOpposition(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -246,8 +241,8 @@ func NextOpposition(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsProgradeToRetrograde(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -255,8 +250,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsProgradeToRetrograde(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -264,8 +259,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsRetrogradeToPrograde(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -273,8 +268,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsRetrogradeToPrograde(jde), date.Location(), false) } // LastEasternQuadrature 上一次东方照 / previous eastern quadrature. @@ -282,8 +277,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or before date, keeping date's time zone. func LastEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsEasternQuadrature(jde), date.Location(), false) } // NextEasternQuadrature 下一次东方照 / next eastern quadrature. @@ -291,8 +286,8 @@ func LastEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or after date, keeping date's time zone. func NextEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsEasternQuadrature(jde), date.Location(), false) } // LastWesternQuadrature 上一次西方照 / previous western quadrature. @@ -300,8 +295,8 @@ func NextEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or before date, keeping date's time zone. func LastWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMarsWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMarsWesternQuadrature(jde), date.Location(), false) } // NextWesternQuadrature 下一次西方照 / next western quadrature. @@ -309,6 +304,6 @@ func LastWesternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or after date, keeping date's time zone. func NextWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMarsWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMarsWesternQuadrature(jde), date.Location(), false) } diff --git a/mars/nodes.go b/mars/nodes.go index 4c924a7..7c28fb9 100644 --- a/mars/nodes.go +++ b/mars/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 火星升交点黄经(截断版) / truncated ascending node longitude of Mars. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 火星降交点黄经 / descending node longitude of Mars. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 火星降交点黄经(截断版) / truncated descending node longitude of Mars. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/mars/phase.go b/mars/phase.go index d3dd2e0..28e29b4 100644 --- a/mars/phase.go +++ b/mars/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/mars/physical.go b/mars/physical.go index 7f0f17f..045c375 100644 --- a/mars/physical.go +++ b/mars/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 火星物理观测参数(截断版) / truncated physical observing parameters of Mars. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.MarsPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.MarsPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/mars/physical_test.go b/mars/physical_test.go index fed4265..3777716 100644 --- a/mars/physical_test.go +++ b/mars/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.MarsPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.MarsPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/mars/truncated.go b/mars/truncated.go index b177282..bf9d4cc 100644 --- a/mars/truncated.go +++ b/mars/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MarsMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MarsMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthMarsAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthMarsAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(3, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(3, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsHeightN(jde, lon, lat, timezone, n) + return basic.MarsHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsAzimuthN(jde, lon, lat, timezone, n) + return basic.MarsAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MarsHourAngleN(jde, lon, timezone, n) + return basic.MarsHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.MarsCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.MarsCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MarsRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MarsRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MarsSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MarsSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/mercury/diameter.go b/mercury/diameter.go index 73acb02..cbc8b61 100644 --- a/mercury/diameter.go +++ b/mercury/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 水星视半径(截断版),单位角秒 / truncated apparent Mercury semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercurySemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercurySemidiameterN(basic.UTC2TT(jd), n) } // Diameter 水星视直径,单位角秒 / apparent Mercury diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 水星视直径(截断版),单位角秒 / truncated apparent Mercury diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryDiameterN(basic.UTC2TT(jd), n) } diff --git a/mercury/mercury.go b/mercury/mercury.go index 19203b0..895093c 100644 --- a/mercury/mercury.go +++ b/mercury/mercury.go @@ -15,7 +15,7 @@ var ( ERR_MERCURY_NEVER_DOWN = ERR_MERCURY_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回水星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Mercury at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回水星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Mercury at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回水星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Mercury at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回水星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Mercury at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回水星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Mercury at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回水星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Mercury at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回水星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Mercury to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthMercuryAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthMercuryAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回水星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Mercury to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(1, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(1, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryHeight(jde, lon, lat, timezone) + return basic.MercuryHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryAzimuth(jde, lon, lat, timezone) + return basic.MercuryAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryHourAngle(jde, lon, timezone) + return basic.MercuryHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.MercuryCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.MercuryCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MercuryRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MercuryRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MercurySetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MercurySetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryConjunction(jde), date.Location(), false) } // LastInferiorConjunction 上一次下合 / previous inferior conjunction. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次下合时刻,结果保持 date 的时区。 // Returns the nearest inferior conjunction at or before date, keeping date's time zone. func LastInferiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryInferiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryInferiorConjunctionInclusive(jde), date.Location(), false) } // NextInferiorConjunction 下一次下合 / next inferior conjunction. @@ -237,8 +232,8 @@ func LastInferiorConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次下合时刻,结果保持 date 的时区。 // Returns the nearest inferior conjunction at or after date, keeping date's time zone. func NextInferiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryInferiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryInferiorConjunctionInclusive(jde), date.Location(), false) } // LastSuperiorConjunction 上一次上合 / previous superior conjunction. @@ -246,8 +241,8 @@ func NextInferiorConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次上合时刻,结果保持 date 的时区。 // Returns the nearest superior conjunction at or before date, keeping date's time zone. func LastSuperiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercurySuperiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercurySuperiorConjunctionInclusive(jde), date.Location(), false) } // NextSuperiorConjunction 下一次上合 / next superior conjunction. @@ -255,8 +250,8 @@ func LastSuperiorConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次上合时刻,结果保持 date 的时区。 // Returns the nearest superior conjunction at or after date, keeping date's time zone. func NextSuperiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercurySuperiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercurySuperiorConjunctionInclusive(jde), date.Location(), false) } // LastRetrograde 上一次留 / previous stationary point. @@ -264,8 +259,8 @@ func NextSuperiorConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。 // Returns the nearest stationary point at or before date, regardless of the direction change, keeping date's time zone. func LastRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryRetrogradeInclusive(jde), date.Location(), false) } // NextRetrograde 下一次留 / next stationary point. @@ -273,8 +268,8 @@ func LastRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。 // Returns the nearest stationary point at or after date, regardless of the direction change, keeping date's time zone. func NextRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryRetrogradeInclusive(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -282,8 +277,8 @@ func NextRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -291,8 +286,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -300,8 +295,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -309,8 +304,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false) } // LastGreatestElongation 上一次大距 / previous greatest elongation. @@ -318,8 +313,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次大距时刻,不区分东西大距,结果保持 date 的时区。 // Returns the nearest greatest elongation at or before date, regardless of east or west, keeping date's time zone. func LastGreatestElongation(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryGreatestElongationInclusive(jde), date.Location(), false) } // NextGreatestElongation 下一次大距 / next greatest elongation. @@ -327,8 +322,8 @@ func LastGreatestElongation(date time.Time) time.Time { // 返回 date 当前或之后最近一次大距时刻,不区分东西大距,结果保持 date 的时区。 // Returns the nearest greatest elongation at or after date, regardless of east or west, keeping date's time zone. func NextGreatestElongation(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryGreatestElongationInclusive(jde), date.Location(), false) } // LastGreatestElongationEast 上一次东大距 / previous greatest eastern elongation. @@ -336,8 +331,8 @@ func NextGreatestElongation(date time.Time) time.Time { // 返回 date 当前或之前最近一次东大距时刻,结果保持 date 的时区。 // Returns the nearest eastern greatest elongation at or before date, keeping date's time zone. func LastGreatestElongationEast(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationEastInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryGreatestElongationEastInclusive(jde), date.Location(), false) } // NextGreatestElongationEast 下一次东大距 / next greatest eastern elongation. @@ -345,8 +340,8 @@ func LastGreatestElongationEast(date time.Time) time.Time { // 返回 date 当前或之后最近一次东大距时刻,结果保持 date 的时区。 // Returns the nearest eastern greatest elongation at or after date, keeping date's time zone. func NextGreatestElongationEast(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationEastInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryGreatestElongationEastInclusive(jde), date.Location(), false) } // LastGreatestElongationWest 上一次西大距 / previous greatest western elongation. @@ -354,8 +349,8 @@ func NextGreatestElongationEast(date time.Time) time.Time { // 返回 date 当前或之前最近一次西大距时刻,结果保持 date 的时区。 // Returns the nearest western greatest elongation at or before date, keeping date's time zone. func LastGreatestElongationWest(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationWestInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMercuryGreatestElongationWestInclusive(jde), date.Location(), false) } // NextGreatestElongationWest 下一次西大距 / next greatest western elongation. @@ -363,6 +358,6 @@ func LastGreatestElongationWest(date time.Time) time.Time { // 返回 date 当前或之后最近一次西大距时刻,结果保持 date 的时区。 // Returns the nearest western greatest elongation at or after date, keeping date's time zone. func NextGreatestElongationWest(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationWestInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMercuryGreatestElongationWestInclusive(jde), date.Location(), false) } diff --git a/mercury/nodes.go b/mercury/nodes.go index cb087d9..106191f 100644 --- a/mercury/nodes.go +++ b/mercury/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 水星升交点黄经(截断版) / truncated ascending node longitude of Mercury. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 水星降交点黄经 / descending node longitude of Mercury. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 水星降交点黄经(截断版) / truncated descending node longitude of Mercury. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/mercury/phase.go b/mercury/phase.go index 4f13b33..46eef3a 100644 --- a/mercury/phase.go +++ b/mercury/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/mercury/physical.go b/mercury/physical.go index bf610c9..dd3f70a 100644 --- a/mercury/physical.go +++ b/mercury/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 水星物理观测参数(截断版) / truncated physical observing parameters of Mercury. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.MercuryPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.MercuryPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/mercury/physical_test.go b/mercury/physical_test.go index a3082ba..0592e06 100644 --- a/mercury/physical_test.go +++ b/mercury/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.MercuryPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.MercuryPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/mercury/transit.go b/mercury/transit.go index ba39b57..a95f3b0 100644 --- a/mercury/transit.go +++ b/mercury/transit.go @@ -33,26 +33,26 @@ type TransitInfo struct { // NextTransit 下一次地心水星凌日 / next geocentric Mercury transit. func NextTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.NextMercuryTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.NextMercuryTransit(basic.Date2JD(date.UTC())), date.Location()) } // LastTransit 上一次地心水星凌日 / previous geocentric Mercury transit. func LastTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.LastMercuryTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.LastMercuryTransit(basic.Date2JD(date.UTC())), date.Location()) } // ClosestTransit 最近一次地心水星凌日 / closest geocentric Mercury transit. func ClosestTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.ClosestMercuryTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.ClosestMercuryTransit(basic.Date2JD(date.UTC())), date.Location()) } func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) TransitInfo { if !result.Valid { return TransitInfo{} } - start := basic.JDE2DateByZone(result.ExternalIngress, loc, false) - greatest := basic.JDE2DateByZone(result.Greatest, loc, false) - end := basic.JDE2DateByZone(result.ExternalEgress, loc, false) + start := basic.JD2DateByZone(result.ExternalIngress, loc, false) + greatest := basic.JD2DateByZone(result.Greatest, loc, false) + end := basic.JD2DateByZone(result.ExternalEgress, loc, false) info := TransitInfo{ Valid: true, Start: start, @@ -65,8 +65,8 @@ func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) HasInternal: result.HasInternal, } if result.HasInternal { - info.InternalStart = basic.JDE2DateByZone(result.InternalIngress, loc, false) - info.InternalEnd = basic.JDE2DateByZone(result.InternalEgress, loc, false) + info.InternalStart = basic.JD2DateByZone(result.InternalIngress, loc, false) + info.InternalEnd = basic.JD2DateByZone(result.InternalEgress, loc, false) info.InternalDuration = info.InternalEnd.Sub(info.InternalStart) } return info diff --git a/mercury/truncated.go b/mercury/truncated.go index fee6b97..f79e1e6 100644 --- a/mercury/truncated.go +++ b/mercury/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.MercuryMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.MercuryMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthMercuryAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthMercuryAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(1, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(1, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryHeightN(jde, lon, lat, timezone, n) + return basic.MercuryHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryAzimuthN(jde, lon, lat, timezone, n) + return basic.MercuryAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.MercuryHourAngleN(jde, lon, timezone, n) + return basic.MercuryHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.MercuryCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.MercuryCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MercuryRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MercuryRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.MercurySetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.MercurySetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/moon/apsis.go b/moon/apsis.go index b246d9f..333ed27 100644 --- a/moon/apsis.go +++ b/moon/apsis.go @@ -28,7 +28,7 @@ func convertMoonApsisInfos(events []basic.ApsisEvent) []ApsisInfo { result := make([]ApsisInfo, 0, len(events)) for _, event := range events { result = append(result, ApsisInfo{ - Time: basic.JDE2DateByZone(event.JDE, time.UTC, false), + Time: basic.JD2DateByZone(event.JD, time.UTC, false), Distance: event.Distance, }) } diff --git a/moon/apsis_test.go b/moon/apsis_test.go index 15212f6..4c78e50 100644 --- a/moon/apsis_test.go +++ b/moon/apsis_test.go @@ -15,7 +15,7 @@ func TestApsisWrappersMatchBasic(t *testing.T) { t.Fatalf("perigee count mismatch: got %d want %d", len(perigeesWrapped), len(perigees)) } for i, event := range perigees { - wantTime := basic.JDE2DateByZone(event.JDE, time.UTC, false) + wantTime := basic.JD2DateByZone(event.JD, time.UTC, false) if !perigeesWrapped[i].Time.Equal(wantTime) { t.Fatalf("perigee #%d time mismatch: got %s want %s", i+1, perigeesWrapped[i].Time.Format(time.RFC3339Nano), wantTime.Format(time.RFC3339Nano)) } @@ -30,7 +30,7 @@ func TestApsisWrappersMatchBasic(t *testing.T) { t.Fatalf("apogee count mismatch: got %d want %d", len(apogeesWrapped), len(apogees)) } for i, event := range apogees { - wantTime := basic.JDE2DateByZone(event.JDE, time.UTC, false) + wantTime := basic.JD2DateByZone(event.JD, time.UTC, false) if !apogeesWrapped[i].Time.Equal(wantTime) { t.Fatalf("apogee #%d time mismatch: got %s want %s", i+1, apogeesWrapped[i].Time.Format(time.RFC3339Nano), wantTime.Format(time.RFC3339Nano)) } diff --git a/moon/conjunction.go b/moon/conjunction.go index 02cd2a9..9266086 100644 --- a/moon/conjunction.go +++ b/moon/conjunction.go @@ -49,8 +49,8 @@ func LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Ti if !validConjunctionPlanet(planet) { return time.Time{} } - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) } // NextConjunctionWithPlanet 下一次行星合月(赤经合) / next Moon-planet conjunction. @@ -58,8 +58,8 @@ func NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Ti if !validConjunctionPlanet(planet) { return time.Time{} } - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) } // ClosestConjunctionWithPlanet 最近一次行星合月(赤经合) / closest Moon-planet conjunction. @@ -67,6 +67,6 @@ func ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time if !validConjunctionPlanet(planet) { return time.Time{} } - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.ClosestMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.ClosestMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false) } diff --git a/moon/conjunction_test.go b/moon/conjunction_test.go index b501ba1..7e6fbac 100644 --- a/moon/conjunction_test.go +++ b/moon/conjunction_test.go @@ -11,7 +11,7 @@ import ( func TestConjunctionPlanetWrappersMatchBasic(t *testing.T) { loc := time.FixedZone("CST", 8*3600) query := time.Date(2026, 1, 15, 20, 0, 0, 0, loc) - queryTT := basic.TD2UT(basic.Date2JDE(query.UTC()), true) + queryTT := basic.UTC2TT(basic.Date2JD(query.UTC())) cases := []struct { name string @@ -38,7 +38,7 @@ func TestConjunctionPlanetWrappersMatchBasic(t *testing.T) { func assertSameConjunctionTime(t *testing.T, name string, got time.Time, wantJDE float64, loc *time.Location) { t.Helper() - want := basic.JDE2DateByZone(wantJDE, loc, false) + want := basic.JD2DateByZone(wantJDE, loc, false) if got.Location() != loc { t.Fatalf("%s location mismatch: got %q want %q", name, got.Location().String(), loc.String()) } diff --git a/moon/diameter.go b/moon/diameter.go index 1a7b04a..7e32b59 100644 --- a/moon/diameter.go +++ b/moon/diameter.go @@ -13,8 +13,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 月亮视半径(截断版),单位角秒 / truncated apparent lunar semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.MoonSemidiameterN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.MoonSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds. @@ -24,6 +24,6 @@ func Diameter(date time.Time) float64 { // DiameterN 月亮视直径(截断版),单位角秒 / truncated apparent lunar diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.MoonDiameterN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.MoonDiameterN(basic.UTC2TT(jd), n) } diff --git a/moon/geocentric_apparent_test.go b/moon/geocentric_apparent_test.go index 8ad1708..0194fea 100644 --- a/moon/geocentric_apparent_test.go +++ b/moon/geocentric_apparent_test.go @@ -35,7 +35,7 @@ func TestGeocentricApparentRaDecDiffersFromTopocentricAtSite(t *testing.T) { func TestTrueRaDecUsesBasicGeocentricTrue(t *testing.T) { date := time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC) - wantRA, wantDec := basic.HMoonGeocentricTrueRaDec(basic.TD2UT(basic.Date2JDE(date.UTC()), true)) + wantRA, wantDec := basic.HMoonGeocentricTrueRaDec(basic.UTC2TT(basic.Date2JD(date.UTC()))) gotRA, gotDec := TrueRaDec(date) if math.Abs(gotRA-wantRA) > 1e-12 || math.Abs(gotDec-wantDec) > 1e-12 { t.Fatalf("TrueRaDec mismatch: got (%.15f, %.15f) want (%.15f, %.15f)", gotRA, gotDec, wantRA, wantDec) diff --git a/moon/max_declination.go b/moon/max_declination.go index 7595bee..97c1014 100644 --- a/moon/max_declination.go +++ b/moon/max_declination.go @@ -68,11 +68,11 @@ func convertMaximumDeclinationInfo(date time.Time, event basic.DeclinationEvent) location = date.Location() } return MaximumDeclinationInfo{ - Time: basic.JDE2DateByZone(event.JDE, location, false), + Time: basic.JD2DateByZone(event.JD, location, false), Declination: event.Declination, } } func timeToUTJDE(date time.Time) float64 { - return basic.Date2JDE(date.UTC()) + return basic.Date2JD(date.UTC()) } diff --git a/moon/max_declination_test.go b/moon/max_declination_test.go index ede4500..b52fc2d 100644 --- a/moon/max_declination_test.go +++ b/moon/max_declination_test.go @@ -15,7 +15,7 @@ func TestMaximumDeclinationWrappersMatchBasic(t *testing.T) { t.Fatalf("north count mismatch: got %d want %d", len(northWrapped), len(north)) } for i, event := range north { - wantTime := basic.JDE2DateByZone(event.JDE, time.UTC, false) + wantTime := basic.JD2DateByZone(event.JD, time.UTC, false) if !northWrapped[i].Time.Equal(wantTime) { t.Fatalf("north #%d time mismatch: got %s want %s", i+1, northWrapped[i].Time.Format(time.RFC3339Nano), wantTime.Format(time.RFC3339Nano)) } @@ -30,7 +30,7 @@ func TestMaximumDeclinationWrappersMatchBasic(t *testing.T) { t.Fatalf("south count mismatch: got %d want %d", len(southWrapped), len(south)) } for i, event := range south { - wantTime := basic.JDE2DateByZone(event.JDE, time.UTC, false) + wantTime := basic.JD2DateByZone(event.JD, time.UTC, false) if !southWrapped[i].Time.Equal(wantTime) { t.Fatalf("south #%d time mismatch: got %s want %s", i+1, southWrapped[i].Time.Format(time.RFC3339Nano), wantTime.Format(time.RFC3339Nano)) } @@ -43,20 +43,20 @@ func TestMaximumDeclinationWrappersMatchBasic(t *testing.T) { func TestMaximumDeclinationSearchWrappersMatchBasic(t *testing.T) { loc := time.FixedZone("CST", 8*3600) query := time.Date(2026, time.January, 10, 18, 30, 0, 0, loc) - queryJDE := basic.Date2JDE(query.UTC()) + queryJD := basic.Date2JD(query.UTC()) - assertMaximumDeclinationInfoMatchesBasic(t, "last north", LastMaximumNorthDeclination(query), basic.LastMoonMaximumNorthDeclination(queryJDE), loc) - assertMaximumDeclinationInfoMatchesBasic(t, "next north", NextMaximumNorthDeclination(query), basic.NextMoonMaximumNorthDeclination(queryJDE), loc) - assertMaximumDeclinationInfoMatchesBasic(t, "closest north", ClosestMaximumNorthDeclination(query), basic.ClosestMoonMaximumNorthDeclination(queryJDE), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "last north", LastMaximumNorthDeclination(query), basic.LastMoonMaximumNorthDeclination(queryJD), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "next north", NextMaximumNorthDeclination(query), basic.NextMoonMaximumNorthDeclination(queryJD), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "closest north", ClosestMaximumNorthDeclination(query), basic.ClosestMoonMaximumNorthDeclination(queryJD), loc) - assertMaximumDeclinationInfoMatchesBasic(t, "last south", LastMaximumSouthDeclination(query), basic.LastMoonMaximumSouthDeclination(queryJDE), loc) - assertMaximumDeclinationInfoMatchesBasic(t, "next south", NextMaximumSouthDeclination(query), basic.NextMoonMaximumSouthDeclination(queryJDE), loc) - assertMaximumDeclinationInfoMatchesBasic(t, "closest south", ClosestMaximumSouthDeclination(query), basic.ClosestMoonMaximumSouthDeclination(queryJDE), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "last south", LastMaximumSouthDeclination(query), basic.LastMoonMaximumSouthDeclination(queryJD), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "next south", NextMaximumSouthDeclination(query), basic.NextMoonMaximumSouthDeclination(queryJD), loc) + assertMaximumDeclinationInfoMatchesBasic(t, "closest south", ClosestMaximumSouthDeclination(query), basic.ClosestMoonMaximumSouthDeclination(queryJD), loc) } func assertMaximumDeclinationInfoMatchesBasic(t *testing.T, name string, got MaximumDeclinationInfo, want basic.DeclinationEvent, loc *time.Location) { t.Helper() - wantTime := basic.JDE2DateByZone(want.JDE, loc, false) + wantTime := basic.JD2DateByZone(want.JD, loc, false) if got.Time.Location() != loc { t.Fatalf("%s location mismatch: got %q want %q", name, got.Time.Location().String(), loc.String()) } diff --git a/moon/moon.go b/moon/moon.go index 209df55..49d788b 100644 --- a/moon/moon.go +++ b/moon/moon.go @@ -1,6 +1,7 @@ package moon import ( + "b612.me/astro/internal/civiltime" "b612.me/astro/tools" "errors" "math" @@ -17,7 +18,7 @@ var ( ERR_NOT_TODAY = errors.New("ERROR:月亮已在(昨日/明日)(升起/降下)") ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNotOnThisDate): @@ -30,7 +31,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // TrueLo 月亮真黄经 / true ecliptic longitude. @@ -38,8 +40,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回月亮在 date 对应绝对时刻的地心真黄经,单位度。 // Returns the Moon's geocentric true ecliptic longitude at the instant represented by date, in degrees. func TrueLo(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonTrueLo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonTrueLo(basic.UTC2TT(jd)) } // TrueLoN 截断项月亮真黄经 / truncated true ecliptic longitude. @@ -47,8 +49,8 @@ func TrueLo(date time.Time) float64 { // 参数与 TrueLo 相同;n<0 使用当前仓库内嵌的全部 ELP 项,其余值用于截断月球级数。 // Uses the same inputs as TrueLo. n<0 keeps all embedded ELP terms in this repository; other values truncate the lunar series. func TrueLoN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonTrueLoN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonTrueLoN(basic.UTC2TT(jd), n) } // TrueBo 月亮真黄纬 / true ecliptic latitude. @@ -56,8 +58,8 @@ func TrueLoN(date time.Time, n int) float64 { // 返回月亮在 date 对应绝对时刻的地心真黄纬,单位度。 // Returns the Moon's geocentric true ecliptic latitude at the instant represented by date, in degrees. func TrueBo(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonTrueBo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonTrueBo(basic.UTC2TT(jd)) } // TrueBoN 截断项月亮真黄纬 / truncated true ecliptic latitude. @@ -65,8 +67,8 @@ func TrueBo(date time.Time) float64 { // 参数与 TrueBo 相同;n<0 使用当前仓库内嵌的全部 ELP 项,其余值用于截断月球级数。 // Uses the same inputs as TrueBo. n<0 keeps all embedded ELP terms in this repository; other values truncate the lunar series. func TrueBoN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonTrueBoN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonTrueBoN(basic.UTC2TT(jd), n) } // ApparentLo 月亮地心视黄经 / apparent geocentric ecliptic longitude. @@ -74,8 +76,8 @@ func TrueBoN(date time.Time, n int) float64 { // 返回月亮在 date 对应绝对时刻的地心视黄经,单位度。 // Returns the Moon's apparent geocentric ecliptic longitude at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonApparentLo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonApparentLo(basic.UTC2TT(jd)) } // TrueRa 月亮地心真赤经 / true geocentric right ascension. @@ -83,8 +85,8 @@ func ApparentLo(date time.Time) float64 { // 返回月亮在 date 对应绝对时刻的地心真赤经,单位度。 // Returns the Moon's geocentric true right ascension at the instant represented by date, in degrees. func TrueRa(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricTrueRa(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricTrueRa(basic.UTC2TT(jd)) } // TrueDec 月亮地心真赤纬 / true geocentric declination. @@ -92,8 +94,8 @@ func TrueRa(date time.Time) float64 { // 返回月亮在 date 对应绝对时刻的地心真赤纬,单位度。 // Returns the Moon's geocentric true declination at the instant represented by date, in degrees. func TrueDec(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricTrueDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricTrueDec(basic.UTC2TT(jd)) } // TrueRaDec 月亮地心真赤经、真赤纬 / true geocentric right ascension and declination. @@ -101,8 +103,8 @@ func TrueDec(date time.Time) float64 { // 返回月亮在 date 对应绝对时刻的地心真赤经与真赤纬,单位度。 // Returns the Moon's geocentric true right ascension and declination at the instant represented by date, in degrees. func TrueRaDec(date time.Time) (float64, float64) { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricTrueRaDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricTrueRaDec(basic.UTC2TT(jd)) } // GeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension. @@ -110,8 +112,8 @@ func TrueRaDec(date time.Time) (float64, float64) { // 返回月亮在 date 对应绝对时刻的地心视赤经,单位度。 // Returns the Moon's apparent geocentric right ascension at the instant represented by date, in degrees. func GeocentricApparentRa(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricApparentRa(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricApparentRa(basic.UTC2TT(jd)) } // GeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination. @@ -119,8 +121,8 @@ func GeocentricApparentRa(date time.Time) float64 { // 返回月亮在 date 对应绝对时刻的地心视赤纬,单位度。 // Returns the Moon's apparent geocentric declination at the instant represented by date, in degrees. func GeocentricApparentDec(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricApparentDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricApparentDec(basic.UTC2TT(jd)) } // GeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination. @@ -128,8 +130,8 @@ func GeocentricApparentDec(date time.Time) float64 { // 返回月亮在 date 对应绝对时刻的地心视赤经与视赤纬,单位度。 // Returns the Moon's apparent geocentric right ascension and declination at the instant represented by date, in degrees. func GeocentricApparentRaDec(date time.Time) (float64, float64) { - jde := basic.Date2JDE(date.UTC()) - return basic.HMoonGeocentricApparentRaDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HMoonGeocentricApparentRaDec(basic.UTC2TT(jd)) } // ApparentRa 月亮站心视赤经 / apparent topocentric right ascension. @@ -137,9 +139,9 @@ func GeocentricApparentRaDec(date time.Time) (float64, float64) { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees. func ApparentRa(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonApparentRa(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonApparentRa(localJD, lon, lat, float64(loc)/3600.0) } // ApparentDec 月亮站心视赤纬 / apparent topocentric declination. @@ -147,9 +149,9 @@ func ApparentRa(date time.Time, lon, lat float64) float64 { // 参数与 ApparentRa 相同,返回月亮站心视赤纬,单位度。 // Uses the same inputs as ApparentRa and returns the Moon's apparent topocentric declination in degrees. func ApparentDec(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonApparentDec(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonApparentDec(localJD, lon, lat, float64(loc)/3600.0) } // ApparentRaDec 月亮站心视赤经、视赤纬 / apparent topocentric right ascension and declination. @@ -157,9 +159,9 @@ func ApparentDec(date time.Time, lon, lat float64) float64 { // 参数与 ApparentRa 相同,返回月亮站心视赤经与视赤纬,单位度。 // Uses the same inputs as ApparentRa and returns the Moon's apparent topocentric right ascension and declination in degrees. func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonApparentRaDec(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonApparentRaDec(localJD, lon, lat, float64(loc)/3600.0) } // HourAngle 月亮时角 / hour angle. @@ -167,9 +169,9 @@ func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees. func HourAngle(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.MoonTimeAngle(jde, lon, lat, float64(loc)/3600.0) + return basic.MoonTimeAngle(localJD, lon, lat, float64(loc)/3600.0) } // Azimuth 月亮方位角 / azimuth. @@ -177,9 +179,9 @@ func HourAngle(date time.Time, lon, lat float64) float64 { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonAzimuth(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonAzimuth(localJD, lon, lat, float64(loc)/3600.0) } // Altitude 月亮高度角 / lunar altitude. @@ -187,9 +189,9 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.HMoonHeight(jde, lon, lat, float64(loc)/3600.0) + return basic.HMoonHeight(localJD, lon, lat, float64(loc)/3600.0) } // Zenith 月亮天顶距 / lunar zenith distance. @@ -205,34 +207,36 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive. func CulminationTime(date time.Time, lon, lat float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() - return basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true) + calcJD := basic.MoonCulminationTime(localJD, lon, lat, float64(loc)/3600.0) - float64(loc)/86400 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 月出时刻 / moonrise time. // // date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负; -// height 为海拔高度,单位米;aero 为 true 时按动态标准大气折射和实时月球视半径计算上缘过地平线。 +// height 为椭球高(大地高),单位米;aero 为 true 时按动态标准大气折射和实时月球视半径计算上缘过地平线。 // date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive; -// height 为观测者海拔,单位米;aero 使用动态标准折射和实时月球视半径计算上缘过地平线。 +// height 为观测者椭球高(大地高),单位米;aero 使用动态标准折射和实时月球视半径计算上缘过地平线。 // height is observer elevation in meters; aero uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) { + return RiseTime(d, lon, lat, height, aero) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 aeroFloat := 0.00 if aero { aeroFloat = 1 } - riseJde, err := basic.GetMoonRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.GetMoonRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 月落时刻别名 / deprecated moonset alias. @@ -250,18 +254,21 @@ func DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, e // 参数与 RiseTime 相同,返回给定当地日期内的月落时刻。 // Uses the same inputs as RiseTime and returns the moonset time on the corresponding local civil day. func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) { + return SetTime(d, lon, lat, height, aero) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 aeroFloat := 0.00 if aero { aeroFloat = 1 } - downJde, err := basic.GetMoonSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, downJde, err) + downJD, err := basic.GetMoonSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, downJD, err) } // SunMoonLoDiff 日月黄经差 / Moon-Sun longitude difference. @@ -269,7 +276,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回月亮视黄经减去太阳视黄经的结果,单位度,取值范围 [0, 360);新月附近接近 0°,满月附近接近 180°。 // Returns apparent lunar longitude minus apparent solar longitude in degrees, normalized to [0, 360). It is near 0° at new moon and near 180° at full moon. func SunMoonLoDiff(date time.Time) float64 { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) sunLo := basic.HSunApparentLo(jde) moonLo := basic.HMoonApparentLo(jde) return tools.Limit360(moonLo - sunLo) @@ -307,8 +314,8 @@ func PhaseDesc(date time.Time) string { // 返回月亮在 date 对应绝对时刻的受照比例,范围 [0, 1]。 // Returns the Moon's illuminated fraction at the instant represented by date, in the range [0, 1]. func Phase(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.MoonPhase(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.MoonPhase(basic.UTC2TT(jd)) } // ShuoYue 朔月锚点解 / new-moon solution near a decimal year anchor. @@ -316,8 +323,8 @@ func Phase(date time.Time) float64 { // year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次朔月时刻,结果为 UTC。 // year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one new moon solved near that anchor, in UTC. func ShuoYue(year float64) time.Time { - jde := basic.TD2UT(basic.CalcMoonSH(year, 0), false) - return basic.JDE2DateByZone(jde, time.UTC, false) + jd := basic.TT2UTC(basic.CalcMoonSH(year, 0)) + return basic.JD2DateByZone(jd, time.UTC, false) } // NextShuoYue 下一次朔月 / next new moon. @@ -366,11 +373,11 @@ func ClosestNewMoon(date time.Time) time.Time { func closestMoonPhase(date time.Time, typed int) time.Time { //0=shuo 1=wang 2=shangxian 3=xiaxian - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) if typed < 2 { - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonSHByJDE(jde, typed), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false) } - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonXHByJDE(jde, typed-2), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false) } func nextMoonPhase(date time.Time, typed int) time.Time { @@ -384,7 +391,7 @@ func nextMoonPhase(date time.Time, typed int) time.Time { case 3: diffCode = 270 } - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) cost := basic.HMoonApparentLo(jde) - basic.HSunApparentLo(jde) - float64(diffCode) for cost < 0 { cost += 360 @@ -396,9 +403,9 @@ func nextMoonPhase(date time.Time, typed int) time.Time { jde += (240 - cost) / 11.19 } if typed < 2 { - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonSHByJDE(jde, typed), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false) } - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonXHByJDE(jde, typed-2), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false) } func lastMoonPhase(date time.Time, typed int) time.Time { @@ -412,7 +419,7 @@ func lastMoonPhase(date time.Time, typed int) time.Time { case 3: diffCode = 270 } - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) cost := basic.HMoonApparentLo(jde) - basic.HSunApparentLo(jde) - float64(diffCode) for cost < 0 { cost += 360 @@ -424,9 +431,9 @@ func lastMoonPhase(date time.Time, typed int) time.Time { jde -= (cost - 120) / 11.19 } if typed < 2 { - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonSHByJDE(jde, typed), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false) } - return basic.JDE2DateByZone(basic.TD2UT(basic.CalcMoonXHByJDE(jde, typed-2), false), date.Location(), false) + return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false) } // WangYue 望月锚点解 / full-moon solution near a decimal year anchor. @@ -434,8 +441,8 @@ func lastMoonPhase(date time.Time, typed int) time.Time { // year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次望月时刻,结果为 UTC。 // year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one full moon solved near that anchor, in UTC. func WangYue(year float64) time.Time { - jde := basic.TD2UT(basic.CalcMoonSH(year, 1), false) - return basic.JDE2DateByZone(jde, time.UTC, false) + jd := basic.TT2UTC(basic.CalcMoonSH(year, 1)) + return basic.JD2DateByZone(jd, time.UTC, false) } // NextWangYue 下一次望月 / next full moon. @@ -487,8 +494,8 @@ func ClosestFullMoon(date time.Time) time.Time { // year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次上弦时刻,结果为 UTC。 // year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one first-quarter solution near that anchor, in UTC. func ShangXianYue(year float64) time.Time { - jde := basic.TD2UT(basic.CalcMoonXH(year, 0), false) - return basic.JDE2DateByZone(jde, time.UTC, false) + jd := basic.TT2UTC(basic.CalcMoonXH(year, 0)) + return basic.JD2DateByZone(jd, time.UTC, false) } // NextShangXianYue 下一次上弦 / next first quarter. @@ -540,8 +547,8 @@ func ClosestFirstQuarter(date time.Time) time.Time { // year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次下弦时刻,结果为 UTC。 // year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one last-quarter solution near that anchor, in UTC. func XiaXianYue(year float64) time.Time { - jde := basic.TD2UT(basic.CalcMoonXH(year, 1), false) - return basic.JDE2DateByZone(jde, time.UTC, false) + jd := basic.TT2UTC(basic.CalcMoonXH(year, 1)) + return basic.JD2DateByZone(jd, time.UTC, false) } // NextXiaXianYue 下一次下弦 / next last quarter. @@ -593,7 +600,7 @@ func ClosestLastQuarter(date time.Time) time.Time { // 返回月亮在 date 对应绝对时刻到地球质心的距离,单位千米。 // Returns the distance from the Moon to Earth's center at the instant represented by date, in kilometers. func EarthDistance(date time.Time) float64 { - jde := basic.Date2JDE(date) - jde = basic.TD2UT(jde, true) + jd := basic.Date2JD(date) + jde := basic.UTC2TT(jd) return basic.MoonAway(jde) } diff --git a/moon/nodes.go b/moon/nodes.go index f5173a7..74eaa05 100644 --- a/moon/nodes.go +++ b/moon/nodes.go @@ -13,7 +13,7 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 月球升交点黄经(截断版) / truncated ascending node longitude of the Moon. func AscendingNodeN(date time.Time, n int) float64 { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) return basic.MoonAscendingNodeN(jde, n) } @@ -24,6 +24,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 月球降交点黄经(截断版) / truncated descending node longitude of the Moon. func DescendingNodeN(date time.Time, n int) float64 { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) return basic.MoonDescendingNodeN(jde, n) } diff --git a/moon/occultation_star_test.go b/moon/occultation_star_test.go index 9d5764b..c588db5 100644 --- a/moon/occultation_star_test.go +++ b/moon/occultation_star_test.go @@ -149,13 +149,13 @@ func TestFindStarOccultationsReturnsCompleteContactsOutsideQueryWindow(t *testin } func occultationTestTimeToTT(value time.Time) float64 { - return basic.TD2UT(basic.Date2JDE(value.UTC()), true) + return basic.UTC2TT(basic.Date2JD(value.UTC())) } func occultationTestMoonTopocentricRaDec(tt float64, observer Observer, n int) (float64, float64) { ra, dec := basic.HMoonGeocentricApparentRaDecN(tt, n) distanceAU := basic.HMoonAwayN(tt, n) / 149597870.7 - ra, dec = basic.TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, basic.TD2UT(tt, false), distanceAU, observer.Height) + ra, dec = basic.TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, basic.TT2UTC(tt), distanceAU, observer.Height) ra = math.Mod(ra, 360) if ra < 0 { ra += 360 diff --git a/moon/occultation_ut1.go b/moon/occultation_ut1.go new file mode 100644 index 0000000..98c8539 --- /dev/null +++ b/moon/occultation_ut1.go @@ -0,0 +1,131 @@ +package moon + +import ( + "time" + + "b612.me/astro" + "b612.me/astro/basic" +) + +// 月掩各载体的 UT1 时刻换算,供月掩 SVG 使用。两条铁律与日食侧一致: +// 零值 time.Time 表示该点或阶段不存在,一律原样保留;返回新副本,不改写调用方传进来的 path/info。 + +func occultationUT1Label(t time.Time) time.Time { + if t.IsZero() { + return t + } + return astro.LabelIn(astro.TimeScaleUT1, t) +} + +func occultationUT1PathPoint(p basic.OccultationPathPoint) basic.OccultationPathPoint { + p.Time = occultationUT1Label(p.Time) + return p +} + +func occultationUT1PathPoints(points []basic.OccultationPathPoint) []basic.OccultationPathPoint { + out := make([]basic.OccultationPathPoint, len(points)) + for i := range points { + out[i] = occultationUT1PathPoint(points[i]) + } + return out +} + +func occultationUT1PathGrid(grid [][]basic.OccultationPathPoint) [][]basic.OccultationPathPoint { + out := make([][]basic.OccultationPathPoint, len(grid)) + for i := range grid { + out[i] = occultationUT1PathPoints(grid[i]) + } + return out +} + +func occultationUT1Footprints(list []basic.OccultationFootprint) []basic.OccultationFootprint { + out := make([]basic.OccultationFootprint, len(list)) + for i := range list { + out[i] = list[i] + out[i].Time = occultationUT1Label(list[i].Time) + out[i].Polygons = occultationUT1PathGrid(list[i].Polygons) + out[i].InteriorPolygons = occultationUT1PathGrid(list[i].InteriorPolygons) + out[i].Boundaries = occultationUT1PathGrid(list[i].Boundaries) + } + return out +} + +func occultationUT1RiseSetCurves(list []basic.OccultationRiseSetCurve) []basic.OccultationRiseSetCurve { + out := make([]basic.OccultationRiseSetCurve, len(list)) + for i := range list { + out[i] = list[i] + out[i].Segments = occultationUT1PathGrid(list[i].Segments) + } + return out +} + +func occultationUT1GreatestTimeContours(list []basic.OccultationGreatestTimeContour) []basic.OccultationGreatestTimeContour { + out := make([]basic.OccultationGreatestTimeContour, len(list)) + for i := range list { + out[i] = list[i] + out[i].Time = occultationUT1Label(list[i].Time) + out[i].Segments = occultationUT1PathGrid(list[i].Segments) + } + return out +} + +// StarOccultationPathInUT1 返回恒星月掩全球路径的 UT1 时刻副本 / copy in UT1 of a stellar path. +func StarOccultationPathInUT1(path basic.StarOccultationPath) basic.StarOccultationPath { + path.Start = occultationUT1PathPoint(path.Start) + path.Greatest = occultationUT1PathPoint(path.Greatest) + path.End = occultationUT1PathPoint(path.End) + path.CenterLine = occultationUT1PathPoints(path.CenterLine) + path.NorthernLimit = occultationUT1PathPoints(path.NorthernLimit) + path.SouthernLimit = occultationUT1PathPoints(path.SouthernLimit) + path.BandContours = occultationUT1PathGrid(path.BandContours) + path.VisibilityContours = occultationUT1PathGrid(path.VisibilityContours) + path.Footprints = occultationUT1Footprints(path.Footprints) + path.BandFootprints = occultationUT1Footprints(path.BandFootprints) + path.RiseSetCurves = occultationUT1RiseSetCurves(path.RiseSetCurves) + path.GreatestTimeContours = occultationUT1GreatestTimeContours(path.GreatestTimeContours) + return path +} + +// PlanetOccultationPathInUT1 返回行星月掩全球掩带的 UT1 时刻副本 / copy in UT1 of a planetary path. +func PlanetOccultationPathInUT1(path basic.PlanetOccultationPath) basic.PlanetOccultationPath { + path.Start = occultationUT1PathPoint(path.Start) + path.Greatest = occultationUT1PathPoint(path.Greatest) + path.End = occultationUT1PathPoint(path.End) + path.CenterLine = occultationUT1PathPoints(path.CenterLine) + path.NorthernLimit = occultationUT1PathPoints(path.NorthernLimit) + path.SouthernLimit = occultationUT1PathPoints(path.SouthernLimit) + path.PartialBandContours = occultationUT1PathGrid(path.PartialBandContours) + path.PartialVisibilityContours = occultationUT1PathGrid(path.PartialVisibilityContours) + path.PartialFootprints = occultationUT1Footprints(path.PartialFootprints) + path.PartialBandFootprints = occultationUT1Footprints(path.PartialBandFootprints) + path.RiseSetCurves = occultationUT1RiseSetCurves(path.RiseSetCurves) + path.TotalStart = occultationUT1PathPoint(path.TotalStart) + path.TotalEnd = occultationUT1PathPoint(path.TotalEnd) + path.NorthernTotalLimit = occultationUT1PathPoints(path.NorthernTotalLimit) + path.SouthernTotalLimit = occultationUT1PathPoints(path.SouthernTotalLimit) + path.TotalBandContours = occultationUT1PathGrid(path.TotalBandContours) + path.TotalVisibilityContours = occultationUT1PathGrid(path.TotalVisibilityContours) + path.TotalFootprints = occultationUT1Footprints(path.TotalFootprints) + path.TotalBandFootprints = occultationUT1Footprints(path.TotalBandFootprints) + path.TotalRiseSetCurves = occultationUT1RiseSetCurves(path.TotalRiseSetCurves) + path.GreatestTimeContours = occultationUT1GreatestTimeContours(path.GreatestTimeContours) + return path +} + +// StarOccultationInfoInUT1 返回固定地点恒星月掩的 UT1 时刻副本 / copy in UT1 of a local stellar result. +func StarOccultationInfoInUT1(info basic.StarOccultationInfo) basic.StarOccultationInfo { + info.Immersion = occultationUT1Label(info.Immersion) + info.Greatest = occultationUT1Label(info.Greatest) + info.Emersion = occultationUT1Label(info.Emersion) + return info +} + +// PlanetOccultationInfoInUT1 返回固定地点行星月掩的 UT1 时刻副本 / copy in UT1 of a local planetary result. +func PlanetOccultationInfoInUT1(info basic.PlanetOccultationInfo) basic.PlanetOccultationInfo { + info.ExternalImmersion = occultationUT1Label(info.ExternalImmersion) + info.InternalImmersion = occultationUT1Label(info.InternalImmersion) + info.Greatest = occultationUT1Label(info.Greatest) + info.InternalEmersion = occultationUT1Label(info.InternalEmersion) + info.ExternalEmersion = occultationUT1Label(info.ExternalEmersion) + return info +} diff --git a/moon/phase.go b/moon/phase.go index 290ee4c..2ff93d0 100644 --- a/moon/phase.go +++ b/moon/phase.go @@ -18,7 +18,7 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { // TopocentricBrightLimbPositionAngle 月亮站心明亮边缘位置角,单位度 / topocentric position angle of the Moon's bright limb in degrees. // -// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为海拔高度,单位米。 +// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为椭球高(大地高),单位米。 // date is the observing instant; observerLon/observerLat are east-positive and north-positive; height is observer elevation in meters. func TopocentricBrightLimbPositionAngle(date time.Time, observerLon, observerLat, height float64) float64 { return TopocentricBrightLimbPositionAngleN(date, observerLon, observerLat, height, -1) @@ -30,5 +30,5 @@ func TopocentricBrightLimbPositionAngleN(date time.Time, observerLon, observerLa } func observationTT(date time.Time) float64 { - return basic.TD2UT(basic.Date2JDE(date.UTC()), true) + return basic.UTC2TT(basic.Date2JD(date.UTC())) } diff --git a/moon/physical.go b/moon/physical.go index 4296702..0f4b9e0 100644 --- a/moon/physical.go +++ b/moon/physical.go @@ -31,8 +31,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 月球物理观测参数(截断版) / truncated physical observing parameters of the Moon. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.MoonPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.MoonPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ OpticalLongitude: info.OpticalLongitude, OpticalLatitude: info.OpticalLatitude, diff --git a/moon/physical_test.go b/moon/physical_test.go index b348f9b..0ea8365 100644 --- a/moon/physical_test.go +++ b/moon/physical_test.go @@ -10,10 +10,10 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) - want := basic.MoonPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.MoonPhysicalN(basic.UTC2TT(jde), -1) assertMoonPhysicalSameFloat(t, "OpticalLongitude", got.OpticalLongitude, want.OpticalLongitude) assertMoonPhysicalSameFloat(t, "OpticalLatitude", got.OpticalLatitude, want.OpticalLatitude) diff --git a/moon/physical_topocentric.go b/moon/physical_topocentric.go index dae56ff..79de323 100644 --- a/moon/physical_topocentric.go +++ b/moon/physical_topocentric.go @@ -8,7 +8,7 @@ import ( // TopocentricPhysical 月球站心物理观测参数 / topocentric physical observing parameters of the Moon. // -// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为海拔高度,单位米。 +// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为椭球高(大地高),单位米。 // date is the observing instant; observerLon/observerLat are east-positive and north-positive; height is observer elevation in meters. func TopocentricPhysical(date time.Time, observerLon, observerLat, height float64) PhysicalInfo { return TopocentricPhysicalN(date, observerLon, observerLat, height, -1) diff --git a/moon/svg/footer_margin_contract_test.go b/moon/svg/footer_margin_contract_test.go new file mode 100644 index 0000000..02e432d --- /dev/null +++ b/moon/svg/footer_margin_contract_test.go @@ -0,0 +1,107 @@ +package svg + +import ( + "fmt" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,避免末行贴边或被裁掉。 +const occultationChartBottomMarginMinimum = 6.0 + +func occultationBottomMarginViolations(t *testing.T, diagram string, height int) []string { + t.Helper() + violations := []string{} + for _, text := range occultationSVGTexts(t, diagram) { + if text.value == "" { + continue + } + _, below := svgchart.EstimatedTextExtents(text.fontSize) + if margin := float64(height) - (text.y + below); margin < occultationChartBottomMarginMinimum { + violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", text.value, margin, occultationChartBottomMarginMinimum)) + } + } + return violations +} + +func TestOccultationChartsKeepBottomMargin(t *testing.T) { + long := footerFitLongText("margin ") + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + cases := []struct { + name string + height int + document string + }{} + add := func(name string, height int, document string, err error) { + if err != nil { + t.Fatalf("%s: %v", name, err) + } + cases = append(cases, struct { + name string + height int + document string + }{name, height, document}) + } + for _, size := range [][2]int{{640, 480}, {920, 720}} { + options := footerFitStarTexts(long) + options.Width, options.Height = size[0], size[1] + diagram, err := StarOccultationPathSVG(starPath, options) + add(fmt.Sprintf("恒星全球 %dx%d", size[0], size[1]), size[1], diagram, err) + planetOptions := PlanetOccultationSVGOptions(options) + planetDiagram, err := PlanetOccultationPathSVG(planetPath, planetOptions) + add(fmt.Sprintf("行星全球 %dx%d", size[0], size[1]), size[1], planetDiagram, err) + } + local, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Width: 640, Height: 520, Title: long, DirectionText: long, FooterNote: long, + }) + add("站心恒星", 520, local, err) + localPlanet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{ + Width: 760, Height: 600, Title: long, DirectionText: long, FooterNote: long, + }) + add("站心行星", 600, localPlanet, err) + detailed, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), OccultationDetailedSVGOptions{ + Width: 700, Height: 990, Title: long, FooterNote: long, + }) + add("恒星详图", 990, detailed, err) + + for _, tc := range cases { + if violations := occultationBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 { + t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0]) + } + } +} + +// 页脚时标声明契约:超长自定义说明可以吃掉说明文字,但不能吃掉时标声明。 +func TestOccultationChartsKeepTimeScaleDeclaration(t *testing.T) { + long := footerFitLongText("scale ") + cst := time.FixedZone("CST", 8*3600) + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + options := footerFitStarTexts(long) + options.Width, options.Height = 920, 720 + options.Location = cst + global, err := StarOccultationPathSVG(starPath, options) + if err != nil { + t.Fatalf("恒星全球:%v", err) + } + planetOptions := PlanetOccultationSVGOptions(options) + planet, err := PlanetOccultationPathSVG(planetPath, planetOptions) + if err != nil { + t.Fatalf("行星全球:%v", err) + } + local, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Width: 920, Height: 720, Location: cst, FooterNote: long, + }) + if err != nil { + t.Fatalf("站心恒星:%v", err) + } + for name, document := range map[string]string{"恒星全球": global, "行星全球": planet, "站心恒星": local} { + if !strings.Contains(document, "显示时区") && !strings.Contains(document, "图中时刻为 UTC") { + t.Errorf("%s:超长说明把时标声明挤掉了", name) + } + } +} diff --git a/moon/svg/occultation.go b/moon/svg/occultation.go index 0b93f76..2b648d8 100644 --- a/moon/svg/occultation.go +++ b/moon/svg/occultation.go @@ -3,6 +3,7 @@ package svg import ( + "b612.me/astro/internal/timenote" "errors" "fmt" "html" @@ -10,6 +11,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/internal/occultationgeo" "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" @@ -53,6 +55,11 @@ type StarOccultationSVGOptions struct { // Location 控制显示的事件时刻;nil 使用 UTC+8。 // Location controls displayed event times. Nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale. + TimeScale astro.TimeScale // Projection 选择全球地图投影;零值会为限于单半球的高纬事件自动使用极区地图。 // Projection selects the global map projection. The zero value automatically uses a polar map for a high-latitude event confined to one hemisphere. Projection MapProjection @@ -92,12 +99,20 @@ func StarOccultationPathSVG( path moon.StarOccultationPath, options StarOccultationSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validateStarOccultationPath(path); err != nil { return "", err } if err := validateStarOccultationSVGOptions(options); err != nil { return "", err } + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + path = moon.StarOccultationPathInUT1(path) + } + options = normalizeStarOccultationSVGOptions(options) diagram, err := renderStarOccultationPathSVG(path, options) if err != nil { @@ -238,7 +253,7 @@ func renderOccultationPathSVG( } writePlanetOccultationEventsPanel(&b, *planetPath, layout, options) } - writeStarOccultationFooter(&b, layout, options, flags) + writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath)) b.WriteString(``) return b.String(), nil } @@ -734,18 +749,37 @@ func writeOccultationEventsPanel( } } +// occultationScaleInstant 取时标声明用的掩甚时刻:行星带优先用行星路径。 +func occultationScaleInstant(path moon.StarOccultationPath, planetPath *moon.PlanetOccultationPath) time.Time { + if planetPath != nil { + return planetPath.Greatest.Time + } + return path.Greatest.Time +} + func writeStarOccultationFooter( b *strings.Builder, layout starOccultationSVGLayout, options StarOccultationSVGOptions, flags occultationSVGTextFlags, + instant time.Time, ) { - lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, layout.width-80, 11) - lines = svgchart.TruncateTextLines(lines, layout.width-80, 11, - svgchart.BaselineLineLimit(11, 15, layout.footerY, layout.height)) + maxWidth := layout.width - 80 + lineHeight := svgchart.FooterLineHeight(11) + scaleNote := timenote.Scale(options.TimeScale, instant, options.Location, options.Language) + // 页脚默认排在画布底边之内(与既有版面一致),说明行放得下时声明单独占最后一行; + // 自定义说明太长时改用按字号留白的行数上限:截断说明也要保住时标声明和底边留白。 + lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, maxWidth, 11) + if roomy := svgchart.BaselineLineLimit(11, lineHeight, layout.footerY, layout.height); len(lines) < roomy { + lines = append(lines, scaleNote) + } else { + tight := svgchart.BaselineLineLimit(11, lineHeight, layout.footerY, + layout.height-svgchart.FooterBottomPadding(11)) + lines = svgchart.FooterLinesWithScale(lines, scaleNote, maxWidth, 11, tight) + } for index, line := range lines { fmt.Fprintf(b, `%s`, - layout.footerY+float64(index)*15, html.EscapeString(line)) + layout.footerY+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line)) } } diff --git a/moon/svg/occultation_detailed.go b/moon/svg/occultation_detailed.go index 814ee50..1066f9d 100644 --- a/moon/svg/occultation_detailed.go +++ b/moon/svg/occultation_detailed.go @@ -1,6 +1,7 @@ package svg import ( + "b612.me/astro/internal/timenote" "errors" "fmt" "html" @@ -8,6 +9,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/basic" "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" @@ -27,18 +29,30 @@ const ( ) // OccultationDetailedSVGOptions 控制详细版式的月掩星组合图。 +// OccultationDetailedSVGOptions configures the detailed-layout occultation chart. type OccultationDetailedSVGOptions struct { // Width 与 Height 是画布尺寸;<=0 使用 1000x1414 的整页开本。 // 版式必须容得下地图与数据块,否则返回 ErrInvalidOccultationDetailedSVGOptions。 + // Width and Height are the canvas size; <=0 uses the 1000x1414 whole-page format. + // The layout must fit the map and the data block, otherwise the renderer returns ErrInvalidOccultationDetailedSVGOptions. Width int Height int // Language 为 "en" 时使用英文,其他值使用中文。 + // Language renders English for "en" and Chinese for any other value. Language string // Location 控制显示时刻的时区;nil 使用 UTC+8。 + // Location sets the zone of displayed instants; nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale. + TimeScale astro.TimeScale // TimeLabelStep 控制中心线上的时刻标签;零值用 30 分钟,负值禁用。 + // TimeLabelStep controls the time labels along the center line; zero uses 30 minutes and a negative value disables them. TimeLabelStep time.Duration // Title 与 FooterNote 为空时自动生成。 + // Title and FooterNote are generated automatically when left empty. Title string FooterNote string } @@ -200,7 +214,7 @@ func writeOccultationDetailedGrid(b *strings.Builder, view occultationDetailedVi // occultationDetailedMoonRows 月亮的地心坐标;S.D./H.P. 由地心月距换算,与日食月食同口径。 func occultationDetailedMoonRows(at time.Time, language string) []svgchart.PanelRow { - jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(at.UTC())) ra, dec := basic.HMoonTrueRaDec(jde) sd := basic.MoonSemidiameter(jde) hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600 @@ -247,10 +261,18 @@ func StarOccultationDetailedSVG( star moon.StarCoordinate, options OccultationDetailedSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validateStarOccultationPath(path); err != nil { return "", err } - return renderOccultationDetailedSVG(path, star, nil, options) + civilGreatest := path.Greatest.Time + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + path = moon.StarOccultationPathInUT1(path) + } + return renderOccultationDetailedSVG(path, star, nil, civilGreatest, options) } // PlanetOccultationDetailedSVG 生成详细版式的行星月掩星图。 @@ -259,21 +281,31 @@ func PlanetOccultationDetailedSVG( path moon.PlanetOccultationPath, options OccultationDetailedSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validatePlanetOccultationPath(path); err != nil { return "", err } + civilGreatest := path.Greatest.Time + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + path = moon.PlanetOccultationPathInUT1(path) + } + // 中文标题与目标块要写"木星"这类中文行星名,不能直接用 TargetID 里的英文。 shape := planetOccultationStarShape(path, path.TargetID) if options.Language == starOccultationSVGLanguageChinese { shape.TargetID = planetOccultationChineseName(path.Planet) } - return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, options) + return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, civilGreatest, options) } func renderOccultationDetailedSVG( path moon.StarOccultationPath, star moon.StarCoordinate, planetPath *moon.PlanetOccultationPath, + civilGreatest time.Time, options OccultationDetailedSVGOptions, ) (string, error) { if err := validateOccultationDetailedSVGOptions(options); err != nil { @@ -326,12 +358,12 @@ func renderOccultationDetailedSVG( return "", fmt.Errorf("planetary occultation detailed band geometry: %w", err) } } - blocks := occultationDetailedBlocks(path, star, planetPath, options) + blocks := occultationDetailedBlocks(path, star, planetPath, civilGreatest, options) if err := validateOccultationDetailedBlocks(view, blocks); err != nil { return "", err } writeOccultationDetailedGrid(&b, view, blocks) - writeOccultationDetailedFooter(&b, view, options) + writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath)) b.WriteString(``) return b.String(), nil } @@ -401,7 +433,7 @@ func writeOccultationDetailedSummary( } } -func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions) { +func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time) { text := options.FooterNote if text == "" { if options.Language == starOccultationSVGLanguageEnglish { @@ -411,15 +443,18 @@ func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailed } } maxWidth := view.width - 2*view.margin - // 默认说明本来就是单行,只有调用方文本才折行截断。 + // 默认说明本来就是单行,只有调用方文本才折行截断;时标声明单独占一行。 lines := []string{text} if options.FooterNote != "" { - lines = svgchart.TruncateTextLines(svgchart.WrapText(text, maxWidth, 11), maxWidth, 11, - svgchart.BaselineLineLimit(11, 15, view.footerY, view.height)) + lines = svgchart.WrapText(text, maxWidth, 11) } + lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), + maxWidth, 11, + svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), view.footerY, + view.height-svgchart.FooterBottomPadding(11))) for index, line := range lines { fmt.Fprintf(b, `%s`, - view.margin, view.footerY+float64(index)*15, html.EscapeString(line)) + view.margin, view.footerY+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line)) } } @@ -431,7 +466,7 @@ func occultationDetailedTargetRows( language string, ) []svgchart.PanelRow { english := language == starOccultationSVGLanguageEnglish - jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(at.UTC())) rows := []svgchart.PanelRow{{Label: "目标", Value: star.ID}} if english { rows[0].Label = "Target" @@ -511,6 +546,7 @@ func occultationDetailedBlocks( path moon.StarOccultationPath, star moon.StarCoordinate, planetPath *moon.PlanetOccultationPath, + civilGreatest time.Time, options OccultationDetailedSVGOptions, ) []svgchart.PanelBlock { english := options.Language == starOccultationSVGLanguageEnglish @@ -550,15 +586,15 @@ func occultationDetailedBlocks( } ephemeris := []svgchart.PanelRow{ {Label: name("投影", "Projection"), Value: name("正射球面", "Orthographic")}, - {Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true), true))}, - {Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)))}, + {Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.UTC2TT(basic.Date2JD(civilGreatest.UTC())), true))}, + {Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.UTC2TT(basic.Date2JD(civilGreatest.UTC()))))}, {Label: name("部分掩带宽", "Partial-band width"), Value: fmt.Sprintf("%.1f km", partialWidth)}, } if planetPath != nil && planetPath.HasTotalBand { ephemeris = append(ephemeris, svgchart.PanelRow{Label: name("全掩带宽", "Total-band width"), Value: fmt.Sprintf("%.1f km", planetPath.GreatestTotalWidthKM)}) } - jde := basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true) + jde := basic.UTC2TT(basic.Date2JD(civilGreatest.UTC())) physical := basic.MoonPhysical(jde) libration := []svgchart.PanelRow{ {Label: name("经天平动 l", "Libration l"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLongitude)}, @@ -580,8 +616,8 @@ func occultationDetailedBlocks( contacts = append(contacts, svgchart.PanelRow{Label: name("外掩终", "Partial ends"), Value: format(path.End.Time)}) return []svgchart.PanelBlock{ - {Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(path.Greatest.Time, options.Language)}, - {Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, path.Greatest.Time, options.Language)}, + {Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(civilGreatest, options.Language)}, + {Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, civilGreatest, options.Language)}, {Title: name("掩带路径点", "Band path points"), Rows: pathRows}, {Title: name("接触时刻", "Contacts"), Rows: contacts}, {Title: name("历表与常数", "Ephemeris and constants"), Rows: ephemeris}, diff --git a/moon/svg/occultation_detailed_test.go b/moon/svg/occultation_detailed_test.go index 688b502..f7a2a6e 100644 --- a/moon/svg/occultation_detailed_test.go +++ b/moon/svg/occultation_detailed_test.go @@ -159,7 +159,7 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) { title, label, value string }{ {"月亮(地心坐标)", "赤经 R.A.", "23h37m22.4s"}, - {"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.0\""}, + {"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.1\""}, {"月亮(地心坐标)", "视半径 S.D.", "00°15'25.5\""}, {"月亮(地心坐标)", "地平视差 H.P.", "00°56'37.7\""}, {"目标天体", "目标", "HR 4799"}, diff --git a/moon/svg/occultation_local.go b/moon/svg/occultation_local.go index 2ca0144..b83652a 100644 --- a/moon/svg/occultation_local.go +++ b/moon/svg/occultation_local.go @@ -1,6 +1,7 @@ package svg import ( + "b612.me/astro/internal/timenote" "errors" "fmt" "html" @@ -8,6 +9,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/internal/svgasset" "b612.me/astro/internal/svgchart" "b612.me/astro/moon" @@ -55,6 +57,11 @@ type LocalStarOccultationSVGOptions struct { // Location 控制显示的事件时刻;nil 使用 UTC+8。 // Location controls displayed event times. Nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale. + TimeScale astro.TimeScale } type localStarOccultationSVGLayout struct { @@ -115,12 +122,19 @@ func LocalStarOccultationSVG( star moon.StarCoordinate, options LocalStarOccultationSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validateLocalStarOccultationInfo(info, star); err != nil { return "", err } if err := validateLocalStarOccultationSVGOptions(options); err != nil { return "", err } + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + } + options = normalizeLocalStarOccultationSVGOptions(options) diagram := moon.StarOccultationDiagram(info, star, moon.StarOccultationDiagramOptions{ StepDays: options.Step.Hours() / 24, @@ -128,6 +142,9 @@ func LocalStarOccultationSVG( if len(diagram.Frames) == 0 { return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalStarOccultationInfo) } + if options.TimeScale == astro.TimeScaleUT1 { + info = moon.StarOccultationInfoInUT1(info) + } return renderLocalStarOccultationSVG(info, diagram, options), nil } @@ -237,7 +254,7 @@ func renderLocalStarOccultationSVG( writeLocalStarOccultationOverview(&b, diagram, events, layout, options) writeLocalStarOccultationContacts(&b, events, layout, options) writeLocalStarOccultationStages(&b, events, layout, options) - writeLocalStarOccultationFooter(&b, layout, options) + writeLocalStarOccultationFooter(&b, layout, options, info.Greatest) b.WriteString(``) return b.String() } @@ -250,7 +267,9 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he panelWidth := math.Max(188, math.Min(236, width*0.25)) overviewLeft := margin overviewRight := width - margin - panelWidth - gap - footerSpace := 64.0 + // 页脚末行按 9px 字号留 12 px 底边留白,预留高度与页脚起始位置随之上移, + // 否则英文默认说明会被截到只剩一行、时标声明整行消失。 + footerSpace := 74.0 stageHeight := math.Max(132, math.Min(170, height*0.23)) stageBottom := height - footerSpace stageTop := stageBottom - stageHeight @@ -268,7 +287,7 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he panelWidth: panelWidth, stageTop: stageTop, stageBottom: stageBottom, - footerY: height - 43, + footerY: height - 49, headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight), } } @@ -297,18 +316,28 @@ func writeLocalStarOccultationOverview( mapY := func(value float64) float64 { return cy - value*scale } moonRadius := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale - writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language) + table := &svgchart.LabelTable{} + table.ReserveText(layout.overviewLeft, layout.overviewTop-10, 14, overviewTitle, "start") + table.Reserve(layout.panelX-12, layout.overviewTop-16, layout.width-(layout.panelX-12), layout.height-layout.overviewTop) + table.Reserve(cx-moonRadius, cy-moonRadius, 2*moonRadius, 2*moonRadius) + writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table) writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon", localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer), "local-star-occultation-moon-overview") writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language) writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY) + overviewLabels := make([]localOccultationOverviewLabel, 0, len(events)) for _, event := range events { x := mapX(event.frame.StarXArcsec) y := mapY(event.frame.StarYArcsec) writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "overview-star", event.label) - writeLocalStarOccultationOverviewLabel(b, event, x, y, cx, cy, options.Language) + overviewLabels = append(overviewLabels, localOccultationOverviewLabel{ + text: localStarOccultationSVGEventName(event.label, options.Language), + x: x, + y: y, + }) } + writeLocalOccultationOverviewLabels(b, overviewLabels, table, cx, cy, moonRadius, "#792d28") } func localStarOccultationSVGExtent(frames []moon.StarOccultationDiagramFrame) float64 { @@ -416,7 +445,7 @@ func localStarOccultationSVGLunarPathLabel(language string) string { return "白道" } -func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string) { +func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string, table *svgchart.LabelTable) { north, east, west, south := "北", "东", "西", "南" if language == starOccultationSVGLanguageEnglish { north, east, west, south = "N", "E", "W", "S" @@ -432,6 +461,9 @@ func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, l {cx, cy + radius + 24, "middle", south}, } for _, label := range labels { + if table != nil { + table.ReserveText(label.x, label.y, 12, label.text, label.anchor) + } fmt.Fprintf(b, `%s`, label.x, label.y, label.anchor, html.EscapeString(label.text)) } @@ -448,24 +480,6 @@ func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool fmt.Fprintf(b, ``, x, y-6, x, y+6, stroke) b.WriteString(``) } - -func writeLocalStarOccultationOverviewLabel( - b *strings.Builder, - event localStarOccultationEventFrame, - x, y, cx, cy float64, - language string, -) { - dx, dy, anchor := 8.0, -9.0, "start" - if x > cx { - dx, anchor = -8, "end" - } - if y < cy-20 { - dy = 15 - } - fmt.Fprintf(b, `%s`, - x+dx, y+dy, anchor, html.EscapeString(localStarOccultationSVGEventName(event.label, language))) -} - func writeLocalStarOccultationContacts( b *strings.Builder, events []localStarOccultationEventFrame, @@ -543,10 +557,12 @@ func writeLocalStarOccultationFooter( b *strings.Builder, layout localStarOccultationSVGLayout, options LocalStarOccultationSVGOptions, + instant time.Time, ) { directionLines, footerLines := localOccultationFooterLines( - localStarOccultationSVGDirectionText(options), options.DirectionText != "", - localStarOccultationSVGFooterNote(options), options.FooterNote != "", + localStarOccultationSVGDirectionText(options), + localStarOccultationSVGFooterNote(options), + timenote.Scale(options.TimeScale, instant, options.Location, options.Language), layout.footerY, layout.width, layout.height) for index, line := range directionLines { fmt.Fprintf(b, `%s`, @@ -613,23 +629,29 @@ func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options L return lines } -// localOccultationFooterLines 排布本地图页脚:补充说明贴画布底边,方向说明占用上方余高,两块都按行数上限截断。 +// localOccultationFooterLines 排布本地图页脚:时标声明并进补充说明后一起均衡折行,方向说明占用上方余高,两块都按行数上限截断。 func localOccultationFooterLines( - directionText string, directionCustom bool, - noteText string, noteCustom bool, + directionText, noteText, scaleNote string, footerY, width, height float64, ) ([]string, []string) { maxWidth := width - 80 - direction := occultationWrappedTextLines(directionText, directionCustom, maxWidth, 10) - note := occultationWrappedTextLines(noteText, noteCustom, maxWidth, 9) + direction := svgchart.WrapTextBalanced(directionText, maxWidth, 10) + // 默认把时标声明并进说明一起均衡折行;说明太长折不下时改成"说明截断 + 声明独占最后一行"。 + mergedLines := svgchart.WrapTextBalanced(strings.TrimSpace(noteText+" "+scaleNote), maxWidth, 9) + notesOnly := svgchart.WrapTextBalanced(strings.TrimSpace(noteText), maxWidth, 9) noteAbove, noteBelow := svgchart.EstimatedTextExtents(9) - noteHeight := noteAbove + float64(len(note)-1)*12 + noteBelow + noteHeight := noteAbove + float64(len(mergedLines)-1)*12 + noteBelow direction = svgchart.TruncateTextLines(direction, maxWidth, 10, - svgchart.BaselineLineLimit(10, 14, footerY-8, height-2-noteHeight)) + svgchart.BaselineLineLimit(10, 14, footerY-8, height-svgchart.FooterBottomPadding(9)-noteHeight)) noteFirst := footerY + float64(len(direction))*14 - 6 - note = svgchart.TruncateTextLines(note, maxWidth, 9, - svgchart.BaselineLineLimit(9, 12, noteFirst, height-2)) - return direction, note + maxNoteLines := svgchart.BaselineLineLimit(9, 12, noteFirst, height-svgchart.FooterBottomPadding(9)) + noteLines := mergedLines + if len(mergedLines) > maxNoteLines { + noteLines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, 9, maxNoteLines) + } else { + noteLines = svgchart.TruncateTextLines(mergedLines, maxWidth, 9, maxNoteLines) + } + return direction, noteLines } // localOccultationHeaderLineLimit 由版式的页脚预留与阶段区高度推出页眉行数上限,总览区被压到 120 px 以下时圆盘与方向标记会跑出画布。 @@ -676,7 +698,7 @@ func localStarOccultationSVGSummaryText(info moon.StarOccultationInfo, options L return fmt.Sprintf("Site %s | elevation %.0f m | %s | duration %s", coordinates, info.Observer.Height, localStarOccultationSVGTypeName(info.Type, options.Language), duration) } - return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height, + return fmt.Sprintf("观测点 %s | 椭球高 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height, localStarOccultationSVGTypeName(info.Type, options.Language), duration) } diff --git a/moon/svg/occultation_local_footer_balance_test.go b/moon/svg/occultation_local_footer_balance_test.go new file mode 100644 index 0000000..4c99353 --- /dev/null +++ b/moon/svg/occultation_local_footer_balance_test.go @@ -0,0 +1,176 @@ +package svg + +import ( + "encoding/xml" + "io" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 本地月掩页脚契约:方向说明与补充说明(含时标声明)都排在可用宽度内,同一块各行宽度接近, +// 不留孤立短尾行,且显示时区的偏移只写一次。 + +const ( + localOccultationFooterDirectionFill = "#4f595b" + localOccultationFooterNoteFill = "#747c7d" +) + +type localOccultationFooterText struct { + fill string + fontSize float64 + value string +} + +func localOccultationFooterTexts(t *testing.T, diagram string) []localOccultationFooterText { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + texts := []localOccultationFooterText{} + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("decode SVG: %v", err) + } + element, ok := token.(xml.StartElement) + if !ok || element.Name.Local != "text" { + continue + } + text := localOccultationFooterText{} + for _, attribute := range element.Attr { + switch attribute.Name.Local { + case "fill": + text.fill = attribute.Value + case "font-size": + text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) + } + } + if text.fill != localOccultationFooterDirectionFill && text.fill != localOccultationFooterNoteFill { + continue + } + var content string + if err := decoder.DecodeElement(&content, &element); err != nil { + t.Fatalf("decode text element: %v", err) + } + text.value = content + texts = append(texts, text) + } + return texts +} + +func localOccultationFooterWidths(lines []localOccultationFooterText) ([]string, []float64) { + values := make([]string, 0, len(lines)) + widths := make([]float64, 0, len(lines)) + for _, line := range lines { + values = append(values, line.value) + widths = append(widths, svgchart.EstimatedTextWidth(line.value, line.fontSize)) + } + return values, widths +} + +func localOccultationFooterSpread(lines []localOccultationFooterText) float64 { + _, widths := localOccultationFooterWidths(lines) + minimum, maximum := widths[0], widths[0] + for _, width := range widths { + minimum = math.Min(minimum, width) + maximum = math.Max(maximum, width) + } + return maximum / minimum +} + +func localOccultationFooterGroup(lines []localOccultationFooterText, fill string) []localOccultationFooterText { + group := []localOccultationFooterText{} + for _, line := range lines { + if line.fill == fill { + group = append(group, line) + } + } + return group +} + +// 说明块各行的最大最小宽度比不得超过 15%,整块最短行不得短于最长行的 60%(旧实现里时标声明独占的短尾行只有 25%~47%)。 +func TestLocalOccultationFooterLinesAreEven(t *testing.T) { + location := time.FixedZone("UTC+08:00", 8*3600) + const width, height = 920, 720 + for _, language := range []string{"zh", "en"} { + star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Language: language, Location: location, Width: width, Height: height, + }) + if err != nil { + t.Fatalf("star %s: %v", language, err) + } + planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{ + Language: language, Location: location, Width: width, Height: height, + }) + if err != nil { + t.Fatalf("planet %s: %v", language, err) + } + for name, diagram := range map[string]string{"star": star, "planet": planet} { + lines := localOccultationFooterTexts(t, diagram) + if len(lines) < 2 { + t.Fatalf("%s %s: footer lines = %#v, want the direction and note blocks", name, language, lines) + } + values, widths := localOccultationFooterWidths(lines) + for index, line := range lines { + if limit := float64(width) - 80; widths[index] > limit { + t.Fatalf("%s %s: footer line %q width %.1f exceeds %.1f", name, language, line.value, widths[index], limit) + } + if strings.Contains(line.value, "…") { + t.Fatalf("%s %s: default footer text is truncated: %q", name, language, line.value) + } + } + note := localOccultationFooterGroup(lines, localOccultationFooterNoteFill) + if len(note) == 0 { + t.Fatalf("%s %s: missing the footer note block", name, language) + } + // 说明块与方向说明行字号不同(fs9 / fs10),故按块判 1.15;整块只兜"孤尾短行"(1.60)。 + if spread := localOccultationFooterSpread(note); spread > 1.15 { + t.Fatalf("%s %s: note block spread = %.3f, want <= 1.15 (%q)", name, language, spread, values) + } + if spread := localOccultationFooterSpread(lines); spread > 1.60 { + t.Fatalf("%s %s: footer spread = %.3f, want no orphan short line (%q)", name, language, spread, values) + } + joined := strings.Join(values, " ") + if zone := "显示时区"; language == "en" { + if !strings.Contains(joined, "shown in") { + t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined) + } + } else if !strings.Contains(joined, zone) { + t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined) + } + } + } +} + +// 偏移型时区名(UTC+08:00)不得让时标声明写成“显示时区 UTC+08:00,UTC+08:00”。 +func TestLocalOccultationFooterNamesTheZoneOnce(t *testing.T) { + location := time.FixedZone("UTC+08:00", 8*3600) + for _, language := range []string{"zh", "en"} { + star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Language: language, Location: location, + }) + if err != nil { + t.Fatalf("star %s: %v", language, err) + } + planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{ + Language: language, Location: location, + }) + if err != nil { + t.Fatalf("planet %s: %v", language, err) + } + for name, diagram := range map[string]string{"star": star, "planet": planet} { + note := localOccultationFooterGroup(localOccultationFooterTexts(t, diagram), localOccultationFooterNoteFill) + values, _ := localOccultationFooterWidths(note) + joined := strings.Join(values, " ") + if got := strings.Count(joined, "UTC+08:00"); got != 1 { + t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", name, language, got, joined) + } + } + } +} diff --git a/moon/svg/occultation_local_labels.go b/moon/svg/occultation_local_labels.go new file mode 100644 index 0000000..9aec2c4 --- /dev/null +++ b/moon/svg/occultation_local_labels.go @@ -0,0 +1,79 @@ +package svg + +import ( + "fmt" + "html" + "math" + "strings" + + "b612.me/astro/internal/svgchart" +) + +// localOccultationOverviewLabel 是总览区一个接触标签:文本与它在图上的接触点位置。 +type localOccultationOverviewLabel struct { + text string + x, y float64 +} + +// writeLocalOccultationOverviewLabels 排布总览区的接触标签:先按原偏移试位,压叠时沿接触点径向 +// 外推、再上下错开,仍放不下就贴着月球外缘排成竖列;离接触点较远的标签用细引线连回去。 +func writeLocalOccultationOverviewLabels( + b *strings.Builder, + labels []localOccultationOverviewLabel, + table *svgchart.LabelTable, + cx, cy, moonRadius float64, + color string, +) { + if len(labels) == 0 { + return + } + const fontSize = 11.0 + spillTop := cy - moonRadius - 24 + spill := 0 + for _, item := range labels { + anchor, side := "start", 1.0 + if item.x > cx { + anchor, side = "end", -1 + } + baseY := item.y - 9 + if item.y < cy-20 { + baseY = item.y + 15 + } + candidates := []svgchart.LabelPlacement{{X: item.x + side*8, Y: baseY, Anchor: anchor}} + ux, uy := item.x-cx, item.y-cy + if norm := math.Hypot(ux, uy); norm > 1e-9 { + ux, uy = ux/norm, uy/norm + } else { + ux, uy = 0, -1 + } + for step := 1; step <= 5; step++ { + reach := 12 + float64(step)*13 + candidates = append(candidates, + svgchart.LabelPlacement{X: item.x + ux*reach + side*6, Y: item.y + uy*reach, Anchor: anchor}, + svgchart.LabelPlacement{X: item.x + ux*reach - side*6, Y: item.y + uy*reach, Anchor: svgchart.MirrorLabelAnchor(anchor)}, + ) + } + for step := 1; step <= 4; step++ { + candidates = append(candidates, + svgchart.LabelPlacement{X: item.x + side*8, Y: baseY - float64(step)*15, Anchor: anchor}, + svgchart.LabelPlacement{X: item.x + side*8, Y: baseY + float64(step)*15, Anchor: anchor}, + ) + } + placement, placed := table.Place(item.text, fontSize, candidates) + if !placed { + placement = svgchart.LabelPlacement{X: cx + side*(moonRadius+30), Y: spillTop + float64(spill)*15, Anchor: anchor} + for spill < 40 && table.OverlapsText(placement.X, placement.Y, fontSize, item.text, placement.Anchor) { + spill++ + placement.Y = spillTop + float64(spill)*15 + } + table.ReserveText(placement.X, placement.Y, fontSize, item.text, placement.Anchor) + spill++ + } + if math.Hypot(placement.X-item.x, placement.Y-item.y) > 16 { + fmt.Fprintf(b, ``, + item.x, item.y, placement.X, placement.Y, color) + } + fmt.Fprintf(b, `%s`, + placement.X, placement.Y, color, fontSize, placement.Anchor, html.EscapeString(item.text)) + } +} diff --git a/moon/svg/occultation_local_labels_test.go b/moon/svg/occultation_local_labels_test.go new file mode 100644 index 0000000..af3265b --- /dev/null +++ b/moon/svg/occultation_local_labels_test.go @@ -0,0 +1,88 @@ +package svg + +import ( + "testing" + "time" + + "b612.me/astro/moon" +) + +// 总览区接触标签契约:掠掩时四次接触只差几十角秒,标签必须自动避让排开,既不互相压叠、也不压到方位字、 +// 月球圆盘与右侧接触时刻栏;被推远的标签要用引线连回接触点。2026-09-14 保山月掩金星就是这种掠掩样本。 + +const localOccultationOverviewLabelFill = "#792d28" + +func localPlanetOccultationOverviewLabels(t *testing.T, diagram string) []occultationSVGText { + t.Helper() + labels := []occultationSVGText{} + for _, text := range occultationSVGTexts(t, diagram) { + if text.fill == localOccultationOverviewLabelFill { + labels = append(labels, text) + } + } + return labels +} + +func localOccultationOverviewLabelOverlaps(labels []occultationSVGText) [][2]string { + overlaps := [][2]string{} + for first := 0; first < len(labels); first++ { + for second := first + 1; second < len(labels); second++ { + a := occultationSVGTextBox(labels[first]) + b := occultationSVGTextBox(labels[second]) + if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] { + overlaps = append(overlaps, [2]string{labels[first].value, labels[second].value}) + } + } + } + return overlaps +} + +func grazingVenusOccultation(t *testing.T) moon.PlanetOccultationInfo { + t.Helper() + location := time.FixedZone("CST", 8*3600) + events, err := moon.FindPlanetOccultations( + time.Date(2026, time.September, 14, 0, 0, 0, 0, location), + time.Date(2026, time.September, 15, 0, 0, 0, 0, location), + moon.OccultationVenus, 99.1611, 25.1120, 1650, moon.OccultationSearchOptions{}, + ) + if err != nil { + t.Fatalf("FindPlanetOccultations() error = %v", err) + } + if len(events) != 1 { + t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events)) + } + return events[0] +} + +func TestLocalPlanetOccultationOverviewLabelsAvoidEachOther(t *testing.T) { + location := time.FixedZone("CST", 8*3600) + for _, tc := range []struct { + name string + info moon.PlanetOccultationInfo + width int + heigh int + }{ + {"保山掠掩金星", grazingVenusOccultation(t), 935, 683}, + {"土星全掩", localSaturnOccultation(t), 920, 720}, + } { + diagram, err := LocalPlanetOccultationSVG(tc.info, LocalPlanetOccultationSVGOptions{ + Width: tc.width, Height: tc.heigh, Location: location, + }) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + labels := localPlanetOccultationOverviewLabels(t, diagram) + if len(labels) < 2 { + t.Fatalf("%s:总览区只有 %d 个接触标签", tc.name, len(labels)) + } + if overlaps := localOccultationOverviewLabelOverlaps(labels); len(overlaps) > 0 { + t.Fatalf("%s:接触标签互相压叠 %v", tc.name, overlaps) + } + for _, text := range labels { + box := occultationSVGTextBox(text) + if box[0] < 0 || box[2] > float64(tc.width) || box[1] < 0 || box[3] > float64(tc.heigh) { + t.Fatalf("%s:标签「%s」越出画布 %v", tc.name, text.value, box) + } + } + } +} diff --git a/moon/svg/occultation_planet.go b/moon/svg/occultation_planet.go index cf5c8ff..c323dd6 100644 --- a/moon/svg/occultation_planet.go +++ b/moon/svg/occultation_planet.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/internal/geodata" "b612.me/astro/internal/occultationgeo" "b612.me/astro/internal/svgmap" @@ -59,9 +60,16 @@ func PlanetOccultationPathSVG( path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validatePlanetOccultationPath(path); err != nil { return "", err } + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + path = moon.PlanetOccultationPathInUT1(path) + } targetID := path.TargetID if targetID == "" { targetID = path.Planet.String() diff --git a/moon/svg/occultation_planet_local.go b/moon/svg/occultation_planet_local.go index 907a1b8..b666be1 100644 --- a/moon/svg/occultation_planet_local.go +++ b/moon/svg/occultation_planet_local.go @@ -1,6 +1,7 @@ package svg import ( + "b612.me/astro/internal/timenote" "errors" "fmt" "html" @@ -8,6 +9,7 @@ import ( "strings" "time" + "b612.me/astro" "b612.me/astro/internal/svgasset" "b612.me/astro/internal/svgchart" "b612.me/astro/moon" @@ -55,6 +57,11 @@ type LocalPlanetOccultationSVGOptions struct { // Location 控制显示的事件时刻;nil 使用 UTC+8。 // Location controls displayed event times. Nil uses UTC+8. Location *time.Location + // TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是 + // nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。 + // TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires + // Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale. + TimeScale astro.TimeScale } type localPlanetOccultationEventFrame struct { @@ -101,12 +108,19 @@ func LocalPlanetOccultationSVG( info moon.PlanetOccultationInfo, options LocalPlanetOccultationSVGOptions, ) (string, error) { + if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC { + return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location") + } if err := validateLocalPlanetOccultationInfo(info); err != nil { return "", err } if err := validateLocalPlanetOccultationSVGOptions(options); err != nil { return "", err } + if options.TimeScale == astro.TimeScaleUT1 { + options.Location = time.UTC + } + options = normalizeLocalPlanetOccultationSVGOptions(options) diagram := moon.PlanetOccultationDiagram(info, moon.PlanetOccultationDiagramOptions{ StepDays: options.Step.Hours() / 24, @@ -114,6 +128,9 @@ func LocalPlanetOccultationSVG( if len(diagram.Frames) == 0 { return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalPlanetOccultationInfo) } + if options.TimeScale == astro.TimeScaleUT1 { + info = moon.PlanetOccultationInfoInUT1(info) + } return renderLocalPlanetOccultationSVG(info, diagram, options), nil } @@ -293,7 +310,15 @@ func writeLocalPlanetOccultationOverview( mapY := func(value float64) float64 { return cy - value*scale } moonRadius := localPlanetOccultationSVGMaximumMoonRadius(diagram.Frames) * scale - writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language) + table := &svgchart.LabelTable{} + table.ReserveText(layout.overviewLeft, layout.overviewTop-10, 14, overviewTitle, "start") + table.Reserve(layout.panelX-12, layout.overviewTop-16, layout.width-(layout.panelX-12), layout.height-layout.overviewTop) + table.Reserve(cx-moonRadius, cy-moonRadius, 2*moonRadius, 2*moonRadius) + for _, event := range events { + radius := math.Max(6, event.frame.PlanetRadiusArcsec*scale) + table.Reserve(mapX(event.frame.PlanetXArcsec)-radius, mapY(event.frame.PlanetYArcsec)-radius, 2*radius, 2*radius) + } + writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table) writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language) writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY) for _, event := range events { @@ -305,6 +330,7 @@ func writeLocalPlanetOccultationOverview( writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon", localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer), "local-planet-occultation-moon-overview") + overviewLabels := make([]localOccultationOverviewLabel, 0, len(events)) for _, event := range events { x := mapX(event.frame.PlanetXArcsec) y := mapY(event.frame.PlanetYArcsec) @@ -312,9 +338,10 @@ func writeLocalPlanetOccultationOverview( writeLocalPlanetOccultationDisk(b, x, y, event.frame.PlanetRadiusArcsec*scale, true, "overview-planet-outline", event.label) } if event.label != "C2" && event.label != "C3" { - writeLocalPlanetOccultationOverviewLabel(b, event, x, y, cx, cy) + overviewLabels = append(overviewLabels, localOccultationOverviewLabel{text: event.name, x: x, y: y}) } } + writeLocalOccultationOverviewLabels(b, overviewLabels, table, cx, cy, moonRadius, "#792d28") } func localPlanetOccultationSVGExtent(frames []moon.PlanetOccultationDiagramFrame) float64 { @@ -425,23 +452,6 @@ func writeLocalPlanetOccultationDisk( fmt.Fprintf(b, ``, html.EscapeString(class), html.EscapeString(label), x, y, math.Max(radius, 0.15), fill, stroke, dash) } - -func writeLocalPlanetOccultationOverviewLabel( - b *strings.Builder, - event localPlanetOccultationEventFrame, - x, y, cx, cy float64, -) { - dx, dy, anchor := 8.0, -9.0, "start" - if x > cx { - dx, anchor = -8, "end" - } - if y < cy-20 { - dy = 15 - } - fmt.Fprintf(b, `%s`, - x+dx, y+dy, anchor, html.EscapeString(event.name)) -} - func writeLocalPlanetOccultationContacts( b *strings.Builder, events []localPlanetOccultationEventFrame, @@ -548,8 +558,9 @@ func writeLocalPlanetOccultationFooter( options LocalPlanetOccultationSVGOptions, ) { directionLines, footerLines := localOccultationFooterLines( - localPlanetOccultationSVGDirectionText(options), options.DirectionText != "", - localPlanetOccultationSVGFooterNote(info, options), options.FooterNote != "", + localPlanetOccultationSVGDirectionText(options), + localPlanetOccultationSVGFooterNote(info, options), + timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language), layout.footerY, layout.width, layout.height) for index, line := range directionLines { fmt.Fprintf(b, `%s`, @@ -672,7 +683,7 @@ func localPlanetOccultationSVGSummaryText( return fmt.Sprintf("Site %s | elevation %.0f m | %s | C1-C4 duration %s", coordinates, info.Observer.Height, localPlanetOccultationSVGTypeName(info.Type, options.Language), duration) } - return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height, + return fmt.Sprintf("观测点 %s | 椭球高 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height, localPlanetOccultationSVGTypeName(info.Type, options.Language), duration) } diff --git a/moon/svg/occultation_svg_support_test.go b/moon/svg/occultation_svg_support_test.go index 5200163..6622060 100644 --- a/moon/svg/occultation_svg_support_test.go +++ b/moon/svg/occultation_svg_support_test.go @@ -20,6 +20,7 @@ type occultationSVGText struct { parent string parentClass string value string + fill string x, y float64 fontSize float64 anchor string @@ -67,6 +68,8 @@ func occultationSVGTexts(t *testing.T, diagram string) []occultationSVGText { text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) case "text-anchor": text.anchor = attribute.Value + case "fill": + text.fill = attribute.Value } } texts = append(texts, text) diff --git a/moon/svg/occultation_timescale_test.go b/moon/svg/occultation_timescale_test.go new file mode 100644 index 0000000..3500d1b --- /dev/null +++ b/moon/svg/occultation_timescale_test.go @@ -0,0 +1,151 @@ +package svg + +import ( + "strings" + "testing" + "time" + + "b612.me/astro" + "b612.me/astro/basic" + "b612.me/astro/moon" +) + +// 月掩五族的 UT1 选项:渲染成功、详细图声明尺度、非 UTC 时区报错,且绝不改写调用方传进来的 path。 +func TestOccultationSVGTimeScaleUT1(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + path := occultationTestStarPath(t) + before := path.Start.Time + + starSVG, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1}) + if err != nil { + t.Fatalf("恒星月掩 UT1 详细图渲染失败: %v", err) + } + assertOccultationUT1Declaration(t, "恒星月掩详细图", starSVG) + if !path.Start.Time.Equal(before) { + t.Error("渲染器不得改写调用方传入的 path") + } + + starPathSVG, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1}) + if err != nil { + t.Fatalf("恒星月掩 UT1 路径图渲染失败: %v", err) + } + assertOccultationUT1Declaration(t, "恒星月掩全球路径图", starPathSVG) + if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil { + t.Error("UT1 配非 UTC 时区应报错") + } + if _, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil { + t.Error("UT1 配非 UTC 时区应报错(详细图)") + } + + planetPath := samplePlanetOccultationPath() + if _, err := PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1}); err != nil { + t.Fatalf("行星月掩 UT1 详细图渲染失败: %v", err) + } + if _, err := PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil { + t.Error("行星月掩 UT1 配非 UTC 时区应报错") + } + // 行星全球路径图与恒星侧同契约:UT1 配非 UTC 必须报错,配 UTC 必须声明时标。 + if _, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil { + t.Error("行星月掩全球路径图 UT1 配非 UTC 时区应报错") + } + planetPathSVG, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{TimeScale: astro.TimeScaleUT1}) + if err != nil { + t.Fatalf("行星月掩 UT1 全球路径图渲染失败: %v", err) + } + assertOccultationUT1Declaration(t, "行星月掩全球路径图", planetPathSVG) + + info := localHR4799Occultation(t) + if _, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.UTC}); err != nil { + t.Fatalf("地方月掩 UT1 渲染失败: %v", err) + } + if _, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil { + t.Error("地方月掩 UT1 配非 UTC 时区应报错") + } +} + +// assertOccultationUT1Declaration 钉住 UT1 声明是可解析的 元素并写出 DUT1:曾经的裸文本写入不会渲染。 +func assertOccultationUT1Declaration(t *testing.T, name, diagram string) { + t.Helper() + for _, text := range occultationSVGTexts(t, diagram) { + if strings.Contains(text.value, "DUT1 = UT1−UTC = ") { + return + } + } + t.Errorf("%s 应在图内的 元素里声明 UT1 与 DUT1 差值", name) +} + +// 换算器必须保留"该阶段不存在"的零值语义。 +func TestOccultationInfoConvertersPreserveZero(t *testing.T) { + info := moon.StarOccultationInfo{Greatest: time.Date(2025, 1, 5, 17, 0, 0, 0, time.UTC)} + got := moon.StarOccultationInfoInUT1(info) + if !got.Immersion.IsZero() || !got.Emersion.IsZero() { + t.Error("不存在的阶段应保持零值") + } + if !got.Greatest.After(info.Greatest) { + t.Error("UT1 时刻应领先民用时刻") + } +} + +// 详细版历表面板(ΔT、月距、天平动、月亮地心坐标)必须与输出时标无关:注入一个偏移 1 小时的 +// TT−UTC 覆盖,让"把 UT1 读数当民用时刻"的写法必然算出不同历表值;修复后两次渲染的 +// 非时刻文字必须逐个相同。 +func TestOccultationDetailedPanelIgnoresTimeScale(t *testing.T) { + astro.SetTTMinusUTC(func(jd float64) float64 { + return basic.TTMinusUTCSecondsDefault(jd) + 3600 + }) + defer astro.SetTTMinusUTC(nil) + + path := occultationTestStarPath(t) + utc, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{}) + if err != nil { + t.Fatalf("UTC 详细图渲染失败: %v", err) + } + ut1, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1}) + if err != nil { + t.Fatalf("UT1 详细图渲染失败: %v", err) + } + utcValues := occultationPanelValues(t, utc) + ut1Values := occultationPanelValues(t, ut1) + if len(utcValues) == 0 { + t.Fatal("未解析到面板文字") + } + if len(utcValues) != len(ut1Values) { + t.Fatalf("两图文字数不同: %d vs %d", len(utcValues), len(ut1Values)) + } + for index := range utcValues { + if utcValues[index] != ut1Values[index] { + t.Errorf("第 %d 条文字随时标改变: %q vs %q", index, utcValues[index], ut1Values[index]) + } + } +} + +// occultationPanelValues 取出不随时标变化的文字:跳过时刻、时区声明与包含它们的行。 +func occultationPanelValues(t *testing.T, diagram string) []string { + t.Helper() + values := make([]string, 0, 32) + for _, text := range occultationSVGTexts(t, diagram) { + value := strings.TrimSpace(text.value) + if value == "" || strings.Contains(value, "UT1") || strings.Contains(value, "DUT1") || + strings.Contains(value, "UTC") || strings.Contains(value, "时区") || strings.Contains(value, "图中时刻") { + continue + } + if occultationTimeLike(value) { + continue + } + values = append(values, value) + } + return values +} + +func occultationTimeLike(value string) bool { + for _, field := range strings.Fields(value) { + trimmed := strings.Trim(field, "(),") + if len(trimmed) >= 5 && trimmed[2] == ':' && trimmed[0] >= '0' && trimmed[0] <= '9' { + return true + } + if len(trimmed) >= 8 && trimmed[4] == '-' && trimmed[7] == '-' { + return true + } + } + return false +} diff --git a/neptune/diameter.go b/neptune/diameter.go index d6ee2da..9c683ce 100644 --- a/neptune/diameter.go +++ b/neptune/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 海王星视半径(截断版),单位角秒 / truncated apparent Neptune semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 海王星视直径,单位角秒 / apparent Neptune diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 海王星视直径(截断版),单位角秒 / truncated apparent Neptune diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneDiameterN(basic.UTC2TT(jd), n) } diff --git a/neptune/neptune.go b/neptune/neptune.go index da6be4e..1df34a4 100644 --- a/neptune/neptune.go +++ b/neptune/neptune.go @@ -15,7 +15,7 @@ var ( ERR_NEPTUNE_NEVER_DOWN = ERR_NEPTUNE_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回海王星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Neptune at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Neptune at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Neptune at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Neptune at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Neptune at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回海王星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Neptune at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Neptune to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthNeptuneAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthNeptuneAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回海王星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Neptune to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(7, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(7, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneHeight(jde, lon, lat, timezone) + return basic.NeptuneHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneAzimuth(jde, lon, lat, timezone) + return basic.NeptuneAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneHourAngle(jde, lon, timezone) + return basic.NeptuneHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.NeptuneCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.NeptuneCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.NeptuneRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.NeptuneRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.NeptuneSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.NeptuneSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneConjunction(jde), date.Location(), false) } // LastOpposition 上一次冲日 / previous opposition. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or before date, keeping date's time zone. func LastOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneOpposition(jde), date.Location(), false) } // NextOpposition 下一次冲日 / next opposition. @@ -237,8 +232,8 @@ func LastOpposition(date time.Time) time.Time { // 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or after date, keeping date's time zone. func NextOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneOpposition(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -246,8 +241,8 @@ func NextOpposition(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneProgradeToRetrograde(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -255,8 +250,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneProgradeToRetrograde(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -264,8 +259,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneRetrogradeToPrograde(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -273,8 +268,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneRetrogradeToPrograde(jde), date.Location(), false) } // LastEasternQuadrature 上一次东方照 / previous eastern quadrature. @@ -282,8 +277,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or before date, keeping date's time zone. func LastEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneEasternQuadrature(jde), date.Location(), false) } // NextEasternQuadrature 下一次东方照 / next eastern quadrature. @@ -291,8 +286,8 @@ func LastEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or after date, keeping date's time zone. func NextEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneEasternQuadrature(jde), date.Location(), false) } // LastWesternQuadrature 上一次西方照 / previous western quadrature. @@ -300,8 +295,8 @@ func NextEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or before date, keeping date's time zone. func LastWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastNeptuneWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastNeptuneWesternQuadrature(jde), date.Location(), false) } // NextWesternQuadrature 下一次西方照 / next western quadrature. @@ -309,6 +304,6 @@ func LastWesternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or after date, keeping date's time zone. func NextWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextNeptuneWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextNeptuneWesternQuadrature(jde), date.Location(), false) } diff --git a/neptune/nodes.go b/neptune/nodes.go index 7777294..92aa3f2 100644 --- a/neptune/nodes.go +++ b/neptune/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 海王星升交点黄经(截断版) / truncated ascending node longitude of Neptune. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 海王星降交点黄经 / descending node longitude of Neptune. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 海王星降交点黄经(截断版) / truncated descending node longitude of Neptune. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/neptune/phase.go b/neptune/phase.go index 7adf74d..3af0de4 100644 --- a/neptune/phase.go +++ b/neptune/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/neptune/physical.go b/neptune/physical.go index 747d3a7..422839e 100644 --- a/neptune/physical.go +++ b/neptune/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 海王星物理观测参数(截断版) / truncated physical observing parameters of Neptune. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.NeptunePhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.NeptunePhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/neptune/physical_test.go b/neptune/physical_test.go index 2fb8076..720a70a 100644 --- a/neptune/physical_test.go +++ b/neptune/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.NeptunePhysicalN(basic.TD2UT(jde, true), -1) + want := basic.NeptunePhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/neptune/truncated.go b/neptune/truncated.go index af9cc9b..a3b0873 100644 --- a/neptune/truncated.go +++ b/neptune/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.NeptuneMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.NeptuneMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthNeptuneAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthNeptuneAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(7, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(7, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneHeightN(jde, lon, lat, timezone, n) + return basic.NeptuneHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneAzimuthN(jde, lon, lat, timezone, n) + return basic.NeptuneAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.NeptuneHourAngleN(jde, lon, timezone, n) + return basic.NeptuneHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.NeptuneCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.NeptuneCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.NeptuneRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.NeptuneRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.NeptuneSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.NeptuneSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/orbit/orbit.go b/orbit/orbit.go index aa2a6bb..1381011 100644 --- a/orbit/orbit.go +++ b/orbit/orbit.go @@ -179,11 +179,11 @@ func ApparentTopocentricEquatorial(date time.Time, elements Elements, observerLo // Altitude 视高度角 / apparent altitude. // -// 返回目标在观测者所在地的视高度角,单位度;经度东正西负,纬度北正南负,海拔单位米。 +// 返回目标在观测者所在地的视高度角,单位度;经度东正西负,纬度北正南负,椭球高单位米。 // Returns the apparent altitude of the target for the observing site, in degrees. Longitude is east-positive, latitude is north-positive, and height is in meters. func Altitude(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 { - jde := basic.Date2JDE(date) - return basic.OrbitHeight(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) + localJD := basic.Date2JD(date) + return basic.OrbitHeight(localJD, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) } // Zenith 天顶距 / zenith distance. @@ -199,8 +199,8 @@ func Zenith(date time.Time, elements Elements, observerLon, observerLat, observe // 返回目标在观测者所在地的视方位角,按正北为 0°、向东增加。 // Returns the apparent azimuth of the target for the observing site, measured from north toward east. func Azimuth(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 { - jde := basic.Date2JDE(date) - return basic.OrbitAzimuth(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) + localJD := basic.Date2JD(date) + return basic.OrbitAzimuth(localJD, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) } // HourAngle 站心视时角 / topocentric hour angle. @@ -208,8 +208,8 @@ func Azimuth(date time.Time, elements Elements, observerLon, observerLat, observ // 返回目标在观测者所在地的站心视时角,单位度。 // Returns the apparent topocentric hour angle of the target for the observing site, in degrees. func HourAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 { - jde := basic.Date2JDE(date) - return basic.OrbitHourAngle(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) + localJD := basic.Date2JD(date) + return basic.OrbitHourAngle(localJD, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements)) } // CulminationTime 中天时刻 / culmination time. @@ -217,13 +217,11 @@ func HourAngle(date time.Time, elements Elements, observerLon, observerLat, obse // 返回目标在给定当地日期内的中天时刻,结果保持输入 `date` 的时区。 // Returns the culmination time of the target on the supplied local civil day. The result keeps the timezone of `date`. func CulminationTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) time.Time { - if date.Hour() > 12 { - date = date.Add(-12 * time.Hour) - } + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) timezone := observationTimezone(date) - jde := basic.Date2JDE(date) - calcJde := basic.OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight, toBasicElements(elements)) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + localJD := basic.Date2JD(date) + calcJD := basic.OrbitCulminationTime(localJD, observerLon, observerLat, timezone, observerHeight, toBasicElements(elements)) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时刻 / rise time. @@ -235,13 +233,11 @@ func RiseTime(date time.Time, elements Elements, observerLon, observerLat, obser if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(-12 * time.Hour) - } + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) timezone := observationTimezone(date) - jde := basic.Date2JDE(date) - calcJde, err := basic.OrbitRiseTime(jde, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements)) - return orbitRiseSetResult(date, calcJde, err) + localJD := basic.Date2JD(date) + calcJD, err := basic.OrbitRiseTime(localJD, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements)) + return orbitRiseSetResult(date, calcJD, err) } // SetTime 落下时刻 / set time. @@ -253,16 +249,14 @@ func SetTime(date time.Time, elements Elements, observerLon, observerLat, observ if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(-12 * time.Hour) - } + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) timezone := observationTimezone(date) - jde := basic.Date2JDE(date) - calcJde, err := basic.OrbitSetTime(jde, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements)) - return orbitRiseSetResult(date, calcJde, err) + localJD := basic.Date2JD(date) + calcJD, err := basic.OrbitSetTime(localJD, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements)) + return orbitRiseSetResult(date, calcJD, err) } -func orbitRiseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func orbitRiseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -273,7 +267,8 @@ func orbitRiseSetResult(date time.Time, jde float64, err error) (time.Time, erro return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } func observationTimezone(date time.Time) float64 { @@ -282,8 +277,8 @@ func observationTimezone(date time.Time) float64 { } func ttJulianDay(date time.Time) float64 { - jdeUTC := basic.Date2JDE(date.UTC()) - return basic.TD2UT(jdeUTC, true) + jdUTC := basic.Date2JD(date.UTC()) + return basic.UTC2TT(jdUTC) } func toBasicElements(elements Elements) basic.OrbitElements { diff --git a/orbit/orbit_test.go b/orbit/orbit_test.go index e861914..71a6b4a 100644 --- a/orbit/orbit_test.go +++ b/orbit/orbit_test.go @@ -90,7 +90,7 @@ func TestGeometricOrbitMatchesJPLBaseline(t *testing.T) { basicElements := toBasicElements(elements) for _, sample := range object.Samples { - date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false) + date := basic.JD2DateByZone(basic.TT2UTC(sample.JDTT), time.UTC, false) helVector := basic.OrbitHeliocentricXYZJ2000(sample.JDTT, basicElements) helVectorDiff := vectorDiffAU(helVector, sample.Heliocentric.Vector) @@ -213,7 +213,7 @@ func runObservationBaseline( for _, object := range objects { elements := elementsFromBaseline(object) for _, sample := range object.Samples { - date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false) + date := basic.JD2DateByZone(basic.TT2UTC(sample.JDTT), time.UTC, false) got := gotFn(date, elements) want := wantFn(sample) raDiff := angleDiffAbs(got.RA, want.RA) @@ -366,10 +366,10 @@ func TestObservationHelpersMatchTopocentricCoordinates(t *testing.T) { } } - jde := basic.Date2JDE(date) + jde := basic.Date2JD(date) _, offsetSeconds := date.Zone() timezone := float64(offsetSeconds) / 3600.0 - siderealLongitude := normalize360(basic.ApparentSiderealTime(jde-timezone/24.0)*15 + shanghaiLon) + siderealLongitude := normalize360(basic.ApparentSiderealTime(basic.UTC2UT1(jde-timezone/24.0))*15 + shanghaiLon) wantHourAngle := normalize360(siderealLongitude - topocentric.RA) if angleDiffAbs(hourAngle, wantHourAngle) > 1e-9 { t.Fatalf("hour angle mismatch: got %.12f want %.12f", hourAngle, wantHourAngle) diff --git a/orbit/parallactic.go b/orbit/parallactic.go index 5182aae..59cee24 100644 --- a/orbit/parallactic.go +++ b/orbit/parallactic.go @@ -14,7 +14,7 @@ func ParallacticAngle(date time.Time, elements Elements, observerLon, observerLa // 时角与赤纬须取自同一次站心求解:分属两条儒略日路径(相差 1 ULP ≈ 40 µs)会引入 ≤2e-9 度漂移, // 因此统一到同一时刻后的亚纳度量级输出变化是有意为之,不是纯性能改动。 _, dec, hourAngle := basic.OrbitHourAngleWithTopocentric( - basic.Date2JDE(date), + basic.Date2JD(date), observerLon, observerLat, observationTimezone(date), diff --git a/orbit/parallactic_test.go b/orbit/parallactic_test.go index c9a77d8..7d84d01 100644 --- a/orbit/parallactic_test.go +++ b/orbit/parallactic_test.go @@ -15,7 +15,7 @@ func TestParallacticAngleMatchesHourAngleForm(t *testing.T) { date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)) _, dec, _ := basic.OrbitHourAngleWithTopocentric( - basic.Date2JDE(date), + basic.Date2JD(date), shanghaiLon, shanghaiLat, observationTimezone(date), shanghaiHeightMeters, toBasicElements(elements), ) @@ -41,8 +41,8 @@ func duplicatedSolveParallacticAngle(date time.Time, elements Elements, observer } func parallacticJDPathsAgree(date time.Time) bool { - utcPath := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - hourAnglePath := basic.TD2UT(basic.Date2JDE(date)-observationTimezone(date)/24.0, true) + utcPath := basic.UTC2TT(basic.Date2JD(date.UTC())) + hourAnglePath := basic.UTC2TT(basic.Date2JD(date) - observationTimezone(date)/24.0) return utcPath == hourAnglePath } @@ -137,9 +137,13 @@ func parallacticRandomCases(count int) []parallacticCase { return cases } +// 固定民用时标换算政策,避免比较样本随默认值变化。 func TestParallacticAngleDifferentialAgainstDuplicatedSolve(t *testing.T) { + previous := basic.GetTimeScaleFuturePolicy() + basic.SetTimeScaleFuturePolicy(basic.TimeScaleLeapSecond) + t.Cleanup(func() { basic.SetTimeScaleFuturePolicy(previous) }) const ( - maxAbsoluteTolerance = 2e-9 + maxAbsoluteTolerance = 3e-9 maxRelativeTolerance = 5e-11 ) cases := append(parallacticFixedCases(), parallacticRandomCases(240)...) diff --git a/planet/doc.go b/planet/doc.go index 066181a..1f7e6a3 100644 --- a/planet/doc.go +++ b/planet/doc.go @@ -1,3 +1,3 @@ -// Package planet 用内置 VSOP87 截断级数求行星黄经、黄纬与日心距离;jd 为 TT 儒略日,角度单位为度,距离单位为 AU。 -// Package planet evaluates the built-in truncated VSOP87 series for planetary longitude, latitude and heliocentric distance; jd is a TT Julian day, angles are degrees and distances are AU. +// Package planet 用内置 VSOP87 截断级数求行星黄经、黄纬与日心距离;jde 为 TT 儒略日,角度单位为度,距离单位为 AU。 +// Package planet evaluates the built-in truncated VSOP87 series for planetary longitude, latitude and heliocentric distance; jde is a TT Julian day, angles are degrees and distances are AU. package planet diff --git a/planet/moon_low.go b/planet/moon_low.go index 66340a1..a8d2aad 100644 --- a/planet/moon_low.go +++ b/planet/moon_low.go @@ -201,26 +201,26 @@ var lowMoonBTerms = []lowMoonTerm{ {d: 2, m: -2, mp: 0, f: 1, amp: 107}, } -func MoonLo(JD float64) float64 { //'月球平黄经 - T := (JD - 2451545) / 36525 +func MoonLo(JDE float64) float64 { //'月球平黄经 + T := (JDE - 2451545) / 36525 MoonLo := 218.3164591 + 481267.88134236*T - 0.0013268*T*T + T*T*T/538841 - T*T*T*T/65194000 return MoonLo } -func SunMoonAngle(JD float64) float64 { // '月日距角 - T := (JD - 2451545) / 36525 +func SunMoonAngle(JDE float64) float64 { // '月日距角 + T := (JDE - 2451545) / 36525 SunMoonAngle := 297.8502042 + 445267.1115168*T - 0.00163*T*T + T*T*T/545868 - T*T*T*T/113065000 return SunMoonAngle } -func MoonM(JD float64) float64 { // '月平近点角 - T := (JD - 2451545) / 36525 +func MoonM(JDE float64) float64 { // '月平近点角 + T := (JDE - 2451545) / 36525 MoonM := 134.9634114 + 477198.8676313*T + 0.008997*T*T + T*T*T/69699 - T*T*T*T/14712000 return MoonM } -func MoonLonX(JD float64) float64 { // As Double '月球经度参数(到升交点的平角距离) - T := (JD - 2451545) / 36525 +func MoonLonX(JDE float64) float64 { // As Double '月球经度参数(到升交点的平角距离) + T := (JDE - 2451545) / 36525 MoonLonX := 93.2720993 + 483202.0175273*T - 0.0034029*T*T - T*T*T/3526000 + T*T*T*T/863310000 return MoonLonX } @@ -237,12 +237,12 @@ func lowMoonTermValue(term lowMoonTerm, D, IsunM, IMoonM, F, E float64, trig fun } } -func MoonI(JD float64) float64 { - T := (JD - 2451545) / 36525 - D := Limit360(SunMoonAngle(JD)) - IsunM := SunM(JD) - IMoonM := MoonM(JD) - F := Limit360(MoonLonX(JD)) +func MoonI(JDE float64) float64 { + T := (JDE - 2451545) / 36525 + D := Limit360(SunMoonAngle(JDE)) + IsunM := SunM(JDE) + IMoonM := MoonM(JDE) + F := Limit360(MoonLonX(JDE)) E := 1 - 0.002516*T - 0.0000074*T*T A1 := 119.75 + 131.849*T A2 := Limit360(53.09 + 479264.29*T) @@ -250,16 +250,16 @@ func MoonI(JD float64) float64 { for _, term := range lowMoonITerms { MoonI += lowMoonTermValue(term, D, IsunM, IMoonM, F, E, Sin) } - MoonI = MoonI + 3958*Sin(A1) + 1962*Sin(MoonLo(JD)-F) + 318*Sin(A2) + MoonI = MoonI + 3958*Sin(A1) + 1962*Sin(MoonLo(JDE)-F) + 318*Sin(A2) return FR(MoonI) } -func MoonR(JD float64) float64 { - T := (JD - 2451545) / 36525 - D := SunMoonAngle(JD) - IsunM := SunM(JD) - IMoonM := Limit360(MoonM(JD)) - F := Limit360(MoonLonX(JD)) +func MoonR(JDE float64) float64 { + T := (JDE - 2451545) / 36525 + D := SunMoonAngle(JDE) + IsunM := SunM(JDE) + IMoonM := Limit360(MoonM(JDE)) + F := Limit360(MoonLonX(JDE)) E := 1 - 0.002516*T - 0.0000074*T*T var MoonR float64 for _, term := range lowMoonRTerms { @@ -268,12 +268,12 @@ func MoonR(JD float64) float64 { return MoonR } -func MoonB(JD float64) float64 { - T := (JD - 2451545) / 36525 - D := Limit360(SunMoonAngle(JD)) - IsunM := Limit360(SunM(JD)) - IMoonM := Limit360(MoonM(JD)) - F := Limit360(MoonLonX(JD)) +func MoonB(JDE float64) float64 { + T := (JDE - 2451545) / 36525 + D := Limit360(SunMoonAngle(JDE)) + IsunM := Limit360(SunM(JDE)) + IMoonM := Limit360(MoonM(JDE)) + F := Limit360(MoonLonX(JDE)) E := 1 - 0.002516*T - 0.0000074*T*T A1 := Limit360(119.75 + 131.849*T) A3 := Limit360(313.45 + 481266.484*T) @@ -281,19 +281,19 @@ func MoonB(JD float64) float64 { for _, term := range lowMoonBTerms { MoonB += lowMoonTermValue(term, D, IsunM, IMoonM, F, E, Sin) } - MoonB += -2235*Sin(MoonLo(JD)) + 382*Sin(A3) + 175*Sin(A1-F) + 175*Sin(A1+F) + 127*Sin(MoonLo(JD)-IMoonM) - 115*Sin(MoonLo(JD)+IMoonM) + MoonB += -2235*Sin(MoonLo(JDE)) + 382*Sin(A3) + 175*Sin(A1-F) + 175*Sin(A1+F) + 127*Sin(MoonLo(JDE)-IMoonM) - 115*Sin(MoonLo(JDE)+IMoonM) return MoonB } -func MoonTrueLo(JD float64) float64 { - return Limit360(MoonLo(JD) + (MoonI(JD) / 1000000)) +func MoonTrueLo(JDE float64) float64 { + return Limit360(MoonLo(JDE) + (MoonI(JDE) / 1000000)) } -func MoonTrueBo(JD float64) float64 { - return MoonB(JD) / 1000000 +func MoonTrueBo(JDE float64) float64 { + return MoonB(JDE) / 1000000 } -func MoonAway(JD float64) float64 { //'月地距离 - MoonAway := 385000.56 + MoonR(JD)/1000 +func MoonAway(JDE float64) float64 { //'月地距离 + MoonAway := 385000.56 + MoonR(JDE)/1000 return MoonAway } diff --git a/planet/planet.go b/planet/planet.go index b8385a4..dcc5729 100644 --- a/planet/planet.go +++ b/planet/planet.go @@ -5,21 +5,21 @@ import ( "math" ) -// WherePlanet 天体 xt 在儒略日 jd 的 VSOP 结果 / VSOP result for body xt at Julian day jd. +// WherePlanet 天体 xt 在儒略日 jde 的 VSOP 结果 / VSOP result for body xt at Julian day jde. // // xt 取 -1 或 0..7:0 为地球,1..7 依次为水星、金星、火星、木星、土星、天王星、海王星;-1 表示地球,zn 取 0 时给日心黄经。 // zn 取 0 黄经、1 黄纬、2 日心距(AU);xt 或 zn 越界返回 NaN 而不 panic。 // xt is -1 or 0..7 (0 Earth, 1..7 Mercury through Neptune; -1 selects Earth, giving its heliocentric longitude for zn 0). // zn is 0 longitude, 1 latitude, 2 heliocentric distance in AU; an out-of-range xt or zn yields NaN instead of panicking. -func WherePlanet(xt, zn int, jd float64) float64 { - return WherePlanetN(xt, zn, jd, -1) +func WherePlanet(xt, zn int, jde float64) float64 { + return WherePlanetN(xt, zn, jde, -1) } // WherePlanetN 同 WherePlanet 的截断版 / truncated form of WherePlanet. // // n < 0 时使用全部项;否则保留约 n 个主项并按比例缩短高阶项。取值域与越界行为同 WherePlanet。 // When n < 0 all terms are used; otherwise roughly n principal terms are kept and higher orders scaled proportionally. Domain and out-of-range behavior match WherePlanet. -func WherePlanetN(xt, zn int, jd float64, n int) float64 { +func WherePlanetN(xt, zn int, jde float64, n int) float64 { if xt < -1 || xt > 7 || zn < 0 || zn > 2 { return math.NaN() } @@ -31,7 +31,7 @@ func WherePlanetN(xt, zn int, jd float64, n int) float64 { } rad := 180.0000 * 3600.0000 / math.Pi - t := (jd - 2451545) / 36525.0000 + t := (jde - 2451545) / 36525.0000 t /= 10 // 转为儒略千年数 body := planetViews()[xt] diff --git a/planet/sun_low.go b/planet/sun_low.go index 7eef2d8..83c5b5d 100644 --- a/planet/sun_low.go +++ b/planet/sun_low.go @@ -3,51 +3,51 @@ package planet import . "b612.me/astro/tools" // SunLo 太阳几何黄经 -func SunLo(jd float64) float64 { - T := (jd - 2451545) / 365250 +func SunLo(jde float64) float64 { + T := (jde - 2451545) / 365250 SunLo := 280.4664567 + 360007.6982779*T + 0.03032028*T*T + T*T*T/49931 - T*T*T*T/15299 - T*T*T*T*T/1988000 return Limit360(SunLo) } -func SunM(JD float64) float64 { - T := (JD - 2451545) / 36525 +func SunM(JDE float64) float64 { + T := (JDE - 2451545) / 36525 sunM := 357.5291092 + 35999.0502909*T - 0.0001559*T*T - 0.00000048*T*T*T return Limit360(sunM) } // Earthe 地球偏心率 -func Earthe(JD float64) float64 { - T := (JD - 2451545) / 36525 +func Earthe(JDE float64) float64 { + T := (JDE - 2451545) / 36525 Earthe := 0.016708617 - 0.000042037*T - 0.0000001236*T*T return Earthe } -func EarthPI(JD float64) float64 { - T := (JD - 2451545) / 36525 +func EarthPI(JDE float64) float64 { + T := (JDE - 2451545) / 36525 return 102.93735 + 1.71953*T + 0.00046*T*T } -func SunMidFun(JD float64) float64 { - T := (JD - 2451545) / 36525 - M := SunM(JD) +func SunMidFun(JDE float64) float64 { + T := (JDE - 2451545) / 36525 + M := SunM(JDE) SunMidFun := (1.9146-0.004817*T-0.000014*T*T)*Sin(M) + (0.019993-0.000101*T)*Sin(2*M) + 0.00029*Sin(3*M) return SunMidFun } -func SunTrueLo(JD float64) float64 { - SunTrueLo := SunLo(JD) + SunMidFun(JD) +func SunTrueLo(JDE float64) float64 { + SunTrueLo := SunLo(JDE) + SunMidFun(JDE) return SunTrueLo } -func SunApparentLo(JD float64) float64 { - T := (JD - 2451545) / 36525 - SunApparentLo := SunTrueLo(JD) - 0.00569 - 0.00478*Sin(125.04-1934.136*T) +func SunApparentLo(JDE float64) float64 { + T := (JDE - 2451545) / 36525 + SunApparentLo := SunTrueLo(JDE) - 0.00569 - 0.00478*Sin(125.04-1934.136*T) return SunApparentLo } -func Distance(jd float64) float64 { - f := SunMidFun(jd) - m := SunM(jd) - e := Earthe(jd) +func Distance(jde float64) float64 { + f := SunMidFun(jde) + m := SunM(jde) + e := Earthe(jde) return 1.000001018 * (1 - e*e) / (1 + e*Cos(f+m)) } diff --git a/saturn/diameter.go b/saturn/diameter.go index 6d2f3d5..ed02c46 100644 --- a/saturn/diameter.go +++ b/saturn/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 土星视半径(截断版),单位角秒 / truncated apparent Saturn semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 土星视直径,单位角秒 / apparent Saturn diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 土星视直径(截断版),单位角秒 / truncated apparent Saturn diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnDiameterN(basic.UTC2TT(jd), n) } diff --git a/saturn/nodes.go b/saturn/nodes.go index ff08fd8..5c09c8d 100644 --- a/saturn/nodes.go +++ b/saturn/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 土星升交点黄经(截断版) / truncated ascending node longitude of Saturn. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 土星降交点黄经 / descending node longitude of Saturn. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 土星降交点黄经(截断版) / truncated descending node longitude of Saturn. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/saturn/phase.go b/saturn/phase.go index 1329ad7..bd1e5ef 100644 --- a/saturn/phase.go +++ b/saturn/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/saturn/physical.go b/saturn/physical.go index 947403c..ed95d69 100644 --- a/saturn/physical.go +++ b/saturn/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 土星物理观测参数(截断版) / truncated physical observing parameters of Saturn. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.SaturnPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.SaturnPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/saturn/physical_test.go b/saturn/physical_test.go index 9e10c61..f14b220 100644 --- a/saturn/physical_test.go +++ b/saturn/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.SaturnPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.SaturnPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/saturn/ring.go b/saturn/ring.go index ae4a505..5f69137 100644 --- a/saturn/ring.go +++ b/saturn/ring.go @@ -30,8 +30,8 @@ func Ring(date time.Time) RingInfo { // RingN 土星环观测参数(截断版) / truncated Saturn ring observing parameters. func RingN(date time.Time, n int) RingInfo { - jde := calendar.Date2JDE(date.UTC()) - earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis := basic.SaturnRingParametersN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + earthLatitude, sunLatitude, positionAngle, deltaU, majorAxis, minorAxis := basic.SaturnRingParametersN(basic.UTC2TT(jd), n) return RingInfo{ EarthLatitude: earthLatitude, SunLatitude: sunLatitude, diff --git a/saturn/saturn.go b/saturn/saturn.go index 584f0fd..dcee9d5 100644 --- a/saturn/saturn.go +++ b/saturn/saturn.go @@ -15,7 +15,7 @@ var ( ERR_SATURN_NEVER_DOWN = ERR_SATURN_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回土星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Saturn at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回土星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Saturn at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回土星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Saturn at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回土星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Saturn at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回土星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Saturn at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回土星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Saturn at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回土星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Saturn to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthSaturnAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthSaturnAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回土星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Saturn to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(5, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(5, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnHeight(jde, lon, lat, timezone) + return basic.SaturnHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnAzimuth(jde, lon, lat, timezone) + return basic.SaturnAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnHourAngle(jde, lon, timezone) + return basic.SaturnHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.SaturnCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.SaturnCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.SaturnRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.SaturnRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.SaturnSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.SaturnSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnConjunction(jde), date.Location(), false) } // LastOpposition 上一次冲日 / previous opposition. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or before date, keeping date's time zone. func LastOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnOpposition(jde), date.Location(), false) } // NextOpposition 下一次冲日 / next opposition. @@ -237,8 +232,8 @@ func LastOpposition(date time.Time) time.Time { // 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or after date, keeping date's time zone. func NextOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnOpposition(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -246,8 +241,8 @@ func NextOpposition(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnProgradeToRetrograde(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -255,8 +250,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnProgradeToRetrograde(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -264,8 +259,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnRetrogradeToPrograde(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -273,8 +268,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnRetrogradeToPrograde(jde), date.Location(), false) } // LastEasternQuadrature 上一次东方照 / previous eastern quadrature. @@ -282,8 +277,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or before date, keeping date's time zone. func LastEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnEasternQuadrature(jde), date.Location(), false) } // NextEasternQuadrature 下一次东方照 / next eastern quadrature. @@ -291,8 +286,8 @@ func LastEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or after date, keeping date's time zone. func NextEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnEasternQuadrature(jde), date.Location(), false) } // LastWesternQuadrature 上一次西方照 / previous western quadrature. @@ -300,8 +295,8 @@ func NextEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or before date, keeping date's time zone. func LastWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastSaturnWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastSaturnWesternQuadrature(jde), date.Location(), false) } // NextWesternQuadrature 下一次西方照 / next western quadrature. @@ -309,6 +304,6 @@ func LastWesternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or after date, keeping date's time zone. func NextWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextSaturnWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextSaturnWesternQuadrature(jde), date.Location(), false) } diff --git a/saturn/truncated.go b/saturn/truncated.go index da9cb4d..0808ce7 100644 --- a/saturn/truncated.go +++ b/saturn/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.SaturnMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.SaturnMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthSaturnAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthSaturnAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(5, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(5, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnHeightN(jde, lon, lat, timezone, n) + return basic.SaturnHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnAzimuthN(jde, lon, lat, timezone, n) + return basic.SaturnAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SaturnHourAngleN(jde, lon, timezone, n) + return basic.SaturnHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.SaturnCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.SaturnCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.SaturnRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.SaturnRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.SaturnSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.SaturnSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/semantics_regression_test.go b/semantics_regression_test.go index 2a3b667..20127c0 100644 --- a/semantics_regression_test.go +++ b/semantics_regression_test.go @@ -111,17 +111,17 @@ func TestPlanetAbsoluteQuantitiesIgnoreInputTimezone(t *testing.T) { } } -func TestJDECalcRejectsGregorianGap(t *testing.T) { +func TestJDCalcRejectsGregorianGap(t *testing.T) { cases := []float64{5, 6.5, 10, 14.25} for _, day := range cases { - got := basic.JDECalc(1582, 10, day) + got := basic.JDCalc(1582, 10, day) if !math.IsNaN(got) { t.Fatalf("1582-10-%v should be rejected, got %.15f", day, got) } } - before := basic.JDECalc(1582, 10, 4) - after := basic.JDECalc(1582, 10, 15) + before := basic.JDCalc(1582, 10, 4) + after := basic.JDCalc(1582, 10, 15) if math.IsNaN(before) || math.IsNaN(after) { t.Fatal("boundary dates around Gregorian reform should remain valid") } @@ -186,10 +186,10 @@ func TestObservationZenithMatchesIndependentFormula(t *testing.T) { for _, date := range dates { for _, place := range places { - jde := basic.Date2JDE(date) + jde := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - tt := basic.TD2UT(jde-timezone/24, true) + tt := basic.UTC2TT(jde - timezone/24) checks := []struct { name string diff --git a/star/parallactic.go b/star/parallactic.go index df55359..8b4b6a4 100644 --- a/star/parallactic.go +++ b/star/parallactic.go @@ -13,8 +13,8 @@ import ( // ra/dec are apparent equatorial coordinates in degrees; lon/lat are east-positive and north-positive. // Returns the signed parallactic angle in degrees. func ParallacticAngle(date time.Time, ra, dec, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.StarParallacticAngle(jde, ra, dec, lon, lat, timezone) + return basic.StarParallacticAngle(localJD, ra, dec, lon, lat, timezone) } diff --git a/star/star.go b/star/star.go index eb176ed..19f1d90 100644 --- a/star/star.go +++ b/star/star.go @@ -2,7 +2,6 @@ package star import ( "errors" - "math" "time" "b612.me/astro/basic" @@ -15,7 +14,7 @@ var ( ERR_STAR_NEVER_DOWN = ERR_STAR_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +25,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // Constellation 星座中文名 / Chinese constellation name. @@ -34,8 +34,9 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // ra/dec 为给定时刻的赤经赤纬,单位度;date 作为所属历元使用。 // ra/dec are equatorial coordinates in degrees and date provides the epoch used by the constellation boundaries. func Constellation(ra, dec float64, date time.Time) string { - jde := basic.Date2JDE(date.UTC()) - return basic.ConstellationNameZH(ra, dec, jde) + // 星座边界按 TT 历元做岁差定位,这里传 UTC 民用 JD:69 s 的历元差在 1e-4″ 量级。 + jdUTC := basic.Date2JD(date.UTC()) + return basic.ConstellationNameZH(ra, dec, jdUTC) } // ConstellationCode IAU 星座代码 / IAU constellation code. @@ -43,8 +44,8 @@ func Constellation(ra, dec float64, date time.Time) string { // ra/dec 为给定时刻的赤经赤纬,单位度;date 作为所属历元使用。 // ra/dec are equatorial coordinates in degrees and date provides the epoch used by the constellation boundaries. func ConstellationCode(ra, dec float64, date time.Time) string { - jde := basic.Date2JDE(date.UTC()) - return basic.ConstellationCode(ra, dec, jde) + jdUTC := basic.Date2JD(date.UTC()) + return basic.ConstellationCode(ra, dec, jdUTC) } // ConstellationEN 星座英文名 / English constellation name. @@ -52,8 +53,8 @@ func ConstellationCode(ra, dec float64, date time.Time) string { // ra/dec 为给定时刻的赤经赤纬,单位度;date 作为所属历元使用。 // ra/dec are equatorial coordinates in degrees and date provides the epoch used by the constellation boundaries. func ConstellationEN(ra, dec float64, date time.Time) string { - jde := basic.Date2JDE(date.UTC()) - return basic.ConstellationNameEN(ra, dec, jde) + jdUTC := basic.Date2JD(date.UTC()) + return basic.ConstellationNameEN(ra, dec, jdUTC) } // MeanSiderealTime 平恒星时 / mean sidereal time. @@ -61,7 +62,7 @@ func ConstellationEN(ra, dec float64, date time.Time) string { // 返回 date 对应绝对时刻的格林尼治平恒星时,单位小时。 // Returns Greenwich mean sidereal time at the instant represented by date, in hours. func MeanSiderealTime(date time.Time) float64 { - return basic.MeanSiderealTime(basic.Date2JDE(date.UTC())) + return basic.MeanSiderealTime(basic.UTC2UT1(basic.Date2JD(date.UTC()))) } // ApparentSiderealTime 真恒星时 / apparent sidereal time. @@ -69,24 +70,22 @@ func MeanSiderealTime(date time.Time) float64 { // 返回 date 对应绝对时刻的格林尼治真恒星时,单位小时。 // Returns Greenwich apparent sidereal time at the instant represented by date, in hours. func ApparentSiderealTime(date time.Time) float64 { - return basic.ApparentSiderealTime(basic.Date2JDE(date.UTC())) + return basic.ApparentSiderealTime(basic.UTC2UT1(basic.Date2JD(date.UTC()))) } // RiseTime 恒星升起时刻 / stellar rise time. // // date 取其所在时区的当地日期,返回值保持相同时区;ra/dec 为该日期附近使用的瞬时赤经赤纬,单位度; -// lon/lat 为观测者经纬度,东正西负、北正南负;height 为海拔高度,单位米;aero 为 true 时加入标准大气折射。 +// lon/lat 为观测者经纬度,东正西负、北正南负;height 为椭球高(大地高),单位米;aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. ra/dec are apparent coordinates in degrees; // lon/lat are east-positive and north-positive, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, ra, dec, lon, lat, height float64, aero bool) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.StarRiseTime(jde, ra, dec, lon, lat, height, timezone, aero) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.StarRiseTime(localJD, ra, dec, lon, lat, height, timezone, aero) + return riseSetResult(date, riseJD, err) } // DownTime 恒星落下时刻别名 / deprecated stellar set-time alias. @@ -104,14 +103,12 @@ func DownTime(date time.Time, ra, dec, lon, lat, height float64, aero bool) (tim // 参数与 RiseTime 相同,返回给定当地日期内的落下时刻。 // Uses the same inputs as RiseTime and returns the set time on the corresponding local civil day. func SetTime(date time.Time, ra, dec, lon, lat, height float64, aero bool) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.StarSetTime(jde, ra, dec, lon, lat, height, timezone, aero) - return riseSetResult(date, riseJde, err) + downJD, err := basic.StarSetTime(localJD, ra, dec, lon, lat, height, timezone, aero) + return riseSetResult(date, downJD, err) } // HourAngle 恒星时角 / hour angle. @@ -119,10 +116,10 @@ func SetTime(date time.Time, ra, dec, lon, lat, height float64, aero bool) (time // ra 为瞬时赤经,单位度;lon 为观测者经度,东正西负;date 为观测时刻,会读取其时区参与地方时计算。 // ra is the apparent right ascension in degrees; lon is east-positive longitude; date is the observing instant and its zone offset participates in local-time calculations. func HourAngle(date time.Time, ra, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.StarHourAngle(jde, ra, lon, timezone) + return basic.StarHourAngle(localJD, ra, lon, timezone) } // Azimuth 恒星方位角 / azimuth. @@ -130,10 +127,10 @@ func HourAngle(date time.Time, ra, lon float64) float64 { // ra/dec 为瞬时赤经赤纬,单位度;lon/lat 为观测者经纬度,东正西负、北正南负;返回值按正北为 0°、向东增加。 // ra/dec are apparent equatorial coordinates in degrees; lon/lat are east-positive and north-positive; azimuth is measured from north toward east. func Azimuth(date time.Time, ra, dec, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.StarAzimuth(jde, ra, dec, lon, lat, timezone) + return basic.StarAzimuth(localJD, ra, dec, lon, lat, timezone) } // Altitude 恒星高度角 / stellar altitude. @@ -141,10 +138,10 @@ func Azimuth(date time.Time, ra, dec, lon, lat float64) float64 { // ra/dec 为瞬时赤经赤纬,单位度;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。 // ra/dec are apparent equatorial coordinates in degrees; lon/lat are east-positive and north-positive; the result is in degrees. func Altitude(date time.Time, ra, dec, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.StarHeight(jde, ra, dec, lon, lat, timezone) + return basic.StarHeight(localJD, ra, dec, lon, lat, timezone) } // Zenith 恒星天顶距 / stellar zenith distance. @@ -160,14 +157,12 @@ func Zenith(date time.Time, ra, dec, lon, lat float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;ra 为瞬时赤经,单位度;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. ra is the apparent right ascension in degrees and lon is east-positive longitude. func CulminationTime(date time.Time, ra, lon float64) time.Time { - jde := basic.Date2JDE(date) - if jde-math.Floor(jde) < 0.5 { - jde-- - } + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.StarCulminationTime(jde, ra, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.StarCulminationTime(localJD, ra, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // InitStarDatabase 初始化恒星数据库 / initializes the embedded star catalog. diff --git a/sun/diameter.go b/sun/diameter.go index d86e50a..d180de5 100644 --- a/sun/diameter.go +++ b/sun/diameter.go @@ -13,8 +13,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 太阳视半径(截断版),单位角秒 / truncated apparent solar semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.SunSemidiameterN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.SunSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 太阳视直径,单位角秒 / apparent solar diameter in arcseconds. @@ -24,6 +24,6 @@ func Diameter(date time.Time) float64 { // DiameterN 太阳视直径(截断版),单位角秒 / truncated apparent solar diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.SunDiameterN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.SunDiameterN(basic.UTC2TT(jd), n) } diff --git a/sun/parallactic.go b/sun/parallactic.go index 22b31f9..1b27665 100644 --- a/sun/parallactic.go +++ b/sun/parallactic.go @@ -15,10 +15,10 @@ func ParallacticAngle(date time.Time, lon, lat float64) float64 { // ParallacticAngleN 截断项太阳视差角(天顶方向角) / truncated solar parallactic angle. func ParallacticAngleN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) return basic.ParallacticAngleByHourAngle( HourAngleN(date, lon, lat, n), - basic.HSunApparentDecN(basic.TD2UT(jde, true), n), + basic.HSunApparentDecN(basic.UTC2TT(jd), n), lat, ) } diff --git a/sun/physical.go b/sun/physical.go index 094eb44..03ab370 100644 --- a/sun/physical.go +++ b/sun/physical.go @@ -23,8 +23,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 太阳物理观测参数(截断版) / truncated physical observing parameters of the Sun. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.SunPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.SunPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ P: info.P, B0: info.B0, diff --git a/sun/physical_test.go b/sun/physical_test.go index 5d37610..aa0f8da 100644 --- a/sun/physical_test.go +++ b/sun/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.SunPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.SunPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "P", got.P, want.P) assertSamePhysicalFloat(t, "B0", got.B0, want.B0) diff --git a/sun/sun.go b/sun/sun.go index a1b2525..8ab452a 100644 --- a/sun/sun.go +++ b/sun/sun.go @@ -1,6 +1,7 @@ package sun import ( + "b612.me/astro/internal/civiltime" "errors" "math" "time" @@ -16,7 +17,7 @@ var ( ERR_TWILIGHT_NOT_EXISTS = errors.New("ERROR:今日晨昏朦影不存在!") ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -27,14 +28,16 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } -func twilightResult(date time.Time, jde float64, err error) (time.Time, error) { +func twilightResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { return time.Time{}, ERR_TWILIGHT_NOT_EXISTS } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } /* @@ -49,25 +52,28 @@ func twilightResult(date time.Time, jde float64, err error) (time.Time, error) { // RiseTime 日出时刻 / sunrise time. // // date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负; -// height 为海拔高度,单位米;aero 为 true 时按动态标准大气折射和实时太阳视半径计算上缘过地平线。 +// height 为椭球高(大地高),单位米;aero 为 true 时按动态标准大气折射和实时太阳视半径计算上缘过地平线。 // date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive; -// height 为观测者海拔,单位米;aero 使用动态标准折射和实时太阳视半径计算上缘过地平线。 +// height 为观测者椭球高(大地高),单位米;aero 使用动态标准折射和实时太阳视半径计算上缘过地平线。 // height is observer elevation in meters; aero uses dynamic standard refraction and the instantaneous solar semidiameter for an upper-limb crossing. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return RiseTime(d, lon, lat, height, aero) + }); handled { + return result, err + } var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) // 以 date 的当地日期为锚点,并读取其时区偏移参与地方时计算。 - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 // 返回值保持与输入 date 一致的时区。 - riseJde, err := basic.GetSunRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.GetSunRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // RiseTimeN 截断项日出时刻 / truncated sunrise time. @@ -75,18 +81,21 @@ func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, e // 参数与 RiseTime 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as RiseTime. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return RiseTimeN(d, lon, lat, height, aero, n) + }); handled { + return result, err + } var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.GetSunRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.GetSunRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTime 日落时刻别名 / deprecated sunset alias. @@ -114,18 +123,21 @@ func DownTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time // 参数与 RiseTime 相同,返回给定当地日期内的日落时刻。 // Uses the same inputs as RiseTime and returns the sunset time on the corresponding local civil day. func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return SetTime(d, lon, lat, height, aero) + }); handled { + return result, err + } var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - downJde, err := basic.GetSunSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, downJde, err) + downJD, err := basic.GetSunSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, downJD, err) } // SetTimeN 截断项日落时刻 / truncated sunset time. @@ -133,18 +145,21 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 参数与 RiseTimeN 相同,返回给定当地日期内的日落时刻。 // Uses the same inputs as RiseTimeN and returns the sunset time on the corresponding local civil day. func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) { + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return SetTimeN(d, lon, lat, height, aero, n) + }); handled { + return result, err + } var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - downJde, err := basic.GetSunSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, downJde, err) + downJD, err := basic.GetSunSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, downJD, err) } // MorningTwilight 晨光始时 / morning twilight. @@ -154,14 +169,17 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. // date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive; // angle is the target solar altitude in degrees, typically -6, -12, or -18 for civil, nautical, and astronomical twilight. func MorningTwilight(date time.Time, lon, lat, angle float64) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return MorningTwilight(d, lon, lat, angle) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde, err := basic.MorningTwilight(jde, lon, lat, timezone, angle) - return twilightResult(date, calcJde, err) + calcJD, err := basic.MorningTwilight(localJD, lon, lat, timezone, angle) + return twilightResult(date, calcJD, err) } // MorningTwilightN 截断项晨光始时 / truncated morning twilight. @@ -169,14 +187,17 @@ func MorningTwilight(date time.Time, lon, lat, angle float64) (time.Time, error) // 参数与 MorningTwilight 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as MorningTwilight. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func MorningTwilightN(date time.Time, lon, lat, angle float64, n int) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return MorningTwilightN(d, lon, lat, angle, n) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde, err := basic.MorningTwilightN(jde, lon, lat, timezone, angle, n) - return twilightResult(date, calcJde, err) + calcJD, err := basic.MorningTwilightN(localJD, lon, lat, timezone, angle, n) + return twilightResult(date, calcJD, err) } // EveningTwilight 暮光终时 / evening twilight. @@ -184,15 +205,18 @@ func MorningTwilightN(date time.Time, lon, lat, angle float64, n int) (time.Time // 参数与 MorningTwilight 相同,返回对应当地日期的暮光结束时刻。 // Uses the same inputs as MorningTwilight and returns the evening-twilight time on the corresponding local civil day. func EveningTwilight(date time.Time, lon, lat, angle float64) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return EveningTwilight(d, lon, lat, angle) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 //不需要进行力学时转换,会在GetBanTime中转换 - calcJde, err := basic.EveningTwilight(jde, lon, lat, timezone, angle) - return twilightResult(date, calcJde, err) + calcJD, err := basic.EveningTwilight(localJD, lon, lat, timezone, angle) + return twilightResult(date, calcJD, err) } // EveningTwilightN 截断项暮光终时 / truncated evening twilight. @@ -200,14 +224,17 @@ func EveningTwilight(date time.Time, lon, lat, angle float64) (time.Time, error) // 参数与 MorningTwilightN 相同,返回对应当地日期的暮光结束时刻。 // Uses the same inputs as MorningTwilightN and returns the evening-twilight time on the corresponding local civil day. func EveningTwilightN(date time.Time, lon, lat, angle float64, n int) (time.Time, error) { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) + if result, err, handled := civiltime.Event(date, basic.ErrNotOnThisDate, func(d time.Time) (time.Time, error) { + return EveningTwilightN(d, lon, lat, angle, n) + }); handled { + return result, err } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde, err := basic.EveningTwilightN(jde, lon, lat, timezone, angle, n) - return twilightResult(date, calcJde, err) + calcJD, err := basic.EveningTwilightN(localJD, lon, lat, timezone, angle, n) + return twilightResult(date, calcJD, err) } // EclipticObliquity 黄赤交角 / ecliptic obliquity. @@ -216,9 +243,9 @@ func EveningTwilightN(date time.Time, lon, lat, angle float64, n int) (time.Time // Returns the obliquity of the ecliptic at the instant represented by date, in degrees. When nutation is true, obliquity nutation is included. func EclipticObliquity(date time.Time, nutation bool) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) //进行力学时转换 - jde = basic.TD2UT(jde, true) + jde := basic.UTC2TT(jd) //黄赤交角计算 return basic.EclipticObliquity(jde, nutation) } @@ -229,9 +256,9 @@ func EclipticObliquity(date time.Time, nutation bool) float64 { // Returns nutation in longitude at the instant represented by date, in degrees. func EclipticNutation(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) //进行力学时转换与章动计算 - return basic.Nutation2000Bi(basic.TD2UT(jde, true)) + return basic.Nutation2000Bi(basic.UTC2TT(jd)) } // EclipticNutation1980 黄经章动(IAU 1980) / nutation in longitude, IAU 1980. @@ -240,9 +267,9 @@ func EclipticNutation(date time.Time) float64 { // Returns nutation in longitude at the instant represented by date, in degrees. func EclipticNutation1980(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) //进行力学时转换与章动计算 - return basic.Nutation1980i(basic.TD2UT(jde, true)) + return basic.Nutation1980i(basic.UTC2TT(jd)) } // AxialtiltNutation 交角章动(IAU 2000B) / nutation in obliquity, IAU 2000B. @@ -251,9 +278,9 @@ func EclipticNutation1980(date time.Time) float64 { // Returns nutation in obliquity at the instant represented by date, in degrees. func AxialtiltNutation(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) //进行力学时转换与章动计算 - return basic.Nutation2000Bs(basic.TD2UT(jde, true)) + return basic.Nutation2000Bs(basic.UTC2TT(jd)) } // AxialtiltNutation1980 交角章动(IAU 1980) / nutation in obliquity, IAU 1980. @@ -262,9 +289,9 @@ func AxialtiltNutation(date time.Time) float64 { // Returns nutation in obliquity at the instant represented by date, in degrees. func AxialtiltNutation1980(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) + jd := basic.Date2JD(date.UTC()) //进行力学时转换与章动计算 - return basic.Nutation1980s(basic.TD2UT(jde, true)) + return basic.Nutation1980s(basic.UTC2TT(jd)) } // GeometricLo 太阳几何黄经 / geometric ecliptic longitude. @@ -273,8 +300,8 @@ func AxialtiltNutation1980(date time.Time) float64 { // Returns the Sun's geometric ecliptic longitude at the instant represented by date, in degrees. func GeometricLo(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.SunLo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.SunLo(basic.UTC2TT(jd)) } // TrueLo 太阳真黄经 / true ecliptic longitude. @@ -283,8 +310,8 @@ func GeometricLo(date time.Time) float64 { // Returns the Sun's true ecliptic longitude at the instant represented by date, in degrees. func TrueLo(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunTrueLo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunTrueLo(basic.UTC2TT(jd)) } // TrueLoN 截断项太阳真黄经 / truncated true ecliptic longitude. @@ -292,8 +319,8 @@ func TrueLo(date time.Time) float64 { // 参数与 TrueLo 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as TrueLo. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func TrueLoN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HSunTrueLoN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.HSunTrueLoN(basic.UTC2TT(jd), n) } // TrueBo 太阳真黄纬 / true ecliptic latitude. @@ -302,8 +329,8 @@ func TrueLoN(date time.Time, n int) float64 { // Returns the Sun's true ecliptic latitude at the instant represented by date, in degrees. func TrueBo(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunTrueBo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunTrueBo(basic.UTC2TT(jd)) } // TrueBoN 截断项太阳真黄纬 / truncated true ecliptic latitude. @@ -311,8 +338,8 @@ func TrueBo(date time.Time) float64 { // 参数与 TrueBo 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as TrueBo. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func TrueBoN(date time.Time, n int) float64 { - jde := basic.Date2JDE(date.UTC()) - return basic.HSunTrueBoN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + return basic.HSunTrueBoN(basic.UTC2TT(jd), n) } // ApparentLo 太阳视黄经 / apparent ecliptic longitude. @@ -321,8 +348,8 @@ func TrueBoN(date time.Time, n int) float64 { // Returns the Sun's apparent ecliptic longitude at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunApparentLo(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunApparentLo(basic.UTC2TT(jd)) } // ApparentRa 太阳地心视赤经 / apparent geocentric right ascension. @@ -331,8 +358,8 @@ func ApparentLo(date time.Time) float64 { // Returns the Sun's apparent geocentric right ascension at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunApparentRa(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunApparentRa(basic.UTC2TT(jd)) } // ApparentDec 太阳地心视赤纬 / apparent geocentric declination. @@ -341,8 +368,8 @@ func ApparentRa(date time.Time) float64 { // Returns the Sun's apparent geocentric declination at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunApparentDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 太阳地心视赤经、视赤纬 / apparent geocentric right ascension and declination. @@ -351,8 +378,8 @@ func ApparentDec(date time.Time) float64 { // Returns the Sun's apparent geocentric right ascension and declination at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.HSunApparentRaDec(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.HSunApparentRaDec(basic.UTC2TT(jd)) } // MidFunc 太阳中心差 / solar equation of center. @@ -361,8 +388,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // Returns the Sun's equation of center at the instant represented by date, in degrees. func MidFunc(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.SunMidFun(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.SunMidFun(basic.UTC2TT(jd)) } // EquationTime 均时差 / equation of time. @@ -371,8 +398,8 @@ func MidFunc(date time.Time) float64 { // Returns the equation of time at the instant represented by date, in hours. func EquationTime(date time.Time) float64 { //转换为UTC时间 - jde := basic.Date2JDE(date.UTC()) - return basic.SunTime(basic.TD2UT(jde, true)) + jd := basic.Date2JD(date.UTC()) + return basic.SunTime(basic.UTC2TT(jd)) } // HourAngle 太阳时角 / hour angle. @@ -382,10 +409,10 @@ func EquationTime(date time.Time) float64 { // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the result is in degrees. // lat is currently unused and kept only for API symmetry with the other observation helpers. func HourAngle(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunTimeAngle(jde, lon, lat, timezone) + return basic.SunTimeAngle(localJD, lon, lat, timezone) } // HourAngleN 截断项太阳时角 / truncated hour angle. @@ -393,10 +420,10 @@ func HourAngle(date time.Time, lon, lat float64) float64 { // 参数与 HourAngle 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as HourAngle. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func HourAngleN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunTimeAngleN(jde, lon, lat, timezone, n) + return basic.SunTimeAngleN(localJD, lon, lat, timezone, n) } // Azimuth 太阳方位角 / azimuth. @@ -404,10 +431,10 @@ func HourAngleN(date time.Time, lon, lat float64, n int) float64 { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunAzimuth(jde, lon, lat, timezone) + return basic.SunAzimuth(localJD, lon, lat, timezone) } // AzimuthN 截断项太阳方位角 / truncated azimuth. @@ -415,10 +442,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // 参数与 Azimuth 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as Azimuth. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunAzimuthN(jde, lon, lat, timezone, n) + return basic.SunAzimuthN(localJD, lon, lat, timezone, n) } // Altitude 太阳高度角 / solar altitude. @@ -426,10 +453,10 @@ func AzimuthN(date time.Time, lon, lat float64, n int) float64 { // date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunHeight(jde, lon, lat, timezone) + return basic.SunHeight(localJD, lon, lat, timezone) } // Zenith 太阳天顶距 / solar zenith distance. @@ -445,10 +472,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // 参数与 Altitude 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as Altitude. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.SunHeightN(jde, lon, lat, timezone, n) + return basic.SunHeightN(localJD, lon, lat, timezone, n) } // ZenithN 截断项太阳天顶距 / truncated solar zenith distance. @@ -464,11 +491,12 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - jde := basic.Date2JDE(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5 + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) + 0.5 _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.CulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.CulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // CulminationTimeN 截断项太阳中天时刻 / truncated culmination time. @@ -476,11 +504,12 @@ func CulminationTime(date time.Time, lon float64) time.Time { // 参数与 CulminationTime 相同;n<0 使用当前仓库内嵌的全部 VSOP 项,其余值用于截断太阳位置级数。 // Uses the same inputs as CulminationTime. n<0 keeps all embedded VSOP terms in this repository; other values truncate the solar series. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - jde := basic.Date2JDE(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5 + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) + 0.5 _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.CulminationTimeN(jde, lon, timezone, n) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.CulminationTimeN(localJD, lon, timezone, n) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // EarthDistance 日地距离 / Earth-Sun distance. @@ -488,8 +517,8 @@ func CulminationTimeN(date time.Time, lon float64, n int) time.Time { // 返回 date 对应绝对时刻的日地距离,单位 AU。 // Returns the Earth-Sun distance at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := basic.Date2JDE(date.UTC()) - jde = basic.TD2UT(jde, true) + jd := basic.Date2JD(date.UTC()) + jde := basic.UTC2TT(jd) return basic.EarthAway(jde) } diff --git a/timescale.go b/timescale.go new file mode 100644 index 0000000..6585f26 --- /dev/null +++ b/timescale.go @@ -0,0 +1,87 @@ +package astro + +import ( + "time" + + "b612.me/astro/basic" +) + +// TimeScale 导出时间标签的时标,零值为民用 UTC / output-label time scale, defaulting to civil UTC. +type TimeScale int + +const ( + // TimeScaleUTC 默认民用时标 / default civil time scale. + TimeScaleUTC TimeScale = iota + // TimeScaleUT1 UT1 读数,输出 Location 须为 nil 或 UTC / UT1 reading; output Location must be nil or UTC. + TimeScaleUT1 +) + +// LabelIn 把民用时刻换成该时标下的时刻值 / converts a civil instant to a value in one scale. +func LabelIn(scale TimeScale, date time.Time) time.Time { + if scale == TimeScaleUT1 { + return UT1FromUTC(date) + } + return date +} + +// UT1FromUTC 民用时刻转 UT1 读数,以 UTC Location 承载 / converts a civil instant to a UT1 reading in a UTC Location. +func UT1FromUTC(date time.Time) time.Time { + return utcLabel(basic.UTC2UT1(basic.Date2JD(date.UTC()))) +} + +// UTCFromUT1 是 UT1FromUTC 的逆 / inverts UT1FromUTC. +func UTCFromUT1(ut1 time.Time) time.Time { + return utcLabel(basic.UT12UTC(basic.Date2JD(ut1.UTC()))) +} + +// TTFromUTC 民用时刻转 TT,按实例的 UTC 时刻取值 / converts a civil instant to TT. +func TTFromUTC(date time.Time) time.Time { + return utcLabel(basic.UTC2TT(basic.Date2JD(date.UTC()))) +} + +// UTCFromTT 是 TTFromUTC 的逆 / inverts TTFromUTC. +func UTCFromTT(tt time.Time) time.Time { + return utcLabel(basic.TT2UTC(basic.Date2JD(tt.UTC()))) +} + +// DUT1 返回民用时刻的 UT1−UTC,单位秒 / UT1−UTC in seconds. +func DUT1(date time.Time) float64 { + return basic.DUT1Seconds(basic.Date2JD(date.UTC())) +} + +// TCGFromTT 地球时转地心坐标时 / converts TT to TCG. +func TCGFromTT(tt time.Time) time.Time { return utcLabel(basic.TT2TCG(basic.Date2JD(tt.UTC()))) } + +// TTFromTCG 是 TCGFromTT 的逆 / inverts TCGFromTT. +func TTFromTCG(tcg time.Time) time.Time { return utcLabel(basic.TCG2TT(basic.Date2JD(tcg.UTC()))) } + +// TCBFromTT 地球时转太阳系质心坐标时,采用地心近似 / converts TT to TCB using a geocentric approximation. +func TCBFromTT(tt time.Time) time.Time { return utcLabel(basic.TT2TCB(basic.Date2JD(tt.UTC()))) } + +// TTFromTCB 是 TCBFromTT 的逆 / inverts TCBFromTT. +func TTFromTCB(tcb time.Time) time.Time { return utcLabel(basic.TCB2TT(basic.Date2JD(tcb.UTC()))) } + +// TDBFromTT 地球时转太阳系质心力学时,采用地心近似 / converts TT to TDB using a geocentric approximation. +func TDBFromTT(tt time.Time) time.Time { return utcLabel(basic.TT2TDB(basic.Date2JD(tt.UTC()))) } + +// TTFromTDB 是 TDBFromTT 的逆 / inverts TDBFromTT. +func TTFromTDB(tdb time.Time) time.Time { return utcLabel(basic.TDB2TT(basic.Date2JD(tdb.UTC()))) } + +// TCBFromTDB 质心力学时转质心坐标时 / converts TDB to TCB. +func TCBFromTDB(tdb time.Time) time.Time { return utcLabel(basic.TDB2TCB(basic.Date2JD(tdb.UTC()))) } + +// TDBFromTCB 是 TCBFromTDB 的逆 / inverts TCBFromTDB. +func TDBFromTCB(tcb time.Time) time.Time { return utcLabel(basic.TCB2TDB(basic.Date2JD(tcb.UTC()))) } + +// TCGMinusTT 返回 TT 时刻的 TCG−TT,单位秒 / TCG−TT in seconds for a TT instant. +func TCGMinusTT(tt time.Time) float64 { return basic.TCGMinusTTSeconds(basic.Date2JD(tt.UTC())) } + +// TCBMinusTT 返回 TT 时刻的 TCB−TT,单位秒 / TCB−TT in seconds for a TT instant. +func TCBMinusTT(tt time.Time) float64 { return basic.TCBMinusTTSeconds(basic.Date2JD(tt.UTC())) } + +// TDBMinusTT 返回 TT 时刻的地心 TDB−TT 近似值(秒)/ geocentric TDB−TT approximation in seconds at a TT instant. +func TDBMinusTT(tt time.Time) float64 { return basic.TDBMinusTTSeconds(basic.Date2JD(tt.UTC())) } + +func utcLabel(jd float64) time.Time { + return basic.JD2DateByZone(jd, time.UTC, false) +} diff --git a/timescale_public_test.go b/timescale_public_test.go new file mode 100644 index 0000000..018a506 --- /dev/null +++ b/timescale_public_test.go @@ -0,0 +1,156 @@ +// 根包时标封面的契约:time.Time 进出、按 UTC 时刻取值、往返自洽。 +package astro_test + +import ( + "math" + "testing" + "time" + + "b612.me/astro" +) + +func TestTimeScalePublicFacade(t *testing.T) { + utc := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + if got := astro.DUT1(utc); math.Abs(got-0.051) > 1e-3 { + t.Errorf("DUT1=%v 秒, want 0.051", got) + } + if got := astro.TTFromUTC(utc).Sub(utc).Seconds(); math.Abs(got-69.184) > 1e-3 { + t.Errorf("TT−UTC=%v 秒, want 69.184", got) + } + for _, date := range []time.Time{ + time.Date(1960, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(1972, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2000, 6, 30, 12, 0, 0, 0, time.UTC), + time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), + time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC), + } { + if got := astro.UTCFromUT1(astro.UT1FromUTC(date)).Sub(date).Seconds(); math.Abs(got) > 1e-3 { + t.Errorf("UT1 往返 %v: 差 %g 秒", date, got) + } + if got := astro.UTCFromTT(astro.TTFromUTC(date)).Sub(date).Seconds(); math.Abs(got) > 1e-3 { + t.Errorf("TT 往返 %v: 差 %g 秒", date, got) + } + } + before := time.Date(1960, 1, 1, 0, 0, 0, 0, time.UTC) + if got := astro.UT1FromUTC(before).Sub(before).Seconds(); math.Abs(got) > 1e-3 { + t.Errorf("1972 前 UT1 应等于民用时刻, 差 %g 秒", got) + } + cst := utc.In(time.FixedZone("CST", 8*3600)) + if got, want := astro.UT1FromUTC(cst), astro.UT1FromUTC(utc); !got.Equal(want) { + t.Errorf("时区不应改变结果: %v vs %v", got, want) + } +} + +func TestTimeScaleLabelSelection(t *testing.T) { + utc := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + if got := astro.LabelIn(astro.TimeScaleUTC, utc); !got.Equal(utc) { + t.Errorf("UTC 时刻不应改动: %v", got) + } + ut1 := astro.LabelIn(astro.TimeScaleUT1, utc) + if delta := ut1.Sub(utc).Seconds(); math.Abs(delta-0.051) > 5e-4 { + t.Errorf("UT1 时刻应领先民用时刻约 DUT1: got %g 秒", delta) + } + if ut1.Location() != time.UTC { + t.Errorf("UT1 时刻应带 UTC 时区(无时区语义), got %v", ut1.Location()) + } + if zero := astro.LabelIn(0, utc); !zero.Equal(utc) { + t.Errorf("零值时标应为 UTC: %v", zero) + } +} + +func TestTimeScaleModelInjectionFacade(t *testing.T) { + utc := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + if astro.TTMinusUTC() != nil { + t.Fatalf("默认 TT−UTC 应来自内置闰秒表(覆盖函数为 nil)") + } + baseTTMinusUTC := astro.TTFromUTC(utc).Sub(utc).Seconds() + baseDUT1 := astro.DUT1(utc) + if math.Abs(baseTTMinusUTC-69.184) > 1e-3 { + t.Fatalf("默认 TT−UTC = %g 秒, want 69.184", baseTTMinusUTC) + } + + astro.SetTTMinusUTC(func(float64) float64 { return 42.184 }) + defer astro.SetTTMinusUTC(nil) + if astro.TTMinusUTC() == nil { + t.Fatalf("注入后 TTMinusUTC() 不应为 nil") + } + if got := astro.TTFromUTC(utc).Sub(utc).Seconds(); math.Abs(got-42.184) > 1e-3 { + t.Fatalf("注入后 TT−UTC = %g 秒, want 42.184", got) + } + if got := astro.UTCFromTT(astro.TTFromUTC(utc)).Sub(utc).Seconds(); math.Abs(got) > 1e-3 { + t.Fatalf("注入后 TT 往返差 %g 秒", got) + } + // DUT1 = (TT−UTC) − ΔT,ΔT 未变,因此只随 TT−UTC 口径平移。 + if got, want := astro.DUT1(utc), baseDUT1+42.184-baseTTMinusUTC; math.Abs(got-want) > 1e-3 { + t.Fatalf("注入后 DUT1 = %g 秒, want %g", got, want) + } + + astro.SetTTMinusUTC(nil) + if astro.TTMinusUTC() != nil { + t.Fatalf("恢复后 TTMinusUTC() 应为 nil") + } + if got := astro.TTFromUTC(utc).Sub(utc).Seconds(); math.Abs(got-baseTTMinusUTC) > 1e-3 { + t.Fatalf("恢复后 TT−UTC = %g 秒, want %g", got, baseTTMinusUTC) + } + if got := astro.DUT1(utc); math.Abs(got-baseDUT1) > 1e-6 { + t.Fatalf("恢复后 DUT1 = %g 秒, want %g", got, baseDUT1) + } + + // ΔT 一侧同样对称:默认模型可通过 DefaultDeltaT 取回。 + if astro.DefaultDeltaT() == nil { + t.Fatalf("DefaultDeltaT() 不应为 nil") + } + astro.SetDeltaT(nil) + if astro.DeltaT() == nil { + t.Fatalf("传 nil 后 DeltaT() 应回到默认模型") + } +} + +func TestTimeScaleDefaultModelAndFuturePolicyFacade(t *testing.T) { + astro.SetTTMinusUTC(nil) + astro.SetTimeScaleFuturePolicy(astro.TimeScaleAssumeUT1Tracking) + defer func() { + astro.SetTTMinusUTC(nil) + astro.SetTimeScaleFuturePolicy(astro.TimeScaleAssumeUT1Tracking) + }() + + // 注入覆盖后,默认模型仍给出内置闰秒表口径。 + astro.SetTTMinusUTC(func(float64) float64 { return 42.184 }) + defaultFn := astro.DefaultTTMinusUTC() + if defaultFn == nil { + t.Fatalf("DefaultTTMinusUTC() 不应为 nil") + } + jd := 2460000.5 + if got := defaultFn(jd); math.Abs(got-69.184) > 1e-9 { + t.Fatalf("DefaultTTMinusUTC()(JD)=%v, want 69.184(忽略覆盖)", got) + } + if got := astro.TTMinusUTC()(jd); math.Abs(got-42.184) > 1e-9 { + t.Fatalf("TTMinusUTC()(JD)=%v, want 42.184", got) + } + astro.SetTTMinusUTC(nil) + + // 未来政策:根包与 basic 同一份状态;切换后窗口外的 TT−UTC 取值改变,恢复后回到原值。 + if got := astro.GetTimeScaleFuturePolicy(); got != astro.TimeScaleAssumeUT1Tracking { + t.Fatalf("默认政策=%v, want TimeScaleAssumeUT1Tracking", got) + } + tail := time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC) + tracking := astro.TTFromUTC(tail).Sub(tail).Seconds() + + astro.SetTimeScaleFuturePolicy(astro.TimeScaleFreezeUTCOffset) + if got := astro.GetTimeScaleFuturePolicy(); got != astro.TimeScaleFreezeUTCOffset { + t.Fatalf("切换后政策=%v, want TimeScaleFreezeUTCOffset", got) + } + frozen := astro.TTFromUTC(tail).Sub(tail).Seconds() + // jd≈2.46e6 上 float64 只能还原到几十微秒,故按毫秒级比较。 + if math.Abs(frozen-69.184) > 1e-3 { + t.Fatalf("冻结政策下 TT−UTC=%v, want 69.184", frozen) + } + if math.Abs(tracking-frozen) < 1e-6 { + t.Fatalf("两种政策在 2035 应给出不同 TT−UTC:tracking=%v frozen=%v", tracking, frozen) + } + + astro.SetTimeScaleFuturePolicy(astro.TimeScaleAssumeUT1Tracking) + if got := astro.TTFromUTC(tail).Sub(tail).Seconds(); math.Abs(got-tracking) > 1e-3 { + t.Fatalf("恢复政策后 TT−UTC=%v, want %v", got, tracking) + } +} diff --git a/tools/distance.go b/tools/distance.go new file mode 100644 index 0000000..e55df68 --- /dev/null +++ b/tools/distance.go @@ -0,0 +1,40 @@ +package tools + +import "math" + +// DistanceUnit 距离输入单位 / a unit accepted for distance input. +type DistanceUnit uint8 + +const ( + // DistanceParsec 秒差距 / parsec. + DistanceParsec DistanceUnit = iota + // DistanceLightYear 光年 / light-year. + DistanceLightYear + // DistanceAU 天文单位 / astronomical unit. + DistanceAU +) + +// 天文单位与光年取 IAU 定义值,秒差距由精确关系 648000/π 天文单位导出。 +const ( + AstronomicalUnitKilometers = 149597870.7 + LightYearKilometers = 9460730472580.8 + parsecAU = 648000 / math.Pi +) + +// DistanceToParsecs 把给定单位的距离换算为秒差距 / converts a distance in the given unit to parsecs. +// +// 非正数、NaN 与未知单位返回 NaN。 +func DistanceToParsecs(value float64, unit DistanceUnit) float64 { + if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 { + return math.NaN() + } + switch unit { + case DistanceParsec: + return value + case DistanceLightYear: + return value * LightYearKilometers / AstronomicalUnitKilometers / parsecAU + case DistanceAU: + return value / parsecAU + } + return math.NaN() +} diff --git a/tools/distance_test.go b/tools/distance_test.go new file mode 100644 index 0000000..71e40c2 --- /dev/null +++ b/tools/distance_test.go @@ -0,0 +1,49 @@ +package tools + +import ( + "math" + "testing" +) + +// 参考值由同一组 IAU 定义值独立推出,不使用 9 位凑整常数。 +const ( + testPcPerLy = LightYearKilometers / AstronomicalUnitKilometers / parsecAU + testAuPerLy = LightYearKilometers / AstronomicalUnitKilometers +) + +func assertDistanceClose(t *testing.T, name string, got, want, tolerance float64) { + t.Helper() + if math.Abs(got-want) > tolerance { + t.Fatalf("%s mismatch: got %.15f want %.15f", name, got, want) + } +} + +func TestDistanceToParsecsUnits(t *testing.T) { + assertDistanceClose(t, "1 pc", DistanceToParsecs(1, DistanceParsec), 1, 1e-15) + assertDistanceClose(t, "1 ly in pc", DistanceToParsecs(1, DistanceLightYear), testPcPerLy, 1e-15) + assertDistanceClose(t, "1 ly in pc 参考 0.3066", testPcPerLy, 0.306601394, 1e-9) + assertDistanceClose(t, "1 pc in ly 参考 3.261563777", 1/testPcPerLy, 3.261563777, 1e-9) + assertDistanceClose(t, "1 ly in au 参考 63241.077084", DistanceToParsecs(1, DistanceLightYear)*parsecAU, 63241.077084, 1e-6) + assertDistanceClose(t, "1 pc in au 参考 206264.806247096", DistanceToParsecs(1, DistanceParsec)*parsecAU, 206264.806247096, 1e-9) + assertDistanceClose(t, "648000/pi au in pc", DistanceToParsecs(parsecAU, DistanceAU), 1, 1e-14) +} + +func TestDistanceToParsecsRoundTrip(t *testing.T) { + for _, pc := range []float64{0.0001, 1, 2.637, 5.7803, 1000} { + assertDistanceClose(t, "pc round trip", DistanceToParsecs(pc, DistanceParsec), pc, 1e-15) + ly := pc / testPcPerLy + assertDistanceClose(t, "pc->ly->pc", DistanceToParsecs(ly, DistanceLightYear), pc, 1e-12) + assertDistanceClose(t, "au round trip", DistanceToParsecs(pc*parsecAU, DistanceAU), pc, 1e-12) + } +} + +func TestDistanceToParsecsRejectsInvalidInput(t *testing.T) { + for _, value := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} { + if got := DistanceToParsecs(value, DistanceParsec); !math.IsNaN(got) { + t.Fatalf("DistanceToParsecs(%v, parsec) = %v, want NaN", value, got) + } + } + if got := DistanceToParsecs(1, DistanceUnit(200)); !math.IsNaN(got) { + t.Fatalf("DistanceToParsecs(1, unknown unit) = %v, want NaN", got) + } +} diff --git a/uranus/diameter.go b/uranus/diameter.go index 46d5df2..a416f4f 100644 --- a/uranus/diameter.go +++ b/uranus/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 天王星视半径(截断版),单位角秒 / truncated apparent Uranus semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 天王星视直径,单位角秒 / apparent Uranus diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 天王星视直径(截断版),单位角秒 / truncated apparent Uranus diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusDiameterN(basic.UTC2TT(jd), n) } diff --git a/uranus/nodes.go b/uranus/nodes.go index 83782d4..ec78177 100644 --- a/uranus/nodes.go +++ b/uranus/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 天王星升交点黄经(截断版) / truncated ascending node longitude of Uranus. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 天王星降交点黄经 / descending node longitude of Uranus. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 天王星降交点黄经(截断版) / truncated descending node longitude of Uranus. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/uranus/phase.go b/uranus/phase.go index 26cb6e4..d9bdacc 100644 --- a/uranus/phase.go +++ b/uranus/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/uranus/physical.go b/uranus/physical.go index dc3d37e..7deac0a 100644 --- a/uranus/physical.go +++ b/uranus/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 天王星物理观测参数(截断版) / truncated physical observing parameters of Uranus. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.UranusPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.UranusPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/uranus/physical_test.go b/uranus/physical_test.go index 56a848e..af3398d 100644 --- a/uranus/physical_test.go +++ b/uranus/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.UranusPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.UranusPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/uranus/truncated.go b/uranus/truncated.go index 2c0c45c..a76a1bd 100644 --- a/uranus/truncated.go +++ b/uranus/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthUranusAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthUranusAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(6, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(6, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusHeightN(jde, lon, lat, timezone, n) + return basic.UranusHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusAzimuthN(jde, lon, lat, timezone, n) + return basic.UranusAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusHourAngleN(jde, lon, timezone, n) + return basic.UranusHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.UranusCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.UranusCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.UranusRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.UranusRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.UranusSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.UranusSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/uranus/uranus.go b/uranus/uranus.go index a3b9433..91213ac 100644 --- a/uranus/uranus.go +++ b/uranus/uranus.go @@ -15,7 +15,7 @@ var ( ERR_URANUS_NEVER_DOWN = ERR_URANUS_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回天王星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Uranus at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Uranus at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Uranus at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Uranus at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Uranus at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回天王星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Uranus at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.UranusMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.UranusMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Uranus to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthUranusAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthUranusAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回天王星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Uranus to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(6, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(6, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusHeight(jde, lon, lat, timezone) + return basic.UranusHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusAzimuth(jde, lon, lat, timezone) + return basic.UranusAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.UranusHourAngle(jde, lon, timezone) + return basic.UranusHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.UranusCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.UranusCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.UranusRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.UranusRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.UranusSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.UranusSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusConjunction(jde), date.Location(), false) } // LastOpposition 上一次冲日 / previous opposition. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or before date, keeping date's time zone. func LastOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusOpposition(jde), date.Location(), false) } // NextOpposition 下一次冲日 / next opposition. @@ -237,8 +232,8 @@ func LastOpposition(date time.Time) time.Time { // 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。 // Returns the nearest opposition at or after date, keeping date's time zone. func NextOpposition(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusOpposition(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusOpposition(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -246,8 +241,8 @@ func NextOpposition(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusProgradeToRetrograde(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -255,8 +250,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusProgradeToRetrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusProgradeToRetrograde(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -264,8 +259,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusRetrogradeToPrograde(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -273,8 +268,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusRetrogradeToPrograde(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusRetrogradeToPrograde(jde), date.Location(), false) } // LastEasternQuadrature 上一次东方照 / previous eastern quadrature. @@ -282,8 +277,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or before date, keeping date's time zone. func LastEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusEasternQuadrature(jde), date.Location(), false) } // NextEasternQuadrature 下一次东方照 / next eastern quadrature. @@ -291,8 +286,8 @@ func LastEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。 // Returns the nearest eastern quadrature at or after date, keeping date's time zone. func NextEasternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusEasternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusEasternQuadrature(jde), date.Location(), false) } // LastWesternQuadrature 上一次西方照 / previous western quadrature. @@ -300,8 +295,8 @@ func NextEasternQuadrature(date time.Time) time.Time { // 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or before date, keeping date's time zone. func LastWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastUranusWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastUranusWesternQuadrature(jde), date.Location(), false) } // NextWesternQuadrature 下一次西方照 / next western quadrature. @@ -309,6 +304,6 @@ func LastWesternQuadrature(date time.Time) time.Time { // 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。 // Returns the nearest western quadrature at or after date, keeping date's time zone. func NextWesternQuadrature(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextUranusWesternQuadrature(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextUranusWesternQuadrature(jde), date.Location(), false) } diff --git a/ut1_cover_test.go b/ut1_cover_test.go new file mode 100644 index 0000000..38e6d07 --- /dev/null +++ b/ut1_cover_test.go @@ -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 diff --git a/venus/diameter.go b/venus/diameter.go index d3540f8..9cc6a58 100644 --- a/venus/diameter.go +++ b/venus/diameter.go @@ -14,8 +14,8 @@ func Semidiameter(date time.Time) float64 { // SemidiameterN 金星视半径(截断版),单位角秒 / truncated apparent Venus semidiameter in arcseconds. func SemidiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusSemidiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusSemidiameterN(basic.UTC2TT(jd), n) } // Diameter 金星视直径,单位角秒 / apparent Venus diameter in arcseconds. @@ -25,6 +25,6 @@ func Diameter(date time.Time) float64 { // DiameterN 金星视直径(截断版),单位角秒 / truncated apparent Venus diameter in arcseconds. func DiameterN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusDiameterN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusDiameterN(basic.UTC2TT(jd), n) } diff --git a/venus/nodes.go b/venus/nodes.go index 52bc411..8c95434 100644 --- a/venus/nodes.go +++ b/venus/nodes.go @@ -14,8 +14,8 @@ func AscendingNode(date time.Time) float64 { // AscendingNodeN 金星升交点黄经(截断版) / truncated ascending node longitude of Venus. func AscendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusAscendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusAscendingNodeN(basic.UTC2TT(jd), n) } // DescendingNode 金星降交点黄经 / descending node longitude of Venus. @@ -25,6 +25,6 @@ func DescendingNode(date time.Time) float64 { // DescendingNodeN 金星降交点黄经(截断版) / truncated descending node longitude of Venus. func DescendingNodeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusDescendingNodeN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusDescendingNodeN(basic.UTC2TT(jd), n) } diff --git a/venus/phase.go b/venus/phase.go index 6731022..59baecd 100644 --- a/venus/phase.go +++ b/venus/phase.go @@ -48,5 +48,5 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 { } func phaseJD(date time.Time) float64 { - return basic.TD2UT(calendar.Date2JDE(date.UTC()), true) + return basic.UTC2TT(calendar.Date2JD(date.UTC())) } diff --git a/venus/physical.go b/venus/physical.go index b3935a8..d143df3 100644 --- a/venus/physical.go +++ b/venus/physical.go @@ -27,8 +27,8 @@ func Physical(date time.Time) PhysicalInfo { // PhysicalN 金星物理观测参数(截断版) / truncated physical observing parameters of Venus. func PhysicalN(date time.Time, n int) PhysicalInfo { - jde := basic.Date2JDE(date.UTC()) - info := basic.VenusPhysicalN(basic.TD2UT(jde, true), n) + jd := basic.Date2JD(date.UTC()) + info := basic.VenusPhysicalN(basic.UTC2TT(jd), n) return PhysicalInfo{ SubEarthLongitude: info.SubEarthLongitude, SubEarthLatitude: info.SubEarthLatitude, diff --git a/venus/physical_test.go b/venus/physical_test.go index 90e0be0..056dfb4 100644 --- a/venus/physical_test.go +++ b/venus/physical_test.go @@ -10,11 +10,11 @@ import ( func TestPhysicalWrapperMatchesBasic(t *testing.T) { date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC) - jde := basic.Date2JDE(date.UTC()) + jde := basic.Date2JD(date.UTC()) got := Physical(date) gotN := PhysicalN(date, -1) - want := basic.VenusPhysicalN(basic.TD2UT(jde, true), -1) + want := basic.VenusPhysicalN(basic.UTC2TT(jde), -1) assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude) assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude) diff --git a/venus/transit.go b/venus/transit.go index 5598a66..262cb72 100644 --- a/venus/transit.go +++ b/venus/transit.go @@ -33,26 +33,26 @@ type TransitInfo struct { // NextTransit 下一次地心金星凌日 / next geocentric Venus transit. func NextTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.NextVenusTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.NextVenusTransit(basic.Date2JD(date.UTC())), date.Location()) } // LastTransit 上一次地心金星凌日 / previous geocentric Venus transit. func LastTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.LastVenusTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.LastVenusTransit(basic.Date2JD(date.UTC())), date.Location()) } // ClosestTransit 最近一次地心金星凌日 / closest geocentric Venus transit. func ClosestTransit(date time.Time) TransitInfo { - return transitInfoFromBasic(basic.ClosestVenusTransit(basic.Date2JDE(date.UTC())), date.Location()) + return transitInfoFromBasic(basic.ClosestVenusTransit(basic.Date2JD(date.UTC())), date.Location()) } func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) TransitInfo { if !result.Valid { return TransitInfo{} } - start := basic.JDE2DateByZone(result.ExternalIngress, loc, false) - greatest := basic.JDE2DateByZone(result.Greatest, loc, false) - end := basic.JDE2DateByZone(result.ExternalEgress, loc, false) + start := basic.JD2DateByZone(result.ExternalIngress, loc, false) + greatest := basic.JD2DateByZone(result.Greatest, loc, false) + end := basic.JD2DateByZone(result.ExternalEgress, loc, false) info := TransitInfo{ Valid: true, Start: start, @@ -65,8 +65,8 @@ func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) HasInternal: result.HasInternal, } if result.HasInternal { - info.InternalStart = basic.JDE2DateByZone(result.InternalIngress, loc, false) - info.InternalEnd = basic.JDE2DateByZone(result.InternalEgress, loc, false) + info.InternalStart = basic.JD2DateByZone(result.InternalIngress, loc, false) + info.InternalEnd = basic.JD2DateByZone(result.InternalEgress, loc, false) info.InternalDuration = info.InternalEnd.Sub(info.InternalStart) } return info diff --git a/venus/truncated.go b/venus/truncated.go index 1e55b44..6911035 100644 --- a/venus/truncated.go +++ b/venus/truncated.go @@ -12,58 +12,58 @@ import ( // ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude. func ApparentLoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentLoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentLoN(basic.UTC2TT(jd), n) } // ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude. func ApparentBoN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentBoN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentBoN(basic.UTC2TT(jd), n) } // ApparentRaN 视赤经(截断版) / truncated apparent right ascension. func ApparentRaN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentRaN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentRaN(basic.UTC2TT(jd), n) } // ApparentDecN 视赤纬(截断版) / truncated apparent declination. func ApparentDecN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentDecN(basic.UTC2TT(jd), n) } // ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination. func ApparentRaDecN(date time.Time, n int) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentRaDecN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentRaDecN(basic.UTC2TT(jd), n) } // ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude. func ApparentMagnitudeN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusMagN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusMagN(basic.UTC2TT(jd), n) } // EarthDistanceN 地球距离(截断版) / truncated Earth distance. func EarthDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthVenusAwayN(basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthVenusAwayN(basic.UTC2TT(jd), n) } // SunDistanceN 太阳距离(截断版) / truncated Sun distance. func SunDistanceN(date time.Time, n int) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanetN(2, 2, basic.TD2UT(jde, true), n) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanetN(2, 2, basic.UTC2TT(jd), n) } // AltitudeN 高度角(截断版) / truncated altitude angle. func AltitudeN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusHeightN(jde, lon, lat, timezone, n) + return basic.VenusHeightN(localJD, lon, lat, timezone, n) } // ZenithN 天顶距(截断版) / truncated zenith distance. @@ -73,30 +73,28 @@ func ZenithN(date time.Time, lon, lat float64, n int) float64 { // AzimuthN 方位角(截断版) / truncated azimuth angle. func AzimuthN(date time.Time, lon, lat float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusAzimuthN(jde, lon, lat, timezone, n) + return basic.VenusAzimuthN(localJD, lon, lat, timezone, n) } // HourAngleN 时角(截断版) / truncated hour angle. func HourAngleN(date time.Time, lon float64, n int) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusHourAngleN(jde, lon, timezone, n) + return basic.VenusHourAngleN(localJD, lon, timezone, n) } // CulminationTimeN 中天时间(截断版) / truncated culmination time. func CulminationTimeN(date time.Time, lon float64, n int) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.VenusCulminationTimeN(jde, lon, timezone, n) - timezone/24.0 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.VenusCulminationTimeN(localJD, lon, timezone, n) - timezone/24.0 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTimeN 升起时间(截断版) / truncated rise time. @@ -105,14 +103,12 @@ func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.VenusRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.VenusRiseTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } // DownTimeN 落下时间别名(截断版) / truncated down-time alias. @@ -126,12 +122,10 @@ func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time. if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.VenusSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.VenusSetTimeN(localJD, lon, lat, timezone, aeroFloat, height, n) + return riseSetResult(date, riseJD, err) } diff --git a/venus/venus.go b/venus/venus.go index f9681a0..0797a06 100644 --- a/venus/venus.go +++ b/venus/venus.go @@ -15,7 +15,7 @@ var ( ERR_VENUS_NEVER_DOWN = ERR_VENUS_NEVER_SET ) -func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { +func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) { if err != nil { switch { case errors.Is(err, basic.ErrNeverRise): @@ -26,7 +26,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { return time.Time{}, err } } - return basic.JDE2DateByZone(jde, date.Location(), true), nil + _, offset := date.Zone() + return basic.JD2DateByZone(jd-float64(offset)/86400, date.Location(), false), nil } // ApparentLo 视黄经 / apparent ecliptic longitude. @@ -34,8 +35,8 @@ func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) { // 返回金星在 date 对应绝对时刻的瞬时视黄经,单位度。 // Returns the apparent ecliptic longitude of Venus at the instant represented by date, in degrees. func ApparentLo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentLo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentLo(basic.UTC2TT(jd)) } // ApparentBo 视黄纬 / apparent ecliptic latitude. @@ -43,8 +44,8 @@ func ApparentLo(date time.Time) float64 { // 返回金星在 date 对应绝对时刻的瞬时视黄纬,单位度。 // Returns the apparent ecliptic latitude of Venus at the instant represented by date, in degrees. func ApparentBo(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentBo(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentBo(basic.UTC2TT(jd)) } // ApparentRa 视赤经 / apparent right ascension. @@ -52,8 +53,8 @@ func ApparentBo(date time.Time) float64 { // 返回金星在 date 对应绝对时刻的瞬时视赤经,单位度。 // Returns the apparent right ascension of Venus at the instant represented by date, in degrees. func ApparentRa(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentRa(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentRa(basic.UTC2TT(jd)) } // ApparentDec 视赤纬 / apparent declination. @@ -61,8 +62,8 @@ func ApparentRa(date time.Time) float64 { // 返回金星在 date 对应绝对时刻的瞬时视赤纬,单位度。 // Returns the apparent declination of Venus at the instant represented by date, in degrees. func ApparentDec(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentDec(basic.UTC2TT(jd)) } // ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination. @@ -70,8 +71,8 @@ func ApparentDec(date time.Time) float64 { // 返回金星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。 // Returns the apparent right ascension and declination of Venus at the instant represented by date, in degrees. func ApparentRaDec(date time.Time) (float64, float64) { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusApparentRaDec(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusApparentRaDec(basic.UTC2TT(jd)) } // ApparentMagnitude 视星等 / apparent magnitude. @@ -79,8 +80,8 @@ func ApparentRaDec(date time.Time) (float64, float64) { // 返回金星在 date 对应绝对时刻的视星等。 // Returns the apparent visual magnitude of Venus at the instant represented by date. func ApparentMagnitude(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.VenusMag(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.VenusMag(basic.UTC2TT(jd)) } // EarthDistance 地心距离 / Earth distance. @@ -88,8 +89,8 @@ func ApparentMagnitude(date time.Time) float64 { // 返回金星在 date 对应绝对时刻到地球的距离,单位 AU。 // Returns the distance from Venus to Earth at the instant represented by date, in astronomical units. func EarthDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return basic.EarthVenusAway(basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return basic.EarthVenusAway(basic.UTC2TT(jd)) } // SunDistance 日心距离 / Sun distance. @@ -97,8 +98,8 @@ func EarthDistance(date time.Time) float64 { // 返回金星在 date 对应绝对时刻到太阳的距离,单位 AU。 // Returns the distance from Venus to the Sun at the instant represented by date, in astronomical units. func SunDistance(date time.Time) float64 { - jde := calendar.Date2JDE(date.UTC()) - return planet.WherePlanet(2, 2, basic.TD2UT(jde, true)) + jd := calendar.Date2JD(date.UTC()) + return planet.WherePlanet(2, 2, basic.UTC2TT(jd)) } // Altitude 高度角 / altitude. @@ -106,10 +107,10 @@ func SunDistance(date time.Time) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees. func Altitude(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusHeight(jde, lon, lat, timezone) + return basic.VenusHeight(localJD, lon, lat, timezone) } // Zenith 天顶距 / zenith distance. @@ -125,10 +126,10 @@ func Zenith(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east. func Azimuth(date time.Time, lon, lat float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusAzimuth(jde, lon, lat, timezone) + return basic.VenusAzimuth(localJD, lon, lat, timezone) } // HourAngle 时角 / hour angle. @@ -136,10 +137,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 { // date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。 // date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees. func HourAngle(date time.Time, lon float64) float64 { - jde := basic.Date2JDE(date) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - return basic.VenusHourAngle(jde, lon, timezone) + return basic.VenusHourAngle(localJD, lon, timezone) } // CulminationTime 中天时刻 / culmination time. @@ -147,33 +148,29 @@ func HourAngle(date time.Time, lon float64) float64 { // date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude. func CulminationTime(date time.Time, lon float64) time.Time { - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - calcJde := basic.VenusCulminationTime(jde, lon, timezone) - timezone/24.00 - return basic.JDE2DateByZone(calcJde, date.Location(), false) + calcJD := basic.VenusCulminationTime(localJD, lon, timezone) - timezone/24.00 + return basic.JD2DateByZone(calcJD, date.Location(), false) } // RiseTime 升起时间 / rise time. // -// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。 +// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点椭球高(大地高,米);aero 为 true 时加入标准大气折射。 // date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction. func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) { var aeroFloat float64 if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.VenusRiseTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.VenusRiseTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // DownTime 落下时间别名 / deprecated set-time alias. @@ -195,14 +192,12 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er if aero { aeroFloat = 1 } - if date.Hour() > 12 { - date = date.Add(time.Hour * -12) - } - jde := basic.Date2JDE(date) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + localJD := basic.Date2JD(date) _, loc := date.Zone() timezone := float64(loc) / 3600.0 - riseJde, err := basic.VenusSetTime(jde, lon, lat, timezone, aeroFloat, height) - return riseSetResult(date, riseJde, err) + riseJD, err := basic.VenusSetTime(localJD, lon, lat, timezone, aeroFloat, height) + return riseSetResult(date, riseJD, err) } // LastConjunction 上一次合日 / previous conjunction with the Sun. @@ -210,8 +205,8 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er // 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or before date, keeping date's time zone. func LastConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusConjunction(jde), date.Location(), false) } // NextConjunction 下一次合日 / next conjunction with the Sun. @@ -219,8 +214,8 @@ func LastConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。 // Returns the nearest conjunction with the Sun at or after date, keeping date's time zone. func NextConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusConjunction(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusConjunction(jde), date.Location(), false) } // LastInferiorConjunction 上一次下合 / previous inferior conjunction. @@ -228,8 +223,8 @@ func NextConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次下合时刻,结果保持 date 的时区。 // Returns the nearest inferior conjunction at or before date, keeping date's time zone. func LastInferiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusInferiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusInferiorConjunctionInclusive(jde), date.Location(), false) } // NextInferiorConjunction 下一次下合 / next inferior conjunction. @@ -237,8 +232,8 @@ func LastInferiorConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次下合时刻,结果保持 date 的时区。 // Returns the nearest inferior conjunction at or after date, keeping date's time zone. func NextInferiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusInferiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusInferiorConjunctionInclusive(jde), date.Location(), false) } // LastSuperiorConjunction 上一次上合 / previous superior conjunction. @@ -246,8 +241,8 @@ func NextInferiorConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次上合时刻,结果保持 date 的时区。 // Returns the nearest superior conjunction at or before date, keeping date's time zone. func LastSuperiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusSuperiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusSuperiorConjunctionInclusive(jde), date.Location(), false) } // NextSuperiorConjunction 下一次上合 / next superior conjunction. @@ -255,8 +250,8 @@ func LastSuperiorConjunction(date time.Time) time.Time { // 返回 date 当前或之后最近一次上合时刻,结果保持 date 的时区。 // Returns the nearest superior conjunction at or after date, keeping date's time zone. func NextSuperiorConjunction(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusSuperiorConjunctionInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusSuperiorConjunctionInclusive(jde), date.Location(), false) } // LastRetrograde 上一次留 / previous stationary point. @@ -264,8 +259,8 @@ func NextSuperiorConjunction(date time.Time) time.Time { // 返回 date 当前或之前最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。 // Returns the nearest stationary point at or before date, regardless of the direction change, keeping date's time zone. func LastRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusRetrogradeInclusive(jde), date.Location(), false) } // NextRetrograde 下一次留 / next stationary point. @@ -273,8 +268,8 @@ func LastRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。 // Returns the nearest stationary point at or after date, regardless of the direction change, keeping date's time zone. func NextRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusRetrogradeInclusive(jde), date.Location(), false) } // LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde. @@ -282,8 +277,8 @@ func NextRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone. func LastProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusProgradeToRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusProgradeToRetrogradeInclusive(jde), date.Location(), false) } // NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde. @@ -291,8 +286,8 @@ func LastProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone. func NextProgradeToRetrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusProgradeToRetrogradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusProgradeToRetrogradeInclusive(jde), date.Location(), false) } // LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde. @@ -300,8 +295,8 @@ func NextProgradeToRetrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone. func LastRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusRetrogradeToProgradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusRetrogradeToProgradeInclusive(jde), date.Location(), false) } // NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde. @@ -309,8 +304,8 @@ func LastRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。 // Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone. func NextRetrogradeToPrograde(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusRetrogradeToProgradeInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusRetrogradeToProgradeInclusive(jde), date.Location(), false) } // LastGreatestElongation 上一次大距 / previous greatest elongation. @@ -318,8 +313,8 @@ func NextRetrogradeToPrograde(date time.Time) time.Time { // 返回 date 当前或之前最近一次大距时刻,不区分东西大距,结果保持 date 的时区。 // Returns the nearest greatest elongation at or before date, regardless of east or west, keeping date's time zone. func LastGreatestElongation(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusGreatestElongationInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusGreatestElongationInclusive(jde), date.Location(), false) } // NextGreatestElongation 下一次大距 / next greatest elongation. @@ -327,8 +322,8 @@ func LastGreatestElongation(date time.Time) time.Time { // 返回 date 当前或之后最近一次大距时刻,不区分东西大距,结果保持 date 的时区。 // Returns the nearest greatest elongation at or after date, regardless of east or west, keeping date's time zone. func NextGreatestElongation(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusGreatestElongationInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusGreatestElongationInclusive(jde), date.Location(), false) } // LastGreatestElongationEast 上一次东大距 / previous greatest eastern elongation. @@ -336,8 +331,8 @@ func NextGreatestElongation(date time.Time) time.Time { // 返回 date 当前或之前最近一次东大距时刻,结果保持 date 的时区。 // Returns the nearest eastern greatest elongation at or before date, keeping date's time zone. func LastGreatestElongationEast(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusGreatestElongationEastInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusGreatestElongationEastInclusive(jde), date.Location(), false) } // NextGreatestElongationEast 下一次东大距 / next greatest eastern elongation. @@ -345,8 +340,8 @@ func LastGreatestElongationEast(date time.Time) time.Time { // 返回 date 当前或之后最近一次东大距时刻,结果保持 date 的时区。 // Returns the nearest eastern greatest elongation at or after date, keeping date's time zone. func NextGreatestElongationEast(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusGreatestElongationEastInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusGreatestElongationEastInclusive(jde), date.Location(), false) } // LastGreatestElongationWest 上一次西大距 / previous greatest western elongation. @@ -354,8 +349,8 @@ func NextGreatestElongationEast(date time.Time) time.Time { // 返回 date 当前或之前最近一次西大距时刻,结果保持 date 的时区。 // Returns the nearest western greatest elongation at or before date, keeping date's time zone. func LastGreatestElongationWest(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.LastVenusGreatestElongationWestInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.LastVenusGreatestElongationWestInclusive(jde), date.Location(), false) } // NextGreatestElongationWest 下一次西大距 / next greatest western elongation. @@ -363,6 +358,6 @@ func LastGreatestElongationWest(date time.Time) time.Time { // 返回 date 当前或之后最近一次西大距时刻,结果保持 date 的时区。 // Returns the nearest western greatest elongation at or after date, keeping date's time zone. func NextGreatestElongationWest(date time.Time) time.Time { - jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) - return basic.JDE2DateByZone(basic.NextVenusGreatestElongationWestInclusive(jde), date.Location(), false) + jde := basic.UTC2TT(basic.Date2JD(date.UTC())) + return basic.JD2DateByZone(basic.NextVenusGreatestElongationWestInclusive(jde), date.Location(), false) }