30 Commits

Author SHA1 Message Date
b612 0e3825d5ba test: 修正日食中心阴影边界测试的时标换算
- 将日下点计算由 TT2UTC 改为 TT2UT1,使测试与地球自转几何口径一致
2026-10-16 07:52:20 +08:00
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00
b612 1f31a9b5b5 docs: 修正README中月球公式计算口径表述笔误 2026-09-17 21:33:28 +08:00
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00
b612 9ee2163cc7 feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
2026-08-06 12:00:56 +08:00
b612 25dc7ac0bc calendar: 补齐前104古历纪年解析与回归测试
- 为先秦与秦汉古历结果填充周、鲁、秦、西汉早期纪年信息
- 支持默认与显式古历下的年号日期解析
2026-06-11 09:33:46 +08:00
b612 a8e7513683 • feat(calendar): 扩展先秦至秦汉古历支持
- 新增显式古历 API,支持先秦古历与秦汉颛顼历选择
- 将默认公农历转换范围扩展至 -721..3000
- 支持后九月解析、负年份干支日和古历法相符节气
- 补充秦汉、先秦、交接边界和节气回归测试
2026-06-09 19:35:18 +08:00
b612 c8dd777a7b docs: 统一公开 API 的中英双语注释
- 补齐公开接口说明段的英文描述,保持签名注释和详细说明均为中英双语结构
- 规范农历、坐标、公式、轨道、日晷、太阳、恒星及行星事件等 API 的注释口径
2026-05-27 16:08:11 +08:00
b612 46b555cd49 fix: 修复天象事件 API 在事件边界附近的重复返回问题
- 修正合月、合日、留等Last/Next/Closest接口在精确命中和事件后秒级查询时仍返回当前事件的问题,避免应用侧枚举卡死
- 收紧事件边界判定,并补强水星、金星、火星近事件路径的稳定化,保证公开API的单调前进语义
- 补充公开wrapper、跨世纪样本和外部基线回归,覆盖合月邻近事件与边界场景
2026-05-23 23:08:05 +08:00
b612 be3af3884c feat(moon): 新增行星合月查询并修正月球地心赤经赤纬接口
- 修正月球地心真/视赤经赤纬接口口径
- 新增月球与七大行星合月时刻查询
2026-05-23 19:00:53 +08:00
b612 34ff6a36ae fix: 修正行星事件边界与留点计算
- 统一 UT 事件时刻与 TT 查询时刻的边界判断
- 将外行星留点搜索锚定到对应冲日周期
- 修正水星、金星合日、留、大距事件选择
- 统一七大行星视位置计算辅助逻辑
- 增加公开 Last/Next 边界和 JPL/NAOJ 基线回归测试
2026-05-22 12:24:41 +08:00
b612 d40c4dfcd9 fix: 兼容 TinyGo wasm 编译行星星历初始化
- 将 planetViews 从包级初始化改为 sync.Once 懒加载,避免 TinyGo interp 在编译期处理大型 float64 表切片索引时触发 unsupported fcmp
- 将行星视图构建失败改为内部返回 error,并由兼容层统一 panic
- 补充无效行星数据切片边界测试
2026-05-17 21:19:23 +08:00
b612 bec7b8a0d8 feat: 增强日月食搜索、沙罗周期与内行星凌日
- 使用压缩表加速查找日月食沙罗周期信息
- 优化日月食搜索跳步,减少非食季朔望月扫描
- 新增本地日全食、日环食、月全食搜索接口,返回 ok 区分未找到结果
- 新增水星、金星地心凌日查询及测试
2026-05-03 19:00:08 +08:00
b612 3ffdbe0034 feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00
b612 98ff574495 bug fix:修正公农历历算问题 2026-02-20 12:11:38 +08:00
b612 dadbba7171 improve:月升月落算法优化 2026-02-17 23:23:52 +08:00
b612 add07bbd85 add deltaT func to astro.go 2026-02-16 13:20:22 +08:00
b612 27b7e4ab77 年号异体字修正 2025-11-02 12:01:04 +08:00
b612 0ab91bcd2d fix:修正百年闰年2月29日处理过程 2025-10-04 17:00:19 +08:00
b612 543abcafa5 更换岁差、章动算法 2025-09-18 13:16:04 +08:00
b612 9f688024e8 1.修正:农历年返回不正确的问题 2025-09-16 11:48:54 +08:00
b612 b865e64fa9 1. 补充农历覆盖到公元前104年
2. 修正年号问题
2025-09-16 00:11:10 +08:00
b612 3389c33cb5 1. 补充农历覆盖到公元前104年
2. 修正时区导致的计算问题
2025-09-15 23:40:09 +08:00
b612 d6b6452304 1. 补充农历覆盖到公元前104年
2. 农历信息中包含君主、年号、朝代
3. 丰富一些算法,修正一些问题
2025-09-15 20:55:38 +08:00
b612 438f1700c7 - fix: Correct sun.TrueBo calculation, now using basic.HSunTrueBo for correct solar latitude
- fix: Rename GetSunDownTime to GetSunSetTime in basic/sun.go and update related calls/outputs
- fix: Update test cases to use new method and error constant names

- improve: Rename all DownTime/Down functions to SetTime/Set for consistency
- improve: Standardize ERR_XXX_NEVER_DOWN error constants to ERR_XXX_NEVER_SET, with ERR_XXX_NEVER_DOWN kept as compatibility alias
- improve: More standard naming for interfaces and errors to improve maintainability and readability
2025-09-08 10:59:04 +08:00
b612 126cf68cab Merge remote-tracking branch 'origin/master' 2025-09-08 10:40:37 +08:00
b612 4302981518 - fix: Correct sun.TrueBo calculation, now using basic.HSunTrueBo for correct solar latitude
- fix: Rename GetSunDownTime to GetSunSetTime in basic/sun.go and update related calls/outputs
- fix: Update test cases to use new method and error constant names

- improve: Rename all DownTime/Down functions to SetTime/Set for consistency
- improve: Standardize ERR_XXX_NEVER_DOWN error constants to ERR_XXX_NEVER_SET, with ERR_XXX_NEVER_DOWN kept as compatibility alias
- improve: More standard naming for interfaces and errors to improve maintainability and readability
2025-09-08 10:37:46 +08:00
b612 94aeb84da5 update 2025-01-31 15:38:25 +08:00
b612 1952df0c30 update 2025-01-31 15:35:36 +08:00
1131 changed files with 276980 additions and 18528 deletions
+1
View File
@@ -0,0 +1 @@
.idea
-8
View File
@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 数据源本地存储已忽略文件
/../../../../../../:\gocode\src\b612.me\astro\.idea/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/astro.iml" filepath="$PROJECT_DIR$/.idea/astro.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+160
View File
@@ -0,0 +1,160 @@
本文件是第三方数据与条款声明。代码与文档采用 Apache-2.0;下列数据文件**不适用** Apache-2.0,各自遵循其来源条款。分发内嵌了这些数据的二进制(含 c-shared DLL/SO)时,须一并附上对应署名。
This file is the per-component licence and attribution notice. The code and documentation are Apache-2.0; the data files listed below are not, and each follows the terms of its own source. Distributing a binary that embeds them — including the c-shared DLL/SO build — requires carrying the corresponding attribution.
================================================================================
1. SELENE (KAGUYA) lunar limb profile — moon/limb/kaguya_lalt.bin
================================================================================
Source data
SELENE Global Grid Topographic Data of the Moon (LALT_GGT_NUM), dataset
sln-l-lalt-5-topo-ggt-num-v2.0, version 2.0, published 2017.
Origin of the data: ISAS/JAXA.
Provided by the SELENE (KAGUYA) Data Archive (DARTS), ISAS/JAXA:
https://darts.isas.jaxa.jp/datasets/darts:sln-l-lalt-5-topo-ggt-num-v2.0
https://data.darts.isas.jaxa.jp/pub/pds3/sln-l-lalt-5-topo-ggt-num-v2.0/
Acknowledgement, as requested by the SELENE Data Archive
"We thank the SELENE(KAGUYA) LALT team and the SELENE Data Archive for
providing the SELENE(KAGUYA) data. SELENE is a Japanese mission developed
and operated by JAXA."
Modifications
The original PDS3 fixed-width table (1/16 degree global grid, 2880 x 5760
samples) was converted to a float32 elevation grid and then resampled into a
limb-radius profile table: the limb height is tabulated on a 1 degree grid of
selenographic (libration) longitude and latitude and on a 0.1 degree grid of
position angle, taking max (R + h) cos(alpha) over a +/-9 degree arc for each
direction. Values are quantized to 4 m, delta-encoded along longitude and
latitude, and then stored in this project's own zlib-compressed LLB1
container. No other changes were made.
Licence
The source data are provided under the ISAS Data Policy,
https://www.isas.jaxa.jp/en/researchers/data-policy/
which states that its rules are compatible with Creative Commons Attribution
4.0 International, https://creativecommons.org/licenses/by/4.0/
This file is redistributed under the ISAS Data Policy. It is provided "as
is", without warranties of any kind. ISAS/JAXA is not responsible for the
actions of data users and does not endorse this project.
Embedding
Compiled into the moon/limb package with //go:embed, and linked only when
that package is imported. A copy of this section ships as moon/limb/NOTICE
so that vendored copies of the package keep the attribution.
================================================================================
2. Planetary and lunar theories — planet/internal/semianalytic/**/*.bin
================================================================================
Upstream theories and authors
VSOP2013 — planetary theory, Simon, Francou, Fienga & Manche (2013),
distributed by IMCCE at https://ftp.imcce.fr/pub/ephem/planets/vsop2013/
TOP2013 — planetary theory for the outer planets, IMCCE, distributed at
https://ftp.imcce.fr/pub/ephem/planets/top2013/
ELP/MPP02 — lunar theory, J. Chapront, M. Chapront & G. Francou, 2003,
distributed by SYRTE, Observatoire de Paris (not through IMCCE's ftp server)
at ftp://cyrano-se.obspm.fr/pub/2_lunar_solutions/2_elpmpp02/
Its distribution README.TXT (December 2002) is purely scientific — authors,
description, file list and references — and states no licence and no terms
of use.
Used by the legacy model as well:
ELP2000-82B — earlier lunar solution, Chapront-Touzé & Chapront, distributed
by IMCCE at https://ftp.imcce.fr/pub/ephem/moon/elp82b/
VSOP87 — earlier planetary series, Bretagnon & Francou (1988).
What this project distributes
Its own numerical refits of those theories' argument structure to the JPL
DE440/DE441 ephemerides. The amplitudes in the *.bin tables are produced by
this project's fitting pipeline; the upstream coefficient files are not
redistributed here. Relative to the upstream theories the tables are
modified in two ways: (1) the argument structure is truncated to the terms
the fit retains, and (2) every amplitude is re-fitted to DE440/DE441 instead
of being taken from the published series.
Published constants
The argument phases and frequencies (the VSOP2013 "ll(1..17)" set) and the
GM constants used by the generator are published in the VSOP2013 README.pdf
distributed by IMCCE and in Simon, Francou, Fienga & Manche, A&A 557, A49
(2013); for the Moon, in Chapront & Francou, A&A 404, 735 (2003).
Rights in the underlying theories
This project can neither allow nor forbid the use of the VSOP2013, TOP2013,
ELP/MPP02, ELP2000-82B or VSOP87 theories. Apache-2.0 covers only this
project's own work — its code, and its compilation and rearrangement of the
published series — and not the theories themselves. Downstream users who
need to rely on the terms of the underlying theories should consult the IMCCE
legal notice,
https://www.imcce.fr/mentions-legales
which restricts reproduction and makes commercial or professional use
subject to IMCCE authorisation.
ELP/MPP02 terms
The official ELP/MPP02 distribution ships README.TXT (December 2002) and the
explanatory note elpmpp02.pdf; neither states a licence or terms of use, and
no separate terms file is included. This project therefore treats ELP/MPP02
as carrying no stated terms and relies on the disclaimer under "Rights in
the underlying theories" above. Note also that IMCCE's ftp server carries
only ELP2000-82B, not ELP/MPP02. Downstream users who need a formal position
should consult SYRTE, https://syrte.obspm.fr/, and the IMCCE legal notice.
================================================================================
3. JPL ephemerides — DE440 / DE441 and Horizons kernels
================================================================================
Files
basic/testdata/semianalytic/planet_apparent_de441.csv
planet/internal/semianalytic/testdata/de441-states.csv
planet/spk/testdata/de441_ref.csv
planet/spk/testdata/ceres.bsp
Source
JPL DE440/DE441 planetary ephemerides via NAIF,
https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/
and JPL Horizons, https://ssd.jpl.nasa.gov/horizons/
The DE440/DE441 data are also the fitting target of the tables in section 2.
Terms
Produced by the United States Government (NASA/JPL); not subject to
copyright protection in the United States. NAIF and PDS ask that the source
be credited, as done here.
================================================================================
4. Natural Earth land polygons — internal/svgmap/land_*.path
================================================================================
Source
Natural Earth 1:50m physical land, without administrative boundaries,
https://www.naturalearthdata.com/
Terms
Public domain. Natural Earth states that the data may be used for any
purpose without permission. Credit is given here as good practice, and the
rendered charts carry the "Natural Earth 1:50m physical land" label.
================================================================================
5. Star catalogue data — basic/star_catalog.dat
================================================================================
Astrometry
The built-in catalogue carries Bright Star Catalogue / Harvard (HR) numbers
1..9110 with J2000 positions, proper motions and magnitudes. Its astrometric
content derives from the Yale Bright Star Catalogue (Hoffleit & Warren),
distributed by CDS as V/50,
https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
Catalogue data from CDS is distributed free of charge subject to the CDS
general terms of use and to citation of the source.
================================================================================
References
================================================================================
ISAS Data Policy ...... https://www.isas.jaxa.jp/en/researchers/data-policy/
CC BY 4.0 ............. https://creativecommons.org/licenses/by/4.0/
SELENE Data Archive ... https://darts.isas.jaxa.jp/
IMCCE legal notice .... https://www.imcce.fr/mentions-legales
Natural Earth ......... https://www.naturalearthdata.com/
NAIF SPK kernels ...... https://naif.jpl.nasa.gov/pub/naif/generic_kernels/
JPL Horizons .......... https://ssd.jpl.nasa.gov/horizons/
CDS VizieR V/50 ....... https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
SYRTE (Observatoire de Paris) ... https://syrte.obspm.fr/
ELP/MPP02 distribution .......... ftp://cyrano-se.obspm.fr/pub/2_lunar_solutions/2_elpmpp02/
+149
View File
@@ -0,0 +1,149 @@
# Astro
**English | [中文](README.md)**
[![Go Reference](https://pkg.go.dev/badge/b612.me/astro.svg)](https://pkg.go.dev/b612.me/astro)
An astronomy library I have used for years for my own interest in astronomy and calendars.
> ### ⚠️ Time-scale change: UTC switches to leap hours on 2027-05-20
>
> On 15 October 2026 the 28th General Conference on Weights and Measures (CGPM) adopted [Resolution 5, "On the technical actions needed to ensure the continuity of UTC"](https://www.bipm.org/en/cgpm-2026/resolution-5) (`Resolution-CGPM-28-5`, DOI `10.59161/CGPM2026RES5E`): **continuous UTC becomes effective on 20 May 2027** and the maximum value of `|UT1−UTC|` is widened from 0.9 seconds to **3600 seconds (1 hour)** — a leap hour in place of a leap second.
>
> [IERS Bulletin C 72 (2026-07-06)](https://datacenter.iers.org/data/latestVersion/bulletinC.txt) confirms that **no leap second will be introduced at the end of December 2026**; the leap-second mechanism ends on 2027-05-20, so the possible insertion point at the end of June 2027 will not carry one either.
>
> From this release the library keeps **1972–2026 on the definitive leap-second table** (those historical values are unchanged) and changes the default policy beyond the built-in window (which ends 2026-10-01) from `TimeScaleLeapSecond` to **`TimeScaleLeapHour`**; past the window TT−UTC stays at 69.184 seconds. The numeric values of the `SetTimeScaleFuturePolicy` constants changed accordingly (`TimeScaleLeapHour` is now the zero value), so callers passing raw integers must update. See [Time scales](doc/manual/en/timescale.md).
The library ships two ephemerides, selectable in one process or used side by side:
1. **Semianalytic model** (`ModelSemianalytic`, default): the argument structure and initial amplitudes of VSOP2013/TOP2013 (planets) and ELP/MPP02 (Moon), refitted to the DE ephemerides — DE441 for the planetary long range, DE440 for the modern segment and DE441 for the Moon — and folded into a single compact series table. The planetary table is about 452 KiB and the lunar table about 42 KiB, with no external ephemeris files. Its geocentric apparent direction error (RMS) in the modern range is about 0.007″ (Sun) and 0.076″ (Moon).
2. **Legacy model** (`ModelLegacy`): the VSOP87/ELP2000-82 series with added long-term and per-body corrections. Switch to it to reproduce old values; its geocentric apparent direction error (RMS) in the modern range is about 0.04″ (Sun) and 0.75″ (Moon). See [Accuracy and Performance](doc/manual/en/accuracy.md).
External ephemerides can also be supplied through SPK or a custom engine.
## Install
```sh
go get b612.me/astro
```
## Highlights
- Civil and Chinese calendar conversion from 721 BCE to 3000 CE, solar terms, sexagenary dates, era names and historical calendars.
- Positions, rise/set, transit, distance, apparent diameter and physical ephemerides for the Sun, Moon and seven planets; lightweight Sun and Moon algorithms are also included.
- Solar and lunar eclipses, stellar and planetary lunar occultations, event searches, local contact times and global paths.
- Generic sky-event searches: ecliptic-longitude crossings, stationary points, phases, closest approach, rise/set at any altitude, perihelion/aphelion and heliacal visibility.
- Switchable ephemeris models, multiple instances used in parallel in one process, SPK multi-source stacking and optional perturbed propagation.
- SVG sky-event charts, GeoJSON geographic data and KML for viewing and time playback in Google Earth.
- A built-in catalog of 9100 stars with constellation lookup, coordinate corrections and rise/set calculations; CSV, Hipparcos, BSC5 and Stellarium external catalogs are also available.
- Celestial coordinate transformations, sidereal time, precession and nutation, refraction, astronomical formulas, sundial geometry and an optional lunar-limb terrain correction.
## Example
Calculate the sunrise time, lunar phase and Chinese calendar date for a day in Xi'an:
```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 `true` makes the rise/set calculation account for atmospheric refraction. Rise/set functions return an error during polar day, polar night or when no event occurs on that date.
Package-level functions use the process-wide ephemeris model (semianalytic in default builds). To pin a model for a calculation, use `astro.NewContext`; multiple contexts can be used in parallel in one process:
```go
func compareModels(date time.Time) {
legacy, err := astro.NewContext(astro.ModelLegacy)
if err != nil {
panic(err)
}
modern, err := astro.NewContext(astro.ModelSemianalytic)
if err != nil {
panic(err)
}
fmt.Println(legacy.Moon().PhaseDesc(date), modern.Moon().PhaseDesc(date))
riseLegacy, _ := legacy.Sun().RiseTime(date, 108.93, 34.27, 0, true)
riseModern, _ := modern.Sun().RiseTime(date, 108.93, 34.27, 0, true)
fmt.Println(riseLegacy.Format("15:04:05"), riseModern.Format("15:04:05"))
}
```
A context exposes per-module entry points such as `Sun()`, `Moon()`, `Mars()`, `Eclipse()` and `Phenomena()`, with the same signatures as their package-level counterparts. Models isolate the ephemeris only; ΔT, TT−UTC and civil time-scale policy remain process-wide settings. See [Explicit ephemeris instances](doc/manual/en/ephemeris.md) for custom engines, SPK and multi-source stacking.
## Package Overview
| Package | Purpose and documentation |
| --- | --- |
| `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, lunar phases and physical quantities](doc/manual/en/sun-moon.md) |
| `mercury` / `venus` / `mars` / `jupiter` / `saturn` / `uranus` / `neptune` / `earth` | [Planetary positions, special events, Jupiter's satellites and Saturn's rings](doc/manual/en/planets.md) |
| `eclipse` / `eclipse/svg` | [Solar and lunar eclipse and transit searches, local visibility, paths and charts](doc/manual/en/eclipse.md) |
| `moon` / `moon/svg` / `moon/limb` | [Stellar and planetary lunar occultations, contact times, bands and the optional lunar-limb terrain correction](doc/manual/en/occultation.md) |
| `phenomena` | [Ecliptic-longitude crossings, stationary points, phases, closest approach, heliacal visibility and rise/set at any altitude for any two bodies](doc/manual/en/phenomena.md) |
| `geojson` / `kml` | [Event maps, GeoJSON and KML](doc/manual/en/map-geojson.md) |
| `star` / `star/catalog` | [Star database, constellations, observing quantities and external catalogs](doc/manual/en/star.md#external-catalogs) |
| `coord` | [Coordinate conversion, sidereal time, precession, nutation and refraction](doc/manual/en/coord.md) |
| `orbit` | [Asteroid and comet two-body or optional perturbed propagation, visual binaries](doc/manual/en/orbit.md) |
| `sundial` | [Apparent solar time and sundial geometry](doc/manual/en/sundial.md) |
| `formula` | [Radiation, magnitudes, telescopes, atmospheric refraction, extinction and Earth-orientation tools](doc/manual/en/formula.md) |
| `astro` / `basic` / `planet/spk` | [Ephemeris models and instances, multi-source ephemerides and custom engines](doc/manual/en/ephemeris.md) |
| `astro` (root) | [UTC, UT1, TT conversion and ΔT models](doc/manual/en/timescale.md) |
`basic` contains the underlying algorithms, `planet` holds the built-in models and series, `planet/spk` reads external kernels, and `tools` provides numerical helpers. See [Accuracy and performance](doc/manual/en/accuracy.md) for model composition, measured errors and performance; full signatures are also available in the [Go API documentation](https://pkg.go.dev/b612.me/astro).
## 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 time-scale requirements.
Angles default to degrees, apparent 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 an explicit UT1 option. See [Time scales](doc/manual/en/timescale.md) for the exact conventions.
Beyond the monthly ΔT table, DUT1 and TT−UTC default to **leap-hour** extrapolation (`TimeScaleLeapHour`, matching the continuous UTC effective 2027-05-20); `SetTimeScaleFuturePolicy` switches between `TimeScaleLeapHour` (default), `TimeScaleLeapSecond`, `TimeScaleAssumeUT1Tracking`, `TimeScaleFreezeUTCOffset` and `TimeScaleUT1Civil`.
## Observer Height Convention
Observer height is ellipsoidal height in metres. If only orthometric height `H` is available, convert it with the local geoid undulation `N`: `height = H + N`. See [Observer height](doc/manual/en/coord.md#observer-height).
## Scope And Accuracy
Accuracy varies with model, epoch, body and calculated quantity. The default model has changed from VSOP87/ELP2000-82 to the semianalytic tables: signatures are unchanged but values may differ. `lite/sun` and `lite/moon` use independent approximation chains and do not follow model switches.
See [Accuracy and performance](doc/manual/en/accuracy.md) for model composition, error conventions and measured results. Professional occultation prediction, spacecraft navigation and similar uses also need ephemerides, time scales and physical models of matching accuracy; loading an SPK alone does not make every high-level result as accurate as the kernel.
## License
The code and documentation are licensed under [Apache-2.0](LICENSE). The third-party data embedded in this repository is **not** covered by Apache-2.0 and follows the terms of its own source; see [NOTICE](NOTICE) for the item-by-item statement:
- **SELENE (KAGUYA) LALT lunar limb profile** (`moon/limb/kaguya_lalt.bin`): ISAS/JAXA, distributed under the ISAS Data Policy, which states that its rules are compatible with CC BY 4.0.
- **This project's own refits of VSOP2013/TOP2013 and ELP/MPP02** (`planet/internal/semianalytic/`): the underlying theories are IMCCE / SYRTE. This project can neither grant nor restrict rights in those theories; Apache-2.0 applies only to this project's own code and compilation.
- **JPL DE440/DE441 and Horizons data** (`*/testdata/`, `planet/spk/testdata/`): NASA/JPL.
- **Astrometry in the built-in star catalogue** (`basic/star_catalog.dat`): derived from the Yale Bright Star Catalogue (VizieR [V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50)).
- **Natural Earth 1:50m physical land** (`internal/svgmap/land_*.path`): public domain.
+149
View File
@@ -0,0 +1,149 @@
# Astro
**[English](README.en.md) | 中文**
[![Go Reference](https://pkg.go.dev/badge/b612.me/astro.svg)](https://pkg.go.dev/b612.me/astro)
自用多年的天文算法库,用于个人天文历法爱好。
> ### ⚠️ 时标变更:2027-05-20 起 UTC 改用闰时
>
> 2026 年 10 月 15 日,第 28 届国际计量大会(CGPM)通过[第 5 号决议《关于确保 UTC 连续性所需的技术行动》](https://www.bipm.org/en/cgpm-2026/resolution-5)(`Resolution-CGPM-28-5`,DOI `10.59161/CGPM2026RES5E`):**连续 UTC 自 2027 年 5 月 20 日起生效**,`|UT1−UTC|` 的最大值由 0.9 秒放宽到 **3600 秒(1 小时)**,即以闰时取代闰秒。
>
> [IERS Bulletin C 72(2026-07-06)](https://datacenter.iers.org/data/latestVersion/bulletinC.txt) 已确认 **2026 年 12 月底不插入闰秒**;闰秒机制在 2027-05-20 终止,2027 年 6 月底这一可能的插入点同样不会有闰秒。
>
> 本库从本版本起:**1972–2026 仍按确定的闰秒表计算**,历史值不变;内置窗口(截至 2026-10-01)之后的默认政策由 `TimeScaleLeapSecond` 改为 **`TimeScaleLeapHour`**,窗口之后 TT−UTC 将长期保持 69.184 秒。`SetTimeScaleFuturePolicy` 的常量数值随之重排(`TimeScaleLeapHour` 现为零值),传裸整数的调用方需要更新。详见[时标](doc/manual/timescale.md)。
本库内置两套星历,可在同一进程内切换或并行使用:
1. **半解析模型**(`ModelSemianalytic`,默认):以 VSOP2013/TOP2013(行星)和 ELP/MPP02(月球)为基准,按 DE 星历重新拟合——行星长时段用 DE441、现代段用 DE440,月球用 DE441——折叠成一张紧凑级数表。行星表约 452 KiB、月球表约 42 KiB,无需外部星历文件。现代段地心视方向均方根误差约 0.007″(太阳)、0.076″(月球)。
2. **旧模型**(`ModelLegacy`):VSOP87/ELP2000-82 级数,另加长期项与逐天体订正。需要复现旧版数值时切换;现代段地心视方向均方根误差约 0.04″(太阳)、0.75″(月球),详见[精度与性能](doc/manual/accuracy.md)。
除内置模型外,也可以通过 SPK 或自定义引擎接入外部星历。
## 安装
```sh
go get b612.me/astro
```
## 功能概览
- 公历与农历互转,支持公元前 721 年至公元 3000 年;节气、干支、年号与古历。
- 太阳、月亮、七大行星的位置、升落、中天、距离、视直径与物理星历;另有轻量日月算法。
- 日月食、恒星与行星月掩的事件搜索、地方接触时刻和全球路径。
- 通用天象搜索:黄经穿越、留、相位、最近角距、任意高度升落、近远日点与偕日可见。
- 可切换星历模型、同一进程并行使用多个实例,支持 SPK 多源叠加与可选摄动传播。
- SVG 天象图、GeoJSON 地理数据和可供 Google Earth 查看及按时间播放的 KML。
- 内置 9100 颗恒星,支持星座判定、坐标修正与升落计算;另有 CSV、Hipparcos、BSC5 与 Stellarium 外挂星表。
- 天球坐标转换、恒星时、岁差章动、折射、天文公式、日晷几何与可选月缘地形修正。
## 使用示例
计算西安某日的日出时刻、月相,以及当天的农历日期:
```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` 表示升落计算考虑大气折射。极昼、极夜或当天没有升落事件时,升落接口会返回错误。
包级函数使用进程当前的星历模型(默认构建为半解析)。要给某次计算固定模型,用 `astro.NewContext`,同一进程里可以有多个上下文并行:
```go
func compareModels(date time.Time) {
legacy, err := astro.NewContext(astro.ModelLegacy)
if err != nil {
panic(err)
}
modern, err := astro.NewContext(astro.ModelSemianalytic)
if err != nil {
panic(err)
}
fmt.Println(legacy.Moon().PhaseDesc(date), modern.Moon().PhaseDesc(date))
riseLegacy, _ := legacy.Sun().RiseTime(date, 108.93, 34.27, 0, true)
riseModern, _ := modern.Sun().RiseTime(date, 108.93, 34.27, 0, true)
fmt.Println(riseLegacy.Format("15:04:05"), riseModern.Format("15:04:05"))
}
```
上下文按模块提供 `Sun()`、`Moon()`、`Mars()`、`Eclipse()`、`Phenomena()` 等入口,方法与同名包级函数签名一致。模型只隔离星历,ΔT、TT−UTC 和民用时标政策仍是进程级设置。自定义引擎、SPK 与多源叠加见[显式星历实例](doc/manual/ephemeris.md)。
## 包概览
| 包 | 用途与文档 |
| --- | --- |
| `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` / `moon/limb` | [恒星及行星月掩、接触时刻、掩带与可选月缘地形修正](doc/manual/occultation.md) |
| `phenomena` | [任意两天体黄经穿越、留、相位、最近角距、偕日可见与任意高度升落](doc/manual/phenomena.md) |
| `geojson` / `kml` | [天象地图、GeoJSON 与 KML](doc/manual/map-geojson.md) |
| `star` / `star/catalog` | [恒星数据库、星座、观测量与外挂星表](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` / `basic` / `planet/spk` | [星历模型与实例、多源星历与自定义引擎](doc/manual/ephemeris.md) |
| `astro`(根包) | [UTC、UT1、TT 换算与 ΔT 模型](doc/manual/timescale.md) |
`basic` 是底层算法包,`planet` 保存内置模型与级数,`planet/spk` 读取外部核,`tools` 提供数值辅助函数。模型构成、实测误差和性能见[精度与性能](doc/manual/accuracy.md);完整函数签名也可查 [Go API 文档](https://pkg.go.dev/b612.me/astro)。
## 时标约定
一般观测接口接收表示民用时刻的 `time.Time`,内部处理 UTC、UT1 与 TT 换算。1972 年以前,库将民用时间按 UT1 处理,之后按 UTC 处理;轨道历元和部分底层接口另有时标要求。
角度默认用度,视直径用角秒,恒星时用小时,距离按接口使用 AU 或 km。SVG 和 GeoJSON 默认输出 UTC 时间标签,也可显式选择 UT1。具体约定见[时标手册](doc/manual/timescale.md)。
DUT1 与 TT−UTC 在月度 ΔT 表覆盖范围之后默认按**闰时**外推(`TimeScaleLeapHour`,对应 2027-05-20 起的连续 UTC),可用 `SetTimeScaleFuturePolicy` 在 `TimeScaleLeapHour`(默认)、`TimeScaleLeapSecond`、`TimeScaleAssumeUT1Tracking`、`TimeScaleFreezeUTCOffset`、`TimeScaleUT1Civil` 之间切换。
## 观测点高度约定
观测点高度为椭球高,单位米。若只有海拔(正高)`H`,应结合当地大地水准面差距 `N` 换算:`height = H + N`。详见[观测点高度](doc/manual/coord.md#观测点高度)。
## 适用范围与精度
精度随模型、年代、天体和计算项目变化。默认模型已从 VSOP87/ELP2000-82 换成半解析表,签名不变但数值可能不同;`lite/sun`、`lite/moon` 使用独立近似链路,不随模型切换。
模型构成、误差口径和实测结果见[精度与性能](doc/manual/accuracy.md)。专业掩星、航天导航等用途还需要相应精度的星历、时标与物理模型,接入 SPK 本身不保证全部高层结果达到核文件精度。
## 许可
代码与文档采用 [Apache-2.0](LICENSE)。仓库内嵌的第三方数据**不适用** Apache-2.0,各自遵循其来源条款,逐项声明见 [NOTICE](NOTICE):
- **SELENE(KAGUYA)LALT 月缘剖面**(`moon/limb/kaguya_lalt.bin`):ISAS/JAXA,按 ISAS 数据政策分发(该政策声明其规则与 CC BY 4.0 兼容)。
- **VSOP2013/TOP2013 与 ELP/MPP02 的自行再拟合表**(`planet/internal/semianalytic/`):上游理论属 IMCCE / SYRTE。 Apache-2.0 只适用本库自己的代码与汇编。
- **JPL DE440/DE441 与 Horizons 数据**(`*/testdata/`、`planet/spk/testdata/`):NASA/JPL。
- **内置星表的天测数据**(`basic/star_catalog.dat`):源自 Yale Bright Star Catalogue(VizieR [V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50))。
- **Natural Earth 1:50m 物理陆地**(`internal/svgmap/land_*.path`):公有领域。
+93 -1
View File
@@ -1,2 +1,94 @@
// Package astro
// Package astro 提供高层星历上下文、模型切换与进程级时标配置 / high-level ephemeris contexts, model switching and process-wide time-scale configuration.
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, leap hour as the zero value; separate from output TimeScale.
type TimeScaleFuturePolicy = basic.TimeScaleFuturePolicy
const (
// TimeScaleLeapHour 按 ΔT 越限施加整小时校正(2027 起 CGPM 决议) / applies hour-sized corrections as adopted by CGPM from 2027.
TimeScaleLeapHour = basic.TimeScaleLeapHour
// TimeScaleLeapSecond 按 ΔT 越限施加整数秒校正(1972–2026 确定值) / integer-second corrections (definitive 1972–2026).
TimeScaleLeapSecond = basic.TimeScaleLeapSecond
// TimeScaleAssumeUT1Tracking 窗口外固定末端 DUT1,平滑跟随 UT1 / holds the last observed DUT1 beyond the window.
TimeScaleAssumeUT1Tracking = basic.TimeScaleAssumeUT1Tracking
// TimeScaleFreezeUTCOffset 窗口外固定末端 TT−UTC / holds the last TT−UTC offset beyond the window.
TimeScaleFreezeUTCOffset = basic.TimeScaleFreezeUTCOffset
// 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)
}
+69
View File
@@ -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)
}
}
}
+267
View File
@@ -0,0 +1,267 @@
// 根包回归:testdata 的位级基线与 n 截断契约(n<0 用全项,n>=0 保留约 n 个主项)。
package astro_test
import (
"encoding/json"
"fmt"
"math"
"os"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/moon"
"b612.me/astro/planet"
"b612.me/astro/sun"
"b612.me/astro/internal/ephemtest"
)
type baselinePlanetSnapshot struct {
Name string `json:"name"`
XT int `json:"xt"`
LonBits uint64 `json:"lon_bits"`
LatBits uint64 `json:"lat_bits"`
RadBits uint64 `json:"rad_bits"`
}
type baselineMoonSnapshot struct {
LonBits uint64 `json:"lon_bits"`
LatBits uint64 `json:"lat_bits"`
DisBits uint64 `json:"dis_bits"`
}
type baselineSample struct {
UTC string `json:"utc"`
TTJD float64 `json:"tt_jd"`
Planets []baselinePlanetSnapshot `json:"planets"`
Moon baselineMoonSnapshot `json:"moon"`
}
func loadBaselineSamples(t *testing.T) []baselineSample {
t.Helper()
data, err := os.ReadFile("testdata/planet_moon_baseline.json")
if err != nil {
t.Fatal(err)
}
var samples []baselineSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatal(err)
}
if len(samples) == 0 {
t.Fatal("empty baseline samples")
}
return samples
}
// truncationTermCounts 是截断检验抽取的 n 值。
var truncationTermCounts = []int{2, 4, 8, 16}
// angularDifference 两个角度量的最小夹角,单位度。
func angularDifference(a, b float64) float64 {
d := math.Mod(a-b, 360)
if d > 180 {
d -= 360
}
if d < -180 {
d += 360
}
return math.Abs(d)
}
// truncationCase 是一个截断探针:err 给出第 sample 个采样点在截断项数 n 下相对全项 n<0 的偏差。
type truncationCase struct {
name string
tol []float64
err func(sample, n int) float64
}
// scalarTruncation 构造标量探针;jd 按采样索引取值,angular 为真时偏差取角度最小夹角。
func scalarTruncation(name string, jd func(index int) float64, f func(float64, int) float64, angular bool, tol []float64) truncationCase {
return truncationCase{
name: name,
tol: tol,
err: func(sample, n int) float64 {
got := f(jd(sample), n)
full := f(jd(sample), -1)
if angular {
return angularDifference(got, full)
}
return math.Abs(got - full)
},
}
}
// pairTruncation 构造经纬成对返回的探针,取两个分量偏差的较大者。
func pairTruncation(name string, jd func(index int) float64, f func(float64, int) (float64, float64), tol []float64) truncationCase {
return truncationCase{
name: name,
tol: tol,
err: func(sample, n int) float64 {
gotA, gotB := f(jd(sample), n)
fullA, fullB := f(jd(sample), -1)
return math.Max(angularDifference(gotA, fullA), angularDifference(gotB, fullB))
},
}
}
// dateTruncation 构造以 time.Time 为入口的探针,偏差按角度最小夹角计。
func dateTruncation(name string, date func(index int) time.Time, f func(time.Time, int) float64, tol []float64) truncationCase {
return truncationCase{
name: name,
tol: tol,
err: func(sample, n int) float64 {
return angularDifference(f(date(sample), n), f(date(sample), -1))
},
}
}
// baselineDates 解析基线样本的 UTC 时刻。
func baselineDates(t *testing.T, samples []baselineSample) []time.Time {
t.Helper()
dates := make([]time.Time, len(samples))
for i, sample := range samples {
parsed, err := time.Parse(time.RFC3339Nano, sample.UTC)
if err != nil {
t.Fatal(err)
}
dates[i] = parsed
}
return dates
}
// assertTruncationConverges 逐采样点检验误差上限,并要求最坏误差随 n 不增、多数采样点严格下降。
// 单点截断误差不是嵌套部分和,允许同量级抖动,因此“不增”约束的是采样集上的误差包络。
func assertTruncationConverges(t *testing.T, cases []truncationCase, samples int) {
t.Helper()
for _, tc := range cases {
envelope := make([]float64, len(truncationTermCounts))
strict := 0
for sample := 0; sample < samples; sample++ {
first, last := 0.0, 0.0
for i, n := range truncationTermCounts {
e := tc.err(sample, n)
if e > tc.tol[i] {
t.Fatalf("%s: sample %d truncation error %.6g at n=%d exceeds %.6g", tc.name, sample, e, n, tc.tol[i])
}
if e > envelope[i] {
envelope[i] = e
}
if i == 0 {
first = e
}
last = e
}
if last < first {
strict++
}
}
for i := 1; i < len(envelope); i++ {
if envelope[i] > envelope[i-1] {
t.Fatalf("%s: worst truncation error grows from %.6g at n=%d to %.6g at n=%d",
tc.name, envelope[i-1], truncationTermCounts[i-1], envelope[i], truncationTermCounts[i])
}
}
if 2*strict < samples {
t.Fatalf("%s: truncation error shrank with n at only %d of %d samples", tc.name, strict, samples)
}
}
}
func TestPlanetMoonBaselineRegression(t *testing.T) {
ephemtest.PinLegacy(t)
samples := loadBaselineSamples(t)
for _, sample := range samples {
for _, body := range sample.Planets {
gotLon := planet.WherePlanet(body.XT, 0, sample.TTJD)
if math.Float64bits(gotLon) != body.LonBits {
t.Fatalf("%s lon regression at %s", body.Name, sample.UTC)
}
gotLat := planet.WherePlanet(body.XT, 1, sample.TTJD)
if math.Float64bits(gotLat) != body.LatBits {
t.Fatalf("%s lat regression at %s", body.Name, sample.UTC)
}
gotRad := planet.WherePlanet(body.XT, 2, sample.TTJD)
if math.Float64bits(gotRad) != body.RadBits {
t.Fatalf("%s rad regression at %s", body.Name, sample.UTC)
}
}
if math.Float64bits(basic.HMoonTrueLo(sample.TTJD)) != sample.Moon.LonBits {
t.Fatalf("moon lon regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonTrueBo(sample.TTJD)) != sample.Moon.LatBits {
t.Fatalf("moon lat regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonAway(sample.TTJD)) != sample.Moon.DisBits {
t.Fatalf("moon distance regression at %s", sample.UTC)
}
}
}
// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。
var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"}
// planetTruncationTolerance 是 zn=黄经/黄纬(度)、日心距(AU)两种量纲的上限,取实测最坏值的 5 倍以上。
var planetTruncationTolerance = [][]float64{
{20, 2, 0.5, 0.1},
{5, 0.5, 0.05, 0.01},
{0.3, 0.1, 0.05, 0.02},
}
func planetMoonTruncationCases(samples []baselineSample) []truncationCase {
jdOf := func(index int) float64 { return samples[index].TTJD }
wherePlanet := func(xt, zn int) truncationCase {
return scalarTruncation(
fmt.Sprintf("planet.WherePlanetN(%s, zn=%d)", planetTruncationName[xt], zn),
jdOf,
func(jd float64, n int) float64 { return planet.WherePlanetN(xt, zn, jd, n) },
zn != 2,
planetTruncationTolerance[zn],
)
}
cases := make([]truncationCase, 0, 27)
for xt := 0; xt <= 7; xt++ {
for zn := 0; zn <= 2; zn++ {
cases = append(cases, wherePlanet(xt, zn))
}
}
return append(cases,
scalarTruncation("basic.HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}),
scalarTruncation("basic.HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}),
scalarTruncation("basic.HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}),
)
}
func TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) {
ephemtest.PinLegacy(t)
samples := loadBaselineSamples(t)
assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples))
}
func publicSunMoonTruncationCases(t *testing.T, samples []baselineSample) []truncationCase {
t.Helper()
dates := baselineDates(t, samples)
dateOf := func(index int) time.Time { return dates[index] }
return []truncationCase{
dateTruncation("sun.TrueLoN", dateOf, sun.TrueLoN, []float64{0.15, 0.05, 0.02, 0.006}),
dateTruncation("sun.TrueBoN", dateOf, sun.TrueBoN, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}),
dateTruncation("moon.TrueLoN", dateOf, moon.TrueLoN, []float64{5, 2.5, 1, 0.25}),
dateTruncation("moon.TrueBoN", dateOf, moon.TrueBoN, []float64{3, 0.75, 0.15, 0.06}),
}
}
func TestPublicSunMoonTruncationConvergesToFullSeries(t *testing.T) {
ephemtest.PinLegacy(t)
samples := loadBaselineSamples(t)
assertTruncationConverges(t, publicSunMoonTruncationCases(t, samples), len(samples))
}
-39
View File
@@ -1,39 +0,0 @@
package basic
import (
"fmt"
"testing"
)
func Test_All(t *testing.T) {
show()
}
func Benchmark_All(b *testing.B) {
for i := 0; i < b.N; i++ {
show()
}
}
func show() {
jde := GetNowJDE() - 1
ra := HSunApparentRa(jde - 8.0/24.0)
dec := HSunApparentDec(jde - 8.0/24.0)
fmt.Printf("当前JDE:%.14f\n", jde)
fmt.Println("当前太阳黄经:", HSunApparentLo(jde-8.0/24.0))
fmt.Println("当前太阳赤经:", ra)
fmt.Println("当前太阳赤纬:", dec)
fmt.Println("当前太阳星座:", WhichCst(ra, dec, jde))
fmt.Println("当前黄赤交角:", EclipticObliquity(jde-8.0/24.0, true))
fmt.Println("当前日出:", JDE2Date(GetSunRiseTime(jde, 115, 32, 8, 1, 10)))
fmt.Println("当前日落:", JDE2Date(GetSunDownTime(jde, 115, 32, 8, 1, 10)))
fmt.Println("当前晨影 -6:", JDE2Date(GetAsaTime(jde, 115, 32, 8, -6)))
fmt.Println("当前晨影 -12:", JDE2Date(GetAsaTime(jde, 115, 32, 8, -12)))
fmt.Println("当前昏影 -6:", JDE2Date(GetBanTime(jde, 115, 32, 8, -6)))
fmt.Println("当前昏影 -12:", JDE2Date(GetBanTime(jde, 115, 32, 8, -12)))
fmt.Print("农历:")
fmt.Println(GetLunar(2019, 10, 23, 8.0/24.0))
fmt.Println("当前月出:", JDE2Date(GetMoonRiseTime(jde, 115, 32, 8, 1, 10)))
fmt.Println("当前月落:", JDE2Date(GetMoonDownTime(jde, 115, 32, 8, 1, 10)))
fmt.Println("月相:", MoonLight(jde-8.0/24.0))
}
+50
View File
@@ -0,0 +1,50 @@
package basic
import (
"math"
"testing"
"time"
)
const ancientStationTolerance = 10.0 / 1440.0
func ancientStationTT(year int, month time.Month, day int) float64 {
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 Date2JD(time.Date(year, month, day, hour, minute, 0, 0, time.UTC))
}
func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) {
t.Helper()
if !eventUTQueryAfterOrEqual(gotUT, queryTT) {
t.Fatalf("station is before query: query TT %.9f, got UT %.9f", queryTT, gotUT)
}
if diff := math.Abs(gotUT - wantUT); diff > ancientStationTolerance {
t.Fatalf("station differs by %.3f minutes: got %s, want %s", diff*1440,
JD2DateByZone(gotUT, time.UTC, false), JD2DateByZone(wantUT, time.UTC, false))
}
}
func TestMarsAncientNextStationsStayOnTheirOppositionSides(t *testing.T) {
queryTT := ancientStationTT(-210, time.April, 1)
p2r := NextMarsProgradeToRetrograde(queryTT)
r2p := NextMarsRetrogradeToPrograde(queryTT)
assertAncientNextStation(t, queryTT, p2r, ancientStationUT(-209, time.March, 25, 20, 20))
assertAncientNextStation(t, queryTT, r2p, ancientStationUT(-209, time.June, 6, 11, 4))
if sameEventJD(p2r, r2p) {
t.Fatalf("typed Mars stations collapsed to one event: %.9f", p2r)
}
}
func TestMercuryAncientNextStationsDoNotSkipOrLoop(t *testing.T) {
janQueryTT := ancientStationTT(-210, time.January, 1)
assertAncientNextStation(t, janQueryTT, NextMercuryProgradeToRetrograde(janQueryTT), ancientStationUT(-210, time.February, 6, 21, 29))
assertAncientNextStation(t, janQueryTT, NextMercuryRetrogradeToPrograde(janQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
marQueryTT := ancientStationTT(-210, time.March, 1)
assertAncientNextStation(t, marQueryTT, NextMercuryProgradeToRetrograde(marQueryTT), ancientStationUT(-210, time.June, 11, 18, 50))
assertAncientNextStation(t, marQueryTT, NextMercuryRetrogradeToPrograde(marQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
}
+6
View File
@@ -0,0 +1,6 @@
package basic
import "math"
const rad = math.Pi / 180.0
const deg = 180.0 / math.Pi
+1794
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// 被测契约:日月视赤经纬单值与双值接口一致,且黄经、黄纬取自同一视方向。
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/ephemtest"
)
func TestSunMoonApparentRaDecUseFullDirection(t *testing.T) {
ephemtest.Pin(t, ephem.Semianalytic)
const jde = 2460409.25
for _, body := range []ephem.Body{ephem.Sun, ephem.Moon} {
lo, bo, _, _, ok := vectorApparent(body, jde, -1)
if !ok {
t.Fatalf("body %d: no vector apparent position", body)
}
wantRA, wantDec := LoBoToRaDec(jde, lo, bo)
var ra, dec float64
if body == ephem.Sun {
ra, dec = HSunApparentRaDec(jde)
if r, d := HSunApparentRa(jde), HSunApparentDec(jde); r != ra || d != dec {
t.Errorf("Sun single (%v, %v) != pair (%v, %v)", r, d, ra, dec)
}
} else {
ra, dec = HMoonGeocentricApparentRaDecN(jde, -1)
}
if math.Abs(ra-wantRA)*3600 > 1e-6 || math.Abs(dec-wantDec)*3600 > 1e-6 {
t.Errorf("body %d: (%v, %v), want (%v, %v)", body, ra, dec, wantRA, wantDec)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
// 被测契约:包级与实例视位置共用同一判据,零方向、非正距离、负光行时与非有限值一律报错。
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
)
type fixedApparentEngine struct {
dir [3]float64
dist, tau float64
}
func (e fixedApparentEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
return pos, vel, ephem.ErrUnsupportedBody
}
func (e fixedApparentEngine) Coverage(ephem.Body) (start, end float64, ok bool) { return 0, 0, false }
func (e fixedApparentEngine) Apparent(ephem.Body, float64) ([3]float64, float64, float64, error) {
return e.dir, e.dist, e.tau, nil
}
func TestApparentStateRejectsInvalid(t *testing.T) {
nan := math.NaN()
for _, c := range []struct {
name string
e fixedApparentEngine
ok bool
}{
{"valid", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, 0.0058}, true},
{"nan direction", fixedApparentEngine{[3]float64{nan, 0, 0}, 1.5e8, 0.0058}, false},
{"zero direction", fixedApparentEngine{[3]float64{}, 1.5e8, 0.0058}, false},
{"zero distance", fixedApparentEngine{[3]float64{1, 0, 0}, 0, 0.0058}, false},
{"inf distance", fixedApparentEngine{[3]float64{1, 0, 0}, math.Inf(1), 0.0058}, false},
{"negative tau", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, -1}, false},
{"nan tau", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, nan}, false},
} {
_, _, _, _, ok, err := apparentState(ephem.Sun, 2451545, ephem.NewInstance(c.e))
if c.ok && (err != nil || !ok) || !c.ok && err == nil {
t.Errorf("%s: ok=%v err=%v", c.name, ok, err)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package basic
import (
"math"
"b612.me/astro/internal/ephem"
)
// planetBody 旧模型行星序号 1..7(水星..海王星,不含地球)对应的 NAIF 编号。
var planetBody = [8]ephem.Body{0, ephem.Mercury, ephem.Venus, ephem.Mars, ephem.Jupiter, ephem.Saturn, ephem.Uranus, ephem.Neptune}
// vectorApparent 新模型全精度时的矢量视位置:真黄道经纬(度)、光行时(日)、几何地心距(AU);不适用时 ok=false。
// 状态非法时与显式实例路径一致:ok=true 且各值为 NaN,不得退回低精度公式掩盖错误。
func vectorApparent(target ephem.Body, jde float64, n int) (lo, bo, tau, distAU float64, ok bool) {
if n >= 0 || ephem.Active() != ephem.Semianalytic {
return 0, 0, 0, 0, false
}
lo, bo, tau, distAU, ok, err := apparentState(target, jde, ephem.Global())
if err != nil {
return math.NaN(), math.NaN(), math.NaN(), math.NaN(), true
}
return lo, bo, tau, distAU, ok
}
// icrfToTrueEcliptic ICRF 单位矢量 → 真黄道经纬(度);黄赤交角与下游 LoBoToRaDec 同口径,往返得到真赤道。
func icrfToTrueEcliptic(d [3]float64, jde float64) (lo, bo float64) {
m := ltpPBMAT(2000 + (jde-2451545)/365.25)
var q [3]float64
for i := range q {
q[i] = m[i][0]*d[0] + m[i][1]*d[1] + m[i][2]*d[2]
}
s, c := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
x, y, z := q[0], q[1]*c+q[2]*s, -q[1]*s+q[2]*c
lo = math.Atan2(y, x)*180/math.Pi + Nutation2000Bi(jde)
bo = math.Atan2(z, math.Hypot(x, y)) * 180 / math.Pi
return math.Mod(lo+720, 360), bo
}
// sunApparentLoBoN 太阳视黄经与黄纬;有矢量视位置时两者取自同一方向,否则退回视黄经配几何黄纬。
func sunApparentLoBoN(jde float64, n int, inst *Instance) (float64, float64) {
if lo, bo, _, _, ok, err := vectorApparentWithInstance(ephem.Sun, jde, n, inst); err != nil {
return math.NaN(), math.NaN()
} else if ok {
return lo, bo
}
return HSunApparentLoNWithInstance(jde, n, inst), HSunTrueBoNWithInstance(jde, n, inst)
}
// moonApparentLoBoN 月球视黄经与黄纬;有矢量视位置时两者取自同一方向,否则退回视黄经配几何黄纬。
func moonApparentLoBoN(jde float64, n int, inst *Instance) (float64, float64) {
if lo, bo, _, _, ok, err := vectorApparentWithInstance(ephem.Moon, jde, n, inst); err != nil {
return math.NaN(), math.NaN()
} else if ok {
return lo, bo
}
return HMoonApparentLoNWithInstance(jde, n, inst), HMoonTrueBoNWithInstance(jde, n, inst)
}
func sunApparentRaDecN(jde float64, n int, inst *Instance) (float64, float64) {
lo, bo := sunApparentLoBoN(jde, n, inst)
return LoBoToRaDec(jde, lo, bo)
}
func sunApparentRaN(jde float64, n int, inst *Instance) float64 {
ra, _ := sunApparentRaDecN(jde, n, inst)
return ra
}
func moonApparentRaDecN(jde float64, n int, inst *Instance) (float64, float64) {
lo, bo := moonApparentLoBoN(jde, n, inst)
return LoBoToRaDec(jde, lo, bo)
}
func moonApparentRaN(jde float64, n int, inst *Instance) float64 {
ra, _ := moonApparentRaDecN(jde, n, inst)
return ra
}
+267
View File
@@ -0,0 +1,267 @@
package basic
import (
"math"
"sort"
"time"
)
const (
earthApsisBaseTTJDE = 2451547.507
earthApsisMeanYearDays = 365.2596358
earthApsisQuadraticTerm = 0.0000000156
earthApsisBaseYear = 2000.01
earthApsisSeedScale = 0.99997
earthApsisBracketHalfWidth = 5.0
earthApsisSampleStep = 0.25
earthApsisDerivativeStep = 1e-3
earthApsisToleranceDays = 1e-8
earthApsisMaxIterations = 24
moonApsisBaseTTJDE = 2451534.6698
moonApsisMeanMonthDays = 27.55454989
moonApsisBaseCycle = 1325.55
moonApsisQuadraticTerm = -0.0006691
moonApsisCubicTerm = -0.000001098
moonApsisQuarticTerm = 0.0000000052
moonApsisBracketHalfWidth = 2.0
moonApsisSampleStep = 0.125
moonApsisDerivativeStep = 1e-4
moonApsisToleranceDays = 1e-8
moonApsisMaxIterations = 24
)
// ApsisEvent 轨道极值事件 / orbital distance extremum event.
type ApsisEvent struct {
// JD 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day.
JD float64
// Distance 是极值距离;地球相关事件单位 AU,月球相关事件单位 km / extremum distance.
Distance float64
}
type apsisSearchConfig struct {
bracketHalfWidth float64
sampleStep float64
derivativeStep float64
toleranceDays float64
maxIterations int
maximize bool
}
// EarthPerihelion 地球指定年份的近日点 / Earth perihelion in the given year.
func EarthPerihelion(year int) ApsisEvent {
return earthApsis(year, false)
}
// EarthAphelion 地球指定年份的远日点 / Earth aphelion in the given year.
func EarthAphelion(year int) ApsisEvent {
return earthApsis(year, true)
}
// MoonPerigeesWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonPerigeesWithInstance(year int, month time.Month, inst *Instance) []ApsisEvent {
return moonApsisInMonth(year, month, false, inst)
}
// MoonApogeesWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonApogeesWithInstance(year int, month time.Month, inst *Instance) []ApsisEvent {
return moonApsisInMonth(year, month, true, inst)
}
func earthApsis(year int, aphelion bool) ApsisEvent {
seedTT := earthApsisSeedTT(year, aphelion)
cfg := apsisSearchConfig{
bracketHalfWidth: earthApsisBracketHalfWidth,
sampleStep: earthApsisSampleStep,
derivativeStep: earthApsisDerivativeStep,
toleranceDays: earthApsisToleranceDays,
maxIterations: earthApsisMaxIterations,
maximize: aphelion,
}
eventTT, distanceAU := refineDistanceExtremum(seedTT, cfg, EarthAway)
return ApsisEvent{
JD: TT2UTC(eventTT),
Distance: distanceAU,
}
}
func moonApsisInMonth(year int, month time.Month, apogee bool, instances ...*Instance) []ApsisEvent {
inst := instanceValue(instances)
startUTC := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
endUTC := startUTC.AddDate(0, 1, 0)
startTT := UTC2TT(Date2JD(startUTC))
endTT := UTC2TT(Date2JD(endUTC))
kStart := int(math.Floor((startTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) - 1
kEnd := int(math.Ceil((endTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) + 1
phase := 0.0
if apogee {
phase = 0.5
}
cfg := apsisSearchConfig{
bracketHalfWidth: moonApsisBracketHalfWidth,
sampleStep: moonApsisSampleStep,
derivativeStep: moonApsisDerivativeStep,
toleranceDays: moonApsisToleranceDays,
maxIterations: moonApsisMaxIterations,
maximize: apogee,
}
events := make([]ApsisEvent, 0, 2)
for k := kStart; k <= kEnd; k++ {
seedTT := moonApsisSeedTT(float64(k) + phase)
eventTT, distanceKM := refineDistanceExtremum(seedTT, cfg, func(jde float64) float64 { return HMoonAwayWithInstance(jde, inst) })
eventUT := TT2UTC(eventTT)
eventTimeUTC := JD2DateByZone(eventUT, time.UTC, false)
if eventTimeUTC.Before(startUTC) || !eventTimeUTC.Before(endUTC) {
continue
}
events = append(events, ApsisEvent{
JD: eventUT,
Distance: distanceKM,
})
}
sort.Slice(events, func(i, j int) bool {
return events[i].JD < events[j].JD
})
return events
}
func earthApsisSeedTT(year int, aphelion bool) float64 {
k := math.Round(earthApsisSeedScale * (float64(year) - earthApsisBaseYear))
if aphelion {
k += 0.5
}
return earthApsisBaseTTJDE + earthApsisMeanYearDays*k + earthApsisQuadraticTerm*k*k
}
func moonApsisSeedTT(k float64) float64 {
t := k / moonApsisBaseCycle
return moonApsisBaseTTJDE +
moonApsisMeanMonthDays*k +
moonApsisQuadraticTerm*t*t +
moonApsisCubicTerm*t*t*t +
moonApsisQuarticTerm*t*t*t*t
}
func refineDistanceExtremum(seed float64, cfg apsisSearchConfig, distanceFn func(float64) float64) (float64, float64) {
if !finite(seed) || !finite(cfg.bracketHalfWidth) || !finite(cfg.sampleStep) || cfg.sampleStep <= 0 {
return seed, math.NaN()
}
best := seed
bestDistance := distanceFn(seed)
// seed 幅度很大时 sample += sampleStep 可能小于一个 ULP(浮点间隔)而永不推进,
// 因此显式检测步长是否真的改变了采样点,避免无界循环。
// At a large |seed| the step can fall below one ULP, so `sample += step` would never
// advance; detect a step that does not move the sample and stop the sweep instead.
for sample, end := seed-cfg.bracketHalfWidth, seed+cfg.bracketHalfWidth+1e-12; sample <= end; {
dist := distanceFn(sample)
if distanceBetter(dist, bestDistance, cfg.maximize) {
best = sample
bestDistance = dist
}
next := sample + cfg.sampleStep
if next == sample {
break
}
sample = next
}
left, right, ok := apsisDerivativeBracket(best, seed, cfg, distanceFn)
if !ok {
return best, bestDistance
}
leftDeriv := apsisDistanceDerivative(distanceFn, left, cfg.derivativeStep)
rightDeriv := apsisDistanceDerivative(distanceFn, right, cfg.derivativeStep)
current := best
for i := 0; i < cfg.maxIterations; i++ {
first, second := apsisDistanceDerivatives(distanceFn, current, cfg.derivativeStep)
next := current
if math.Abs(second) > 0 {
next = current - first/second
}
if !(next > left && next < right) || math.IsNaN(next) || math.IsInf(next, 0) {
next = (left + right) / 2
}
nextDeriv := apsisDistanceDerivative(distanceFn, next, cfg.derivativeStep)
if leftDeriv == 0 {
right = next
rightDeriv = nextDeriv
} else if leftDeriv*nextDeriv <= 0 {
right = next
rightDeriv = nextDeriv
} else {
left = next
leftDeriv = nextDeriv
}
if math.Abs(next-current) <= cfg.toleranceDays || math.Abs(right-left) <= cfg.toleranceDays {
current = next
break
}
current = next
_ = rightDeriv
}
return current, distanceFn(current)
}
func apsisDerivativeBracket(best, seed float64, cfg apsisSearchConfig, distanceFn func(float64) float64) (float64, float64, bool) {
leftBound := seed - cfg.bracketHalfWidth
rightBound := seed + cfg.bracketHalfWidth
left := best - cfg.sampleStep
right := best + cfg.sampleStep
if left < leftBound {
left = leftBound
}
if right > rightBound {
right = rightBound
}
leftDeriv := apsisDistanceDerivative(distanceFn, left, cfg.derivativeStep)
rightDeriv := apsisDistanceDerivative(distanceFn, right, cfg.derivativeStep)
for i := 0; i < cfg.maxIterations; i++ {
if leftDeriv == 0 || rightDeriv == 0 || leftDeriv*rightDeriv < 0 {
return left, right, true
}
if left > leftBound {
left -= cfg.sampleStep
if left < leftBound {
left = leftBound
}
leftDeriv = apsisDistanceDerivative(distanceFn, left, cfg.derivativeStep)
}
if right < rightBound {
right += cfg.sampleStep
if right > rightBound {
right = rightBound
}
rightDeriv = apsisDistanceDerivative(distanceFn, right, cfg.derivativeStep)
}
}
return 0, 0, false
}
func apsisDistanceDerivative(distanceFn func(float64) float64, jd, h float64) float64 {
return (distanceFn(jd+h) - distanceFn(jd-h)) / (2 * h)
}
func apsisDistanceDerivatives(distanceFn func(float64) float64, jd, h float64) (float64, float64) {
prev := distanceFn(jd - h)
curr := distanceFn(jd)
next := distanceFn(jd + h)
first := (next - prev) / (2 * h)
second := (next - 2*curr + prev) / (h * h)
return first, second
}
func distanceBetter(candidate, current float64, maximize bool) bool {
if maximize {
return candidate > current
}
return candidate < current
}
+174
View File
@@ -0,0 +1,174 @@
package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
type earthApsisSample struct {
Kind string `json:"kind"`
Year int `json:"year"`
TimeUTC string `json:"time_utc"`
DistanceAU float64 `json:"distance_au"`
}
type moonApsisSample struct {
Kind string `json:"kind"`
Year int `json:"year"`
Month int `json:"month"`
TimeUTC string `json:"time_utc"`
DistanceKM float64 `json:"distance_km"`
}
type moonApsisMonthState struct {
perigees []ApsisEvent
apogees []ApsisEvent
perigeeI int
apogeeI int
}
func TestEarthApsisMatchesHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/earth_apsis_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []earthApsisSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
const timeTolerance = 2 * time.Minute
const distanceToleranceAU = 5e-8
var maxTimeDiff time.Duration
var maxDistanceDiff float64
for _, sample := range samples {
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
var got ApsisEvent
switch sample.Kind {
case "perihelion":
got = EarthPerihelion(sample.Year)
case "aphelion":
got = EarthAphelion(sample.Year)
default:
t.Fatalf("unknown earth apsis kind %q", sample.Kind)
}
gotTime := JD2DateByZone(got.JD, time.UTC, false)
timeDiff := gotTime.Sub(wantTime)
if timeDiff < 0 {
timeDiff = -timeDiff
}
if timeDiff > maxTimeDiff {
maxTimeDiff = timeDiff
}
if timeDiff > timeTolerance {
t.Fatalf("%s %d time mismatch: got %s want %s tolerance %v", sample.Kind, sample.Year, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, timeTolerance)
}
distanceDiff := math.Abs(got.Distance - sample.DistanceAU)
if distanceDiff > maxDistanceDiff {
maxDistanceDiff = distanceDiff
}
if distanceDiff > distanceToleranceAU {
t.Fatalf("%s %d distance mismatch: got %.12f want %.12f tolerance %.12f", sample.Kind, sample.Year, got.Distance, sample.DistanceAU, distanceToleranceAU)
}
}
t.Logf("earth apsis max diff: time=%v distance=%.12f AU", maxTimeDiff, maxDistanceDiff)
}
func TestMoonApsisMatchesHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/moon_apsis_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []moonApsisSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
const timeTolerance = 20 * time.Minute
const distanceToleranceKM = 50.0
states := make(map[int]*moonApsisMonthState)
var maxTimeDiff time.Duration
var maxDistanceDiff float64
for _, sample := range samples {
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
key := sample.Year*100 + sample.Month
state := states[key]
if state == nil {
state = &moonApsisMonthState{
perigees: MoonPerigees(sample.Year, time.Month(sample.Month)),
apogees: MoonApogees(sample.Year, time.Month(sample.Month)),
}
states[key] = state
}
var got ApsisEvent
switch sample.Kind {
case "perigee":
if state.perigeeI >= len(state.perigees) {
t.Fatalf("%04d-%02d missing perigee #%d", sample.Year, sample.Month, state.perigeeI+1)
}
got = state.perigees[state.perigeeI]
state.perigeeI++
case "apogee":
if state.apogeeI >= len(state.apogees) {
t.Fatalf("%04d-%02d missing apogee #%d", sample.Year, sample.Month, state.apogeeI+1)
}
got = state.apogees[state.apogeeI]
state.apogeeI++
default:
t.Fatalf("unknown moon apsis kind %q", sample.Kind)
}
gotTime := JD2DateByZone(got.JD, time.UTC, false)
timeDiff := gotTime.Sub(wantTime)
if timeDiff < 0 {
timeDiff = -timeDiff
}
if timeDiff > maxTimeDiff {
maxTimeDiff = timeDiff
}
if timeDiff > timeTolerance {
t.Fatalf("%s %04d-%02d time mismatch: got %s want %s tolerance %v", sample.Kind, sample.Year, sample.Month, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, timeTolerance)
}
distanceDiff := math.Abs(got.Distance - sample.DistanceKM)
if distanceDiff > maxDistanceDiff {
maxDistanceDiff = distanceDiff
}
if distanceDiff > distanceToleranceKM {
t.Fatalf("%s %04d-%02d distance mismatch: got %.6f want %.6f tolerance %.6f", sample.Kind, sample.Year, sample.Month, got.Distance, sample.DistanceKM, distanceToleranceKM)
}
}
for key, state := range states {
year := key / 100
month := key % 100
if state.perigeeI != len(state.perigees) {
t.Fatalf("%04d-%02d unconsumed perigees: got %d of %d", year, month, state.perigeeI, len(state.perigees))
}
if state.apogeeI != len(state.apogees) {
t.Fatalf("%04d-%02d unconsumed apogees: got %d of %d", year, month, state.apogeeI, len(state.apogees))
}
}
t.Logf("moon apsis max diff: time=%v distance=%.6f km", maxTimeDiff, maxDistanceDiff)
}
+137
View File
@@ -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)
}
+128
View File
@@ -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)
}
}
-24
View File
@@ -1,24 +0,0 @@
package basic
import (
"fmt"
"testing"
"time"
)
func Test_JDECALC(t *testing.T) {
i := 10
for i > 0 {
fmt.Printf("%.18f\n", GetNowJDE())
time.Sleep(time.Second)
i--
}
}
func Test_TDUT(t *testing.T) {
fmt.Printf("%.18f\n", DeltaT(2119.5, false))
}
func Test_JDE2(t *testing.T) {
fmt.Println(JDE2Date(2458868.500))
}
-438
View File
@@ -1,438 +0,0 @@
package basic
import (
"fmt"
"math"
"strings"
"time"
)
var defDeltaTFn = DefaultDeltaT
/*
@name: 儒略日计算
@dec: 计算给定时间的儒略日,1582年改力后为格里高利历,之前为儒略历
@ 请注意,传入的时间在天文计算中一般为力学时,应当注意和世界时的转化
*/
func JDECalc(Year, Month int, Day float64) float64 {
if Month == 1 || Month == 2 {
Year--
Month += 12
}
var tmpvarB int
tmpvar := fmt.Sprintf("%04d-%02d-%2d", Year, Month, int(math.Floor(Day)))
if strings.Compare(tmpvar, `1582-10-04`) != 1 {
tmpvarB = 0
} else {
tmpvarA := int(Year / 100)
tmpvarB = 2 - tmpvarA + int(tmpvarA/4)
}
return (math.Floor(365.25*(float64(Year)+4716.0)) + math.Floor(30.6001*float64(Month+1)) + Day + float64(tmpvarB) - 1524.5)
}
/*
@name: 获得当前儒略日时间:当地世界时,非格林尼治时间
*/
func GetNowJDE() (NowJDE float64) {
Time := float64(time.Now().Second())/3600.0/24.0 + float64(time.Now().Minute())/60.0/24.0 + float64(time.Now().Hour())/24.0
NowJDE = JDECalc(time.Now().Year(), int(time.Now().Month()), float64(time.Now().Day())+Time)
return
}
func dt_ext(y, jsd float64) float64 { // 二次曲线外推,用于数值外插
dy := (y - 1820) / 100.00
return -20 + jsd*dy*dy
}
func dt_cal(y float64) float64 { //传入年, 返回世界时UT与原子时(力学时 TD)之差, ΔT = TD - UT
dt_at := []float64{
-4000, 108371.7, -13036.80, 392.000, 0.0000,
-500, 17201.0, -627.82, 16.170, -0.3413,
-150, 12200.6, -346.41, 5.403, -0.1593,
150, 9113.8, -328.13, -1.647, 0.0377,
500, 5707.5, -391.41, 0.915, 0.3145,
900, 2203.4, -283.45, 13.034, -0.1778,
1300, 490.1, -57.35, 2.085, -0.0072,
1600, 120.0, -9.81, -1.532, 0.1403,
1700, 10.2, -0.91, 0.510, -0.0370,
1800, 13.4, -0.72, 0.202, -0.0193,
1830, 7.8, -1.81, 0.416, -0.0247,
1860, 8.3, -0.13, -0.406, 0.0292,
1880, -5.4, 0.32, -0.183, 0.0173,
1900, -2.3, 2.06, 0.169, -0.0135,
1920, 21.2, 1.69, -0.304, 0.0167,
1940, 24.2, 1.22, -0.064, 0.0031,
1960, 33.2, 0.51, 0.231, -0.0109,
1980, 51.0, 1.29, -0.026, 0.0032,
2000, 63.87, 0.1, 0, 0,
2005, 64.7, 0.4, 0, 0, //一次项记为x,则 10x=0.4秒/年*(2015-2005),解得x=0.4
2015, 69,
}
y0 := dt_at[len(dt_at)-2] //表中最后一年
t0 := dt_at[len(dt_at)-1] //表中最后一年的 deltatT
if y >= y0 {
jsd := float64(31) // sjd是y1年之后的加速度估计
// 瑞士星历表jsd=31, NASA网站jsd=32, skmap的jsd=29
if y > y0+100.00 {
return dt_ext(y, jsd)
}
v := dt_ext(y, jsd) //二次曲线外推
dv := dt_ext(y0, jsd) - t0 // ye年的二次外推与te的差
return (v - dv*(y0+100.00-y)/100.00)
}
d := dt_at
var i int
for i = 0; i < len(d); i += 5 {
if float64(y) < d[i+5] {
break
// 判断年所在的区间
}
}
t1 := (y - d[i]) / (d[i+5] - d[i]) * 10.00 //////// 三次插值, 保证精确性
t2 := t1 * t1
t3 := t2 * t1
res := d[i+1] + d[i+2]*t1 + d[i+3]*t2 + d[i+4]*t3
return (res)
}
func DeltaT(date float64, isJDE bool) float64 {
return defDeltaTFn(date, isJDE)
}
func SetDeltaTFn(fn func(float64, bool) float64) {
if fn != nil {
defDeltaTFn = fn
}
}
func GetDeltaTFn() func(float64, bool) float64 {
return defDeltaTFn
}
func OldDefaultDeltaT(Date float64, IsJDE bool) (Result float64) { //传入年或儒略日,传出为秒
var Year float64
if IsJDE {
dates := JDE2Date(Date)
Year = float64(dates.Year()) + float64(dates.YearDay())/365.0
} else {
Year = Date
}
if Year < 2100 && Year >= 2010 {
return dt_cal(Year)
}
return DefaultDeltaT(Date, IsJDE)
}
func DefaultDeltaT(Date float64, IsJDE bool) (Result float64) { //传入年或儒略日,传出为秒
var Year float64
if IsJDE {
dates := JDE2Date(Date)
Year = float64(dates.Year()) + float64(dates.YearDay())/365.0
} else {
Year = Date
}
if Year < 2010 {
Result = dt_cal(Year)
return
}
if Year < 2100 && Year >= 2010 {
var t = (Year - 2000.0)
Result = 62.92 + 0.32217*t + 0.005589*t*t
return
}
if Year >= 2100 && Year <= 2150 {
Result = -20 + 32*(((Year-1820.0)/100.0)*((Year-1820.0)/100.0)) - 0.5628*(2150-Year)
return
}
if Year > 2150 {
//tmp=(Year-1820)/100;
//Result= -20 + 32 * tmp*tmp;
Result = dt_cal(Year)
return
}
return
}
func TD2UT(JDE float64, UT2TD bool) float64 { // true 世界时转力学时CC,false 力学时转世界时VV
Deltat := DeltaT(JDE, true)
if UT2TD {
return JDE + Deltat/3600/24
} else {
return JDE - Deltat/3600/24
}
}
/*
* @name: JDE转日期,输出为数组
*/
func JDE2Date(JD float64) time.Time {
JD = JD + 0.5
Z := float64(int(JD))
F := JD - Z
var A, B, Years, Months, Days float64
if Z < 2299161.0 {
A = Z
} else {
alpha := math.Floor((Z - 1867216.25) / 36524.25)
A = Z + 1 + alpha - math.Floor(alpha/4)
}
B = A + 1524
C := math.Floor((B - 122.1) / 365.25)
D := math.Floor(365.25 * C)
E := math.Floor((B - D) / 30.6001)
Days = B - D - math.Floor(30.6001*E) + F
if E < 14 {
Months = E - 1
}
if E == 14 || E == 15 {
Months = E - 13
}
if Months > 2 {
Years = C - 4716
}
if Months == 1 || Months == 2 {
Years = C - 4715
}
tms := (Days - math.Floor(Days)) * 24 * 3600
Days = math.Floor(Days)
tz, _ := time.LoadLocation("Local")
dates := time.Date(int(Years), time.Month(int(Months)), int(Days), 0, 0, 0, 0, tz)
return time.Unix(dates.Unix()+int64(tms), int64((tms-math.Floor(tms))*1000000000))
}
// JDE2DateByZone JDE(儒略日)转日期
// JD: 儒略日
// tz: 目标时区
// byZone: (true: 传入的儒略日视为目标时区当地时间的儒略日,false: 传入的儒略日视为UTC时间的儒略日)
// 回参:转换后的日期,时区始终为目标时区
func JDE2DateByZone(JD float64, tz *time.Location, byZone bool) time.Time {
JD = JD + 0.5
Z := float64(int(JD))
F := JD - Z
var A, B, Years, Months, Days float64
if Z < 2299161.0 {
A = Z
} else {
alpha := math.Floor((Z - 1867216.25) / 36524.25)
A = Z + 1 + alpha - math.Floor(alpha/4)
}
B = A + 1524
C := math.Floor((B - 122.1) / 365.25)
D := math.Floor(365.25 * C)
E := math.Floor((B - D) / 30.6001)
Days = B - D - math.Floor(30.6001*E) + F
if E < 14 {
Months = E - 1
}
if E == 14 || E == 15 {
Months = E - 13
}
if Months > 2 {
Years = C - 4716
}
if Months == 1 || Months == 2 {
Years = C - 4715
}
tms := (Days - math.Floor(Days)) * 24 * 3600
Days = math.Floor(Days)
var transTz = tz
if !byZone {
transTz = time.UTC
}
return time.Date(int(Years), time.Month(int(Months)), int(Days), 0, 0, 0, 0, transTz).
Add(time.Duration(int64(1000000000 * tms))).In(tz)
}
func GetLunar(year, month, day int, tz float64) (lmonth, lday int, leap bool, result string) {
jde := JDECalc(year, month, float64(day)) //计算当前JDE时间
if month == 11 || month == 12 { //判断当前日期属于前一年周期还是后一年周期
//判断方法:当前日期与冬至日所在朔望月的关系
winterday := GetJQTime(year, 270) + tz //冬至日日期(世界时,北京时间)
Fday := TD2UT(CalcMoonS(float64(year)+11.0/12.0+5.0/30.0/12.0, 0), true) + tz //朔月(世界时,北京时间)
Yday := TD2UT(CalcMoonS(float64(year)+1.0, 0), true) + tz //下一朔月(世界时,北京时间)
if Fday-math.Floor(Fday) > 0.5 {
Fday = math.Floor(Fday) + 0.5
} else {
Fday = math.Floor(Fday) - 0.5
}
if Yday-math.Floor(Yday) > 0.5 {
Yday = math.Floor(Yday) + 0.5
} else {
Yday = math.Floor(Yday) - 0.5
}
if winterday >= Fday && winterday < Yday && jde <= Fday {
year--
}
if winterday >= Yday && jde < Yday {
year--
}
} else {
year--
}
jieqi := GetJieqiLoops(year, 25) //一年的节气
moon := GetMoonLoops(float64(year), 17) //一年朔月日
winter1 := jieqi[0] - 8.0/24 + tz //第一年冬至日
winter2 := jieqi[24] - 8.0/24 + tz //第二年冬至日
for idx, v := range moon {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
moon[idx] = math.Floor(v) + 0.5
} else {
moon[idx] = math.Floor(v) - 0.5
}
} //置闰月为0点
for idx, v := range jieqi {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
jieqi[idx] = math.Floor(v) + 0.5
} else {
jieqi[idx] = math.Floor(v) - 0.5
}
} //置节气为0点
mooncount := 0 //年内朔望月计数
var min, max int = 20, 0 //最大最小计数
for i := 0; i < 15; i++ {
if moon[i] >= winter1 && moon[i] < winter2 {
if i <= min {
min = i
}
if i >= max {
max = i
}
mooncount++
}
}
leapmonth := 20
if mooncount == 13 { //存在闰月
var j, i = 2, 0
for i = min; i <= max; i++ {
if !(moon[i] <= jieqi[j] && moon[i+1] > jieqi[j]) {
break
}
j += 2
}
leapmonth = i - min + 1
}
i := 0
for i = min - 1; i <= max; i++ {
if moon[i] > jde {
break
}
}
lmonth = i - min
var sleap bool = false
leap = false
if lmonth >= leapmonth {
sleap = true
}
if lmonth == leapmonth {
leap = true
}
if lmonth < 2 {
lmonth += 11
} else {
lmonth--
}
if sleap {
lmonth--
}
if lmonth <= 0 {
lmonth += 12
}
lday = int(jde-moon[i-1]) + 1
strmonth := []string{"十", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"}
strday := []string{"初", "十", "廿", "三"}
if leap {
result += "闰"
}
if lmonth == 1 {
result += "正月"
} else {
result += strmonth[lmonth] + "月"
}
if lday == 20 {
result += "二十"
} else if lday == 10 {
result += "初十"
} else {
result += strday[lday/10] + strmonth[lday%10]
}
return
}
func GetSolar(year, month, day int, leap bool, tz float64) float64 {
if month < 11 {
year--
}
jieqi := GetJieqiLoops(year, 25) //一年的节气
moon := GetMoonLoops(float64(year), 17) //一年朔月日
winter1 := jieqi[0] - 8.0/24 + tz //第一年冬至日
winter2 := jieqi[24] - 8.0/24 + tz //第二年冬至日
for idx, v := range moon {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
moon[idx] = math.Floor(v) + 0.5
} else {
moon[idx] = math.Floor(v) - 0.5
}
} //置闰月为0点
for idx, v := range jieqi {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
jieqi[idx] = math.Floor(v) + 0.5
} else {
jieqi[idx] = math.Floor(v) - 0.5
}
} //置节气为0点
mooncount := 0 //年内朔望月计数
var min, max int = 20, 0 //最大最小计数
for i := 0; i < 15; i++ {
if moon[i] >= winter1 && moon[i] < winter2 {
if i <= min {
min = i
}
if i >= max {
max = i
}
mooncount++
}
}
leapmonth := 20
if mooncount == 13 { //存在闰月
var j, i = 2, 0
for i = min; i <= max; i++ {
if !(moon[i] <= jieqi[j] && moon[i+1] > jieqi[j]) {
break
}
j += 2
}
leapmonth = i - min + 1
}
if leap {
month++
}
if month > 10 {
month -= 11
} else {
month++
}
if month >= leapmonth && !leap {
month++
}
jde := moon[min-1+month] + float64(day) - 1
return jde
}
// Date2JDE 日期转儒略日
func Date2JDE(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)
}
+55 -110
View File
@@ -1,126 +1,71 @@
package basic
import (
"encoding/json"
"fmt"
"math"
"os"
"testing"
)
func generateMagicNumber() {
//0月份 00000 日期 0000闰月 0000000000000 农历信息
var tz = 8.0000 / 24.000
yearMap := make(map[int][][]int) // {1 month,1 leap,2 29/30}
spYear := make(map[int][]int)
var upper []uint16
var lower []uint16
//var info uint32 = 0
for year := 1899; year <= 2401; year++ {
fmt.Println(year)
jieqi := GetJieqiLoops(year, 25) //一年的节气
moon := GetMoonLoops(float64(year), 17) //一年朔月日
winter1 := jieqi[0] - 8.0/24 + tz //第一年冬至日
winter2 := jieqi[24] - 8.0/24 + tz //第二年冬至日
for idx, v := range moon {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
moon[idx] = math.Floor(v) + 0.5
} else {
moon[idx] = math.Floor(v) - 0.5
}
} //置闰月为0点
for idx, v := range jieqi {
if tz != 8.0/24 {
v = v - 8.0/24 + tz
}
if v-math.Floor(v) > 0.5 {
jieqi[idx] = math.Floor(v) + 0.5
} else {
jieqi[idx] = math.Floor(v) - 0.5
}
} //置节气为0点
mooncount := 0 //年内朔望月计数
var min, max int = 20, 0 //最大最小计数
for i := 0; i < 15; i++ {
if moon[i] >= winter1 && moon[i] < winter2 {
if i <= min {
min = i
}
if i >= max {
max = i
}
mooncount++
}
func TestGetJQTime(t *testing.T) {
originalFunc := func(year, angle int) float64 {
const iterations = 1
var day int
if angle%2 == 0 {
day = 18
} else {
day = 3
}
leapmonth := 20
if mooncount == 13 { //存在闰月
var j, i = 2, 0
for i = min; i <= max; i++ {
if !(moon[i] <= jieqi[j] && moon[i+1] > jieqi[j]) {
month := 3.0
if angle%10 != 0 {
month += float64(angle+15) / 30.0
} else {
month += float64(angle) / 30.0
}
if month > 12 {
month -= 12
}
jd := JDCalc(year, int(month), float64(day))
if angle == 0 {
angle = 360
}
for i := 0; i < iterations; i++ {
for {
jd0 := jd
slope := (JQLospec(jd0+0.000005, float64(angle)) - JQLospec(jd0-0.000005, float64(angle))) / 0.00001
jd = jd0 - (JQLospec(jd0, float64(angle))-float64(angle))/slope
if math.Abs(jd-jd0) <= 0.00001 {
break
}
j += 2
}
leapmonth = i - min + 1
}
month := 11
for idx := min; idx <= max; idx++ {
leap := 0
if idx != leapmonth {
month++
if month > 12 {
month -= 12
}
} else {
leap = 1
}
if leap == 0 && month == 1 {
cp := JDE2Date(moon[idx])
spYear[year+1] = append(spYear[year+1], []int{int(cp.Month()), cp.Day()}...)
}
if idx < 6 && month > 10 {
yearMap[year] = append(yearMap[year], []int{month, leap, int(moon[idx+1] - moon[idx])})
} else {
yearMap[year+1] = append(yearMap[year+1], []int{month, leap, int(moon[idx+1] - moon[idx])})
}
jd -= 0.001
}
jd += 0.001
return TT2UTC(jd)
}
for year := 1900; year <= 2400; year++ {
fmt.Println(year)
up, low := magicNumberSpilt(magicNumber(yearMap[year], spYear[year]))
upper = append(upper, up)
lower = append(lower, uint16(low))
}
res := make(map[string]interface{})
res["up"] = upper
res["low"] = lower
d, _ := json.Marshal(res)
os.WriteFile("test.json", d, 0644)
}
func magicNumber(y1 [][]int, y2 []int) int32 {
var res int32
res = int32(y2[1]) << 18
if y2[0] == 2 {
res = res | 0x800000
testCases := []struct {
year int
angle int
}{
{1900, 0}, {1900, 15}, {1900, 30}, {1900, 45}, {1900, 90}, {1900, 180}, {1900, 270}, {1900, 360},
{1950, 0}, {1950, 30}, {1950, 90}, {1950, 180}, {1950, 270},
{2000, 0}, {2000, 15}, {2000, 45}, {2000, 90}, {2000, 360},
{2023, 0}, {2023, 30}, {2023, 90}, {2023, 180}, {2023, 270},
{2100, 0}, {2100, 15}, {2100, 30}, {2100, 45}, {2100, 90}, {2100, 180}, {2100, 270}, {2100, 360},
{2200, 0}, {2200, 30}, {2200, 90}, {2200, 180}, {2200, 270},
}
for idx, v := range y1 {
if v[2] == 30 {
res = res | (1 << (13 - idx))
}
if v[1] == 1 {
res = (res & 0xFC3FFF) | int32((v[0])<<14)
}
}
return res
}
func magicNumberSpilt(magic int32) (uint16, uint8) {
var upper uint16
var lower uint8
lower = uint8(magic)
upper = uint16(magic >> 8)
return upper, lower
for _, tc := range testCases {
originalResult := originalFunc(tc.year, tc.angle)
optimizedResult := GetJQTime(tc.year, tc.angle)
// 两种解法各自收敛到 1e-5 日,末步残差可达数十微秒;1e-8 日足以区分选错相邻年份的根。
diff := math.Abs(originalResult - optimizedResult)
if diff > 1e-8 {
t.Fatalf("GetJQTime mismatch: year=%d angle=%d original=%.15f optimized=%.15f diff=%.15f",
tc.year, tc.angle, originalResult, optimizedResult, diff)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
type constellationPoint struct {
RA float64 //赤经
DEC float64 //赤纬
}
var constellationPolygons map[string][]constellationPoint
func initConstellationPolygons() {
constellationPolygons = make(map[string][]constellationPoint, 89)
constellationPolygons["AND"] = []constellationPoint{constellationPoint{344.46530375, 35.1682358}, constellationPoint{344.34285125, 53.1680298}, constellationPoint{351.45289375, 53.1870041}, constellationPoint{351.4656825, 50.6870193}, constellationPoint{355.27055708333, 50.6929131}, constellationPoint{355.27607875, 48.6929169}, constellationPoint{4.1463675, 48.6949348}, constellationPoint{4.14327875, 46.6949348}, constellationPoint{14.776077083333, 46.6757545}, constellationPoint{14.7888675, 48.6757393}, constellationPoint{18.588407916667, 48.6632690}, constellationPoint{18.60590375, 50.6632347}, constellationPoint{22.40793625, 50.6478767}, constellationPoint{26.96852375, 50.6257439}, constellationPoint{26.931439583333, 47.6258430}, constellationPoint{32.62149125, 47.5927505}, constellationPoint{32.67380125, 51.0925827}, constellationPoint{39.88547875, 51.0423737}, constellationPoint{39.67934125, 37.2931557}, constellationPoint{31.87109125, 37.3470840}, constellationPoint{31.854250416667, 35.5971375}, constellationPoint{22.910835, 35.6453362}, constellationPoint{22.89742625, 33.6453705}, constellationPoint{12.44306375, 33.6818962}, constellationPoint{12.41349125, 24.4319324}, constellationPoint{14.424064583333, 24.4266243}, constellationPoint{14.414815416667, 21.6766376}, constellationPoint{3.73992125, 21.6951923}, constellationPoint{3.7406445833333, 22.6951923}, constellationPoint{2.61001625, 22.6957588}, constellationPoint{2.6128425, 28.6957588}, constellationPoint{1.60621625, 28.6960354}, constellationPoint{1.6069770833333, 32.0293655}, constellationPoint{357.82874125, 32.0285034}, constellationPoint{357.8280825, 32.7785072}, constellationPoint{354.04915791667, 32.7746468}, constellationPoint{354.04417958333, 35.1913109}}
constellationPolygons["ANT"] = []constellationPoint{constellationPoint{141.904335, -24.5425186}, constellationPoint{141.77159875, -37.2920151}, constellationPoint{141.73406125, -40.2918739}, constellationPoint{166.45650458333, -40.4246216}, constellationPoint{166.47936291667, -35.6746559}, constellationPoint{163.95851541667, -35.6664963}, constellationPoint{163.977885, -31.8332005}, constellationPoint{160.20137875, -31.8185863}, constellationPoint{160.21289375, -29.8186131}, constellationPoint{155.18132708333, -29.7947845}, constellationPoint{155.1993375, -27.1281624}, constellationPoint{147.65928291667, -27.0835037}, constellationPoint{147.67968125, -24.5835705}}
constellationPolygons["APS"] = []constellationPoint{constellationPoint{209.11110875, -83.1200714}, constellationPoint{276.86599791667, -82.4582748}, constellationPoint{274.19506041667, -74.9745178}, constellationPoint{273.28007708333, -67.4800797}, constellationPoint{265.77572875, -67.5711060}, constellationPoint{258.2424825, -67.6610870}, constellationPoint{258.47067875, -70.1597443}, constellationPoint{224.16644125, -70.5115433}, constellationPoint{207.46087041667, -70.6244431}, constellationPoint{207.78143375, -75.6235962}}
constellationPolygons["AQR"] = []constellationPoint{constellationPoint{309.59884625, 0.4361772}, constellationPoint{309.5798775, 2.4360874}, constellationPoint{314.08109708333, 2.4773185}, constellationPoint{321.58347125, 2.5393796}, constellationPoint{323.58427041667, 2.5544112}, constellationPoint{323.57874875, 3.3043909}, constellationPoint{326.58021875, 3.3256676}, constellationPoint{326.58708625, 2.3256910}, constellationPoint{331.588755, 2.3576119}, constellationPoint{331.58726708333, 2.6076074}, constellationPoint{342.84221708333, 2.6622071}, constellationPoint{342.8497125, 0.6622211}, constellationPoint{342.86470375, -3.3377509}, constellationPoint{359.10221125, -3.3042023}, constellationPoint{359.10329875, -6.3042021}, constellationPoint{359.11056458333, -24.8042011}, constellationPoint{346.68096625, -24.8250446}, constellationPoint{329.77028875, -24.9040413}, constellationPoint{329.6561625, -8.4043999}, constellationPoint{321.668415, -8.4602947}, constellationPoint{321.71645125, -14.4601107}, constellationPoint{309.74390125, -14.5631361}, constellationPoint{309.68464791667, -8.5634165}}
constellationPolygons["AQL"] = []constellationPoint{constellationPoint{280.35020875, 0.1154895}, constellationPoint{280.3262325, 2.1153460}, constellationPoint{284.57642541667, 2.1659052}, constellationPoint{284.525985, 6.4156075}, constellationPoint{281.45856875, 6.3791943}, constellationPoint{281.388045, 12.1287737}, constellationPoint{284.45626208333, 12.1651964}, constellationPoint{284.37363625, 18.6647091}, constellationPoint{286.37549375, 18.6882229}, constellationPoint{286.4054775, 16.3550682}, constellationPoint{298.92116541667, 16.4957294}, constellationPoint{298.926, 16.0790844}, constellationPoint{303.5589975, 16.1275158}, constellationPoint{303.636675, 8.8779116}, constellationPoint{306.01395625, 8.9018240}, constellationPoint{306.07910125, 2.4021468}, constellationPoint{309.5798775, 2.4360874}, constellationPoint{309.59884625, 0.4361772}, constellationPoint{309.68464791667, -8.5634165}, constellationPoint{301.69369625, -8.6430750}, constellationPoint{301.72636958333, -11.6762342}, constellationPoint{284.74405375, -11.8664360}, constellationPoint{284.64729291667, -3.8336766}, constellationPoint{280.3981875, -3.8842230}}
constellationPolygons["ARA"] = []constellationPoint{constellationPoint{249.03468125, -60.2644577}, constellationPoint{248.57062375, -45.7670517}, constellationPoint{269.80928375, -45.5163460}, constellationPoint{272.3090175, -45.4859734}, constellationPoint{272.67225291667, -56.9837723}, constellationPoint{265.16818958333, -57.0747757}, constellationPoint{265.77572875, -67.5711060}, constellationPoint{258.2424825, -67.6610870}, constellationPoint{255.72498291667, -67.6905823}, constellationPoint{255.5423925, -65.1916428}, constellationPoint{254.28351458333, -65.2062531}, constellationPoint{254.1951375, -63.7900925}, constellationPoint{251.67632125, -63.8189964}, constellationPoint{251.53784708333, -61.2364578}, constellationPoint{249.08163, -61.2641945}}
constellationPolygons["ARI"] = []constellationPoint{constellationPoint{31.6652475, 10.5143948}, constellationPoint{26.65573375, 10.5432396}, constellationPoint{26.744674583333, 25.6263351}, constellationPoint{30.51371125, 25.6050701}, constellationPoint{30.53061625, 27.8550186}, constellationPoint{38.07014875, 27.8047638}, constellationPoint{38.10319375, 31.2213154}, constellationPoint{42.62838, 31.1865025}, constellationPoint{52.426667916667, 31.1003609}, constellationPoint{52.2906225, 19.4343338}, constellationPoint{51.037234583333, 19.4461136}, constellationPoint{50.94641125, 10.3632069}}
constellationPolygons["AUR"] = []constellationPoint{constellationPoint{69.4869375, 30.9218750}, constellationPoint{69.57384125, 36.2547150}, constellationPoint{72.45734375, 36.2218513}, constellationPoint{72.840285, 52.7196465}, constellationPoint{77.484762083333, 52.6655540}, constellationPoint{77.606764583333, 56.1648331}, constellationPoint{94.13108875, 55.9658089}, constellationPoint{94.05736625, 53.9662552}, constellationPoint{100.04603125, 53.8938293}, constellationPoint{99.919459583333, 49.8945885}, constellationPoint{104.40635875, 49.8410034}, constellationPoint{104.26530291667, 44.3418388}, constellationPoint{112.73412458333, 44.2435493}, constellationPoint{112.56071875, 35.2445297}, constellationPoint{100.09027625, 35.3905640}, constellationPoint{99.965657916667, 27.8913116}, constellationPoint{90.22107125, 28.0092907}, constellationPoint{90.228902916667, 28.5092430}, constellationPoint{73.212475416667, 28.7124405}, constellationPoint{73.235342916667, 30.2123089}, constellationPoint{69.47678875, 30.2552605}}
constellationPolygons["BOO"] = []constellationPoint{constellationPoint{227.78148625, 7.5253930}, constellationPoint{204.06384875, 7.3605771}, constellationPoint{204.02893041667, 14.3604937}, constellationPoint{203.95387208333, 27.8603134}, constellationPoint{210.7888275, 27.8976517}, constellationPoint{210.77085875, 30.1475964}, constellationPoint{211.88893375, 30.1545391}, constellationPoint{211.69873208333, 47.9039383}, constellationPoint{211.58439125, 54.9035759}, constellationPoint{217.25124875, 54.9422379}, constellationPoint{229.59105458333, 55.0448647}, constellationPoint{229.65737375, 52.5451736}, constellationPoint{237.08447375, 52.6174774}, constellationPoint{237.12458541667, 51.1176796}, constellationPoint{237.36542708333, 39.6189079}, constellationPoint{232.64365208333, 39.5721130}, constellationPoint{232.74697791667, 32.5726128}, constellationPoint{229.01591875, 32.5376778}, constellationPoint{229.09951625, 25.5380573}, constellationPoint{227.60549125, 25.5246105}}
constellationPolygons["CAE"] = []constellationPoint{constellationPoint{65.0764125, -39.7007294}, constellationPoint{64.88238625, -48.6996651}, constellationPoint{68.362247916667, -48.7384491}, constellationPoint{68.42409625, -46.2387962}, constellationPoint{73.402082916667, -46.2959023}, constellationPoint{73.482370416667, -42.7963676}, constellationPoint{75.97444375, -42.8255501}, constellationPoint{76.2549375, -27.0772038}, constellationPoint{73.75929625, -27.0479794}, constellationPoint{71.76333125, -27.0248775}, constellationPoint{71.72241875, -29.7746429}, constellationPoint{69.97665125, -29.7546597}, constellationPoint{69.862375416667, -36.7540054}, constellationPoint{65.12985, -36.7010231}}
constellationPolygons["CAM"] = []constellationPoint{constellationPoint{94.13108875, 55.9658089}, constellationPoint{77.606764583333, 56.1648331}, constellationPoint{77.484762083333, 52.6655540}, constellationPoint{72.840285, 52.7196465}, constellationPoint{52.31308625, 52.9366074}, constellationPoint{52.381900416667, 55.4362831}, constellationPoint{49.8540225, 55.4596519}, constellationPoint{49.91349625, 57.4593849}, constellationPoint{48.90093, 57.4684982}, constellationPoint{49.3954575, 68.4662857}, constellationPoint{54.237034583333, 68.4214401}, constellationPoint{55.30874875, 77.4163132}, constellationPoint{56.726209583333, 77.4025955}, constellationPoint{57.53049, 80.3986664}, constellationPoint{80.488894583333, 80.1478500}, constellationPoint{84.536117916667, 85.1239471}, constellationPoint{127.953615, 84.6103745}, constellationPoint{130.40275041667, 86.0975418}, constellationPoint{213.0229575, 85.9308090}, constellationPoint{216.78285625, 79.4449844}, constellationPoint{203.80918958333, 79.3629303}, constellationPoint{204.15701875, 76.3638153}, constellationPoint{195.8206125, 76.3289108}, constellationPoint{174.43479625, 76.3084106}, constellationPoint{174.53158375, 79.3083420}, constellationPoint{162.81859791667, 79.3401794}, constellationPoint{163.10541625, 81.3396072}, constellationPoint{142.191195, 81.4677658}, constellationPoint{140.61547375, 72.9741364}, constellationPoint{123.08622875, 73.1383743}, constellationPoint{122.12910125, 59.6433983}, constellationPoint{107.7531975, 59.8037262}, constellationPoint{107.8515525, 61.8031464}, constellationPoint{94.40745625, 61.9641266}}
constellationPolygons["CNC"] = []constellationPoint{constellationPoint{140.40425875, 6.4700689}, constellationPoint{122.92139125, 6.6302376}, constellationPoint{120.54834291667, 6.6549850}, constellationPoint{120.5806725, 9.6548138}, constellationPoint{118.83248875, 9.6734257}, constellationPoint{118.87160541667, 13.1732168}, constellationPoint{118.94754458333, 19.6728077}, constellationPoint{120.07012375, 19.6608200}, constellationPoint{120.17164625, 27.6602821}, constellationPoint{121.91596958333, 27.6419144}, constellationPoint{121.99323125, 33.1415138}, constellationPoint{140.645985, 32.9691162}}
constellationPolygons["CVN"] = []constellationPoint{constellationPoint{181.59450625, 33.3039627}, constellationPoint{181.59141625, 44.3039627}, constellationPoint{182.82643375, 44.3043365}, constellationPoint{182.8185225, 52.3043365}, constellationPoint{203.74239875, 52.3598061}, constellationPoint{203.79511375, 47.8599281}, constellationPoint{211.69873208333, 47.9039383}, constellationPoint{211.88893375, 30.1545391}, constellationPoint{210.77085875, 30.1475964}, constellationPoint{210.7888275, 27.8976517}, constellationPoint{203.95387208333, 27.8603134}, constellationPoint{200.22657458333, 27.8437748}, constellationPoint{200.20774791667, 31.3437366}, constellationPoint{186.55769375, 31.3074341}, constellationPoint{186.55426041667, 33.3074303}}
constellationPolygons["CMA"] = []constellationPoint{constellationPoint{93.215625, -11.0301533}, constellationPoint{111.97339958333, -11.2521448}, constellationPoint{111.67719875, -33.2504692}, constellationPoint{99.903859583333, -33.1128159}, constellationPoint{92.899067916667, -33.0282326}, constellationPoint{92.99256625, -27.2787991}}
constellationPolygons["CMI"] = []constellationPoint{constellationPoint{122.84900708333, -0.3693900}, constellationPoint{109.59966625, -0.2243290}, constellationPoint{109.61691875, 1.2755718}, constellationPoint{106.86739625, 1.3074419}, constellationPoint{106.91427458333, 5.3071680}, constellationPoint{106.66432208333, 5.3100886}, constellationPoint{106.71787958333, 9.8097754}, constellationPoint{106.7482275, 12.3095980}, constellationPoint{114.24100375, 12.2238722}, constellationPoint{114.2527275, 13.2238064}, constellationPoint{118.87160541667, 13.1732168}, constellationPoint{118.83248875, 9.6734257}, constellationPoint{120.5806725, 9.6548138}, constellationPoint{120.54834291667, 6.6549850}, constellationPoint{122.92139125, 6.6302376}}
constellationPolygons["CAP"] = []constellationPoint{constellationPoint{309.68464791667, -8.5634165}, constellationPoint{301.69369625, -8.6430750}, constellationPoint{301.72636958333, -11.6762342}, constellationPoint{301.91596958333, -27.6419144}, constellationPoint{306.89795541667, -27.5913391}, constellationPoint{321.83163625, -27.4596672}, constellationPoint{321.80777541667, -24.9597607}, constellationPoint{329.77028875, -24.9040413}, constellationPoint{329.6561625, -8.4043999}, constellationPoint{321.668415, -8.4602947}, constellationPoint{321.71645125, -14.4601107}, constellationPoint{309.74390125, -14.5631361}}
constellationPolygons["CAR"] = []constellationPoint{constellationPoint{170.15592125, -57.1843452}, constellationPoint{166.33725625, -57.1744423}, constellationPoint{133.32365541667, -56.9739723}, constellationPoint{133.38017375, -54.9742203}, constellationPoint{127.56711875, -54.9204712}, constellationPoint{127.60929125, -53.4206772}, constellationPoint{123.32011625, -53.3782196}, constellationPoint{123.38112875, -51.1285286}, constellationPoint{120.8616975, -51.1025848}, constellationPoint{90.748902083333, -50.7545471}, constellationPoint{90.693705, -52.5042114}, constellationPoint{93.19435375, -52.5345764}, constellationPoint{93.1074, -55.0340500}, constellationPoint{98.114275416667, -55.0945587}, constellationPoint{97.995077916667, -58.0938416}, constellationPoint{103.01111708333, -58.1537018}, constellationPoint{102.70331375, -64.1518784}, constellationPoint{136.09472708333, -64.4990387}, constellationPoint{135.24368708333, -75.4954681}, constellationPoint{169.85697291667, -75.6840134}, constellationPoint{170.08481125, -64.6842651}}
constellationPolygons["CAS"] = []constellationPoint{constellationPoint{344.34285125, 53.1680298}, constellationPoint{344.30402708333, 56.9179611}, constellationPoint{344.26912375, 59.7512321}, constellationPoint{348.85966375, 59.7646751}, constellationPoint{348.81649041667, 63.6812897}, constellationPoint{355.21757125, 63.6928787}, constellationPoint{355.19785958333, 66.6928711}, constellationPoint{6.76376375, 66.6924438}, constellationPoint{6.92291375, 77.6923447}, constellationPoint{55.30874875, 77.4163132}, constellationPoint{54.237034583333, 68.4214401}, constellationPoint{49.3954575, 68.4662857}, constellationPoint{48.90093, 57.4684982}, constellationPoint{38.762337083333, 57.5513000}, constellationPoint{38.802355416667, 59.0511551}, constellationPoint{30.795625416667, 59.1046104}, constellationPoint{30.77362375, 58.1046753}, constellationPoint{27.5952225, 58.1227188}, constellationPoint{27.53364125, 54.6228828}, constellationPoint{22.45601375, 54.6477699}, constellationPoint{22.40793625, 50.6478767}, constellationPoint{18.60590375, 50.6632347}, constellationPoint{18.588407916667, 48.6632690}, constellationPoint{14.7888675, 48.6757393}, constellationPoint{14.776077083333, 46.6757545}, constellationPoint{4.14327875, 46.6949348}, constellationPoint{4.1463675, 48.6949348}, constellationPoint{355.27607875, 48.6929169}, constellationPoint{355.27055708333, 50.6929131}, constellationPoint{351.4656825, 50.6870193}, constellationPoint{351.45289375, 53.1870041}}
constellationPolygons["CEN"] = []constellationPoint{constellationPoint{166.47936291667, -35.6746559}, constellationPoint{166.45650458333, -40.4246216}, constellationPoint{166.33725625, -57.1744423}, constellationPoint{170.15592125, -57.1843452}, constellationPoint{170.08481125, -64.6842651}, constellationPoint{179.05736375, -64.6957855}, constellationPoint{179.07076791667, -55.6957932}, constellationPoint{194.33451125, -55.6771049}, constellationPoint{194.43838041667, -64.6769638}, constellationPoint{204.68028625, -64.6379395}, constellationPoint{220.51497458333, -64.5390244}, constellationPoint{220.23446541667, -55.5400887}, constellationPoint{214.65681625, -55.5799522}, constellationPoint{214.45026458333, -42.5806465}, constellationPoint{225.79627958333, -42.4941750}, constellationPoint{225.63076958333, -29.9948788}, constellationPoint{190.41739958333, -30.1863899}, constellationPoint{190.42719875, -33.6863785}, constellationPoint{185.38743458333, -33.6938934}, constellationPoint{185.39029625, -35.6938896}}
constellationPolygons["CEP"] = []constellationPoint{constellationPoint{300.5732625, 59.8510780}, constellationPoint{300.48520041667, 61.8506203}, constellationPoint{306.8118675, 61.9143791}, constellationPoint{306.51738125, 67.4129562}, constellationPoint{310.33401458333, 67.4490280}, constellationPoint{309.57304041667, 75.4455261}, constellationPoint{301.87339791667, 75.3708725}, constellationPoint{300.6738, 80.3647766}, constellationPoint{313.70587375, 80.4867859}, constellationPoint{308.72097, 86.4656219}, constellationPoint{308.33135541667, 86.6306305}, constellationPoint{343.51066625, 86.8368912}, constellationPoint{339.26098791667, 88.6638870}, constellationPoint{135.83247125, 87.5689163}, constellationPoint{130.40275041667, 86.0975418}, constellationPoint{127.953615, 84.6103745}, constellationPoint{84.536117916667, 85.1239471}, constellationPoint{80.488894583333, 80.1478500}, constellationPoint{57.53049, 80.3986664}, constellationPoint{56.726209583333, 77.4025955}, constellationPoint{55.30874875, 77.4163132}, constellationPoint{6.92291375, 77.6923447}, constellationPoint{6.76376375, 66.6924438}, constellationPoint{355.19785958333, 66.6928711}, constellationPoint{355.21757125, 63.6928787}, constellationPoint{348.81649041667, 63.6812897}, constellationPoint{348.85966375, 59.7646751}, constellationPoint{344.26912375, 59.7512321}, constellationPoint{344.30402708333, 56.9179611}, constellationPoint{335.91093, 56.8825760}, constellationPoint{335.93130125, 55.6326256}, constellationPoint{333.13762625, 55.6178436}, constellationPoint{333.17467625, 53.3679428}, constellationPoint{330.63921, 53.3532715}, constellationPoint{330.60218875, 55.4364891}, constellationPoint{309.83136125, 55.2753258}, constellationPoint{309.62379458333, 61.3576965}, constellationPoint{308.66080375, 61.3486443}, constellationPoint{308.71659291667, 59.9322395}}
constellationPolygons["CET"] = []constellationPoint{constellationPoint{6.60132875, 0.6925398}, constellationPoint{6.6037875, 2.6925383}, constellationPoint{31.61526625, 2.5978806}, constellationPoint{31.6652475, 10.5143948}, constellationPoint{50.94641125, 10.3632069}, constellationPoint{50.85298375, 0.4469725}, constellationPoint{50.836682916667, -1.3029516}, constellationPoint{41.33922125, -1.2210265}, constellationPoint{41.14875875, -23.8536034}, constellationPoint{26.46599875, -23.7562580}, constellationPoint{26.45888875, -24.8729095}, constellationPoint{359.11056458333, -24.8042011}, constellationPoint{359.10329875, -6.3042021}, constellationPoint{6.5927025, -6.3074551}}
constellationPolygons["CHA"] = []constellationPoint{constellationPoint{111.65211458333, -82.7758865}, constellationPoint{209.11110875, -83.1200714}, constellationPoint{207.78143375, -75.6235962}, constellationPoint{169.85697291667, -75.6840134}, constellationPoint{135.24368708333, -75.4954681}, constellationPoint{114.21470375, -75.2899170}}
constellationPolygons["CIR"] = []constellationPoint{constellationPoint{204.68028625, -64.6379395}, constellationPoint{204.70747958333, -65.6378784}, constellationPoint{207.26802291667, -65.6249542}, constellationPoint{207.46087041667, -70.6244431}, constellationPoint{224.16644125, -70.5115433}, constellationPoint{224.00363375, -68.0122070}, constellationPoint{226.55712625, -67.9909286}, constellationPoint{226.35353541667, -64.0751266}, constellationPoint{230.16657875, -64.0415649}, constellationPoint{230.05456958333, -61.4587479}, constellationPoint{232.58976458333, -61.4353065}, constellationPoint{232.5498675, -60.4354935}, constellationPoint{232.38191125, -55.4362831}, constellationPoint{228.08351125, -55.4754944}, constellationPoint{220.23446541667, -55.5400887}, constellationPoint{220.51497458333, -64.5390244}}
constellationPolygons["COL"] = []constellationPoint{constellationPoint{75.97444375, -42.8255501}, constellationPoint{76.2549375, -27.0772038}, constellationPoint{92.99256625, -27.2787991}, constellationPoint{92.899067916667, -33.0282326}, constellationPoint{99.903859583333, -33.1128159}, constellationPoint{99.70891625, -43.1116486}, constellationPoint{90.951777083333, -43.0057793}}
constellationPolygons["COM"] = []constellationPoint{constellationPoint{179.60453541667, 13.3040485}, constellationPoint{179.60894125, 28.3040466}, constellationPoint{181.59566375, 28.3039627}, constellationPoint{181.59450625, 33.3039627}, constellationPoint{186.55426041667, 33.3074303}, constellationPoint{186.55769375, 31.3074341}, constellationPoint{200.20774791667, 31.3437366}, constellationPoint{200.22657458333, 27.8437748}, constellationPoint{203.95387208333, 27.8603134}, constellationPoint{204.02893041667, 14.3604937}, constellationPoint{194.05906625, 14.3225088}, constellationPoint{194.0620275, 13.3225126}}
constellationPolygons["CRA"] = []constellationPoint{constellationPoint{269.62546375, -37.0174599}, constellationPoint{289.59631958333, -36.7785645}, constellationPoint{289.76964, -45.2775650}, constellationPoint{272.3090175, -45.4859734}, constellationPoint{269.80928375, -45.5163460}}
constellationPolygons["CRB"] = []constellationPoint{constellationPoint{229.09951625, 25.5380573}, constellationPoint{229.01591875, 32.5376778}, constellationPoint{232.74697791667, 32.5726128}, constellationPoint{232.64365208333, 39.5721130}, constellationPoint{237.36542708333, 39.6189079}, constellationPoint{246.07194875, 39.7117195}, constellationPoint{246.2798025, 26.7128716}, constellationPoint{243.78670625, 26.6855240}, constellationPoint{243.8001825, 25.6855946}, constellationPoint{241.80573458333, 25.6641407}}
constellationPolygons["CRV"] = []constellationPoint{constellationPoint{194.1330525, -11.6773882}, constellationPoint{179.09676, -11.6957970}, constellationPoint{179.09131041667, -25.1957951}, constellationPoint{190.404525, -25.1864014}, constellationPoint{190.3985025, -22.6864090}, constellationPoint{194.16687, -22.6773415}}
constellationPolygons["CRT"] = []constellationPoint{constellationPoint{162.82713875, -6.6621790}, constellationPoint{162.80791375, -11.6621428}, constellationPoint{162.77554041667, -19.6620827}, constellationPoint{164.03058625, -19.6666222}, constellationPoint{164.00808291667, -25.1665821}, constellationPoint{179.09131041667, -25.1957951}, constellationPoint{179.09676, -11.6957970}, constellationPoint{179.09860625, -6.6957974}, constellationPoint{174.34229875, -6.6916924}}
constellationPolygons["CRU"] = []constellationPoint{constellationPoint{179.07076791667, -55.6957932}, constellationPoint{179.05736375, -64.6957855}, constellationPoint{194.43838041667, -64.6769638}, constellationPoint{194.33451125, -55.6771049}}
constellationPolygons["CYG"] = []constellationPoint{constellationPoint{290.13264625, 27.7324085}, constellationPoint{290.0952525, 30.2321968}, constellationPoint{291.5987775, 30.2493153}, constellationPoint{291.49260458333, 36.7487144}, constellationPoint{292.11965541667, 36.7558022}, constellationPoint{291.9835275, 43.7550354}, constellationPoint{288.47030708333, 43.7149391}, constellationPoint{288.37552041667, 47.7143936}, constellationPoint{287.1206475, 47.6998672}, constellationPoint{286.87645958333, 55.6984482}, constellationPoint{291.90459291667, 55.7560043}, constellationPoint{291.80955, 58.2554703}, constellationPoint{297.10055375, 58.3138733}, constellationPoint{297.03924125, 59.8135414}, constellationPoint{300.5732625, 59.8510780}, constellationPoint{308.71659291667, 59.9322395}, constellationPoint{308.66080375, 61.3486443}, constellationPoint{309.62379458333, 61.3576965}, constellationPoint{309.83136125, 55.2753258}, constellationPoint{330.60218875, 55.4364891}, constellationPoint{330.63921, 53.3532715}, constellationPoint{330.76266291667, 44.6036453}, constellationPoint{329.87860625, 44.5982513}, constellationPoint{329.88163958333, 44.3482628}, constellationPoint{329.37664041667, 44.3451195}, constellationPoint{329.4610125, 36.5953827}, constellationPoint{327.319965, 36.5815468}, constellationPoint{327.39518125, 28.5817947}, constellationPoint{322.62016375, 28.5480537}, constellationPoint{315.08391458333, 28.4871883}, constellationPoint{315.07258375, 29.4871387}, constellationPoint{296.25094375, 29.3010578}, constellationPoint{296.27220125, 27.8011742}}
constellationPolygons["DEL"] = []constellationPoint{constellationPoint{309.5798775, 2.4360874}, constellationPoint{306.07910125, 2.4021468}, constellationPoint{306.01395625, 8.9018240}, constellationPoint{303.636675, 8.8779116}, constellationPoint{303.5589975, 16.1275158}, constellationPoint{305.18694875, 16.1439629}, constellationPoint{305.13404875, 20.8936996}, constellationPoint{309.89693708333, 20.9399471}, constellationPoint{309.907665, 19.9399967}, constellationPoint{317.17878291667, 20.0046406}, constellationPoint{317.24836375, 12.3382607}, constellationPoint{314.61859625, 12.3157644}, constellationPoint{314.67109625, 6.4826641}, constellationPoint{314.045505, 6.4771614}, constellationPoint{314.08109708333, 2.4773185}}
constellationPolygons["DOR"] = []constellationPoint{constellationPoint{58.318787916667, -52.7968445}, constellationPoint{60.79789625, -52.8228111}, constellationPoint{60.69291875, -56.1555862}, constellationPoint{65.6504625, -56.2093849}, constellationPoint{65.55459, -58.7088547}, constellationPoint{69.274534583333, -58.7506638}, constellationPoint{68.79401875, -67.2479248}, constellationPoint{68.58152375, -69.7467194}, constellationPoint{98.454422916667, -70.1041336}, constellationPoint{98.93724875, -64.1070251}, constellationPoint{90.173642916667, -64.0010529}, constellationPoint{90.34506125, -61.0020981}, constellationPoint{82.85761375, -60.9112892}, constellationPoint{83.01880375, -57.4122620}, constellationPoint{75.547742916667, -57.3230400}, constellationPoint{75.6770175, -53.8238029}, constellationPoint{68.217745416667, -53.7376366}, constellationPoint{68.362247916667, -48.7384491}, constellationPoint{64.88238625, -48.6996651}, constellationPoint{62.149907916667, -48.6699715}, constellationPoint{62.098639583333, -50.6697006}, constellationPoint{58.37716625, -50.6304779}}
constellationPolygons["DRA"] = []constellationPoint{constellationPoint{140.61547375, 72.9741364}, constellationPoint{142.191195, 81.4677658}, constellationPoint{163.10541625, 81.3396072}, constellationPoint{162.81859791667, 79.3401794}, constellationPoint{174.53158375, 79.3083420}, constellationPoint{174.43479625, 76.3084106}, constellationPoint{195.8206125, 76.3289108}, constellationPoint{196.09747375, 69.3293610}, constellationPoint{210.65081125, 69.3991165}, constellationPoint{210.82055541667, 65.3996506}, constellationPoint{235.32956541667, 65.6023483}, constellationPoint{235.05063, 69.6009445}, constellationPoint{247.8410625, 69.7383041}, constellationPoint{247.2207075, 74.7347870}, constellationPoint{261.53663708333, 74.9033127}, constellationPoint{260.21790458333, 79.8953476}, constellationPoint{267.65602041667, 79.9857483}, constellationPoint{261.72223041667, 85.9495697}, constellationPoint{308.72097, 86.4656219}, constellationPoint{313.70587375, 80.4867859}, constellationPoint{300.6738, 80.3647766}, constellationPoint{301.87339791667, 75.3708725}, constellationPoint{309.57304041667, 75.4455261}, constellationPoint{310.33401458333, 67.4490280}, constellationPoint{306.51738125, 67.4129562}, constellationPoint{306.8118675, 61.9143791}, constellationPoint{300.48520041667, 61.8506203}, constellationPoint{300.5732625, 59.8510780}, constellationPoint{297.03924125, 59.8135414}, constellationPoint{297.10055375, 58.3138733}, constellationPoint{291.80955, 58.2554703}, constellationPoint{291.90459291667, 55.7560043}, constellationPoint{286.87645958333, 55.6984482}, constellationPoint{287.1206475, 47.6998672}, constellationPoint{274.34237541667, 47.5476036}, constellationPoint{274.25768875, 50.5470886}, constellationPoint{255.7863525, 50.3244438}, constellationPoint{255.75682625, 51.3242683}, constellationPoint{237.12458541667, 51.1176796}, constellationPoint{237.08447375, 52.6174774}, constellationPoint{229.65737375, 52.5451736}, constellationPoint{229.59105458333, 55.0448647}, constellationPoint{217.25124875, 54.9422379}, constellationPoint{217.04525375, 62.4414825}, constellationPoint{203.57364125, 62.3593979}, constellationPoint{203.55053875, 63.3593445}, constellationPoint{181.58155958333, 63.3039627}, constellationPoint{181.57925541667, 65.8039627}, constellationPoint{171.84934625, 65.8126068}, constellationPoint{171.96136958333, 72.8125000}}
constellationPolygons["EQU"] = []constellationPoint{constellationPoint{314.08109708333, 2.4773185}, constellationPoint{314.045505, 6.4771614}, constellationPoint{314.67109625, 6.4826641}, constellationPoint{314.61859625, 12.3157644}, constellationPoint{317.24836375, 12.3382607}, constellationPoint{318.25026458333, 12.3465548}, constellationPoint{318.244515, 13.0132008}, constellationPoint{321.50110208333, 13.0390635}, constellationPoint{321.58347125, 2.5393796}}
constellationPolygons["ERI"] = []constellationPoint{constellationPoint{55.352905416667, 0.4037257}, constellationPoint{70.852360416667, 0.2375014}, constellationPoint{71.60231375, 0.2289162}, constellationPoint{71.55635875, -3.7708201}, constellationPoint{77.804395416667, -3.8437285}, constellationPoint{77.72003125, -10.8432293}, constellationPoint{75.22175125, -10.8138046}, constellationPoint{75.178714583333, -14.3135529}, constellationPoint{73.929797916667, -14.2989721}, constellationPoint{73.75929625, -27.0479794}, constellationPoint{71.76333125, -27.0248775}, constellationPoint{71.72241875, -29.7746429}, constellationPoint{69.97665125, -29.7546597}, constellationPoint{69.862375416667, -36.7540054}, constellationPoint{65.12985, -36.7010231}, constellationPoint{65.0764125, -39.7007294}, constellationPoint{59.105884583333, -39.6368256}, constellationPoint{59.031364583333, -43.6364403}, constellationPoint{52.3267725, -43.5694046}, constellationPoint{52.2890025, -45.5692215}, constellationPoint{46.09077125, -45.5124779}, constellationPoint{46.03451375, -48.5122337}, constellationPoint{41.08530875, -48.4710045}, constellationPoint{41.04769375, -50.4708595}, constellationPoint{37.34121, -50.4425697}, constellationPoint{37.28334625, -53.4423561}, constellationPoint{33.58414375, -53.4164696}, constellationPoint{33.489424583333, -57.9161568}, constellationPoint{21.20622875, -57.8484154}, constellationPoint{21.2732625, -52.8485603}, constellationPoint{24.967354583333, -52.8658562}, constellationPoint{24.9938475, -50.8659210}, constellationPoint{28.693257083333, -50.8859215}, constellationPoint{28.738322916667, -47.5527229}, constellationPoint{36.1529475, -47.6004944}, constellationPoint{36.26401375, -39.4342155}, constellationPoint{46.187260416667, -39.5128975}, constellationPoint{46.193322083333, -39.0962563}, constellationPoint{53.64428375, -39.1650963}, constellationPoint{53.699605416667, -35.5820351}, constellationPoint{57.430512083333, -35.6192436}, constellationPoint{57.58890125, -24.0033779}, constellationPoint{41.14875875, -23.8536034}, constellationPoint{41.33922125, -1.2210265}, constellationPoint{50.836682916667, -1.3029516}, constellationPoint{55.335582083333, -1.3461887}}
constellationPolygons["FOR"] = []constellationPoint{constellationPoint{26.46599875, -23.7562580}, constellationPoint{41.14875875, -23.8536034}, constellationPoint{57.58890125, -24.0033779}, constellationPoint{57.430512083333, -35.6192436}, constellationPoint{53.699605416667, -35.5820351}, constellationPoint{53.64428375, -39.1650963}, constellationPoint{46.193322083333, -39.0962563}, constellationPoint{46.187260416667, -39.5128975}, constellationPoint{36.26401375, -39.4342155}, constellationPoint{26.350727916667, -39.3726234}, constellationPoint{26.45888875, -24.8729095}}
constellationPolygons["GEM"] = []constellationPoint{constellationPoint{96.37276375, 11.9332972}, constellationPoint{96.4439175, 17.4328651}, constellationPoint{95.069575416667, 17.4495068}, constellationPoint{95.1241275, 21.4491768}, constellationPoint{90.125155416667, 21.5098724}, constellationPoint{90.1440375, 22.8430862}, constellationPoint{90.22107125, 28.0092907}, constellationPoint{99.965657916667, 27.8913116}, constellationPoint{100.09027625, 35.3905640}, constellationPoint{112.56071875, 35.2445297}, constellationPoint{118.28970875, 35.1810532}, constellationPoint{118.25808, 33.1812286}, constellationPoint{121.99323125, 33.1415138}, constellationPoint{121.91596958333, 27.6419144}, constellationPoint{120.17164625, 27.6602821}, constellationPoint{120.07012375, 19.6608200}, constellationPoint{118.94754458333, 19.6728077}, constellationPoint{118.87160541667, 13.1732168}, constellationPoint{114.2527275, 13.2238064}, constellationPoint{114.24100375, 12.2238722}, constellationPoint{106.7482275, 12.3095980}, constellationPoint{106.71787958333, 9.8097754}, constellationPoint{105.71845958333, 9.8214874}, constellationPoint{105.742815, 11.8213453}}
constellationPolygons["GRU"] = []constellationPoint{constellationPoint{321.92805291667, -36.4592972}, constellationPoint{322.04232125, -44.9588585}, constellationPoint{322.11736625, -49.4585724}, constellationPoint{331.998825, -49.3911743}, constellationPoint{332.113695, -56.3908348}, constellationPoint{351.76852208333, -56.3126869}, constellationPoint{351.69270458333, -39.3127594}, constellationPoint{351.6833775, -36.3127670}, constellationPoint{346.72751375, -36.3249741}}
constellationPolygons["HER"] = []constellationPoint{constellationPoint{245.558595, 3.7033811}, constellationPoint{242.80966791667, 3.6735139}, constellationPoint{242.67663, 15.6728001}, constellationPoint{240.18107375, 15.6463346}, constellationPoint{240.1110075, 21.6459675}, constellationPoint{241.85657541667, 21.6644115}, constellationPoint{241.80573458333, 25.6641407}, constellationPoint{243.8001825, 25.6855946}, constellationPoint{243.78670625, 26.6855240}, constellationPoint{246.2798025, 26.7128716}, constellationPoint{246.07194875, 39.7117195}, constellationPoint{237.36542708333, 39.6189079}, constellationPoint{237.12458541667, 51.1176796}, constellationPoint{255.75682625, 51.3242683}, constellationPoint{255.7863525, 50.3244438}, constellationPoint{274.25768875, 50.5470886}, constellationPoint{274.34237541667, 47.5476036}, constellationPoint{273.46687375, 47.5369873}, constellationPoint{273.82438625, 30.0391560}, constellationPoint{276.70077291667, 30.0739765}, constellationPoint{276.76288625, 26.0743504}, constellationPoint{284.2698675, 26.1640968}, constellationPoint{284.27716208333, 25.6641407}, constellationPoint{284.33913291667, 21.2478352}, constellationPoint{284.37363625, 18.6647091}, constellationPoint{284.45626208333, 12.1651964}, constellationPoint{281.388045, 12.1287737}, constellationPoint{275.20308458333, 12.0543308}, constellationPoint{275.17327375, 14.3874788}, constellationPoint{260.17687791667, 14.2060347}, constellationPoint{260.19584708333, 12.7061481}, constellationPoint{252.7014825, 12.6179380}, constellationPoint{252.80590958333, 3.7852108}}
constellationPolygons["HOR"] = []constellationPoint{constellationPoint{65.0764125, -39.7007294}, constellationPoint{64.88238625, -48.6996651}, constellationPoint{62.149907916667, -48.6699715}, constellationPoint{62.098639583333, -50.6697006}, constellationPoint{58.37716625, -50.6304779}, constellationPoint{58.318787916667, -52.7968445}, constellationPoint{53.365002083333, -52.7470779}, constellationPoint{53.23681625, -57.0797844}, constellationPoint{48.79113125, -57.0377846}, constellationPoint{48.362689583333, -67.0358200}, constellationPoint{33.202360416667, -66.9151917}, constellationPoint{33.489424583333, -57.9161568}, constellationPoint{33.58414375, -53.4164696}, constellationPoint{37.28334625, -53.4423561}, constellationPoint{37.34121, -50.4425697}, constellationPoint{41.04769375, -50.4708595}, constellationPoint{41.08530875, -48.4710045}, constellationPoint{46.03451375, -48.5122337}, constellationPoint{46.09077125, -45.5124779}, constellationPoint{52.2890025, -45.5692215}, constellationPoint{52.3267725, -43.5694046}, constellationPoint{59.031364583333, -43.6364403}, constellationPoint{59.105884583333, -39.6368256}}
constellationPolygons["HYA"] = []constellationPoint{constellationPoint{122.84900708333, -0.3693900}, constellationPoint{122.92139125, 6.6302376}, constellationPoint{140.40425875, 6.4700689}, constellationPoint{145.39841708333, 6.4327669}, constellationPoint{145.34890625, -0.5670585}, constellationPoint{145.27027208333, -11.5667810}, constellationPoint{162.80791375, -11.6621428}, constellationPoint{162.77554041667, -19.6620827}, constellationPoint{164.03058625, -19.6666222}, constellationPoint{164.00808291667, -25.1665821}, constellationPoint{179.09131041667, -25.1957951}, constellationPoint{190.404525, -25.1864014}, constellationPoint{190.3985025, -22.6864090}, constellationPoint{194.16687, -22.6773415}, constellationPoint{215.51309125, -22.5727749}, constellationPoint{215.53369041667, -25.0727024}, constellationPoint{225.57655375, -24.9951096}, constellationPoint{225.63076958333, -29.9948788}, constellationPoint{190.41739958333, -30.1863899}, constellationPoint{190.42719875, -33.6863785}, constellationPoint{185.38743458333, -33.6938934}, constellationPoint{185.39029625, -35.6938896}, constellationPoint{166.47936291667, -35.6746559}, constellationPoint{163.95851541667, -35.6664963}, constellationPoint{163.977885, -31.8332005}, constellationPoint{160.20137875, -31.8185863}, constellationPoint{160.21289375, -29.8186131}, constellationPoint{155.18132708333, -29.7947845}, constellationPoint{155.1993375, -27.1281624}, constellationPoint{147.65928291667, -27.0835037}, constellationPoint{147.67968125, -24.5835705}, constellationPoint{141.904335, -24.5425186}, constellationPoint{137.63677625, -24.5086308}, constellationPoint{137.68497, -19.5088310}, constellationPoint{130.1635125, -19.4423733}, constellationPoint{130.18434, -17.4424706}, constellationPoint{126.92709458333, -17.4112568}, constellationPoint{126.98977958333, -11.4115648}, constellationPoint{122.73417875, -11.3687992}}
constellationPolygons["HYI"] = []constellationPoint{constellationPoint{68.79401875, -67.2479248}, constellationPoint{68.58152375, -69.7467194}, constellationPoint{67.957485, -74.7431641}, constellationPoint{52.075782083333, -74.5741272}, constellationPoint{50.091655416667, -82.0644531}, constellationPoint{1.53339125, -81.8039551}, constellationPoint{1.5662970833333, -74.3039627}, constellationPoint{12.3324375, -74.3185730}, constellationPoint{12.295414583333, -75.3185272}, constellationPoint{20.654050416667, -75.3472214}, constellationPoint{21.20622875, -57.8484154}, constellationPoint{33.489424583333, -57.9161568}, constellationPoint{33.202360416667, -66.9151917}, constellationPoint{48.362689583333, -67.0358200}}
constellationPolygons["IND"] = []constellationPoint{constellationPoint{323.1847575, -74.4544678}, constellationPoint{351.99783291667, -74.3124619}, constellationPoint{351.86139125, -66.8125992}, constellationPoint{332.3985675, -66.8899918}, constellationPoint{332.113695, -56.3908348}, constellationPoint{331.998825, -49.3911743}, constellationPoint{322.11736625, -49.4585724}, constellationPoint{322.04232125, -44.9588585}, constellationPoint{307.169295, -45.0900002}, constellationPoint{307.45880125, -56.5885773}, constellationPoint{307.56480125, -59.5880547}, constellationPoint{322.34865125, -59.4576836}}
constellationPolygons["LAC"] = []constellationPoint{constellationPoint{329.4610125, 36.5953827}, constellationPoint{329.37664041667, 44.3451195}, constellationPoint{329.88163958333, 44.3482628}, constellationPoint{329.87860625, 44.5982513}, constellationPoint{330.76266291667, 44.6036453}, constellationPoint{330.63921, 53.3532715}, constellationPoint{333.17467625, 53.3679428}, constellationPoint{333.13762625, 55.6178436}, constellationPoint{335.93130125, 55.6326256}, constellationPoint{335.91093, 56.8825760}, constellationPoint{344.30402708333, 56.9179611}, constellationPoint{344.34285125, 53.1680298}, constellationPoint{344.46530375, 35.1682358}, constellationPoint{343.70919291667, 35.1656151}, constellationPoint{343.70653208333, 35.6656113}, constellationPoint{331.35955791667, 35.6069336}, constellationPoint{331.35046041667, 36.6069069}}
constellationPolygons["LEO"] = []constellationPoint{constellationPoint{162.8497125, -0.6622211}, constellationPoint{162.87601958333, 6.3377299}, constellationPoint{145.39841708333, 6.4327669}, constellationPoint{140.40425875, 6.4700689}, constellationPoint{140.645985, 32.9691162}, constellationPoint{150.08438541667, 32.9022789}, constellationPoint{150.04234375, 27.9024086}, constellationPoint{159.23840125, 27.8529167}, constellationPoint{159.2108775, 22.8529778}, constellationPoint{162.94253875, 22.8376045}, constellationPoint{162.95149375, 24.8375893}, constellationPoint{166.6809375, 24.8250446}, constellationPoint{166.69399791667, 28.3250256}, constellationPoint{179.60894125, 28.3040466}, constellationPoint{179.60453541667, 13.3040485}, constellationPoint{179.60373458333, 10.3040485}, constellationPoint{174.36568791667, 10.3082914}, constellationPoint{174.35052458333, -0.6916979}, constellationPoint{174.34229875, -6.6916924}, constellationPoint{162.82713875, -6.6621790}}
constellationPolygons["LMI"] = []constellationPoint{constellationPoint{140.645985, 32.9691162}, constellationPoint{140.72163125, 39.2188187}, constellationPoint{145.6819725, 39.1817665}, constellationPoint{145.70923791667, 41.4316750}, constellationPoint{154.37822375, 41.3773613}, constellationPoint{154.3594125, 39.3774109}, constellationPoint{163.52316875, 39.3356133}, constellationPoint{163.48940875, 33.3356781}, constellationPoint{166.71422541667, 33.3249931}, constellationPoint{166.69399791667, 28.3250256}, constellationPoint{166.6809375, 24.8250446}, constellationPoint{162.95149375, 24.8375893}, constellationPoint{162.94253875, 22.8376045}, constellationPoint{159.2108775, 22.8529778}, constellationPoint{159.23840125, 27.8529167}, constellationPoint{150.04234375, 27.9024086}, constellationPoint{150.08438541667, 32.9022789}}
constellationPolygons["LEP"] = []constellationPoint{constellationPoint{73.75929625, -27.0479794}, constellationPoint{76.2549375, -27.0772038}, constellationPoint{92.99256625, -27.2787991}, constellationPoint{93.215625, -11.0301533}, constellationPoint{88.965790416667, -10.9785318}, constellationPoint{77.72003125, -10.8432293}, constellationPoint{75.22175125, -10.8138046}, constellationPoint{75.178714583333, -14.3135529}, constellationPoint{73.929797916667, -14.2989721}}
constellationPolygons["LIB"] = []constellationPoint{constellationPoint{227.85301208333, -0.4742887}, constellationPoint{221.6030925, -0.5269387}, constellationPoint{221.66710791667, -8.5266848}, constellationPoint{215.40850625, -8.5731344}, constellationPoint{215.51309125, -22.5727749}, constellationPoint{215.53369041667, -25.0727024}, constellationPoint{225.57655375, -24.9951096}, constellationPoint{225.63076958333, -29.9948788}, constellationPoint{236.92998, -29.8896160}, constellationPoint{236.81306375, -20.3902016}, constellationPoint{240.57177625, -20.3516178}, constellationPoint{240.43727875, -8.3523235}, constellationPoint{240.38695375, -3.6025870}, constellationPoint{227.88195125, -3.7241600}}
constellationPolygons["LUP"] = []constellationPoint{constellationPoint{214.65681625, -55.5799522}, constellationPoint{220.23446541667, -55.5400887}, constellationPoint{228.08351125, -55.4754944}, constellationPoint{228.05671625, -54.4756165}, constellationPoint{232.35337208333, -54.4364166}, constellationPoint{232.20724625, -48.4371071}, constellationPoint{237.24728125, -48.3880234}, constellationPoint{237.12458541667, -42.3886375}, constellationPoint{242.15280625, -42.3366776}, constellationPoint{241.94769875, -29.8377628}, constellationPoint{236.92998, -29.8896160}, constellationPoint{225.63076958333, -29.9948788}, constellationPoint{225.79627958333, -42.4941750}, constellationPoint{214.45026458333, -42.5806465}}
constellationPolygons["LYN"] = []constellationPoint{constellationPoint{112.56071875, 35.2445297}, constellationPoint{112.73412458333, 44.2435493}, constellationPoint{104.26530291667, 44.3418388}, constellationPoint{104.40635875, 49.8410034}, constellationPoint{99.919459583333, 49.8945885}, constellationPoint{100.04603125, 53.8938293}, constellationPoint{94.05736625, 53.9662552}, constellationPoint{94.13108875, 55.9658089}, constellationPoint{94.40745625, 61.9641266}, constellationPoint{107.8515525, 61.8031464}, constellationPoint{107.7531975, 59.8037262}, constellationPoint{122.12910125, 59.6433983}, constellationPoint{128.79913375, 59.5759888}, constellationPoint{128.44010375, 46.5777283}, constellationPoint{139.59071125, 46.4782791}, constellationPoint{139.51249041667, 41.4785957}, constellationPoint{145.70923791667, 41.4316750}, constellationPoint{145.6819725, 39.1817665}, constellationPoint{140.72163125, 39.2188187}, constellationPoint{140.645985, 32.9691162}, constellationPoint{121.99323125, 33.1415138}, constellationPoint{118.25808, 33.1812286}, constellationPoint{118.28970875, 35.1810532}}
constellationPolygons["LYR"] = []constellationPoint{constellationPoint{284.27716208333, 25.6641407}, constellationPoint{284.2698675, 26.1640968}, constellationPoint{276.76288625, 26.0743504}, constellationPoint{276.70077291667, 30.0739765}, constellationPoint{273.82438625, 30.0391560}, constellationPoint{273.46687375, 47.5369873}, constellationPoint{274.34237541667, 47.5476036}, constellationPoint{287.1206475, 47.6998672}, constellationPoint{288.37552041667, 47.7143936}, constellationPoint{288.47030708333, 43.7149391}, constellationPoint{291.9835275, 43.7550354}, constellationPoint{292.11965541667, 36.7558022}, constellationPoint{291.49260458333, 36.7487144}, constellationPoint{291.5987775, 30.2493153}, constellationPoint{290.0952525, 30.2321968}, constellationPoint{290.13264625, 27.7324085}, constellationPoint{290.16131375, 25.7325745}}
constellationPolygons["MEN"] = []constellationPoint{constellationPoint{109.01970875, -85.2614441}, constellationPoint{48.23292, -84.5553818}, constellationPoint{50.091655416667, -82.0644531}, constellationPoint{52.075782083333, -74.5741272}, constellationPoint{67.957485, -74.7431641}, constellationPoint{68.58152375, -69.7467194}, constellationPoint{98.454422916667, -70.1041336}, constellationPoint{97.770709583333, -75.1000366}, constellationPoint{114.21470375, -75.2899170}, constellationPoint{111.65211458333, -82.7758865}}
constellationPolygons["MIC"] = []constellationPoint{constellationPoint{306.89795541667, -27.5913391}, constellationPoint{321.83163625, -27.4596672}, constellationPoint{321.92805291667, -36.4592972}, constellationPoint{322.04232125, -44.9588585}, constellationPoint{307.169295, -45.0900002}}
constellationPolygons["MON"] = []constellationPoint{constellationPoint{95.225680416667, -0.0537102}, constellationPoint{95.34803125, 9.9455481}, constellationPoint{96.347665416667, 9.9334478}, constellationPoint{96.37276375, 11.9332972}, constellationPoint{105.742815, 11.8213453}, constellationPoint{105.71845958333, 9.8214874}, constellationPoint{106.71787958333, 9.8097754}, constellationPoint{106.66432208333, 5.3100886}, constellationPoint{106.91427458333, 5.3071680}, constellationPoint{106.86739625, 1.3074419}, constellationPoint{109.61691875, 1.2755718}, constellationPoint{109.59966625, -0.2243290}, constellationPoint{122.84900708333, -0.3693900}, constellationPoint{122.73417875, -11.3687992}, constellationPoint{111.97339958333, -11.2521448}, constellationPoint{93.215625, -11.0301533}, constellationPoint{88.965790416667, -10.9785318}, constellationPoint{89.052372083333, -3.9790573}, constellationPoint{95.177142083333, -4.0534163}}
constellationPolygons["MUS"] = []constellationPoint{constellationPoint{170.08481125, -64.6842651}, constellationPoint{169.85697291667, -75.6840134}, constellationPoint{207.78143375, -75.6235962}, constellationPoint{207.46087041667, -70.6244431}, constellationPoint{207.26802291667, -65.6249542}, constellationPoint{204.70747958333, -65.6378784}, constellationPoint{204.68028625, -64.6379395}, constellationPoint{194.43838041667, -64.6769638}, constellationPoint{179.05736375, -64.6957855}}
constellationPolygons["NOR"] = []constellationPoint{constellationPoint{232.5498675, -60.4354935}, constellationPoint{249.03468125, -60.2644577}, constellationPoint{248.57062375, -45.7670517}, constellationPoint{248.4947775, -42.2674789}, constellationPoint{242.15280625, -42.3366776}, constellationPoint{237.12458541667, -42.3886375}, constellationPoint{237.24728125, -48.3880234}, constellationPoint{232.20724625, -48.4371071}, constellationPoint{232.35337208333, -54.4364166}, constellationPoint{228.05671625, -54.4756165}, constellationPoint{228.08351125, -55.4754944}, constellationPoint{232.38191125, -55.4362831}}
constellationPolygons["OCT"] = []constellationPoint{constellationPoint{0.80064625, -89.3039017}, constellationPoint{1.53339125, -81.8039551}, constellationPoint{50.091655416667, -82.0644531}, constellationPoint{48.23292, -84.5553818}, constellationPoint{109.01970875, -85.2614441}, constellationPoint{111.65211458333, -82.7758865}, constellationPoint{209.11110875, -83.1200714}, constellationPoint{276.86599791667, -82.4582748}, constellationPoint{274.19506041667, -74.9745178}, constellationPoint{323.1847575, -74.4544678}, constellationPoint{351.99783291667, -74.3124619}, constellationPoint{1.56630625, -74.3039627}, constellationPoint{0.80064625, -89.3039017}, constellationPoint{0.80065208333333, -89.3038940}}
constellationPolygons["OPH"] = []constellationPoint{constellationPoint{245.6026275, -0.2963768}, constellationPoint{245.558595, 3.7033811}, constellationPoint{252.80590958333, 3.7852108}, constellationPoint{252.7014825, 12.6179380}, constellationPoint{260.19584708333, 12.7061481}, constellationPoint{260.17687791667, 14.2060347}, constellationPoint{275.17327375, 14.3874788}, constellationPoint{275.20308458333, 12.0543308}, constellationPoint{281.388045, 12.1287737}, constellationPoint{281.45856875, 6.3791943}, constellationPoint{275.27461041667, 6.3047633}, constellationPoint{275.29601125, 4.5548930}, constellationPoint{277.87113125, 4.5860157}, constellationPoint{277.88929958333, 3.0861249}, constellationPoint{275.31423625, 3.0550034}, constellationPoint{275.35059875, 0.0552235}, constellationPoint{269.10103791667, -0.0206471}, constellationPoint{269.14970375, -4.0203514}, constellationPoint{271.14967375, -3.9960551}, constellationPoint{271.22371625, -9.9956055}, constellationPoint{266.72375708333, -10.0502338}, constellationPoint{266.7447, -11.7167768}, constellationPoint{265.49440375, -11.7319136}, constellationPoint{265.47349041667, -10.0653696}, constellationPoint{259.22206958333, -10.1404381}, constellationPoint{259.29742791667, -16.1399899}, constellationPoint{265.80019041667, -16.0618820}, constellationPoint{266.00183541667, -30.0606632}, constellationPoint{253.23534875, -30.2123089}, constellationPoint{253.15567041667, -24.7960968}, constellationPoint{245.89144625, -24.8781185}, constellationPoint{245.82298375, -19.5451660}, constellationPoint{247.45067541667, -19.5271549}, constellationPoint{247.43823, -18.5272255}, constellationPoint{245.81068041667, -18.5452347}, constellationPoint{245.69120375, -8.2958899}, constellationPoint{240.43727875, -8.3523235}, constellationPoint{240.38695375, -3.6025870}, constellationPoint{245.63838875, -3.5461800}}
constellationPolygons["ORI"] = []constellationPoint{constellationPoint{70.852360416667, 0.2375014}, constellationPoint{71.03402875, 15.7364635}, constellationPoint{76.28892625, 15.6755352}, constellationPoint{76.29527875, 16.1754990}, constellationPoint{81.7987125, 16.1101055}, constellationPoint{81.7922325, 15.6101446}, constellationPoint{85.79364625, 15.5619202}, constellationPoint{85.755057083333, 12.5621548}, constellationPoint{88.255369583333, 12.5318508}, constellationPoint{88.327167083333, 18.0314159}, constellationPoint{87.326982083333, 18.0435486}, constellationPoint{87.39379375, 22.8764725}, constellationPoint{90.1440375, 22.8430862}, constellationPoint{90.125155416667, 21.5098724}, constellationPoint{95.1241275, 21.4491768}, constellationPoint{95.069575416667, 17.4495068}, constellationPoint{96.4439175, 17.4328651}, constellationPoint{96.37276375, 11.9332972}, constellationPoint{96.347665416667, 9.9334478}, constellationPoint{95.34803125, 9.9455481}, constellationPoint{95.225680416667, -0.0537102}, constellationPoint{95.177142083333, -4.0534163}, constellationPoint{89.052372083333, -3.9790573}, constellationPoint{88.965790416667, -10.9785318}, constellationPoint{77.72003125, -10.8432293}, constellationPoint{77.804395416667, -3.8437285}, constellationPoint{71.55635875, -3.7708201}, constellationPoint{71.60231375, 0.2289162}}
constellationPolygons["PAV"] = []constellationPoint{constellationPoint{274.19506041667, -74.9745178}, constellationPoint{323.1847575, -74.4544678}, constellationPoint{322.34865125, -59.4576836}, constellationPoint{307.56480125, -59.5880547}, constellationPoint{307.45880125, -56.5885773}, constellationPoint{272.67225291667, -56.9837723}, constellationPoint{265.16818958333, -57.0747757}, constellationPoint{265.77572875, -67.5711060}, constellationPoint{273.28007708333, -67.4800797}}
constellationPolygons["PEG"] = []constellationPoint{constellationPoint{321.58347125, 2.5393796}, constellationPoint{321.50110208333, 13.0390635}, constellationPoint{318.244515, 13.0132008}, constellationPoint{318.25026458333, 12.3465548}, constellationPoint{317.24836375, 12.3382607}, constellationPoint{317.17878291667, 20.0046406}, constellationPoint{320.18840875, 20.0290813}, constellationPoint{320.15170041667, 24.0289364}, constellationPoint{322.66201958333, 24.0482101}, constellationPoint{322.62016375, 28.5480537}, constellationPoint{327.39518125, 28.5817947}, constellationPoint{327.319965, 36.5815468}, constellationPoint{329.4610125, 36.5953827}, constellationPoint{331.35046041667, 36.6069069}, constellationPoint{331.35955791667, 35.6069336}, constellationPoint{343.70653208333, 35.6656113}, constellationPoint{343.70919291667, 35.1656151}, constellationPoint{344.46530375, 35.1682358}, constellationPoint{354.04417958333, 35.1913109}, constellationPoint{354.04915791667, 32.7746468}, constellationPoint{357.8280825, 32.7785072}, constellationPoint{357.82874125, 32.0285034}, constellationPoint{1.6069770833333, 32.0293655}, constellationPoint{1.60621625, 28.6960354}, constellationPoint{2.6128425, 28.6957588}, constellationPoint{2.61001625, 22.6957588}, constellationPoint{3.7406445833333, 22.6951923}, constellationPoint{3.73992125, 21.6951923}, constellationPoint{3.7341179166667, 13.1951942}, constellationPoint{1.6031745833333, 13.1960354}, constellationPoint{1.6027304166667, 10.6960354}, constellationPoint{359.09711875, 10.6957970}, constellationPoint{359.09803375, 8.1957970}, constellationPoint{342.82141625, 8.1621685}, constellationPoint{342.84221708333, 2.6622071}, constellationPoint{331.58726708333, 2.6076074}, constellationPoint{331.588755, 2.3576119}, constellationPoint{326.58708625, 2.3256910}, constellationPoint{326.58021875, 3.3256676}, constellationPoint{323.57874875, 3.3043909}, constellationPoint{323.58427041667, 2.5544112}}
constellationPolygons["PER"] = []constellationPoint{constellationPoint{42.62838, 31.1865025}, constellationPoint{42.666467916667, 34.5196762}, constellationPoint{40.402382916667, 34.5375137}, constellationPoint{40.43465875, 37.2873878}, constellationPoint{39.67934125, 37.2931557}, constellationPoint{39.88547875, 51.0423737}, constellationPoint{32.67380125, 51.0925827}, constellationPoint{32.62149125, 47.5927505}, constellationPoint{26.931439583333, 47.6258430}, constellationPoint{26.96852375, 50.6257439}, constellationPoint{22.40793625, 50.6478767}, constellationPoint{22.45601375, 54.6477699}, constellationPoint{27.53364125, 54.6228828}, constellationPoint{27.5952225, 58.1227188}, constellationPoint{30.77362375, 58.1046753}, constellationPoint{30.795625416667, 59.1046104}, constellationPoint{38.802355416667, 59.0511551}, constellationPoint{38.762337083333, 57.5513000}, constellationPoint{48.90093, 57.4684982}, constellationPoint{49.91349625, 57.4593849}, constellationPoint{49.8540225, 55.4596519}, constellationPoint{52.381900416667, 55.4362831}, constellationPoint{52.31308625, 52.9366074}, constellationPoint{72.840285, 52.7196465}, constellationPoint{72.45734375, 36.2218513}, constellationPoint{69.57384125, 36.2547150}, constellationPoint{69.4869375, 30.9218750}, constellationPoint{52.426667916667, 31.1003609}}
constellationPolygons["PHE"] = []constellationPoint{constellationPoint{351.69270458333, -39.3127594}, constellationPoint{351.76852208333, -56.3126869}, constellationPoint{351.77839375, -57.8126793}, constellationPoint{21.20622875, -57.8484154}, constellationPoint{21.2732625, -52.8485603}, constellationPoint{24.967354583333, -52.8658562}, constellationPoint{24.9938475, -50.8659210}, constellationPoint{28.693257083333, -50.8859215}, constellationPoint{28.738322916667, -47.5527229}, constellationPoint{36.1529475, -47.6004944}, constellationPoint{36.26401375, -39.4342155}, constellationPoint{26.350727916667, -39.3726234}}
constellationPolygons["PIC"] = []constellationPoint{constellationPoint{90.951777083333, -43.0057793}, constellationPoint{75.97444375, -42.8255501}, constellationPoint{73.482370416667, -42.7963676}, constellationPoint{73.402082916667, -46.2959023}, constellationPoint{68.42409625, -46.2387962}, constellationPoint{68.362247916667, -48.7384491}, constellationPoint{68.217745416667, -53.7376366}, constellationPoint{75.6770175, -53.8238029}, constellationPoint{75.547742916667, -57.3230400}, constellationPoint{83.01880375, -57.4122620}, constellationPoint{82.85761375, -60.9112892}, constellationPoint{90.34506125, -61.0020981}, constellationPoint{90.173642916667, -64.0010529}, constellationPoint{98.93724875, -64.1070251}, constellationPoint{102.70331375, -64.1518784}, constellationPoint{103.01111708333, -58.1537018}, constellationPoint{97.995077916667, -58.0938416}, constellationPoint{98.114275416667, -55.0945587}, constellationPoint{93.1074, -55.0340500}, constellationPoint{93.19435375, -52.5345764}, constellationPoint{90.693705, -52.5042114}, constellationPoint{90.748902083333, -50.7545471}}
constellationPolygons["PSC"] = []constellationPoint{constellationPoint{342.8497125, 0.6622211}, constellationPoint{342.84221708333, 2.6622071}, constellationPoint{342.82141625, 8.1621685}, constellationPoint{359.09803375, 8.1957970}, constellationPoint{359.09711875, 10.6957970}, constellationPoint{1.6027304166667, 10.6960354}, constellationPoint{1.6031745833333, 13.1960354}, constellationPoint{3.7341179166667, 13.1951942}, constellationPoint{3.73992125, 21.6951923}, constellationPoint{14.414815416667, 21.6766376}, constellationPoint{14.424064583333, 24.4266243}, constellationPoint{12.41349125, 24.4319324}, constellationPoint{12.44306375, 33.6818962}, constellationPoint{22.89742625, 33.6453705}, constellationPoint{22.86642, 28.6454391}, constellationPoint{26.76471, 28.6262817}, constellationPoint{26.744674583333, 25.6263351}, constellationPoint{26.65573375, 10.5432396}, constellationPoint{31.6652475, 10.5143948}, constellationPoint{31.61526625, 2.5978806}, constellationPoint{6.6037875, 2.6925383}, constellationPoint{6.60132875, 0.6925398}, constellationPoint{6.5927025, -6.3074551}, constellationPoint{359.10329875, -6.3042021}, constellationPoint{359.10221125, -3.3042023}, constellationPoint{342.86470375, -3.3377509}}
constellationPolygons["PSA"] = []constellationPoint{constellationPoint{346.68096625, -24.8250446}, constellationPoint{329.77028875, -24.9040413}, constellationPoint{321.80777541667, -24.9597607}, constellationPoint{321.83163625, -27.4596672}, constellationPoint{321.92805291667, -36.4592972}, constellationPoint{346.72751375, -36.3249741}}
constellationPolygons["PUP"] = []constellationPoint{constellationPoint{111.97339958333, -11.2521448}, constellationPoint{111.67719875, -33.2504692}, constellationPoint{99.903859583333, -33.1128159}, constellationPoint{99.70891625, -43.1116486}, constellationPoint{90.951777083333, -43.0057793}, constellationPoint{90.748902083333, -50.7545471}, constellationPoint{120.8616975, -51.1025848}, constellationPoint{121.03827875, -43.3535042}, constellationPoint{126.57231291667, -43.4095192}, constellationPoint{126.67779875, -37.1600380}, constellationPoint{126.92709458333, -17.4112568}, constellationPoint{126.98977958333, -11.4115648}, constellationPoint{122.73417875, -11.3687992}}
constellationPolygons["PYX"] = []constellationPoint{constellationPoint{126.92709458333, -17.4112568}, constellationPoint{130.18434, -17.4424706}, constellationPoint{130.1635125, -19.4423733}, constellationPoint{137.68497, -19.5088310}, constellationPoint{137.63677625, -24.5086308}, constellationPoint{141.904335, -24.5425186}, constellationPoint{141.77159875, -37.2920151}, constellationPoint{126.67779875, -37.1600380}}
constellationPolygons["RET"] = []constellationPoint{constellationPoint{48.362689583333, -67.0358200}, constellationPoint{68.79401875, -67.2479248}, constellationPoint{69.274534583333, -58.7506638}, constellationPoint{65.55459, -58.7088547}, constellationPoint{65.6504625, -56.2093849}, constellationPoint{60.69291875, -56.1555862}, constellationPoint{60.79789625, -52.8228111}, constellationPoint{58.318787916667, -52.7968445}, constellationPoint{53.365002083333, -52.7470779}, constellationPoint{53.23681625, -57.0797844}, constellationPoint{48.79113125, -57.0377846}}
constellationPolygons["SGE"] = []constellationPoint{constellationPoint{284.37363625, 18.6647091}, constellationPoint{284.33913291667, 21.2478352}, constellationPoint{290.09631125, 21.3148155}, constellationPoint{290.12128791667, 19.3982983}, constellationPoint{298.88568875, 19.4955387}, constellationPoint{298.860255, 21.5787334}, constellationPoint{305.12540875, 21.6436558}, constellationPoint{305.13404875, 20.8936996}, constellationPoint{305.18694875, 16.1439629}, constellationPoint{303.5589975, 16.1275158}, constellationPoint{298.926, 16.0790844}, constellationPoint{298.92116541667, 16.4957294}, constellationPoint{286.4054775, 16.3550682}, constellationPoint{286.37549375, 18.6882229}}
constellationPolygons["SGR"] = []constellationPoint{constellationPoint{284.74405375, -11.8664360}, constellationPoint{284.79372, -15.8328123}, constellationPoint{275.54952625, -15.9435720}, constellationPoint{265.80019041667, -16.0618820}, constellationPoint{266.00183541667, -30.0606632}, constellationPoint{269.50281125, -30.0182076}, constellationPoint{269.62546375, -37.0174599}, constellationPoint{289.59631958333, -36.7785645}, constellationPoint{289.76964, -45.2775650}, constellationPoint{307.169295, -45.0900002}, constellationPoint{306.89795541667, -27.5913391}, constellationPoint{301.91596958333, -27.6419144}, constellationPoint{301.72636958333, -11.6762342}}
constellationPolygons["SCO"] = []constellationPoint{constellationPoint{240.43727875, -8.3523235}, constellationPoint{245.69120375, -8.2958899}, constellationPoint{245.81068041667, -18.5452347}, constellationPoint{247.43823, -18.5272255}, constellationPoint{247.45067541667, -19.5271549}, constellationPoint{245.82298375, -19.5451660}, constellationPoint{245.89144625, -24.8781185}, constellationPoint{253.15567041667, -24.7960968}, constellationPoint{253.23534875, -30.2123089}, constellationPoint{266.00183541667, -30.0606632}, constellationPoint{269.50281125, -30.0182076}, constellationPoint{269.62546375, -37.0174599}, constellationPoint{269.80928375, -45.5163460}, constellationPoint{248.57062375, -45.7670517}, constellationPoint{248.4947775, -42.2674789}, constellationPoint{242.15280625, -42.3366776}, constellationPoint{241.94769875, -29.8377628}, constellationPoint{236.92998, -29.8896160}, constellationPoint{236.81306375, -20.3902016}, constellationPoint{240.57177625, -20.3516178}}
constellationPolygons["SCL"] = []constellationPoint{constellationPoint{346.68096625, -24.8250446}, constellationPoint{359.11056458333, -24.8042011}, constellationPoint{26.45888875, -24.8729095}, constellationPoint{26.350727916667, -39.3726234}, constellationPoint{351.69270458333, -39.3127594}, constellationPoint{351.6833775, -36.3127670}, constellationPoint{346.72751375, -36.3249741}}
constellationPolygons["SCT"] = []constellationPoint{constellationPoint{275.54952625, -15.9435720}, constellationPoint{284.79372, -15.8328123}, constellationPoint{284.74405375, -11.8664360}, constellationPoint{284.64729291667, -3.8336766}, constellationPoint{280.3981875, -3.8842230}, constellationPoint{275.3991225, -3.9444826}}
constellationPolygons["SER1"] = []constellationPoint{constellationPoint{227.85301208333, -0.4742887}, constellationPoint{227.78148625, 7.5253930}, constellationPoint{227.60549125, 25.5246105}, constellationPoint{229.09951625, 25.5380573}, constellationPoint{241.80573458333, 25.6641407}, constellationPoint{241.85657541667, 21.6644115}, constellationPoint{240.1110075, 21.6459675}, constellationPoint{240.18107375, 15.6463346}, constellationPoint{242.67663, 15.6728001}, constellationPoint{242.80966791667, 3.6735139}, constellationPoint{245.558595, 3.7033811}, constellationPoint{245.6026275, -0.2963768}, constellationPoint{245.63838875, -3.5461800}, constellationPoint{240.38695375, -3.6025870}, constellationPoint{227.88195125, -3.7241600}}
constellationPolygons["SER2"] = []constellationPoint{constellationPoint{275.35059875, 0.0552235}, constellationPoint{275.31423625, 3.0550034}, constellationPoint{277.93922375, 3.0867271}, constellationPoint{277.92105625, 4.5866175}, constellationPoint{275.29601125, 4.5548930}, constellationPoint{275.27461041667, 6.3047633}, constellationPoint{281.45856875, 6.3791943}, constellationPoint{284.525985, 6.4156075}, constellationPoint{284.57642541667, 2.1659052}, constellationPoint{280.3262325, 2.1153460}, constellationPoint{280.35020875, 0.1154895}, constellationPoint{280.3981875, -3.8842230}, constellationPoint{275.3991225, -3.9444826}, constellationPoint{275.54952625, -15.9435720}, constellationPoint{265.80019041667, -16.0618820}, constellationPoint{259.29742791667, -16.1399899}, constellationPoint{259.22206958333, -10.1404381}, constellationPoint{265.47349041667, -10.0653696}, constellationPoint{265.49440375, -11.7319136}, constellationPoint{266.7447, -11.7167768}, constellationPoint{266.72375708333, -10.0502338}, constellationPoint{271.22371625, -9.9956055}, constellationPoint{271.14967375, -3.9960551}, constellationPoint{269.14970375, -4.0203514}, constellationPoint{269.10103791667, -0.0206471}}
constellationPolygons["SEX"] = []constellationPoint{constellationPoint{145.34890625, -0.5670585}, constellationPoint{145.39841708333, 6.4327669}, constellationPoint{162.87601958333, 6.3377299}, constellationPoint{162.8497125, -0.6622211}, constellationPoint{162.82713875, -6.6621790}, constellationPoint{162.80791375, -11.6621428}, constellationPoint{145.27027208333, -11.5667810}}
constellationPolygons["TAU"] = []constellationPoint{constellationPoint{50.836682916667, -1.3029516}, constellationPoint{50.85298375, 0.4469725}, constellationPoint{50.94641125, 10.3632069}, constellationPoint{51.037234583333, 19.4461136}, constellationPoint{52.2906225, 19.4343338}, constellationPoint{52.426667916667, 31.1003609}, constellationPoint{69.4869375, 30.9218750}, constellationPoint{69.47678875, 30.2552605}, constellationPoint{73.235342916667, 30.2123089}, constellationPoint{73.212475416667, 28.7124405}, constellationPoint{90.228902916667, 28.5092430}, constellationPoint{90.22107125, 28.0092907}, constellationPoint{90.1440375, 22.8430862}, constellationPoint{87.39379375, 22.8764725}, constellationPoint{87.326982083333, 18.0435486}, constellationPoint{88.327167083333, 18.0314159}, constellationPoint{88.255369583333, 12.5318508}, constellationPoint{85.755057083333, 12.5621548}, constellationPoint{85.79364625, 15.5619202}, constellationPoint{81.7922325, 15.6101446}, constellationPoint{81.7987125, 16.1101055}, constellationPoint{76.29527875, 16.1754990}, constellationPoint{76.28892625, 15.6755352}, constellationPoint{71.03402875, 15.7364635}, constellationPoint{70.852360416667, 0.2375014}, constellationPoint{55.352905416667, 0.4037257}, constellationPoint{55.335582083333, -1.3461887}}
constellationPolygons["TEL"] = []constellationPoint{constellationPoint{307.45880125, -56.5885773}, constellationPoint{307.169295, -45.0900002}, constellationPoint{289.76964, -45.2775650}, constellationPoint{272.3090175, -45.4859734}, constellationPoint{272.67225291667, -56.9837723}}
constellationPolygons["TRI"] = []constellationPoint{constellationPoint{26.744674583333, 25.6263351}, constellationPoint{26.76471, 28.6262817}, constellationPoint{22.86642, 28.6454391}, constellationPoint{22.89742625, 33.6453705}, constellationPoint{22.910835, 35.6453362}, constellationPoint{31.854250416667, 35.5971375}, constellationPoint{31.87109125, 37.3470840}, constellationPoint{39.67934125, 37.2931557}, constellationPoint{40.43465875, 37.2873878}, constellationPoint{40.402382916667, 34.5375137}, constellationPoint{42.666467916667, 34.5196762}, constellationPoint{42.62838, 31.1865025}, constellationPoint{38.10319375, 31.2213154}, constellationPoint{38.07014875, 27.8047638}, constellationPoint{30.53061625, 27.8550186}, constellationPoint{30.51371125, 25.6050701}}
constellationPolygons["TRA"] = []constellationPoint{constellationPoint{224.16644125, -70.5115433}, constellationPoint{224.00363375, -68.0122070}, constellationPoint{226.55712625, -67.9909286}, constellationPoint{226.35353541667, -64.0751266}, constellationPoint{230.16657875, -64.0415649}, constellationPoint{230.05456958333, -61.4587479}, constellationPoint{232.58976458333, -61.4353065}, constellationPoint{232.5498675, -60.4354935}, constellationPoint{249.03468125, -60.2644577}, constellationPoint{249.08163, -61.2641945}, constellationPoint{251.53784708333, -61.2364578}, constellationPoint{251.67632125, -63.8189964}, constellationPoint{254.1951375, -63.7900925}, constellationPoint{254.28351458333, -65.2062531}, constellationPoint{255.5423925, -65.1916428}, constellationPoint{255.72498291667, -67.6905823}, constellationPoint{258.2424825, -67.6610870}, constellationPoint{258.47067875, -70.1597443}}
constellationPolygons["TUC"] = []constellationPoint{constellationPoint{351.99783291667, -74.3124619}, constellationPoint{1.5662970833333, -74.3039627}, constellationPoint{12.3324375, -74.3185730}, constellationPoint{12.295414583333, -75.3185272}, constellationPoint{20.654050416667, -75.3472214}, constellationPoint{21.20622875, -57.8484154}, constellationPoint{351.77839375, -57.8126793}, constellationPoint{351.76852208333, -56.3126869}, constellationPoint{332.113695, -56.3908348}, constellationPoint{332.3985675, -66.8899918}, constellationPoint{351.86139125, -66.8125992}}
constellationPolygons["UMA"] = []constellationPoint{constellationPoint{145.70923791667, 41.4316750}, constellationPoint{139.51249041667, 41.4785957}, constellationPoint{139.59071125, 46.4782791}, constellationPoint{128.44010375, 46.5777283}, constellationPoint{128.79913375, 59.5759888}, constellationPoint{122.12910125, 59.6433983}, constellationPoint{123.08622875, 73.1383743}, constellationPoint{140.61547375, 72.9741364}, constellationPoint{171.96136958333, 72.8125000}, constellationPoint{171.84934625, 65.8126068}, constellationPoint{181.57925541667, 65.8039627}, constellationPoint{181.58155958333, 63.3039627}, constellationPoint{203.55053875, 63.3593445}, constellationPoint{203.57364125, 62.3593979}, constellationPoint{217.04525375, 62.4414825}, constellationPoint{217.25124875, 54.9422379}, constellationPoint{211.58439125, 54.9035759}, constellationPoint{211.69873208333, 47.9039383}, constellationPoint{203.79511375, 47.8599281}, constellationPoint{203.74239875, 52.3598061}, constellationPoint{182.8185225, 52.3043365}, constellationPoint{182.82643375, 44.3043365}, constellationPoint{181.59141625, 44.3039627}, constellationPoint{181.59450625, 33.3039627}, constellationPoint{181.59566375, 28.3039627}, constellationPoint{179.60894125, 28.3040466}, constellationPoint{166.69399791667, 28.3250256}, constellationPoint{166.71422541667, 33.3249931}, constellationPoint{163.48940875, 33.3356781}, constellationPoint{163.52316875, 39.3356133}, constellationPoint{154.3594125, 39.3774109}, constellationPoint{154.37822375, 41.3773613}}
constellationPolygons["UMI"] = []constellationPoint{constellationPoint{195.8206125, 76.3289108}, constellationPoint{196.09747375, 69.3293610}, constellationPoint{210.65081125, 69.3991165}, constellationPoint{210.82055541667, 65.3996506}, constellationPoint{235.32956541667, 65.6023483}, constellationPoint{235.05063, 69.6009445}, constellationPoint{247.8410625, 69.7383041}, constellationPoint{247.2207075, 74.7347870}, constellationPoint{261.53663708333, 74.9033127}, constellationPoint{260.21790458333, 79.8953476}, constellationPoint{267.65602041667, 79.9857483}, constellationPoint{261.72223041667, 85.9495697}, constellationPoint{308.72097, 86.4656219}, constellationPoint{308.33135541667, 86.6306305}, constellationPoint{343.51066625, 86.8368912}, constellationPoint{339.26098791667, 88.6638870}, constellationPoint{135.83247125, 87.5689163}, constellationPoint{130.40275041667, 86.0975418}, constellationPoint{213.0229575, 85.9308090}, constellationPoint{216.78285625, 79.4449844}, constellationPoint{203.80918958333, 79.3629303}, constellationPoint{204.15701875, 76.3638153}}
constellationPolygons["VEL"] = []constellationPoint{constellationPoint{166.33725625, -57.1744423}, constellationPoint{166.45650458333, -40.4246216}, constellationPoint{141.73406125, -40.2918739}, constellationPoint{141.77159875, -37.2920151}, constellationPoint{126.67779875, -37.1600380}, constellationPoint{126.57231291667, -43.4095192}, constellationPoint{121.03827875, -43.3535042}, constellationPoint{120.8616975, -51.1025848}, constellationPoint{123.38112875, -51.1285286}, constellationPoint{123.32011625, -53.3782196}, constellationPoint{127.60929125, -53.4206772}, constellationPoint{127.56711875, -54.9204712}, constellationPoint{133.38017375, -54.9742203}, constellationPoint{133.32365541667, -56.9739723}}
constellationPolygons["VIR"] = []constellationPoint{constellationPoint{174.35052458333, -0.6916979}, constellationPoint{174.36568791667, 10.3082914}, constellationPoint{179.60373458333, 10.3040485}, constellationPoint{179.60453541667, 13.3040485}, constellationPoint{194.0620275, 13.3225126}, constellationPoint{194.05906625, 14.3225088}, constellationPoint{204.02893041667, 14.3604937}, constellationPoint{204.06384875, 7.3605771}, constellationPoint{227.78148625, 7.5253930}, constellationPoint{227.85301208333, -0.4742887}, constellationPoint{221.6030925, -0.5269387}, constellationPoint{221.66710791667, -8.5266848}, constellationPoint{215.40850625, -8.5731344}, constellationPoint{215.51309125, -22.5727749}, constellationPoint{194.16687, -22.6773415}, constellationPoint{194.1330525, -11.6773882}, constellationPoint{179.09676, -11.6957970}, constellationPoint{179.09860625, -6.6957974}, constellationPoint{174.34229875, -6.6916924}}
constellationPolygons["VOL"] = []constellationPoint{constellationPoint{98.93724875, -64.1070251}, constellationPoint{98.454422916667, -70.1041336}, constellationPoint{97.770709583333, -75.1000366}, constellationPoint{114.21470375, -75.2899170}, constellationPoint{135.24368708333, -75.4954681}, constellationPoint{136.09472708333, -64.4990387}, constellationPoint{102.70331375, -64.1518784}}
constellationPolygons["VUL"] = []constellationPoint{constellationPoint{284.33913291667, 21.2478352}, constellationPoint{284.27716208333, 25.6641407}, constellationPoint{290.16131375, 25.7325745}, constellationPoint{290.13264625, 27.7324085}, constellationPoint{296.27220125, 27.8011742}, constellationPoint{296.25094375, 29.3010578}, constellationPoint{315.07258375, 29.4871387}, constellationPoint{315.08391458333, 28.4871883}, constellationPoint{322.62016375, 28.5480537}, constellationPoint{322.66201958333, 24.0482101}, constellationPoint{320.15170041667, 24.0289364}, constellationPoint{320.18840875, 20.0290813}, constellationPoint{317.17878291667, 20.0046406}, constellationPoint{309.907665, 19.9399967}, constellationPoint{309.89693708333, 20.9399471}, constellationPoint{305.13404875, 20.8936996}, constellationPoint{305.12540875, 21.6436558}, constellationPoint{298.860255, 21.5787334}, constellationPoint{298.88568875, 19.4955387}, constellationPoint{290.12128791667, 19.3982983}, constellationPoint{290.09631125, 21.3148155}}
change := []string{"PSC", "TUC", "PHE", "SCL", "CET", "PEG", "AND", "CAS", "CEP"}
for _, v := range change {
for k, v2 := range constellationPolygons[v] {
if v2.RA < 270 {
constellationPolygons[v][k].RA = v2.RA + 360
}
}
}
}
//选定 RA=277.5 DEC=-40
func isCross(a, b, c, d constellationPoint) bool {
var ac, bc, ad, bd, ca, cb, da, db constellationPoint
var r1, r2 float64
ac.RA = a.RA - c.RA
ac.DEC = a.DEC - c.DEC
ad.RA = a.RA - d.RA
ad.DEC = a.DEC - d.DEC
r1 = ac.RA*ad.DEC - ad.RA*ac.DEC
bc.RA = b.RA - c.RA
bc.DEC = b.DEC - c.DEC
bd.RA = b.RA - d.RA
bd.DEC = b.DEC - d.DEC
r2 = bc.RA*bd.DEC - bd.RA*bc.DEC
//echo r1.' '.r2;
if r1*r2 > 0 {
return false
}
ca.RA = c.RA - a.RA
ca.DEC = c.DEC - a.DEC
cb.RA = c.RA - b.RA
cb.DEC = c.DEC - b.DEC
r1 = ca.RA*cb.DEC - cb.RA*ca.DEC
da.RA = d.RA - a.RA
da.DEC = d.DEC - a.DEC
db.RA = d.RA - b.RA
db.DEC = d.DEC - b.DEC
r2 = da.RA*db.DEC - db.RA*da.DEC
if r1*r2 > 0 {
return false
}
return true
}
func resolveConstellationCode(ra, dec, jde float64) string {
initConstellationData()
code, nra, ndec := lookupConstellationCode(ra, dec, jde)
if code != "" {
return code
}
// 只从 [0,360) 补一圈重试一次,匹配 RA 已加 360 的跨零点星座。
if nra <= 270 && ra < 360 {
if code, _, _ = lookupConstellationCode(ra+360, dec, jde); code != "" {
return code
}
}
if ndec > 50 {
return "UMI"
} else if ndec < -50 {
return "OCT"
}
if ra >= 360 {
return ""
}
// 边界数据相邻多边形之间有细缝,落在缝里的点归最近的边。
return nearestConstellationCode(nra, ndec)
}
func lookupConstellationCode(ra, dec, jde float64) (string, float64, float64) {
nra := ra
if ra >= 360 {
nra -= 360
}
nra, ndec := Precess(nra, dec, jde, 2451545.0)
if ra >= 360 && nra < 270 {
nra += 360
}
return matchConstellationCode(nra, ndec), nra, ndec
}
func nearestConstellationCode(ra, dec float64) string {
cosDec := math.Cos(dec * math.Pi / 180)
best, bestDistance := "", math.Inf(1)
for _, boundary := range constellationBoundaries {
pointCount := len(boundary.points)
for index := 0; index < pointCount; index++ {
p, q := boundary.points[index], boundary.points[(index+1)%pointCount]
px, py := math.Remainder(p.RA-ra, 360)*cosDec, p.DEC-dec
qx, qy := math.Remainder(q.RA-ra, 360)*cosDec, q.DEC-dec
if distance := originSegmentDistance(px, py, qx, qy); distance < bestDistance {
best, bestDistance = boundary.code, distance
}
}
}
return best
}
func originSegmentDistance(px, py, qx, qy float64) float64 {
dx, dy := qx-px, qy-py
length := dx*dx + dy*dy
t := 0.0
if length > 0 {
t = math.Max(0, math.Min(1, -(px*dx+py*dy)/length))
}
return math.Hypot(px+t*dx, py+t*dy)
}
func matchConstellationCode(ra, dec float64) string {
target := constellationPoint{RA: ra, DEC: dec}
for _, boundary := range constellationBoundaries {
if ra < boundary.minRA || ra > boundary.maxRA || dec < boundary.minDec || dec > boundary.maxDec {
continue
}
count := 0
pointCount := len(boundary.points)
for index := 0; index < pointCount-1; index++ {
if index == 0 && isCross(constellationRayStart, target, boundary.points[pointCount-1], boundary.points[0]) {
count++
}
if isCross(constellationRayStart, target, boundary.points[index], boundary.points[index+1]) {
count++
}
if FR((ra-constellationRayStart.RA)*(boundary.points[index].DEC-constellationRayStart.DEC)) == FR((boundary.points[index].RA-constellationRayStart.RA)*(dec-constellationRayStart.DEC)) {
count++
}
}
if count%2 == 1 {
return boundary.code
}
}
return ""
}
func ConstellationNameZH(ra, dec, jde float64) string {
return ConstellationNameByCodeZH(ConstellationCode(ra, dec, jde))
}
+182
View File
@@ -0,0 +1,182 @@
package basic
import "sync"
type constellationMeta struct {
code string
zh string
en string
}
type constellationBoundary struct {
code string
points []constellationPoint
minRA float64
maxRA float64
minDec float64
maxDec float64
}
var (
constellationInitOnce sync.Once
constellationMetas = [...]constellationMeta{
{code: "AND", zh: "仙女座", en: "Andromeda"},
{code: "ANT", zh: "唧筒座", en: "Antlia"},
{code: "APS", zh: "天燕座", en: "Apus"},
{code: "AQR", zh: "宝瓶座", en: "Aquarius"},
{code: "AQL", zh: "天鹰座", en: "Aquila"},
{code: "ARA", zh: "天坛座", en: "Ara"},
{code: "ARI", zh: "白羊座", en: "Aries"},
{code: "AUR", zh: "御夫座", en: "Auriga"},
{code: "BOO", zh: "牧夫座", en: "Bootes"},
{code: "CAE", zh: "雕具座", en: "Caelum"},
{code: "CAM", zh: "鹿豹座", en: "Camelopardalis"},
{code: "CNC", zh: "巨蟹座", en: "Cancer"},
{code: "CVN", zh: "猎犬座", en: "Canes Venatici"},
{code: "CMA", zh: "大犬座", en: "Canis Major"},
{code: "CMI", zh: "小犬座", en: "Canis Minor"},
{code: "CAP", zh: "摩羯座", en: "Capricornus"},
{code: "CAR", zh: "船底座", en: "Carina"},
{code: "CAS", zh: "仙后座", en: "Cassiopeia"},
{code: "CEN", zh: "半人马座", en: "Centaurus"},
{code: "CEP", zh: "仙王座", en: "Cepheus"},
{code: "CET", zh: "鲸鱼座", en: "Cetus"},
{code: "CHA", zh: "蝘蜓座", en: "Chamaeleon"},
{code: "CIR", zh: "圆规座", en: "Circinus"},
{code: "COL", zh: "天鸽座", en: "Columba"},
{code: "COM", zh: "后发座", en: "Coma Berenices"},
{code: "CRA", zh: "南冕座", en: "Corona Australis"},
{code: "CRB", zh: "北冕座", en: "Corona Borealis"},
{code: "CRV", zh: "乌鸦座", en: "Corvus"},
{code: "CRT", zh: "巨爵座", en: "Crater"},
{code: "CRU", zh: "南十字座", en: "Crux"},
{code: "CYG", zh: "天鹅座", en: "Cygnus"},
{code: "DEL", zh: "海豚座", en: "Delphinus"},
{code: "DOR", zh: "剑鱼座", en: "Dorado"},
{code: "DRA", zh: "天龙座", en: "Draco"},
{code: "EQU", zh: "小马座", en: "Equuleus"},
{code: "ERI", zh: "波江座", en: "Eridanus"},
{code: "FOR", zh: "天炉座", en: "Fornax"},
{code: "GEM", zh: "双子座", en: "Gemini"},
{code: "GRU", zh: "天鹤座", en: "Grus"},
{code: "HER", zh: "武仙座", en: "Hercules"},
{code: "HOR", zh: "时钟座", en: "Horologium"},
{code: "HYA", zh: "长蛇座", en: "Hydra"},
{code: "HYI", zh: "水蛇座", en: "Hydrus"},
{code: "IND", zh: "印第安座", en: "Indus"},
{code: "LAC", zh: "蝎虎座", en: "Lacerta"},
{code: "LEO", zh: "狮子座", en: "Leo"},
{code: "LMI", zh: "小狮座", en: "Leo Minor"},
{code: "LEP", zh: "天兔座", en: "Lepus"},
{code: "LIB", zh: "天秤座", en: "Libra"},
{code: "LUP", zh: "豺狼座", en: "Lupus"},
{code: "LYN", zh: "天猫座", en: "Lynx"},
{code: "LYR", zh: "天琴座", en: "Lyra"},
{code: "MEN", zh: "山案座", en: "Mensa"},
{code: "MIC", zh: "显微镜座", en: "Microscopium"},
{code: "MON", zh: "麒麟座", en: "Monoceros"},
{code: "MUS", zh: "苍蝇座", en: "Musca"},
{code: "NOR", zh: "矩尺座", en: "Norma"},
{code: "OCT", zh: "南极座", en: "Octans"},
{code: "OPH", zh: "蛇夫座", en: "Ophiuchus"},
{code: "ORI", zh: "猎户座", en: "Orion"},
{code: "PAV", zh: "孔雀座", en: "Pavo"},
{code: "PEG", zh: "飞马座", en: "Pegasus"},
{code: "PER", zh: "英仙座", en: "Perseus"},
{code: "PHE", zh: "凤凰座", en: "Phoenix"},
{code: "PIC", zh: "绘架座", en: "Pictor"},
{code: "PSC", zh: "双鱼座", en: "Pisces"},
{code: "PSA", zh: "南鱼座", en: "Piscis Austrinus"},
{code: "PUP", zh: "船尾座", en: "Puppis"},
{code: "PYX", zh: "罗盘座", en: "Pyxis"},
{code: "RET", zh: "网罟座", en: "Reticulum"},
{code: "SGE", zh: "天箭座", en: "Sagitta"},
{code: "SGR", zh: "人马座", en: "Sagittarius"},
{code: "SCO", zh: "天蝎座", en: "Scorpius"},
{code: "SCL", zh: "玉夫座", en: "Sculptor"},
{code: "SCT", zh: "盾牌座", en: "Scutum"},
{code: "SER1", zh: "巨蛇座", en: "Serpens Caput"},
{code: "SER2", zh: "巨蛇座", en: "Serpens Cauda"},
{code: "SEX", zh: "六分仪座", en: "Sextans"},
{code: "TAU", zh: "金牛座", en: "Taurus"},
{code: "TEL", zh: "望远镜座", en: "Telescopium"},
{code: "TRI", zh: "三角座", en: "Triangulum"},
{code: "TRA", zh: "南三角座", en: "Triangulum Australe"},
{code: "TUC", zh: "杜鹃座", en: "Tucana"},
{code: "UMA", zh: "大熊座", en: "Ursa Major"},
{code: "UMI", zh: "小熊座", en: "Ursa Minor"},
{code: "VEL", zh: "船帆座", en: "Vela"},
{code: "VIR", zh: "室女座", en: "Virgo"},
{code: "VOL", zh: "飞鱼座", en: "Volans"},
{code: "VUL", zh: "狐狸座", en: "Vulpecula"},
}
constellationNameZH map[string]string
constellationNameEN map[string]string
constellationBoundaries []constellationBoundary
constellationRayStart = constellationPoint{RA: 277.5, DEC: -100}
)
func initConstellationData() {
constellationInitOnce.Do(func() {
initConstellationPolygons()
constellationNameZH = make(map[string]string, len(constellationMetas))
constellationNameEN = make(map[string]string, len(constellationMetas))
constellationBoundaries = make([]constellationBoundary, 0, len(constellationMetas)-2)
for _, meta := range constellationMetas {
constellationNameZH[meta.code] = meta.zh
constellationNameEN[meta.code] = meta.en
if meta.code == "UMI" || meta.code == "OCT" {
continue
}
points := constellationPolygons[meta.code]
if len(points) == 0 {
continue
}
boundary := constellationBoundary{
code: meta.code,
points: points,
minRA: points[0].RA,
maxRA: points[0].RA,
minDec: points[0].DEC,
maxDec: points[0].DEC,
}
for _, point := range points[1:] {
if point.RA < boundary.minRA {
boundary.minRA = point.RA
}
if point.RA > boundary.maxRA {
boundary.maxRA = point.RA
}
if point.DEC < boundary.minDec {
boundary.minDec = point.DEC
}
if point.DEC > boundary.maxDec {
boundary.maxDec = point.DEC
}
}
constellationBoundaries = append(constellationBoundaries, boundary)
}
})
}
// ConstellationCode returns the IAU constellation code / 返回 IAU 星座代码。
func ConstellationCode(ra, dec, jde float64) string {
return resolveConstellationCode(ra, dec, jde)
}
// ConstellationNameEN returns the English constellation name / 返回英文星座名。
func ConstellationNameEN(ra, dec, jde float64) string {
return ConstellationNameByCodeEN(ConstellationCode(ra, dec, jde))
}
// ConstellationNameByCodeZH returns the Chinese name for a code / 返回星座代码对应的中文名。
func ConstellationNameByCodeZH(code string) string {
initConstellationData()
return constellationNameZH[code]
}
// ConstellationNameByCodeEN returns the English name for a code / 返回星座代码对应的英文名。
func ConstellationNameByCodeEN(code string) string {
initConstellationData()
return constellationNameEN[code]
}
+72
View File
@@ -0,0 +1,72 @@
package basic
import (
_ "embed"
"encoding/json"
"testing"
"time"
)
type constellationBaselineSample struct {
RA float64 `json:"ra"`
Dec float64 `json:"dec"`
JDE float64 `json:"jde"`
Code string `json:"code"`
ZH string `json:"zh"`
}
//go:embed testdata/cst_baseline.json
var constellationBaselineJSON []byte
func loadCstBaseline(t *testing.T) []constellationBaselineSample {
t.Helper()
var samples []constellationBaselineSample
if err := json.Unmarshal(constellationBaselineJSON, &samples); err != nil {
t.Fatalf("unmarshal constellation baseline: %v", err)
}
return samples
}
func TestConstellationBaseline(t *testing.T) {
samples := loadCstBaseline(t)
for index, sample := range samples {
if code := resolveConstellationCode(sample.RA, sample.Dec, sample.JDE); code != sample.Code {
t.Fatalf("sample %d code mismatch: ra=%.6f dec=%.6f jde=%.6f got=%q want=%q", index, sample.RA, sample.Dec, sample.JDE, code, sample.Code)
}
if code := ConstellationCode(sample.RA, sample.Dec, sample.JDE); code != sample.Code {
t.Fatalf("sample %d wrapper mismatch: ra=%.6f dec=%.6f jde=%.6f got=%q want=%q", index, sample.RA, sample.Dec, sample.JDE, code, sample.Code)
}
if zh := ConstellationNameZH(sample.RA, sample.Dec, sample.JDE); zh != sample.ZH {
t.Fatalf("sample %d zh mismatch: ra=%.6f dec=%.6f jde=%.6f got=%q want=%q", index, sample.RA, sample.Dec, sample.JDE, zh, sample.ZH)
}
}
}
func TestConstellationNameLookups(t *testing.T) {
tests := []struct {
code string
zh string
en string
}{
{code: "ORI", zh: "猎户座", en: "Orion"},
{code: "PSC", zh: "双鱼座", en: "Pisces"},
{code: "SER1", zh: "巨蛇座", en: "Serpens Caput"},
{code: "SER2", zh: "巨蛇座", en: "Serpens Cauda"},
{code: "UMI", zh: "小熊座", en: "Ursa Minor"},
}
for _, testCase := range tests {
if zh := ConstellationNameByCodeZH(testCase.code); zh != testCase.zh {
t.Fatalf("ConstellationNameByCodeZH(%q) = %q, want %q", testCase.code, zh, testCase.zh)
}
if en := ConstellationNameByCodeEN(testCase.code); en != testCase.en {
t.Fatalf("ConstellationNameByCodeEN(%q) = %q, want %q", testCase.code, en, testCase.en)
}
}
}
func TestConstellationNameEN(t *testing.T) {
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")
}
}
+34
View File
@@ -0,0 +1,34 @@
package basic
import (
"testing"
)
func TestConstellationNameZH(t *testing.T) {
for _, jde := range []float64{2451545.0, 2461328.5} {
for i := 0.00; i <= 360.00; i += 0.25 {
for j := -90.00; j <= 90.00; j += 0.25 {
if ConstellationNameZH(i, j, jde) == "" {
t.Fatalf("ConstellationNameZH(%v, %v, %v) is empty", i, j, jde)
}
}
}
}
}
func TestConstellationBoundarySliver(t *testing.T) {
for _, dec := range []float64{3.25, 3.5, 3.75, 4.0} {
if got := ConstellationNameZH(278.25, dec, 2461328.5); got != "巨蛇座" && got != "蛇夫座" {
t.Fatalf("ConstellationNameZH(278.25, %v) = %q, want 巨蛇座 or 蛇夫座", dec, got)
}
}
}
func BenchmarkConstellationNameZH(b *testing.B) {
jde := GetNowJD()
for i := 0; i < b.N; i++ {
//GetNowJD()
ConstellationNameZH(11.11, 12.12, jde)
}
}
+67 -83
View File
@@ -10,7 +10,8 @@ import (
* 坐标变换,黄道转赤道
*/
func LoToRa(jde, lo, bo float64) float64 {
ra := math.Atan2(Sin(lo)*Cos(Sita(jde))-Tan(bo)*Sin(Sita(jde)), Cos(lo))
eps := TrueObliquity(jde)
ra := math.Atan2(Sin(lo)*Cos(eps)-Tan(bo)*Sin(eps), Cos(lo))
ra = ra * 180 / math.Pi
if ra < 0 {
ra += 360
@@ -19,13 +20,17 @@ func LoToRa(jde, lo, bo float64) float64 {
}
func BoToDec(jde, lo, bo float64) float64 {
dec := ArcSin(Sin(bo)*Cos(Sita(jde)) + Cos(bo)*Sin(Sita(jde))*Sin(lo))
eps := TrueObliquity(jde)
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
return dec
}
func LoBoToRaDec(jde, lo, bo float64) (float64, float64) {
dec := ArcSin(Sin(bo)*Cos(Sita(jde)) + Cos(bo)*Sin(Sita(jde))*Sin(lo))
ra := math.Atan2(Sin(lo)*Cos(Sita(jde))-Tan(bo)*Sin(Sita(jde)), Cos(lo))
eps := TrueObliquity(jde)
sinEps := Sin(eps)
cosEps := Cos(eps)
dec := ArcSin(Sin(bo)*cosEps + Cos(bo)*sinEps*Sin(lo))
ra := math.Atan2(Sin(lo)*cosEps-Tan(bo)*sinEps, Cos(lo))
ra = ra * 180 / math.Pi
if ra < 0 {
ra += 360
@@ -36,9 +41,9 @@ func LoBoToRaDec(jde, lo, bo float64) (float64, float64) {
func RaDecToLoBo(jde, ra, dec float64) (float64, float64) {
//tan(λ) = (sin(α)*cos(ε) + tan(δ)*sin(ε)) / cos(α)
//sin(β)=sin(δ)*cos(ε)-cos(δ)*sin(ε)*sin(α)
sita := Sita(jde)
sinBo := Sin(dec)*Cos(sita) - Cos(dec)*Sin(sita)*Sin(ra)
lo := math.Atan2((Sin(ra)*Cos(sita) + Tan(dec)*Sin(sita)), Cos(ra))
eps := TrueObliquity(jde)
sinBo := Sin(dec)*Cos(eps) - Cos(dec)*Sin(eps)*Sin(ra)
lo := math.Atan2((Sin(ra)*Cos(eps) + Tan(dec)*Sin(eps)), Cos(ra))
lo = Limit360(lo * 180 / math.Pi)
return lo, ArcSin(sinBo)
}
@@ -46,8 +51,8 @@ func RaDecToLoBo(jde, ra, dec float64) (float64, float64) {
func RaToLo(jde, ra, dec float64) float64 {
//tan(λ) = (sin(α)*cos(ε) + tan(δ)*sin(ε)) / cos(α)
//sin(β)=sin(δ)*cos(ε)-cos(δ)*sin(ε)*sin(α)
sita := Sita(jde)
lo := math.Atan2((Sin(ra)*Cos(sita) + Tan(dec)*Sin(sita)), Cos(ra))
eps := TrueObliquity(jde)
lo := math.Atan2((Sin(ra)*Cos(eps) + Tan(dec)*Sin(eps)), Cos(ra))
lo = Limit360(lo * 180 / math.Pi)
return lo
}
@@ -55,119 +60,98 @@ func RaToLo(jde, ra, dec float64) float64 {
func DecToBo(jde, ra, dec float64) float64 {
//tan(λ) = (sin(α)*cos(ε) + tan(δ)*sin(ε)) / cos(α)
//sin(β)=sin(δ)*cos(ε)-cos(δ)*sin(ε)*sin(α)
sita := Sita(jde)
sinBo := Sin(dec)*Cos(sita) - Cos(dec)*Sin(sita)*Sin(ra)
eps := TrueObliquity(jde)
sinBo := Sin(dec)*Cos(eps) - Cos(dec)*Sin(eps)*Sin(ra)
return ArcSin(sinBo)
}
/*
* 赤道坐标岁差变换st end 为JDE时刻
*/
func ZuoBiaoSuiCha(ra, dec, st, end float64) (float64, float64) {
t := (end - st) / 36525.0
l := (2306.2181*t + 0.30188*t*t + 0.017998*t*t*t) / 3600
z := (2306.2181*t + 1.09468*t*t + 0.018203*t*t*t) / 3600
o := (2004.3109*t - 0.42665*t*t + 0.041833*t*t*t) / 3600
A := Cos(dec) * Sin(ra+l)
B := Cos(o)*Cos(dec)*Cos(ra+l) - Sin(o)*Sin(dec)
C := Sin(o)*Cos(dec)*Cos(ra+l) + Cos(o)*Sin(dec)
ras := math.Atan2(A, B)
ras = ras * 180 / math.Pi
if ras < 0 {
ras += 360
}
ra = ras + z
dec = ArcSin(C)
return ra, dec
}
/*
* 地心坐标转站心坐标,参数分别为,地心赤经赤纬 纬度经度,jde,离地心位置au
*/
// 站心视差沿用的旧地球椭球,与 geodata 的赤道半径不同,不可混用。
const (
topocentricEarthRadiusKM = 6378.14
topocentricEarthPolarRadiusKM = 6356.755
)
func pcosi(lat, h float64) float64 {
b := 6356.755
a := 6378.14
b := topocentricEarthPolarRadiusKM
a := topocentricEarthRadiusKM
u := ArcTan(b / a * Tan(lat))
//psin=b/a*Sin(u)+h/6378140*Sin(lat);
pcos := Cos(u) + h/6378140.0*Cos(lat)
//psin=b/a*Sin(u)+h/(topocentricEarthRadiusKM*1000)*Sin(lat);
pcos := Cos(u) + h/(topocentricEarthRadiusKM*1000)*Cos(lat)
return pcos
}
func psini(lat, h float64) float64 {
b := 6356.755
a := 6378.14
b := topocentricEarthPolarRadiusKM
a := topocentricEarthRadiusKM
u := ArcTan(b / a * Tan(lat))
psin := b/a*Sin(u) + h/6378140*Sin(lat)
//pcos=Cos(u)+h/6378140*Cos(lat);
psin := b/a*Sin(u) + h/(topocentricEarthRadiusKM*1000)*Sin(lat)
//pcos=Cos(u)+h/(topocentricEarthRadiusKM*1000)*Cos(lat);
return psin
}
func ZhanXinRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(UTC2UT1(jd))*15, au, h)
}
func topocentricRaDecWithSidereal(ra, dec, lat, lon, siderealDegrees, au, h float64) (float64, float64) {
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
psini := psini(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
tH := Limit360(siderealDegrees + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
return ra + nra, ndec
}
func ZhanXinRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
return ra + nra
func TopocentricRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
topocentricRA, _ := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
return topocentricRA
}
func ZhanXinDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
psini := psini(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
return ndec
func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
_, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
return topocentricDec
}
func ZhanXinLo(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(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
N := Cos(lo)*Cos(bo) - C*sinpi*Cos(tH)
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(S*Sin(Sita(jd))+C*Cos(Sita(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 ZhanXinBo(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(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
N := Cos(lo)*Cos(bo) - C*sinpi*Cos(tH)
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(S*Sin(Sita(jd))+C*Cos(Sita(jd))*Sin(tH)), N) * 180 / math.Pi
nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(S*Cos(Sita(jd))-C*Sin(Sita(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 { //光行差修正
// GXCLoWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func GXCLoWithInstance(lo, bo, jde float64, inst *Instance) float64 { //光行差修正
k := 20.49552
sunlo := SunTrueLo(jd)
e := Earthe(jd)
epi := EarthPI(jd)
sunlo := SunTrueLoWithInstance(jde, inst)
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 {
// GXCBoWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func GXCBoWithInstance(lo, bo, jde float64, inst *Instance) float64 {
k := 20.49552
sunlo := SunTrueLo(jd)
e := Earthe(jd)
epi := EarthPI(jd)
sunlo := SunTrueLoWithInstance(jde, inst)
e := Earthe(jde)
epi := EarthPI(jde)
tmp := -k * Sin(bo) * (Sin(sunlo-lo) - e*Sin(epi-lo))
return tmp
}
+44
View File
@@ -0,0 +1,44 @@
package basic
import (
"math"
"testing"
. "b612.me/astro/tools"
)
func TestEclipticToEquatorialCachedObliquityMatchesRepeatedEvaluation(t *testing.T) {
for _, test := range []struct {
jde float64
lo float64
bo float64
}{
{jde: 2451545.0, lo: 0, bo: 0},
{jde: 2460832.0, lo: 193.25, bo: -5.75},
{jde: 2378496.5, lo: 359.999, bo: 87.5},
} {
legacyDec := ArcSin(
Sin(test.bo)*Cos(TrueObliquity(test.jde)) +
Cos(test.bo)*Sin(TrueObliquity(test.jde))*Sin(test.lo),
)
legacyRA := math.Atan2(
Sin(test.lo)*Cos(TrueObliquity(test.jde))-Tan(test.bo)*Sin(TrueObliquity(test.jde)),
Cos(test.lo),
) * 180 / math.Pi
if legacyRA < 0 {
legacyRA += 360
}
ra, dec := LoBoToRaDec(test.jde, test.lo, test.bo)
if ra != legacyRA || dec != legacyDec {
t.Fatalf("LoBoToRaDec(%v, %v, %v) = %.15g %.15g, want %.15g %.15g",
test.jde, test.lo, test.bo, ra, dec, legacyRA, legacyDec)
}
if got := LoToRa(test.jde, test.lo, test.bo); got != legacyRA {
t.Fatalf("LoToRa(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyRA)
}
if got := BoToDec(test.jde, test.lo, test.bo); got != legacyDec {
t.Fatalf("BoToDec(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyDec)
}
}
}
-50
View File
@@ -1,50 +0,0 @@
package basic
import (
"fmt"
"testing"
)
func Test_LoBo(t *testing.T) {
fmt.Printf("%.9f", dt_cal(2020.5))
}
func Test_LoBoRaDec(t *testing.T) {
jde := 2451545.0
lo, bo := RaDecToLoBo(jde, 10, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 40, 80)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 90, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 130, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 160, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 180, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 210, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 260, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 270, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 300, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 350, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
lo, bo = RaDecToLoBo(jde, 0, 50)
fmt.Println("LO,BO", lo, bo)
fmt.Println(LoBoToRaDec(jde, lo, bo))
}
+82
View File
@@ -0,0 +1,82 @@
package basic
import (
"math"
"testing"
"time"
. "b612.me/astro/tools"
)
func TestTopocentricRaDecUsesUTJulianDateForSiderealTime(t *testing.T) {
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(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)
// 逐分量比较:零距离处 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 := 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(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 {
t.Fatalf("TopocentricRa = %.12f, want %.12f", got, wantRA)
}
if got := TopocentricDec(ra, dec, lat, lon, ut, distanceAU, 0); got != wantDec {
t.Fatalf("TopocentricDec = %.12f, want %.12f", got, wantDec)
}
}
func TestHMoonHeightUsesUTForTopocentricCorrection(t *testing.T) {
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(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 {
t.Fatalf("HMoonHeightN differs from the UT topocentric position by %.12f degrees", difference)
}
}
func independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, height float64) (float64, float64) {
const (
equatorialRadiusKM = 6378.14
polarRadiusKM = 6356.755
)
u := math.Atan(polarRadiusKM / equatorialRadiusKM * Tan(lat))
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(UTC2UT1(ut))*15 + lon - ra)
deltaRA := math.Atan2(
-rhoCos*sinParallax*Sin(hourAngle),
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
)
topRA := ra + deltaRA*180/math.Pi
topDec := math.Atan2(
(Sin(dec)-rhoSin*sinParallax)*math.Cos(deltaRA),
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
) * 180 / math.Pi
return topRA, topDec
}
func angularDistanceArcsec(ra1, dec1, ra2, dec2 float64) float64 {
cosDistance := Sin(dec1)*Sin(dec2) + Cos(dec1)*Cos(dec2)*Cos(ra1-ra2)
return math.Acos(math.Max(-1, math.Min(1, cosDistance))) * 180 / math.Pi * 3600
}
-290
View File
@@ -1,290 +0,0 @@
package basic
import (
. "b612.me/astro/tools"
)
type cst struct {
RA float64 //赤经
DEC float64 //赤纬
}
var cstLists map[string][]cst
func initCstData() {
cstLists = make(map[string][]cst, 89)
cstLists["AND"] = []cst{cst{344.46530375, 35.1682358}, cst{344.34285125, 53.1680298}, cst{351.45289375, 53.1870041}, cst{351.4656825, 50.6870193}, cst{355.27055708333, 50.6929131}, cst{355.27607875, 48.6929169}, cst{4.1463675, 48.6949348}, cst{4.14327875, 46.6949348}, cst{14.776077083333, 46.6757545}, cst{14.7888675, 48.6757393}, cst{18.588407916667, 48.6632690}, cst{18.60590375, 50.6632347}, cst{22.40793625, 50.6478767}, cst{26.96852375, 50.6257439}, cst{26.931439583333, 47.6258430}, cst{32.62149125, 47.5927505}, cst{32.67380125, 51.0925827}, cst{39.88547875, 51.0423737}, cst{39.67934125, 37.2931557}, cst{31.87109125, 37.3470840}, cst{31.854250416667, 35.5971375}, cst{22.910835, 35.6453362}, cst{22.89742625, 33.6453705}, cst{12.44306375, 33.6818962}, cst{12.41349125, 24.4319324}, cst{14.424064583333, 24.4266243}, cst{14.414815416667, 21.6766376}, cst{3.73992125, 21.6951923}, cst{3.7406445833333, 22.6951923}, cst{2.61001625, 22.6957588}, cst{2.6128425, 28.6957588}, cst{1.60621625, 28.6960354}, cst{1.6069770833333, 32.0293655}, cst{357.82874125, 32.0285034}, cst{357.8280825, 32.7785072}, cst{354.04915791667, 32.7746468}, cst{354.04417958333, 35.1913109}}
cstLists["ANT"] = []cst{cst{141.904335, -24.5425186}, cst{141.77159875, -37.2920151}, cst{141.73406125, -40.2918739}, cst{166.45650458333, -40.4246216}, cst{166.47936291667, -35.6746559}, cst{163.95851541667, -35.6664963}, cst{163.977885, -31.8332005}, cst{160.20137875, -31.8185863}, cst{160.21289375, -29.8186131}, cst{155.18132708333, -29.7947845}, cst{155.1993375, -27.1281624}, cst{147.65928291667, -27.0835037}, cst{147.67968125, -24.5835705}}
cstLists["APS"] = []cst{cst{209.11110875, -83.1200714}, cst{276.86599791667, -82.4582748}, cst{274.19506041667, -74.9745178}, cst{273.28007708333, -67.4800797}, cst{265.77572875, -67.5711060}, cst{258.2424825, -67.6610870}, cst{258.47067875, -70.1597443}, cst{224.16644125, -70.5115433}, cst{207.46087041667, -70.6244431}, cst{207.78143375, -75.6235962}}
cstLists["AQR"] = []cst{cst{309.59884625, 0.4361772}, cst{309.5798775, 2.4360874}, cst{314.08109708333, 2.4773185}, cst{321.58347125, 2.5393796}, cst{323.58427041667, 2.5544112}, cst{323.57874875, 3.3043909}, cst{326.58021875, 3.3256676}, cst{326.58708625, 2.3256910}, cst{331.588755, 2.3576119}, cst{331.58726708333, 2.6076074}, cst{342.84221708333, 2.6622071}, cst{342.8497125, 0.6622211}, cst{342.86470375, -3.3377509}, cst{359.10221125, -3.3042023}, cst{359.10329875, -6.3042021}, cst{359.11056458333, -24.8042011}, cst{346.68096625, -24.8250446}, cst{329.77028875, -24.9040413}, cst{329.6561625, -8.4043999}, cst{321.668415, -8.4602947}, cst{321.71645125, -14.4601107}, cst{309.74390125, -14.5631361}, cst{309.68464791667, -8.5634165}}
cstLists["AQL"] = []cst{cst{280.35020875, 0.1154895}, cst{280.3262325, 2.1153460}, cst{284.57642541667, 2.1659052}, cst{284.525985, 6.4156075}, cst{281.45856875, 6.3791943}, cst{281.388045, 12.1287737}, cst{284.45626208333, 12.1651964}, cst{284.37363625, 18.6647091}, cst{286.37549375, 18.6882229}, cst{286.4054775, 16.3550682}, cst{298.92116541667, 16.4957294}, cst{298.926, 16.0790844}, cst{303.5589975, 16.1275158}, cst{303.636675, 8.8779116}, cst{306.01395625, 8.9018240}, cst{306.07910125, 2.4021468}, cst{309.5798775, 2.4360874}, cst{309.59884625, 0.4361772}, cst{309.68464791667, -8.5634165}, cst{301.69369625, -8.6430750}, cst{301.72636958333, -11.6762342}, cst{284.74405375, -11.8664360}, cst{284.64729291667, -3.8336766}, cst{280.3981875, -3.8842230}}
cstLists["ARA"] = []cst{cst{249.03468125, -60.2644577}, cst{248.57062375, -45.7670517}, cst{269.80928375, -45.5163460}, cst{272.3090175, -45.4859734}, cst{272.67225291667, -56.9837723}, cst{265.16818958333, -57.0747757}, cst{265.77572875, -67.5711060}, cst{258.2424825, -67.6610870}, cst{255.72498291667, -67.6905823}, cst{255.5423925, -65.1916428}, cst{254.28351458333, -65.2062531}, cst{254.1951375, -63.7900925}, cst{251.67632125, -63.8189964}, cst{251.53784708333, -61.2364578}, cst{249.08163, -61.2641945}}
cstLists["ARI"] = []cst{cst{31.6652475, 10.5143948}, cst{26.65573375, 10.5432396}, cst{26.744674583333, 25.6263351}, cst{30.51371125, 25.6050701}, cst{30.53061625, 27.8550186}, cst{38.07014875, 27.8047638}, cst{38.10319375, 31.2213154}, cst{42.62838, 31.1865025}, cst{52.426667916667, 31.1003609}, cst{52.2906225, 19.4343338}, cst{51.037234583333, 19.4461136}, cst{50.94641125, 10.3632069}}
cstLists["AUR"] = []cst{cst{69.4869375, 30.9218750}, cst{69.57384125, 36.2547150}, cst{72.45734375, 36.2218513}, cst{72.840285, 52.7196465}, cst{77.484762083333, 52.6655540}, cst{77.606764583333, 56.1648331}, cst{94.13108875, 55.9658089}, cst{94.05736625, 53.9662552}, cst{100.04603125, 53.8938293}, cst{99.919459583333, 49.8945885}, cst{104.40635875, 49.8410034}, cst{104.26530291667, 44.3418388}, cst{112.73412458333, 44.2435493}, cst{112.56071875, 35.2445297}, cst{100.09027625, 35.3905640}, cst{99.965657916667, 27.8913116}, cst{90.22107125, 28.0092907}, cst{90.228902916667, 28.5092430}, cst{73.212475416667, 28.7124405}, cst{73.235342916667, 30.2123089}, cst{69.47678875, 30.2552605}}
cstLists["BOO"] = []cst{cst{227.78148625, 7.5253930}, cst{204.06384875, 7.3605771}, cst{204.02893041667, 14.3604937}, cst{203.95387208333, 27.8603134}, cst{210.7888275, 27.8976517}, cst{210.77085875, 30.1475964}, cst{211.88893375, 30.1545391}, cst{211.69873208333, 47.9039383}, cst{211.58439125, 54.9035759}, cst{217.25124875, 54.9422379}, cst{229.59105458333, 55.0448647}, cst{229.65737375, 52.5451736}, cst{237.08447375, 52.6174774}, cst{237.12458541667, 51.1176796}, cst{237.36542708333, 39.6189079}, cst{232.64365208333, 39.5721130}, cst{232.74697791667, 32.5726128}, cst{229.01591875, 32.5376778}, cst{229.09951625, 25.5380573}, cst{227.60549125, 25.5246105}}
cstLists["CAE"] = []cst{cst{65.0764125, -39.7007294}, cst{64.88238625, -48.6996651}, cst{68.362247916667, -48.7384491}, cst{68.42409625, -46.2387962}, cst{73.402082916667, -46.2959023}, cst{73.482370416667, -42.7963676}, cst{75.97444375, -42.8255501}, cst{76.2549375, -27.0772038}, cst{73.75929625, -27.0479794}, cst{71.76333125, -27.0248775}, cst{71.72241875, -29.7746429}, cst{69.97665125, -29.7546597}, cst{69.862375416667, -36.7540054}, cst{65.12985, -36.7010231}}
cstLists["CAM"] = []cst{cst{94.13108875, 55.9658089}, cst{77.606764583333, 56.1648331}, cst{77.484762083333, 52.6655540}, cst{72.840285, 52.7196465}, cst{52.31308625, 52.9366074}, cst{52.381900416667, 55.4362831}, cst{49.8540225, 55.4596519}, cst{49.91349625, 57.4593849}, cst{48.90093, 57.4684982}, cst{49.3954575, 68.4662857}, cst{54.237034583333, 68.4214401}, cst{55.30874875, 77.4163132}, cst{56.726209583333, 77.4025955}, cst{57.53049, 80.3986664}, cst{80.488894583333, 80.1478500}, cst{84.536117916667, 85.1239471}, cst{127.953615, 84.6103745}, cst{130.40275041667, 86.0975418}, cst{213.0229575, 85.9308090}, cst{216.78285625, 79.4449844}, cst{203.80918958333, 79.3629303}, cst{204.15701875, 76.3638153}, cst{195.8206125, 76.3289108}, cst{174.43479625, 76.3084106}, cst{174.53158375, 79.3083420}, cst{162.81859791667, 79.3401794}, cst{163.10541625, 81.3396072}, cst{142.191195, 81.4677658}, cst{140.61547375, 72.9741364}, cst{123.08622875, 73.1383743}, cst{122.12910125, 59.6433983}, cst{107.7531975, 59.8037262}, cst{107.8515525, 61.8031464}, cst{94.40745625, 61.9641266}}
cstLists["CNC"] = []cst{cst{140.40425875, 6.4700689}, cst{122.92139125, 6.6302376}, cst{120.54834291667, 6.6549850}, cst{120.5806725, 9.6548138}, cst{118.83248875, 9.6734257}, cst{118.87160541667, 13.1732168}, cst{118.94754458333, 19.6728077}, cst{120.07012375, 19.6608200}, cst{120.17164625, 27.6602821}, cst{121.91596958333, 27.6419144}, cst{121.99323125, 33.1415138}, cst{140.645985, 32.9691162}}
cstLists["CVN"] = []cst{cst{181.59450625, 33.3039627}, cst{181.59141625, 44.3039627}, cst{182.82643375, 44.3043365}, cst{182.8185225, 52.3043365}, cst{203.74239875, 52.3598061}, cst{203.79511375, 47.8599281}, cst{211.69873208333, 47.9039383}, cst{211.88893375, 30.1545391}, cst{210.77085875, 30.1475964}, cst{210.7888275, 27.8976517}, cst{203.95387208333, 27.8603134}, cst{200.22657458333, 27.8437748}, cst{200.20774791667, 31.3437366}, cst{186.55769375, 31.3074341}, cst{186.55426041667, 33.3074303}}
cstLists["CMA"] = []cst{cst{93.215625, -11.0301533}, cst{111.97339958333, -11.2521448}, cst{111.67719875, -33.2504692}, cst{99.903859583333, -33.1128159}, cst{92.899067916667, -33.0282326}, cst{92.99256625, -27.2787991}}
cstLists["CMI"] = []cst{cst{122.84900708333, -0.3693900}, cst{109.59966625, -0.2243290}, cst{109.61691875, 1.2755718}, cst{106.86739625, 1.3074419}, cst{106.91427458333, 5.3071680}, cst{106.66432208333, 5.3100886}, cst{106.71787958333, 9.8097754}, cst{106.7482275, 12.3095980}, cst{114.24100375, 12.2238722}, cst{114.2527275, 13.2238064}, cst{118.87160541667, 13.1732168}, cst{118.83248875, 9.6734257}, cst{120.5806725, 9.6548138}, cst{120.54834291667, 6.6549850}, cst{122.92139125, 6.6302376}}
cstLists["CAP"] = []cst{cst{309.68464791667, -8.5634165}, cst{301.69369625, -8.6430750}, cst{301.72636958333, -11.6762342}, cst{301.91596958333, -27.6419144}, cst{306.89795541667, -27.5913391}, cst{321.83163625, -27.4596672}, cst{321.80777541667, -24.9597607}, cst{329.77028875, -24.9040413}, cst{329.6561625, -8.4043999}, cst{321.668415, -8.4602947}, cst{321.71645125, -14.4601107}, cst{309.74390125, -14.5631361}}
cstLists["CAR"] = []cst{cst{170.15592125, -57.1843452}, cst{166.33725625, -57.1744423}, cst{133.32365541667, -56.9739723}, cst{133.38017375, -54.9742203}, cst{127.56711875, -54.9204712}, cst{127.60929125, -53.4206772}, cst{123.32011625, -53.3782196}, cst{123.38112875, -51.1285286}, cst{120.8616975, -51.1025848}, cst{90.748902083333, -50.7545471}, cst{90.693705, -52.5042114}, cst{93.19435375, -52.5345764}, cst{93.1074, -55.0340500}, cst{98.114275416667, -55.0945587}, cst{97.995077916667, -58.0938416}, cst{103.01111708333, -58.1537018}, cst{102.70331375, -64.1518784}, cst{136.09472708333, -64.4990387}, cst{135.24368708333, -75.4954681}, cst{169.85697291667, -75.6840134}, cst{170.08481125, -64.6842651}}
cstLists["CAS"] = []cst{cst{344.34285125, 53.1680298}, cst{344.30402708333, 56.9179611}, cst{344.26912375, 59.7512321}, cst{348.85966375, 59.7646751}, cst{348.81649041667, 63.6812897}, cst{355.21757125, 63.6928787}, cst{355.19785958333, 66.6928711}, cst{6.76376375, 66.6924438}, cst{6.92291375, 77.6923447}, cst{55.30874875, 77.4163132}, cst{54.237034583333, 68.4214401}, cst{49.3954575, 68.4662857}, cst{48.90093, 57.4684982}, cst{38.762337083333, 57.5513000}, cst{38.802355416667, 59.0511551}, cst{30.795625416667, 59.1046104}, cst{30.77362375, 58.1046753}, cst{27.5952225, 58.1227188}, cst{27.53364125, 54.6228828}, cst{22.45601375, 54.6477699}, cst{22.40793625, 50.6478767}, cst{18.60590375, 50.6632347}, cst{18.588407916667, 48.6632690}, cst{14.7888675, 48.6757393}, cst{14.776077083333, 46.6757545}, cst{4.14327875, 46.6949348}, cst{4.1463675, 48.6949348}, cst{355.27607875, 48.6929169}, cst{355.27055708333, 50.6929131}, cst{351.4656825, 50.6870193}, cst{351.45289375, 53.1870041}}
cstLists["CEN"] = []cst{cst{166.47936291667, -35.6746559}, cst{166.45650458333, -40.4246216}, cst{166.33725625, -57.1744423}, cst{170.15592125, -57.1843452}, cst{170.08481125, -64.6842651}, cst{179.05736375, -64.6957855}, cst{179.07076791667, -55.6957932}, cst{194.33451125, -55.6771049}, cst{194.43838041667, -64.6769638}, cst{204.68028625, -64.6379395}, cst{220.51497458333, -64.5390244}, cst{220.23446541667, -55.5400887}, cst{214.65681625, -55.5799522}, cst{214.45026458333, -42.5806465}, cst{225.79627958333, -42.4941750}, cst{225.63076958333, -29.9948788}, cst{190.41739958333, -30.1863899}, cst{190.42719875, -33.6863785}, cst{185.38743458333, -33.6938934}, cst{185.39029625, -35.6938896}}
cstLists["CEP"] = []cst{cst{300.5732625, 59.8510780}, cst{300.48520041667, 61.8506203}, cst{306.8118675, 61.9143791}, cst{306.51738125, 67.4129562}, cst{310.33401458333, 67.4490280}, cst{309.57304041667, 75.4455261}, cst{301.87339791667, 75.3708725}, cst{300.6738, 80.3647766}, cst{313.70587375, 80.4867859}, cst{308.72097, 86.4656219}, cst{308.33135541667, 86.6306305}, cst{343.51066625, 86.8368912}, cst{339.26098791667, 88.6638870}, cst{135.83247125, 87.5689163}, cst{130.40275041667, 86.0975418}, cst{127.953615, 84.6103745}, cst{84.536117916667, 85.1239471}, cst{80.488894583333, 80.1478500}, cst{57.53049, 80.3986664}, cst{56.726209583333, 77.4025955}, cst{55.30874875, 77.4163132}, cst{6.92291375, 77.6923447}, cst{6.76376375, 66.6924438}, cst{355.19785958333, 66.6928711}, cst{355.21757125, 63.6928787}, cst{348.81649041667, 63.6812897}, cst{348.85966375, 59.7646751}, cst{344.26912375, 59.7512321}, cst{344.30402708333, 56.9179611}, cst{335.91093, 56.8825760}, cst{335.93130125, 55.6326256}, cst{333.13762625, 55.6178436}, cst{333.17467625, 53.3679428}, cst{330.63921, 53.3532715}, cst{330.60218875, 55.4364891}, cst{309.83136125, 55.2753258}, cst{309.62379458333, 61.3576965}, cst{308.66080375, 61.3486443}, cst{308.71659291667, 59.9322395}}
cstLists["CET"] = []cst{cst{6.60132875, 0.6925398}, cst{6.6037875, 2.6925383}, cst{31.61526625, 2.5978806}, cst{31.6652475, 10.5143948}, cst{50.94641125, 10.3632069}, cst{50.85298375, 0.4469725}, cst{50.836682916667, -1.3029516}, cst{41.33922125, -1.2210265}, cst{41.14875875, -23.8536034}, cst{26.46599875, -23.7562580}, cst{26.45888875, -24.8729095}, cst{359.11056458333, -24.8042011}, cst{359.10329875, -6.3042021}, cst{6.5927025, -6.3074551}}
cstLists["CHA"] = []cst{cst{111.65211458333, -82.7758865}, cst{209.11110875, -83.1200714}, cst{207.78143375, -75.6235962}, cst{169.85697291667, -75.6840134}, cst{135.24368708333, -75.4954681}, cst{114.21470375, -75.2899170}}
cstLists["CIR"] = []cst{cst{204.68028625, -64.6379395}, cst{204.70747958333, -65.6378784}, cst{207.26802291667, -65.6249542}, cst{207.46087041667, -70.6244431}, cst{224.16644125, -70.5115433}, cst{224.00363375, -68.0122070}, cst{226.55712625, -67.9909286}, cst{226.35353541667, -64.0751266}, cst{230.16657875, -64.0415649}, cst{230.05456958333, -61.4587479}, cst{232.58976458333, -61.4353065}, cst{232.5498675, -60.4354935}, cst{232.38191125, -55.4362831}, cst{228.08351125, -55.4754944}, cst{220.23446541667, -55.5400887}, cst{220.51497458333, -64.5390244}}
cstLists["COL"] = []cst{cst{75.97444375, -42.8255501}, cst{76.2549375, -27.0772038}, cst{92.99256625, -27.2787991}, cst{92.899067916667, -33.0282326}, cst{99.903859583333, -33.1128159}, cst{99.70891625, -43.1116486}, cst{90.951777083333, -43.0057793}}
cstLists["COM"] = []cst{cst{179.60453541667, 13.3040485}, cst{179.60894125, 28.3040466}, cst{181.59566375, 28.3039627}, cst{181.59450625, 33.3039627}, cst{186.55426041667, 33.3074303}, cst{186.55769375, 31.3074341}, cst{200.20774791667, 31.3437366}, cst{200.22657458333, 27.8437748}, cst{203.95387208333, 27.8603134}, cst{204.02893041667, 14.3604937}, cst{194.05906625, 14.3225088}, cst{194.0620275, 13.3225126}}
cstLists["CRA"] = []cst{cst{269.62546375, -37.0174599}, cst{289.59631958333, -36.7785645}, cst{289.76964, -45.2775650}, cst{272.3090175, -45.4859734}, cst{269.80928375, -45.5163460}}
cstLists["CRB"] = []cst{cst{229.09951625, 25.5380573}, cst{229.01591875, 32.5376778}, cst{232.74697791667, 32.5726128}, cst{232.64365208333, 39.5721130}, cst{237.36542708333, 39.6189079}, cst{246.07194875, 39.7117195}, cst{246.2798025, 26.7128716}, cst{243.78670625, 26.6855240}, cst{243.8001825, 25.6855946}, cst{241.80573458333, 25.6641407}}
cstLists["CRV"] = []cst{cst{194.1330525, -11.6773882}, cst{179.09676, -11.6957970}, cst{179.09131041667, -25.1957951}, cst{190.404525, -25.1864014}, cst{190.3985025, -22.6864090}, cst{194.16687, -22.6773415}}
cstLists["CRT"] = []cst{cst{162.82713875, -6.6621790}, cst{162.80791375, -11.6621428}, cst{162.77554041667, -19.6620827}, cst{164.03058625, -19.6666222}, cst{164.00808291667, -25.1665821}, cst{179.09131041667, -25.1957951}, cst{179.09676, -11.6957970}, cst{179.09860625, -6.6957974}, cst{174.34229875, -6.6916924}}
cstLists["CRU"] = []cst{cst{179.07076791667, -55.6957932}, cst{179.05736375, -64.6957855}, cst{194.43838041667, -64.6769638}, cst{194.33451125, -55.6771049}}
cstLists["CYG"] = []cst{cst{290.13264625, 27.7324085}, cst{290.0952525, 30.2321968}, cst{291.5987775, 30.2493153}, cst{291.49260458333, 36.7487144}, cst{292.11965541667, 36.7558022}, cst{291.9835275, 43.7550354}, cst{288.47030708333, 43.7149391}, cst{288.37552041667, 47.7143936}, cst{287.1206475, 47.6998672}, cst{286.87645958333, 55.6984482}, cst{291.90459291667, 55.7560043}, cst{291.80955, 58.2554703}, cst{297.10055375, 58.3138733}, cst{297.03924125, 59.8135414}, cst{300.5732625, 59.8510780}, cst{308.71659291667, 59.9322395}, cst{308.66080375, 61.3486443}, cst{309.62379458333, 61.3576965}, cst{309.83136125, 55.2753258}, cst{330.60218875, 55.4364891}, cst{330.63921, 53.3532715}, cst{330.76266291667, 44.6036453}, cst{329.87860625, 44.5982513}, cst{329.88163958333, 44.3482628}, cst{329.37664041667, 44.3451195}, cst{329.4610125, 36.5953827}, cst{327.319965, 36.5815468}, cst{327.39518125, 28.5817947}, cst{322.62016375, 28.5480537}, cst{315.08391458333, 28.4871883}, cst{315.07258375, 29.4871387}, cst{296.25094375, 29.3010578}, cst{296.27220125, 27.8011742}}
cstLists["DEL"] = []cst{cst{309.5798775, 2.4360874}, cst{306.07910125, 2.4021468}, cst{306.01395625, 8.9018240}, cst{303.636675, 8.8779116}, cst{303.5589975, 16.1275158}, cst{305.18694875, 16.1439629}, cst{305.13404875, 20.8936996}, cst{309.89693708333, 20.9399471}, cst{309.907665, 19.9399967}, cst{317.17878291667, 20.0046406}, cst{317.24836375, 12.3382607}, cst{314.61859625, 12.3157644}, cst{314.67109625, 6.4826641}, cst{314.045505, 6.4771614}, cst{314.08109708333, 2.4773185}}
cstLists["DOR"] = []cst{cst{58.318787916667, -52.7968445}, cst{60.79789625, -52.8228111}, cst{60.69291875, -56.1555862}, cst{65.6504625, -56.2093849}, cst{65.55459, -58.7088547}, cst{69.274534583333, -58.7506638}, cst{68.79401875, -67.2479248}, cst{68.58152375, -69.7467194}, cst{98.454422916667, -70.1041336}, cst{98.93724875, -64.1070251}, cst{90.173642916667, -64.0010529}, cst{90.34506125, -61.0020981}, cst{82.85761375, -60.9112892}, cst{83.01880375, -57.4122620}, cst{75.547742916667, -57.3230400}, cst{75.6770175, -53.8238029}, cst{68.217745416667, -53.7376366}, cst{68.362247916667, -48.7384491}, cst{64.88238625, -48.6996651}, cst{62.149907916667, -48.6699715}, cst{62.098639583333, -50.6697006}, cst{58.37716625, -50.6304779}}
cstLists["DRA"] = []cst{cst{140.61547375, 72.9741364}, cst{142.191195, 81.4677658}, cst{163.10541625, 81.3396072}, cst{162.81859791667, 79.3401794}, cst{174.53158375, 79.3083420}, cst{174.43479625, 76.3084106}, cst{195.8206125, 76.3289108}, cst{196.09747375, 69.3293610}, cst{210.65081125, 69.3991165}, cst{210.82055541667, 65.3996506}, cst{235.32956541667, 65.6023483}, cst{235.05063, 69.6009445}, cst{247.8410625, 69.7383041}, cst{247.2207075, 74.7347870}, cst{261.53663708333, 74.9033127}, cst{260.21790458333, 79.8953476}, cst{267.65602041667, 79.9857483}, cst{261.72223041667, 85.9495697}, cst{308.72097, 86.4656219}, cst{313.70587375, 80.4867859}, cst{300.6738, 80.3647766}, cst{301.87339791667, 75.3708725}, cst{309.57304041667, 75.4455261}, cst{310.33401458333, 67.4490280}, cst{306.51738125, 67.4129562}, cst{306.8118675, 61.9143791}, cst{300.48520041667, 61.8506203}, cst{300.5732625, 59.8510780}, cst{297.03924125, 59.8135414}, cst{297.10055375, 58.3138733}, cst{291.80955, 58.2554703}, cst{291.90459291667, 55.7560043}, cst{286.87645958333, 55.6984482}, cst{287.1206475, 47.6998672}, cst{274.34237541667, 47.5476036}, cst{274.25768875, 50.5470886}, cst{255.7863525, 50.3244438}, cst{255.75682625, 51.3242683}, cst{237.12458541667, 51.1176796}, cst{237.08447375, 52.6174774}, cst{229.65737375, 52.5451736}, cst{229.59105458333, 55.0448647}, cst{217.25124875, 54.9422379}, cst{217.04525375, 62.4414825}, cst{203.57364125, 62.3593979}, cst{203.55053875, 63.3593445}, cst{181.58155958333, 63.3039627}, cst{181.57925541667, 65.8039627}, cst{171.84934625, 65.8126068}, cst{171.96136958333, 72.8125000}}
cstLists["EQU"] = []cst{cst{314.08109708333, 2.4773185}, cst{314.045505, 6.4771614}, cst{314.67109625, 6.4826641}, cst{314.61859625, 12.3157644}, cst{317.24836375, 12.3382607}, cst{318.25026458333, 12.3465548}, cst{318.244515, 13.0132008}, cst{321.50110208333, 13.0390635}, cst{321.58347125, 2.5393796}}
cstLists["ERI"] = []cst{cst{55.352905416667, 0.4037257}, cst{70.852360416667, 0.2375014}, cst{71.60231375, 0.2289162}, cst{71.55635875, -3.7708201}, cst{77.804395416667, -3.8437285}, cst{77.72003125, -10.8432293}, cst{75.22175125, -10.8138046}, cst{75.178714583333, -14.3135529}, cst{73.929797916667, -14.2989721}, cst{73.75929625, -27.0479794}, cst{71.76333125, -27.0248775}, cst{71.72241875, -29.7746429}, cst{69.97665125, -29.7546597}, cst{69.862375416667, -36.7540054}, cst{65.12985, -36.7010231}, cst{65.0764125, -39.7007294}, cst{59.105884583333, -39.6368256}, cst{59.031364583333, -43.6364403}, cst{52.3267725, -43.5694046}, cst{52.2890025, -45.5692215}, cst{46.09077125, -45.5124779}, cst{46.03451375, -48.5122337}, cst{41.08530875, -48.4710045}, cst{41.04769375, -50.4708595}, cst{37.34121, -50.4425697}, cst{37.28334625, -53.4423561}, cst{33.58414375, -53.4164696}, cst{33.489424583333, -57.9161568}, cst{21.20622875, -57.8484154}, cst{21.2732625, -52.8485603}, cst{24.967354583333, -52.8658562}, cst{24.9938475, -50.8659210}, cst{28.693257083333, -50.8859215}, cst{28.738322916667, -47.5527229}, cst{36.1529475, -47.6004944}, cst{36.26401375, -39.4342155}, cst{46.187260416667, -39.5128975}, cst{46.193322083333, -39.0962563}, cst{53.64428375, -39.1650963}, cst{53.699605416667, -35.5820351}, cst{57.430512083333, -35.6192436}, cst{57.58890125, -24.0033779}, cst{41.14875875, -23.8536034}, cst{41.33922125, -1.2210265}, cst{50.836682916667, -1.3029516}, cst{55.335582083333, -1.3461887}}
cstLists["FOR"] = []cst{cst{26.46599875, -23.7562580}, cst{41.14875875, -23.8536034}, cst{57.58890125, -24.0033779}, cst{57.430512083333, -35.6192436}, cst{53.699605416667, -35.5820351}, cst{53.64428375, -39.1650963}, cst{46.193322083333, -39.0962563}, cst{46.187260416667, -39.5128975}, cst{36.26401375, -39.4342155}, cst{26.350727916667, -39.3726234}, cst{26.45888875, -24.8729095}}
cstLists["GEM"] = []cst{cst{96.37276375, 11.9332972}, cst{96.4439175, 17.4328651}, cst{95.069575416667, 17.4495068}, cst{95.1241275, 21.4491768}, cst{90.125155416667, 21.5098724}, cst{90.1440375, 22.8430862}, cst{90.22107125, 28.0092907}, cst{99.965657916667, 27.8913116}, cst{100.09027625, 35.3905640}, cst{112.56071875, 35.2445297}, cst{118.28970875, 35.1810532}, cst{118.25808, 33.1812286}, cst{121.99323125, 33.1415138}, cst{121.91596958333, 27.6419144}, cst{120.17164625, 27.6602821}, cst{120.07012375, 19.6608200}, cst{118.94754458333, 19.6728077}, cst{118.87160541667, 13.1732168}, cst{114.2527275, 13.2238064}, cst{114.24100375, 12.2238722}, cst{106.7482275, 12.3095980}, cst{106.71787958333, 9.8097754}, cst{105.71845958333, 9.8214874}, cst{105.742815, 11.8213453}}
cstLists["GRU"] = []cst{cst{321.92805291667, -36.4592972}, cst{322.04232125, -44.9588585}, cst{322.11736625, -49.4585724}, cst{331.998825, -49.3911743}, cst{332.113695, -56.3908348}, cst{351.76852208333, -56.3126869}, cst{351.69270458333, -39.3127594}, cst{351.6833775, -36.3127670}, cst{346.72751375, -36.3249741}}
cstLists["HER"] = []cst{cst{245.558595, 3.7033811}, cst{242.80966791667, 3.6735139}, cst{242.67663, 15.6728001}, cst{240.18107375, 15.6463346}, cst{240.1110075, 21.6459675}, cst{241.85657541667, 21.6644115}, cst{241.80573458333, 25.6641407}, cst{243.8001825, 25.6855946}, cst{243.78670625, 26.6855240}, cst{246.2798025, 26.7128716}, cst{246.07194875, 39.7117195}, cst{237.36542708333, 39.6189079}, cst{237.12458541667, 51.1176796}, cst{255.75682625, 51.3242683}, cst{255.7863525, 50.3244438}, cst{274.25768875, 50.5470886}, cst{274.34237541667, 47.5476036}, cst{273.46687375, 47.5369873}, cst{273.82438625, 30.0391560}, cst{276.70077291667, 30.0739765}, cst{276.76288625, 26.0743504}, cst{284.2698675, 26.1640968}, cst{284.27716208333, 25.6641407}, cst{284.33913291667, 21.2478352}, cst{284.37363625, 18.6647091}, cst{284.45626208333, 12.1651964}, cst{281.388045, 12.1287737}, cst{275.20308458333, 12.0543308}, cst{275.17327375, 14.3874788}, cst{260.17687791667, 14.2060347}, cst{260.19584708333, 12.7061481}, cst{252.7014825, 12.6179380}, cst{252.80590958333, 3.7852108}}
cstLists["HOR"] = []cst{cst{65.0764125, -39.7007294}, cst{64.88238625, -48.6996651}, cst{62.149907916667, -48.6699715}, cst{62.098639583333, -50.6697006}, cst{58.37716625, -50.6304779}, cst{58.318787916667, -52.7968445}, cst{53.365002083333, -52.7470779}, cst{53.23681625, -57.0797844}, cst{48.79113125, -57.0377846}, cst{48.362689583333, -67.0358200}, cst{33.202360416667, -66.9151917}, cst{33.489424583333, -57.9161568}, cst{33.58414375, -53.4164696}, cst{37.28334625, -53.4423561}, cst{37.34121, -50.4425697}, cst{41.04769375, -50.4708595}, cst{41.08530875, -48.4710045}, cst{46.03451375, -48.5122337}, cst{46.09077125, -45.5124779}, cst{52.2890025, -45.5692215}, cst{52.3267725, -43.5694046}, cst{59.031364583333, -43.6364403}, cst{59.105884583333, -39.6368256}}
cstLists["HYA"] = []cst{cst{122.84900708333, -0.3693900}, cst{122.92139125, 6.6302376}, cst{140.40425875, 6.4700689}, cst{145.39841708333, 6.4327669}, cst{145.34890625, -0.5670585}, cst{145.27027208333, -11.5667810}, cst{162.80791375, -11.6621428}, cst{162.77554041667, -19.6620827}, cst{164.03058625, -19.6666222}, cst{164.00808291667, -25.1665821}, cst{179.09131041667, -25.1957951}, cst{190.404525, -25.1864014}, cst{190.3985025, -22.6864090}, cst{194.16687, -22.6773415}, cst{215.51309125, -22.5727749}, cst{215.53369041667, -25.0727024}, cst{225.57655375, -24.9951096}, cst{225.63076958333, -29.9948788}, cst{190.41739958333, -30.1863899}, cst{190.42719875, -33.6863785}, cst{185.38743458333, -33.6938934}, cst{185.39029625, -35.6938896}, cst{166.47936291667, -35.6746559}, cst{163.95851541667, -35.6664963}, cst{163.977885, -31.8332005}, cst{160.20137875, -31.8185863}, cst{160.21289375, -29.8186131}, cst{155.18132708333, -29.7947845}, cst{155.1993375, -27.1281624}, cst{147.65928291667, -27.0835037}, cst{147.67968125, -24.5835705}, cst{141.904335, -24.5425186}, cst{137.63677625, -24.5086308}, cst{137.68497, -19.5088310}, cst{130.1635125, -19.4423733}, cst{130.18434, -17.4424706}, cst{126.92709458333, -17.4112568}, cst{126.98977958333, -11.4115648}, cst{122.73417875, -11.3687992}}
cstLists["HYI"] = []cst{cst{68.79401875, -67.2479248}, cst{68.58152375, -69.7467194}, cst{67.957485, -74.7431641}, cst{52.075782083333, -74.5741272}, cst{50.091655416667, -82.0644531}, cst{1.53339125, -81.8039551}, cst{1.5662970833333, -74.3039627}, cst{12.3324375, -74.3185730}, cst{12.295414583333, -75.3185272}, cst{20.654050416667, -75.3472214}, cst{21.20622875, -57.8484154}, cst{33.489424583333, -57.9161568}, cst{33.202360416667, -66.9151917}, cst{48.362689583333, -67.0358200}}
cstLists["IND"] = []cst{cst{323.1847575, -74.4544678}, cst{351.99783291667, -74.3124619}, cst{351.86139125, -66.8125992}, cst{332.3985675, -66.8899918}, cst{332.113695, -56.3908348}, cst{331.998825, -49.3911743}, cst{322.11736625, -49.4585724}, cst{322.04232125, -44.9588585}, cst{307.169295, -45.0900002}, cst{307.45880125, -56.5885773}, cst{307.56480125, -59.5880547}, cst{322.34865125, -59.4576836}}
cstLists["LAC"] = []cst{cst{329.4610125, 36.5953827}, cst{329.37664041667, 44.3451195}, cst{329.88163958333, 44.3482628}, cst{329.87860625, 44.5982513}, cst{330.76266291667, 44.6036453}, cst{330.63921, 53.3532715}, cst{333.17467625, 53.3679428}, cst{333.13762625, 55.6178436}, cst{335.93130125, 55.6326256}, cst{335.91093, 56.8825760}, cst{344.30402708333, 56.9179611}, cst{344.34285125, 53.1680298}, cst{344.46530375, 35.1682358}, cst{343.70919291667, 35.1656151}, cst{343.70653208333, 35.6656113}, cst{331.35955791667, 35.6069336}, cst{331.35046041667, 36.6069069}}
cstLists["LEO"] = []cst{cst{162.8497125, -0.6622211}, cst{162.87601958333, 6.3377299}, cst{145.39841708333, 6.4327669}, cst{140.40425875, 6.4700689}, cst{140.645985, 32.9691162}, cst{150.08438541667, 32.9022789}, cst{150.04234375, 27.9024086}, cst{159.23840125, 27.8529167}, cst{159.2108775, 22.8529778}, cst{162.94253875, 22.8376045}, cst{162.95149375, 24.8375893}, cst{166.6809375, 24.8250446}, cst{166.69399791667, 28.3250256}, cst{179.60894125, 28.3040466}, cst{179.60453541667, 13.3040485}, cst{179.60373458333, 10.3040485}, cst{174.36568791667, 10.3082914}, cst{174.35052458333, -0.6916979}, cst{174.34229875, -6.6916924}, cst{162.82713875, -6.6621790}}
cstLists["LMI"] = []cst{cst{140.645985, 32.9691162}, cst{140.72163125, 39.2188187}, cst{145.6819725, 39.1817665}, cst{145.70923791667, 41.4316750}, cst{154.37822375, 41.3773613}, cst{154.3594125, 39.3774109}, cst{163.52316875, 39.3356133}, cst{163.48940875, 33.3356781}, cst{166.71422541667, 33.3249931}, cst{166.69399791667, 28.3250256}, cst{166.6809375, 24.8250446}, cst{162.95149375, 24.8375893}, cst{162.94253875, 22.8376045}, cst{159.2108775, 22.8529778}, cst{159.23840125, 27.8529167}, cst{150.04234375, 27.9024086}, cst{150.08438541667, 32.9022789}}
cstLists["LEP"] = []cst{cst{73.75929625, -27.0479794}, cst{76.2549375, -27.0772038}, cst{92.99256625, -27.2787991}, cst{93.215625, -11.0301533}, cst{88.965790416667, -10.9785318}, cst{77.72003125, -10.8432293}, cst{75.22175125, -10.8138046}, cst{75.178714583333, -14.3135529}, cst{73.929797916667, -14.2989721}}
cstLists["LIB"] = []cst{cst{227.85301208333, -0.4742887}, cst{221.6030925, -0.5269387}, cst{221.66710791667, -8.5266848}, cst{215.40850625, -8.5731344}, cst{215.51309125, -22.5727749}, cst{215.53369041667, -25.0727024}, cst{225.57655375, -24.9951096}, cst{225.63076958333, -29.9948788}, cst{236.92998, -29.8896160}, cst{236.81306375, -20.3902016}, cst{240.57177625, -20.3516178}, cst{240.43727875, -8.3523235}, cst{240.38695375, -3.6025870}, cst{227.88195125, -3.7241600}}
cstLists["LUP"] = []cst{cst{214.65681625, -55.5799522}, cst{220.23446541667, -55.5400887}, cst{228.08351125, -55.4754944}, cst{228.05671625, -54.4756165}, cst{232.35337208333, -54.4364166}, cst{232.20724625, -48.4371071}, cst{237.24728125, -48.3880234}, cst{237.12458541667, -42.3886375}, cst{242.15280625, -42.3366776}, cst{241.94769875, -29.8377628}, cst{236.92998, -29.8896160}, cst{225.63076958333, -29.9948788}, cst{225.79627958333, -42.4941750}, cst{214.45026458333, -42.5806465}}
cstLists["LYN"] = []cst{cst{112.56071875, 35.2445297}, cst{112.73412458333, 44.2435493}, cst{104.26530291667, 44.3418388}, cst{104.40635875, 49.8410034}, cst{99.919459583333, 49.8945885}, cst{100.04603125, 53.8938293}, cst{94.05736625, 53.9662552}, cst{94.13108875, 55.9658089}, cst{94.40745625, 61.9641266}, cst{107.8515525, 61.8031464}, cst{107.7531975, 59.8037262}, cst{122.12910125, 59.6433983}, cst{128.79913375, 59.5759888}, cst{128.44010375, 46.5777283}, cst{139.59071125, 46.4782791}, cst{139.51249041667, 41.4785957}, cst{145.70923791667, 41.4316750}, cst{145.6819725, 39.1817665}, cst{140.72163125, 39.2188187}, cst{140.645985, 32.9691162}, cst{121.99323125, 33.1415138}, cst{118.25808, 33.1812286}, cst{118.28970875, 35.1810532}}
cstLists["LYR"] = []cst{cst{284.27716208333, 25.6641407}, cst{284.2698675, 26.1640968}, cst{276.76288625, 26.0743504}, cst{276.70077291667, 30.0739765}, cst{273.82438625, 30.0391560}, cst{273.46687375, 47.5369873}, cst{274.34237541667, 47.5476036}, cst{287.1206475, 47.6998672}, cst{288.37552041667, 47.7143936}, cst{288.47030708333, 43.7149391}, cst{291.9835275, 43.7550354}, cst{292.11965541667, 36.7558022}, cst{291.49260458333, 36.7487144}, cst{291.5987775, 30.2493153}, cst{290.0952525, 30.2321968}, cst{290.13264625, 27.7324085}, cst{290.16131375, 25.7325745}}
cstLists["MEN"] = []cst{cst{109.01970875, -85.2614441}, cst{48.23292, -84.5553818}, cst{50.091655416667, -82.0644531}, cst{52.075782083333, -74.5741272}, cst{67.957485, -74.7431641}, cst{68.58152375, -69.7467194}, cst{98.454422916667, -70.1041336}, cst{97.770709583333, -75.1000366}, cst{114.21470375, -75.2899170}, cst{111.65211458333, -82.7758865}}
cstLists["MIC"] = []cst{cst{306.89795541667, -27.5913391}, cst{321.83163625, -27.4596672}, cst{321.92805291667, -36.4592972}, cst{322.04232125, -44.9588585}, cst{307.169295, -45.0900002}}
cstLists["MON"] = []cst{cst{95.225680416667, -0.0537102}, cst{95.34803125, 9.9455481}, cst{96.347665416667, 9.9334478}, cst{96.37276375, 11.9332972}, cst{105.742815, 11.8213453}, cst{105.71845958333, 9.8214874}, cst{106.71787958333, 9.8097754}, cst{106.66432208333, 5.3100886}, cst{106.91427458333, 5.3071680}, cst{106.86739625, 1.3074419}, cst{109.61691875, 1.2755718}, cst{109.59966625, -0.2243290}, cst{122.84900708333, -0.3693900}, cst{122.73417875, -11.3687992}, cst{111.97339958333, -11.2521448}, cst{93.215625, -11.0301533}, cst{88.965790416667, -10.9785318}, cst{89.052372083333, -3.9790573}, cst{95.177142083333, -4.0534163}}
cstLists["MUS"] = []cst{cst{170.08481125, -64.6842651}, cst{169.85697291667, -75.6840134}, cst{207.78143375, -75.6235962}, cst{207.46087041667, -70.6244431}, cst{207.26802291667, -65.6249542}, cst{204.70747958333, -65.6378784}, cst{204.68028625, -64.6379395}, cst{194.43838041667, -64.6769638}, cst{179.05736375, -64.6957855}}
cstLists["NOR"] = []cst{cst{232.5498675, -60.4354935}, cst{249.03468125, -60.2644577}, cst{248.57062375, -45.7670517}, cst{248.4947775, -42.2674789}, cst{242.15280625, -42.3366776}, cst{237.12458541667, -42.3886375}, cst{237.24728125, -48.3880234}, cst{232.20724625, -48.4371071}, cst{232.35337208333, -54.4364166}, cst{228.05671625, -54.4756165}, cst{228.08351125, -55.4754944}, cst{232.38191125, -55.4362831}}
cstLists["OCT"] = []cst{cst{0.80064625, -89.3039017}, cst{1.53339125, -81.8039551}, cst{50.091655416667, -82.0644531}, cst{48.23292, -84.5553818}, cst{109.01970875, -85.2614441}, cst{111.65211458333, -82.7758865}, cst{209.11110875, -83.1200714}, cst{276.86599791667, -82.4582748}, cst{274.19506041667, -74.9745178}, cst{323.1847575, -74.4544678}, cst{351.99783291667, -74.3124619}, cst{1.56630625, -74.3039627}, cst{0.80064625, -89.3039017}, cst{0.80065208333333, -89.3038940}}
cstLists["OPH"] = []cst{cst{245.6026275, -0.2963768}, cst{245.558595, 3.7033811}, cst{252.80590958333, 3.7852108}, cst{252.7014825, 12.6179380}, cst{260.19584708333, 12.7061481}, cst{260.17687791667, 14.2060347}, cst{275.17327375, 14.3874788}, cst{275.20308458333, 12.0543308}, cst{281.388045, 12.1287737}, cst{281.45856875, 6.3791943}, cst{275.27461041667, 6.3047633}, cst{275.29601125, 4.5548930}, cst{277.87113125, 4.5860157}, cst{277.88929958333, 3.0861249}, cst{275.31423625, 3.0550034}, cst{275.35059875, 0.0552235}, cst{269.10103791667, -0.0206471}, cst{269.14970375, -4.0203514}, cst{271.14967375, -3.9960551}, cst{271.22371625, -9.9956055}, cst{266.72375708333, -10.0502338}, cst{266.7447, -11.7167768}, cst{265.49440375, -11.7319136}, cst{265.47349041667, -10.0653696}, cst{259.22206958333, -10.1404381}, cst{259.29742791667, -16.1399899}, cst{265.80019041667, -16.0618820}, cst{266.00183541667, -30.0606632}, cst{253.23534875, -30.2123089}, cst{253.15567041667, -24.7960968}, cst{245.89144625, -24.8781185}, cst{245.82298375, -19.5451660}, cst{247.45067541667, -19.5271549}, cst{247.43823, -18.5272255}, cst{245.81068041667, -18.5452347}, cst{245.69120375, -8.2958899}, cst{240.43727875, -8.3523235}, cst{240.38695375, -3.6025870}, cst{245.63838875, -3.5461800}}
cstLists["ORI"] = []cst{cst{70.852360416667, 0.2375014}, cst{71.03402875, 15.7364635}, cst{76.28892625, 15.6755352}, cst{76.29527875, 16.1754990}, cst{81.7987125, 16.1101055}, cst{81.7922325, 15.6101446}, cst{85.79364625, 15.5619202}, cst{85.755057083333, 12.5621548}, cst{88.255369583333, 12.5318508}, cst{88.327167083333, 18.0314159}, cst{87.326982083333, 18.0435486}, cst{87.39379375, 22.8764725}, cst{90.1440375, 22.8430862}, cst{90.125155416667, 21.5098724}, cst{95.1241275, 21.4491768}, cst{95.069575416667, 17.4495068}, cst{96.4439175, 17.4328651}, cst{96.37276375, 11.9332972}, cst{96.347665416667, 9.9334478}, cst{95.34803125, 9.9455481}, cst{95.225680416667, -0.0537102}, cst{95.177142083333, -4.0534163}, cst{89.052372083333, -3.9790573}, cst{88.965790416667, -10.9785318}, cst{77.72003125, -10.8432293}, cst{77.804395416667, -3.8437285}, cst{71.55635875, -3.7708201}, cst{71.60231375, 0.2289162}}
cstLists["PAV"] = []cst{cst{274.19506041667, -74.9745178}, cst{323.1847575, -74.4544678}, cst{322.34865125, -59.4576836}, cst{307.56480125, -59.5880547}, cst{307.45880125, -56.5885773}, cst{272.67225291667, -56.9837723}, cst{265.16818958333, -57.0747757}, cst{265.77572875, -67.5711060}, cst{273.28007708333, -67.4800797}}
cstLists["PEG"] = []cst{cst{321.58347125, 2.5393796}, cst{321.50110208333, 13.0390635}, cst{318.244515, 13.0132008}, cst{318.25026458333, 12.3465548}, cst{317.24836375, 12.3382607}, cst{317.17878291667, 20.0046406}, cst{320.18840875, 20.0290813}, cst{320.15170041667, 24.0289364}, cst{322.66201958333, 24.0482101}, cst{322.62016375, 28.5480537}, cst{327.39518125, 28.5817947}, cst{327.319965, 36.5815468}, cst{329.4610125, 36.5953827}, cst{331.35046041667, 36.6069069}, cst{331.35955791667, 35.6069336}, cst{343.70653208333, 35.6656113}, cst{343.70919291667, 35.1656151}, cst{344.46530375, 35.1682358}, cst{354.04417958333, 35.1913109}, cst{354.04915791667, 32.7746468}, cst{357.8280825, 32.7785072}, cst{357.82874125, 32.0285034}, cst{1.6069770833333, 32.0293655}, cst{1.60621625, 28.6960354}, cst{2.6128425, 28.6957588}, cst{2.61001625, 22.6957588}, cst{3.7406445833333, 22.6951923}, cst{3.73992125, 21.6951923}, cst{3.7341179166667, 13.1951942}, cst{1.6031745833333, 13.1960354}, cst{1.6027304166667, 10.6960354}, cst{359.09711875, 10.6957970}, cst{359.09803375, 8.1957970}, cst{342.82141625, 8.1621685}, cst{342.84221708333, 2.6622071}, cst{331.58726708333, 2.6076074}, cst{331.588755, 2.3576119}, cst{326.58708625, 2.3256910}, cst{326.58021875, 3.3256676}, cst{323.57874875, 3.3043909}, cst{323.58427041667, 2.5544112}}
cstLists["PER"] = []cst{cst{42.62838, 31.1865025}, cst{42.666467916667, 34.5196762}, cst{40.402382916667, 34.5375137}, cst{40.43465875, 37.2873878}, cst{39.67934125, 37.2931557}, cst{39.88547875, 51.0423737}, cst{32.67380125, 51.0925827}, cst{32.62149125, 47.5927505}, cst{26.931439583333, 47.6258430}, cst{26.96852375, 50.6257439}, cst{22.40793625, 50.6478767}, cst{22.45601375, 54.6477699}, cst{27.53364125, 54.6228828}, cst{27.5952225, 58.1227188}, cst{30.77362375, 58.1046753}, cst{30.795625416667, 59.1046104}, cst{38.802355416667, 59.0511551}, cst{38.762337083333, 57.5513000}, cst{48.90093, 57.4684982}, cst{49.91349625, 57.4593849}, cst{49.8540225, 55.4596519}, cst{52.381900416667, 55.4362831}, cst{52.31308625, 52.9366074}, cst{72.840285, 52.7196465}, cst{72.45734375, 36.2218513}, cst{69.57384125, 36.2547150}, cst{69.4869375, 30.9218750}, cst{52.426667916667, 31.1003609}}
cstLists["PHE"] = []cst{cst{351.69270458333, -39.3127594}, cst{351.76852208333, -56.3126869}, cst{351.77839375, -57.8126793}, cst{21.20622875, -57.8484154}, cst{21.2732625, -52.8485603}, cst{24.967354583333, -52.8658562}, cst{24.9938475, -50.8659210}, cst{28.693257083333, -50.8859215}, cst{28.738322916667, -47.5527229}, cst{36.1529475, -47.6004944}, cst{36.26401375, -39.4342155}, cst{26.350727916667, -39.3726234}}
cstLists["PIC"] = []cst{cst{90.951777083333, -43.0057793}, cst{75.97444375, -42.8255501}, cst{73.482370416667, -42.7963676}, cst{73.402082916667, -46.2959023}, cst{68.42409625, -46.2387962}, cst{68.362247916667, -48.7384491}, cst{68.217745416667, -53.7376366}, cst{75.6770175, -53.8238029}, cst{75.547742916667, -57.3230400}, cst{83.01880375, -57.4122620}, cst{82.85761375, -60.9112892}, cst{90.34506125, -61.0020981}, cst{90.173642916667, -64.0010529}, cst{98.93724875, -64.1070251}, cst{102.70331375, -64.1518784}, cst{103.01111708333, -58.1537018}, cst{97.995077916667, -58.0938416}, cst{98.114275416667, -55.0945587}, cst{93.1074, -55.0340500}, cst{93.19435375, -52.5345764}, cst{90.693705, -52.5042114}, cst{90.748902083333, -50.7545471}}
cstLists["PSC"] = []cst{cst{342.8497125, 0.6622211}, cst{342.84221708333, 2.6622071}, cst{342.82141625, 8.1621685}, cst{359.09803375, 8.1957970}, cst{359.09711875, 10.6957970}, cst{1.6027304166667, 10.6960354}, cst{1.6031745833333, 13.1960354}, cst{3.7341179166667, 13.1951942}, cst{3.73992125, 21.6951923}, cst{14.414815416667, 21.6766376}, cst{14.424064583333, 24.4266243}, cst{12.41349125, 24.4319324}, cst{12.44306375, 33.6818962}, cst{22.89742625, 33.6453705}, cst{22.86642, 28.6454391}, cst{26.76471, 28.6262817}, cst{26.744674583333, 25.6263351}, cst{26.65573375, 10.5432396}, cst{31.6652475, 10.5143948}, cst{31.61526625, 2.5978806}, cst{6.6037875, 2.6925383}, cst{6.60132875, 0.6925398}, cst{6.5927025, -6.3074551}, cst{359.10329875, -6.3042021}, cst{359.10221125, -3.3042023}, cst{342.86470375, -3.3377509}}
cstLists["PSA"] = []cst{cst{346.68096625, -24.8250446}, cst{329.77028875, -24.9040413}, cst{321.80777541667, -24.9597607}, cst{321.83163625, -27.4596672}, cst{321.92805291667, -36.4592972}, cst{346.72751375, -36.3249741}}
cstLists["PUP"] = []cst{cst{111.97339958333, -11.2521448}, cst{111.67719875, -33.2504692}, cst{99.903859583333, -33.1128159}, cst{99.70891625, -43.1116486}, cst{90.951777083333, -43.0057793}, cst{90.748902083333, -50.7545471}, cst{120.8616975, -51.1025848}, cst{121.03827875, -43.3535042}, cst{126.57231291667, -43.4095192}, cst{126.67779875, -37.1600380}, cst{126.92709458333, -17.4112568}, cst{126.98977958333, -11.4115648}, cst{122.73417875, -11.3687992}}
cstLists["PYX"] = []cst{cst{126.92709458333, -17.4112568}, cst{130.18434, -17.4424706}, cst{130.1635125, -19.4423733}, cst{137.68497, -19.5088310}, cst{137.63677625, -24.5086308}, cst{141.904335, -24.5425186}, cst{141.77159875, -37.2920151}, cst{126.67779875, -37.1600380}}
cstLists["RET"] = []cst{cst{48.362689583333, -67.0358200}, cst{68.79401875, -67.2479248}, cst{69.274534583333, -58.7506638}, cst{65.55459, -58.7088547}, cst{65.6504625, -56.2093849}, cst{60.69291875, -56.1555862}, cst{60.79789625, -52.8228111}, cst{58.318787916667, -52.7968445}, cst{53.365002083333, -52.7470779}, cst{53.23681625, -57.0797844}, cst{48.79113125, -57.0377846}}
cstLists["SGE"] = []cst{cst{284.37363625, 18.6647091}, cst{284.33913291667, 21.2478352}, cst{290.09631125, 21.3148155}, cst{290.12128791667, 19.3982983}, cst{298.88568875, 19.4955387}, cst{298.860255, 21.5787334}, cst{305.12540875, 21.6436558}, cst{305.13404875, 20.8936996}, cst{305.18694875, 16.1439629}, cst{303.5589975, 16.1275158}, cst{298.926, 16.0790844}, cst{298.92116541667, 16.4957294}, cst{286.4054775, 16.3550682}, cst{286.37549375, 18.6882229}}
cstLists["SGR"] = []cst{cst{284.74405375, -11.8664360}, cst{284.79372, -15.8328123}, cst{275.54952625, -15.9435720}, cst{265.80019041667, -16.0618820}, cst{266.00183541667, -30.0606632}, cst{269.50281125, -30.0182076}, cst{269.62546375, -37.0174599}, cst{289.59631958333, -36.7785645}, cst{289.76964, -45.2775650}, cst{307.169295, -45.0900002}, cst{306.89795541667, -27.5913391}, cst{301.91596958333, -27.6419144}, cst{301.72636958333, -11.6762342}}
cstLists["SCO"] = []cst{cst{240.43727875, -8.3523235}, cst{245.69120375, -8.2958899}, cst{245.81068041667, -18.5452347}, cst{247.43823, -18.5272255}, cst{247.45067541667, -19.5271549}, cst{245.82298375, -19.5451660}, cst{245.89144625, -24.8781185}, cst{253.15567041667, -24.7960968}, cst{253.23534875, -30.2123089}, cst{266.00183541667, -30.0606632}, cst{269.50281125, -30.0182076}, cst{269.62546375, -37.0174599}, cst{269.80928375, -45.5163460}, cst{248.57062375, -45.7670517}, cst{248.4947775, -42.2674789}, cst{242.15280625, -42.3366776}, cst{241.94769875, -29.8377628}, cst{236.92998, -29.8896160}, cst{236.81306375, -20.3902016}, cst{240.57177625, -20.3516178}}
cstLists["SCL"] = []cst{cst{346.68096625, -24.8250446}, cst{359.11056458333, -24.8042011}, cst{26.45888875, -24.8729095}, cst{26.350727916667, -39.3726234}, cst{351.69270458333, -39.3127594}, cst{351.6833775, -36.3127670}, cst{346.72751375, -36.3249741}}
cstLists["SCT"] = []cst{cst{275.54952625, -15.9435720}, cst{284.79372, -15.8328123}, cst{284.74405375, -11.8664360}, cst{284.64729291667, -3.8336766}, cst{280.3981875, -3.8842230}, cst{275.3991225, -3.9444826}}
cstLists["SER1"] = []cst{cst{227.85301208333, -0.4742887}, cst{227.78148625, 7.5253930}, cst{227.60549125, 25.5246105}, cst{229.09951625, 25.5380573}, cst{241.80573458333, 25.6641407}, cst{241.85657541667, 21.6644115}, cst{240.1110075, 21.6459675}, cst{240.18107375, 15.6463346}, cst{242.67663, 15.6728001}, cst{242.80966791667, 3.6735139}, cst{245.558595, 3.7033811}, cst{245.6026275, -0.2963768}, cst{245.63838875, -3.5461800}, cst{240.38695375, -3.6025870}, cst{227.88195125, -3.7241600}}
cstLists["SER2"] = []cst{cst{275.35059875, 0.0552235}, cst{275.31423625, 3.0550034}, cst{277.93922375, 3.0867271}, cst{277.92105625, 4.5866175}, cst{275.29601125, 4.5548930}, cst{275.27461041667, 6.3047633}, cst{281.45856875, 6.3791943}, cst{284.525985, 6.4156075}, cst{284.57642541667, 2.1659052}, cst{280.3262325, 2.1153460}, cst{280.35020875, 0.1154895}, cst{280.3981875, -3.8842230}, cst{275.3991225, -3.9444826}, cst{275.54952625, -15.9435720}, cst{265.80019041667, -16.0618820}, cst{259.29742791667, -16.1399899}, cst{259.22206958333, -10.1404381}, cst{265.47349041667, -10.0653696}, cst{265.49440375, -11.7319136}, cst{266.7447, -11.7167768}, cst{266.72375708333, -10.0502338}, cst{271.22371625, -9.9956055}, cst{271.14967375, -3.9960551}, cst{269.14970375, -4.0203514}, cst{269.10103791667, -0.0206471}}
cstLists["SEX"] = []cst{cst{145.34890625, -0.5670585}, cst{145.39841708333, 6.4327669}, cst{162.87601958333, 6.3377299}, cst{162.8497125, -0.6622211}, cst{162.82713875, -6.6621790}, cst{162.80791375, -11.6621428}, cst{145.27027208333, -11.5667810}}
cstLists["TAU"] = []cst{cst{50.836682916667, -1.3029516}, cst{50.85298375, 0.4469725}, cst{50.94641125, 10.3632069}, cst{51.037234583333, 19.4461136}, cst{52.2906225, 19.4343338}, cst{52.426667916667, 31.1003609}, cst{69.4869375, 30.9218750}, cst{69.47678875, 30.2552605}, cst{73.235342916667, 30.2123089}, cst{73.212475416667, 28.7124405}, cst{90.228902916667, 28.5092430}, cst{90.22107125, 28.0092907}, cst{90.1440375, 22.8430862}, cst{87.39379375, 22.8764725}, cst{87.326982083333, 18.0435486}, cst{88.327167083333, 18.0314159}, cst{88.255369583333, 12.5318508}, cst{85.755057083333, 12.5621548}, cst{85.79364625, 15.5619202}, cst{81.7922325, 15.6101446}, cst{81.7987125, 16.1101055}, cst{76.29527875, 16.1754990}, cst{76.28892625, 15.6755352}, cst{71.03402875, 15.7364635}, cst{70.852360416667, 0.2375014}, cst{55.352905416667, 0.4037257}, cst{55.335582083333, -1.3461887}}
cstLists["TEL"] = []cst{cst{307.45880125, -56.5885773}, cst{307.169295, -45.0900002}, cst{289.76964, -45.2775650}, cst{272.3090175, -45.4859734}, cst{272.67225291667, -56.9837723}}
cstLists["TRI"] = []cst{cst{26.744674583333, 25.6263351}, cst{26.76471, 28.6262817}, cst{22.86642, 28.6454391}, cst{22.89742625, 33.6453705}, cst{22.910835, 35.6453362}, cst{31.854250416667, 35.5971375}, cst{31.87109125, 37.3470840}, cst{39.67934125, 37.2931557}, cst{40.43465875, 37.2873878}, cst{40.402382916667, 34.5375137}, cst{42.666467916667, 34.5196762}, cst{42.62838, 31.1865025}, cst{38.10319375, 31.2213154}, cst{38.07014875, 27.8047638}, cst{30.53061625, 27.8550186}, cst{30.51371125, 25.6050701}}
cstLists["TRA"] = []cst{cst{224.16644125, -70.5115433}, cst{224.00363375, -68.0122070}, cst{226.55712625, -67.9909286}, cst{226.35353541667, -64.0751266}, cst{230.16657875, -64.0415649}, cst{230.05456958333, -61.4587479}, cst{232.58976458333, -61.4353065}, cst{232.5498675, -60.4354935}, cst{249.03468125, -60.2644577}, cst{249.08163, -61.2641945}, cst{251.53784708333, -61.2364578}, cst{251.67632125, -63.8189964}, cst{254.1951375, -63.7900925}, cst{254.28351458333, -65.2062531}, cst{255.5423925, -65.1916428}, cst{255.72498291667, -67.6905823}, cst{258.2424825, -67.6610870}, cst{258.47067875, -70.1597443}}
cstLists["TUC"] = []cst{cst{351.99783291667, -74.3124619}, cst{1.5662970833333, -74.3039627}, cst{12.3324375, -74.3185730}, cst{12.295414583333, -75.3185272}, cst{20.654050416667, -75.3472214}, cst{21.20622875, -57.8484154}, cst{351.77839375, -57.8126793}, cst{351.76852208333, -56.3126869}, cst{332.113695, -56.3908348}, cst{332.3985675, -66.8899918}, cst{351.86139125, -66.8125992}}
cstLists["UMA"] = []cst{cst{145.70923791667, 41.4316750}, cst{139.51249041667, 41.4785957}, cst{139.59071125, 46.4782791}, cst{128.44010375, 46.5777283}, cst{128.79913375, 59.5759888}, cst{122.12910125, 59.6433983}, cst{123.08622875, 73.1383743}, cst{140.61547375, 72.9741364}, cst{171.96136958333, 72.8125000}, cst{171.84934625, 65.8126068}, cst{181.57925541667, 65.8039627}, cst{181.58155958333, 63.3039627}, cst{203.55053875, 63.3593445}, cst{203.57364125, 62.3593979}, cst{217.04525375, 62.4414825}, cst{217.25124875, 54.9422379}, cst{211.58439125, 54.9035759}, cst{211.69873208333, 47.9039383}, cst{203.79511375, 47.8599281}, cst{203.74239875, 52.3598061}, cst{182.8185225, 52.3043365}, cst{182.82643375, 44.3043365}, cst{181.59141625, 44.3039627}, cst{181.59450625, 33.3039627}, cst{181.59566375, 28.3039627}, cst{179.60894125, 28.3040466}, cst{166.69399791667, 28.3250256}, cst{166.71422541667, 33.3249931}, cst{163.48940875, 33.3356781}, cst{163.52316875, 39.3356133}, cst{154.3594125, 39.3774109}, cst{154.37822375, 41.3773613}}
cstLists["UMI"] = []cst{cst{195.8206125, 76.3289108}, cst{196.09747375, 69.3293610}, cst{210.65081125, 69.3991165}, cst{210.82055541667, 65.3996506}, cst{235.32956541667, 65.6023483}, cst{235.05063, 69.6009445}, cst{247.8410625, 69.7383041}, cst{247.2207075, 74.7347870}, cst{261.53663708333, 74.9033127}, cst{260.21790458333, 79.8953476}, cst{267.65602041667, 79.9857483}, cst{261.72223041667, 85.9495697}, cst{308.72097, 86.4656219}, cst{308.33135541667, 86.6306305}, cst{343.51066625, 86.8368912}, cst{339.26098791667, 88.6638870}, cst{135.83247125, 87.5689163}, cst{130.40275041667, 86.0975418}, cst{213.0229575, 85.9308090}, cst{216.78285625, 79.4449844}, cst{203.80918958333, 79.3629303}, cst{204.15701875, 76.3638153}}
cstLists["VEL"] = []cst{cst{166.33725625, -57.1744423}, cst{166.45650458333, -40.4246216}, cst{141.73406125, -40.2918739}, cst{141.77159875, -37.2920151}, cst{126.67779875, -37.1600380}, cst{126.57231291667, -43.4095192}, cst{121.03827875, -43.3535042}, cst{120.8616975, -51.1025848}, cst{123.38112875, -51.1285286}, cst{123.32011625, -53.3782196}, cst{127.60929125, -53.4206772}, cst{127.56711875, -54.9204712}, cst{133.38017375, -54.9742203}, cst{133.32365541667, -56.9739723}}
cstLists["VIR"] = []cst{cst{174.35052458333, -0.6916979}, cst{174.36568791667, 10.3082914}, cst{179.60373458333, 10.3040485}, cst{179.60453541667, 13.3040485}, cst{194.0620275, 13.3225126}, cst{194.05906625, 14.3225088}, cst{204.02893041667, 14.3604937}, cst{204.06384875, 7.3605771}, cst{227.78148625, 7.5253930}, cst{227.85301208333, -0.4742887}, cst{221.6030925, -0.5269387}, cst{221.66710791667, -8.5266848}, cst{215.40850625, -8.5731344}, cst{215.51309125, -22.5727749}, cst{194.16687, -22.6773415}, cst{194.1330525, -11.6773882}, cst{179.09676, -11.6957970}, cst{179.09860625, -6.6957974}, cst{174.34229875, -6.6916924}}
cstLists["VOL"] = []cst{cst{98.93724875, -64.1070251}, cst{98.454422916667, -70.1041336}, cst{97.770709583333, -75.1000366}, cst{114.21470375, -75.2899170}, cst{135.24368708333, -75.4954681}, cst{136.09472708333, -64.4990387}, cst{102.70331375, -64.1518784}}
cstLists["VUL"] = []cst{cst{284.33913291667, 21.2478352}, cst{284.27716208333, 25.6641407}, cst{290.16131375, 25.7325745}, cst{290.13264625, 27.7324085}, cst{296.27220125, 27.8011742}, cst{296.25094375, 29.3010578}, cst{315.07258375, 29.4871387}, cst{315.08391458333, 28.4871883}, cst{322.62016375, 28.5480537}, cst{322.66201958333, 24.0482101}, cst{320.15170041667, 24.0289364}, cst{320.18840875, 20.0290813}, cst{317.17878291667, 20.0046406}, cst{309.907665, 19.9399967}, cst{309.89693708333, 20.9399471}, cst{305.13404875, 20.8936996}, cst{305.12540875, 21.6436558}, cst{298.860255, 21.5787334}, cst{298.88568875, 19.4955387}, cst{290.12128791667, 19.3982983}, cst{290.09631125, 21.3148155}}
change := []string{"PSC", "TUC", "PHE", "SCL", "CET", "PEG", "AND", "CAS", "CEP"}
for _, v := range change {
for k, v2 := range cstLists[v] {
if v2.RA < 270 {
cstLists[v][k].RA = v2.RA + 360
}
}
}
}
//选定 RA=277.5 DEC=-40
func isCross(a, b, c, d cst) bool {
var ac, bc, ad, bd, ca, cb, da, db cst
var r1, r2 float64
ac.RA = a.RA - c.RA
ac.DEC = a.DEC - c.DEC
ad.RA = a.RA - d.RA
ad.DEC = a.DEC - d.DEC
r1 = ac.RA*ad.DEC - ad.RA*ac.DEC
bc.RA = b.RA - c.RA
bc.DEC = b.DEC - c.DEC
bd.RA = b.RA - d.RA
bd.DEC = b.DEC - d.DEC
r2 = bc.RA*bd.DEC - bd.RA*bc.DEC
//echo r1.' '.r2;
if r1*r2 > 0 {
return false
}
ca.RA = c.RA - a.RA
ca.DEC = c.DEC - a.DEC
cb.RA = c.RA - b.RA
cb.DEC = c.DEC - b.DEC
r1 = ca.RA*cb.DEC - cb.RA*ca.DEC
da.RA = d.RA - a.RA
da.DEC = d.DEC - a.DEC
db.RA = d.RA - b.RA
db.DEC = d.DEC - b.DEC
r2 = da.RA*db.DEC - db.RA*da.DEC
if r1*r2 > 0 {
return false
}
return true
}
func IsXZ(ra, dec, jde float64) string {
var nra, ndec float64
if cstLists == nil || len(cstLists) == 0 {
initCstData()
}
nra = ra
if ra >= 360 {
nra -= 360
}
nra, ndec = ZuoBiaoSuiCha(nra, dec, jde, 2451545.0)
if ra >= 360 && nra < 270 {
nra += 360
}
for k, v := range cstLists {
var count int = 0
for i := 0; i < len(v)-1; i++ {
if k == "UMI" || k == "OCT" {
continue
}
if i == 0 {
if isCross(cst{277.5, -100}, cst{nra, ndec}, v[len(v)-1], v[0]) {
count++
}
}
if isCross(cst{277.5, -100}, cst{nra, ndec}, v[i], v[i+1]) {
count++
}
if FR((nra-277.5)*(v[i].DEC+100)) == FR((v[i].RA-277.5)*(ndec+100)) {
count++
}
}
if count%2 == 1 {
return k
}
}
if nra <= 270 {
ra = ra + 360
return IsXZ(ra, dec, jde)
}
if ndec > 50 {
return "UMI"
} else if ndec < -50 {
return "OCT"
}
return ""
}
func WhichCst(ra, dec, jde float64) string {
cst := make(map[string]string, 88)
cst["AND"] = "仙女座"
cst["ANT"] = "唧筒座"
cst["APS"] = "天燕座"
cst["AQR"] = "宝瓶座"
cst["AQL"] = "天鹰座"
cst["ARA"] = "天坛座"
cst["ARI"] = "白羊座"
cst["AUR"] = "御夫座"
cst["BOO"] = "牧夫座"
cst["CAE"] = "雕具座"
cst["CAM"] = "鹿豹座"
cst["CNC"] = "巨蟹座"
cst["CVN"] = "猎犬座"
cst["CMA"] = "大犬座"
cst["CMI"] = "小犬座"
cst["CAP"] = "摩羯座"
cst["CAR"] = "船底座"
cst["CAS"] = "仙后座"
cst["CEN"] = "半人马座"
cst["CEP"] = "仙王座"
cst["CET"] = "鲸鱼座"
cst["CHA"] = "蝘蜓座"
cst["CIR"] = "圆规座"
cst["COL"] = "天鸽座"
cst["COM"] = "后发座"
cst["CRA"] = "南冕座"
cst["CRB"] = "北冕座"
cst["CRV"] = "乌鸦座"
cst["CRT"] = "巨爵座"
cst["CRU"] = "南十字座"
cst["CYG"] = "天鹅座"
cst["DEL"] = "海豚座"
cst["DOR"] = "剑鱼座"
cst["DRA"] = "天龙座"
cst["EQU"] = "小马座"
cst["ERI"] = "波江座"
cst["FOR"] = "天炉座"
cst["GEM"] = "双子座"
cst["GRU"] = "天鹤座"
cst["HER"] = "武仙座"
cst["HOR"] = "时钟座"
cst["HYA"] = "长蛇座"
cst["HYI"] = "水蛇座"
cst["IND"] = "印第安座"
cst["LAC"] = "蝎虎座"
cst["LEO"] = "狮子座"
cst["LMI"] = "小狮座"
cst["LEP"] = "天兔座"
cst["LIB"] = "天秤座"
cst["LUP"] = "豺狼座"
cst["LYN"] = "天猫座"
cst["LYR"] = "天琴座"
cst["MEN"] = "山案座"
cst["MIC"] = "显微镜座"
cst["MON"] = "麒麟座"
cst["MUS"] = "苍蝇座"
cst["NOR"] = "矩尺座"
cst["OCT"] = "南极座"
cst["OPH"] = "蛇夫座"
cst["ORI"] = "猎户座"
cst["PAV"] = "孔雀座"
cst["PEG"] = "飞马座"
cst["PER"] = "英仙座"
cst["PHE"] = "凤凰座"
cst["PIC"] = "绘架座"
cst["PSC"] = "双鱼座"
cst["PSA"] = "南鱼座"
cst["PUP"] = "船尾座"
cst["PYX"] = "罗盘座"
cst["RET"] = "网罟座"
cst["SGE"] = "天箭座"
cst["SGR"] = "人马座"
cst["SCO"] = "天蝎座"
cst["SCL"] = "玉夫座"
cst["SCT"] = "盾牌座"
cst["SER1"] = "巨蛇座"
cst["SER2"] = "巨蛇座"
cst["SEX"] = "六分仪座"
cst["TAU"] = "金牛座"
cst["TEL"] = "望远镜座"
cst["TRI"] = "三角座"
cst["TRA"] = "南三角座"
cst["TUC"] = "杜鹃座"
cst["UMA"] = "大熊座"
cst["UMI"] = "小熊座"
cst["VEL"] = "船帆座"
cst["VIR"] = "室女座"
cst["VOL"] = "飞鱼座"
cst["VUL"] = "狐狸座"
mystar := IsXZ(ra, dec, jde)
return cst[mystar]
}
-24
View File
@@ -1,24 +0,0 @@
package basic
import (
"testing"
)
func Test_Isxz(t *testing.T) {
now := GetNowJDE()
//finish on 30s
for i := 0.00; i <= 360.00; i += 0.5 {
for j := -90.00; j <= 90.00; j += 0.5 {
WhichCst(float64(i), float64(j), now)
}
}
}
func Benchmark_IsXZ(b *testing.B) {
jde := GetNowJDE()
for i := 0; i < b.N; i++ {
//GetNowJDE()
WhichCst(11.11, 12.12, jde)
}
}
+95
View File
@@ -0,0 +1,95 @@
package basic
import (
"math"
"testing"
"time"
)
// 本文件核实太阳中天(floor 正午锚点)与月亮中天(floor+0.5 午夜锚点)两套锚点:
// 调用方按各自约定补偿后,两者都必须落在同一个本地民用日,并接近当日本地日的真实中天。
//
// 框架约定(由各自调用方固定下来):
// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JD(本地 0 时) 起算;
// - basic.CulminationTime 的返回值是本地民用时框架,sun/sun.go 减 tz/24 得到 UT;
// - basic.MoonCulminationTime 的返回值就是本地民用时框架,moon/moon.go 按 byZone=true 使用。
type culminationAnchorSite struct {
name string
lon float64
lat float64
tz float64
}
var culminationAnchorSites = []culminationAnchorSite{
{"beijing", 116.4074, 39.9042, 8},
{"utc", 0, 0, 0},
{"newyork", -74, 40.7, -5},
{"adelaide", 138.6, -34.9, 9.5},
{"chatham", -176.5, -43.9, 12.75},
{"kiritimati", -157.4, 1.9, 14},
}
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, Date2JD(local)
}
func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) {
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
for _, site := range culminationAnchorSites {
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
location := local.Location()
// sun/sun.go 的补偿:本地 0 时 JD 再加半天,使 floor 落在同一本地日;随后减 tz/24 得 UT。
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
if altitude := SunHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
truthAltitude, truthJD = altitude, jd
}
}
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"))
}
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 1.5 {
t.Fatalf("%s: sun culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
}
}
}
func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) {
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
for _, site := range culminationAnchorSites {
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
location := local.Location()
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
if altitude := HMoonHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
truthAltitude, truthJD = altitude, jd
}
}
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"))
}
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 6.0 {
t.Fatalf("%s: moon culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
}
}
}
func TestSunCulminationAnchorDiffersFromMidnightAnchor(t *testing.T) {
site := culminationAnchorSites[0]
_, midnightJD := culminationAnchorLocalMidnight(site, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
// 不补半天时 floor 落在相邻的 UT 日:这正是调用方必须补偿 +0.5 的原因。
uncompensated := CulminationTime(midnightJD, site.lon, site.tz) - site.tz/24
compensated := CulminationTime(midnightJD+0.5, site.lon, site.tz) - site.tz/24
if math.Abs(uncompensated-compensated) < 0.4 {
t.Fatalf("expected the uncompensated anchor to land on an adjacent day: %.6f vs %.6f", uncompensated, compensated)
}
}
+186
View File
@@ -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
}
+94
View File
@@ -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)
}
}
+107
View File
@@ -0,0 +1,107 @@
package basic
import (
"math"
"testing"
)
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 := 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 {
t.Errorf("year=%v DeltaT=%v, want %v", year, got, want)
}
}
}
func TestDefaultDeltaTInvalidInput(t *testing.T) {
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
for _, isJD := range []bool{false, true} {
if got := DefaultDeltaTv2(value, isJD); !math.IsNaN(got) {
t.Errorf("DeltaT(%v, %v)=%v, want NaN", value, isJD, got)
}
}
}
}
// 闰秒只改 TT−UTC,不改 TT−UT1:ΔT 在闰秒两侧连续,TT−UTC 才跳 1 秒。
func TestDeltaTStaysContinuousAcrossLeapSecond(t *testing.T) {
boundary := 2457754.5
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 TestUTC2TTRoundTrip(t *testing.T) {
for _, year := range []int{-2000, -720, 0, 26, 1426, 2025, 2027, 3627, 4026, 5000} {
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 := 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} {
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 TestUTC2TTRoundTripUnderCustomDeltaT(t *testing.T) {
original := GetDeltaTFn()
t.Cleanup(func() { SetDeltaTFn(original) })
for _, slope := range []float64{0, 0.01} {
SetDeltaTFn(func(jd float64, isJD bool) float64 {
if !isJD {
t.Fatal("conversion must request Delta T at a Julian day")
}
return 10000 + slope*(jd-2451545)
})
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)
}
}
}
// DeltaTSecondsAt:有限值(含 0)是显式覆盖,NaN/±Inf 才回退进程级模型;模型按 UT 求值。
func TestDeltaTSecondsAtOverrideContract(t *testing.T) {
jd := 2460310.5
if got := DeltaTSecondsAt(jd, 0); got != 0 {
t.Fatalf("explicit zero override = %v, want 0", got)
}
if got := DeltaTSecondsAt(jd, 12.5); got != 12.5 {
t.Fatalf("explicit override = %v, want 12.5", got)
}
model := DeltaTSecondsAt(jd, math.NaN())
if !finite(model) || model <= 0 {
t.Fatalf("model fallback = %v, want a positive finite value", model)
}
if got := DeltaTSecondsAt(jd, math.Inf(1)); got != model {
t.Fatalf("+Inf override = %v, want the model %v", got, model)
}
if got := deltaTModelSecondsAtTT(jd); got != model {
t.Fatalf("deltaTModelSecondsAtTT = %v, want %v", got, model)
}
// 模型按 UT 键控:与"把 TT 直接当 UT"的朴素求值有微小差别。
naive := DeltaT(jd, true)
if diff := math.Abs(naive - model); diff > 1e-3 {
t.Fatalf("UT-keyed model differs from naive TT evaluation by %v s", diff)
}
}
+208
View File
@@ -0,0 +1,208 @@
package basic
import (
"math"
)
const (
angularDiameterAstronomicalUnitKM = 149597870.7
angularDiameterArcsecPerRadian = 180.0 * 3600.0 / math.Pi
sunEquatorialRadiusKM = 695700.0
moonEquatorialRadiusKM = 1737.4
mercuryEquatorialRadiusKM = 2440.53
venusEquatorialRadiusKM = 6051.8
marsEquatorialRadiusKM = 3396.19
jupiterEquatorialRadiusKM = 71492.0
saturnEquatorialRadiusKM = 60268.0
uranusEquatorialRadiusKM = 25559.0
neptuneEquatorialRadiusKM = 24764.0
)
func angularSemidiameterArcsec(radiusKM, distanceKM float64) float64 {
return math.Asin(radiusKM/distanceKM) * angularDiameterArcsecPerRadian
}
func angularSemidiameterFromAU(radiusKM, distanceAU float64) float64 {
return angularSemidiameterArcsec(radiusKM, distanceAU*angularDiameterAstronomicalUnitKM)
}
// SunSemidiameter 太阳视半径,单位角秒 / apparent solar semidiameter in arcseconds.
func SunSemidiameter(jde float64) float64 {
return SunSemidiameterN(jde, -1)
}
// SunSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SunSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayNWithInstance(jde, n, inst))
}
// SunDiameter 太阳视直径,单位角秒 / apparent solar diameter in arcseconds.
func SunDiameter(jde float64) float64 {
return SunDiameterN(jde, -1)
}
// SunDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SunDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * SunSemidiameterNWithInstance(jde, n, inst)
}
// MoonSemidiameterWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonSemidiameterWithInstance(jde float64, inst *Instance) float64 {
return MoonSemidiameterNWithInstance(jde, -1, inst)
}
// MoonSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayNWithInstance(jde, n, inst))
}
// MoonDiameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds.
func MoonDiameter(jde float64) float64 {
return MoonDiameterN(jde, -1)
}
// MoonDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MoonSemidiameterNWithInstance(jde, n, inst)
}
// MercurySemidiameter 水星视半径,单位角秒 / apparent Mercury semidiameter in arcseconds.
func MercurySemidiameter(jde float64) float64 {
return MercurySemidiameterN(jde, -1)
}
// MercurySemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercurySemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayNWithInstance(jde, n, inst))
}
// MercuryDiameter 水星视直径,单位角秒 / apparent Mercury diameter in arcseconds.
func MercuryDiameter(jde float64) float64 {
return MercuryDiameterN(jde, -1)
}
// MercuryDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MercurySemidiameterNWithInstance(jde, n, inst)
}
// VenusSemidiameter 金星视半径,单位角秒 / apparent Venus semidiameter in arcseconds.
func VenusSemidiameter(jde float64) float64 {
return VenusSemidiameterN(jde, -1)
}
// VenusSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func VenusSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayNWithInstance(jde, n, inst))
}
// VenusDiameter 金星视直径,单位角秒 / apparent Venus diameter in arcseconds.
func VenusDiameter(jde float64) float64 {
return VenusDiameterN(jde, -1)
}
// VenusDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func VenusDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * VenusSemidiameterNWithInstance(jde, n, inst)
}
// MarsSemidiameter 火星视半径,单位角秒 / apparent Mars semidiameter in arcseconds.
func MarsSemidiameter(jde float64) float64 {
return MarsSemidiameterN(jde, -1)
}
// MarsSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayNWithInstance(jde, n, inst))
}
// MarsDiameter 火星视直径,单位角秒 / apparent Mars diameter in arcseconds.
func MarsDiameter(jde float64) float64 {
return MarsDiameterN(jde, -1)
}
// MarsDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MarsSemidiameterNWithInstance(jde, n, inst)
}
// JupiterSemidiameter 木星视半径,单位角秒 / apparent Jupiter semidiameter in arcseconds.
func JupiterSemidiameter(jde float64) float64 {
return JupiterSemidiameterN(jde, -1)
}
// JupiterSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayNWithInstance(jde, n, inst))
}
// JupiterDiameter 木星视直径,单位角秒 / apparent Jupiter diameter in arcseconds.
func JupiterDiameter(jde float64) float64 {
return JupiterDiameterN(jde, -1)
}
// JupiterDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * JupiterSemidiameterNWithInstance(jde, n, inst)
}
// SaturnSemidiameter 土星视半径,单位角秒 / apparent Saturn semidiameter in arcseconds.
func SaturnSemidiameter(jde float64) float64 {
return SaturnSemidiameterN(jde, -1)
}
// SaturnSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SaturnSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayNWithInstance(jde, n, inst))
}
// SaturnDiameter 土星视直径,单位角秒 / apparent Saturn diameter in arcseconds.
func SaturnDiameter(jde float64) float64 {
return SaturnDiameterN(jde, -1)
}
// SaturnDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SaturnDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * SaturnSemidiameterNWithInstance(jde, n, inst)
}
// UranusSemidiameter 天王星视半径,单位角秒 / apparent Uranus semidiameter in arcseconds.
func UranusSemidiameter(jde float64) float64 {
return UranusSemidiameterN(jde, -1)
}
// UranusSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func UranusSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayNWithInstance(jde, n, inst))
}
// UranusDiameter 天王星视直径,单位角秒 / apparent Uranus diameter in arcseconds.
func UranusDiameter(jde float64) float64 {
return UranusDiameterN(jde, -1)
}
// UranusDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func UranusDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * UranusSemidiameterNWithInstance(jde, n, inst)
}
// NeptuneSemidiameter 海王星视半径,单位角秒 / apparent Neptune semidiameter in arcseconds.
func NeptuneSemidiameter(jde float64) float64 {
return NeptuneSemidiameterN(jde, -1)
}
// NeptuneSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NeptuneSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayNWithInstance(jde, n, inst))
}
// NeptuneDiameter 海王星视直径,单位角秒 / apparent Neptune diameter in arcseconds.
func NeptuneDiameter(jde float64) float64 {
return NeptuneDiameterN(jde, -1)
}
// NeptuneDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NeptuneDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * NeptuneSemidiameterNWithInstance(jde, n, inst)
}
+101
View File
@@ -0,0 +1,101 @@
package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
type angularDiameterSample struct {
InputUTC string `json:"input_utc"`
Values map[string]float64 `json:"values"`
}
func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/angular_diameter_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []angularDiameterSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
type diameterCase struct {
name string
diameter func(float64) float64
semidiameter func(float64) float64
baselineKey string
toleranceArcsec float64
}
cases := []diameterCase{
{name: "Sun", diameter: SunDiameter, semidiameter: SunSemidiameter, baselineKey: "sun", toleranceArcsec: 0.01},
{name: "Moon", diameter: MoonDiameter, semidiameter: MoonSemidiameter, baselineKey: "moon", toleranceArcsec: 0.2},
{name: "Mercury", diameter: MercuryDiameter, semidiameter: MercurySemidiameter, baselineKey: "mercury", toleranceArcsec: 0.01},
{name: "Venus", diameter: VenusDiameter, semidiameter: VenusSemidiameter, baselineKey: "venus", toleranceArcsec: 0.01},
{name: "Mars", diameter: MarsDiameter, semidiameter: MarsSemidiameter, baselineKey: "mars", toleranceArcsec: 0.01},
{name: "Jupiter", diameter: JupiterDiameter, semidiameter: JupiterSemidiameter, baselineKey: "jupiter", toleranceArcsec: 0.01},
{name: "Saturn", diameter: SaturnDiameter, semidiameter: SaturnSemidiameter, baselineKey: "saturn", toleranceArcsec: 0.01},
{name: "Uranus", diameter: UranusDiameter, semidiameter: UranusSemidiameter, baselineKey: "uranus", toleranceArcsec: 0.01},
{name: "Neptune", diameter: NeptuneDiameter, semidiameter: NeptuneSemidiameter, baselineKey: "neptune", toleranceArcsec: 0.01},
}
maxDiffs := make(map[string]float64, len(cases))
for _, sample := range samples {
date, err := time.Parse(time.RFC3339, sample.InputUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
}
jd := UTC2TT(Date2JD(date.UTC()))
for _, tc := range cases {
want := sample.Values[tc.baselineKey]
got := tc.diameter(jd)
diff := math.Abs(got - want)
if diff > maxDiffs[tc.name] {
maxDiffs[tc.name] = diff
}
if math.Abs(got-want) > tc.toleranceArcsec {
t.Fatalf("%s diameter mismatch at %s: got %.9f want %.9f tolerance %.9f", tc.name, sample.InputUTC, got, want, tc.toleranceArcsec)
}
semi := tc.semidiameter(jd)
if math.Abs(got-2*semi) > 1e-12 {
t.Fatalf("%s diameter/semidiameter mismatch at %s: diameter %.12f semidiameter %.12f", tc.name, sample.InputUTC, got, semi)
}
}
}
for _, tc := range cases {
t.Logf("%s diameter max diff: %.6f arcsec", tc.name, maxDiffs[tc.name])
}
}
func TestAngularDiameterNFullMatchesDefault(t *testing.T) {
jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
cases := []struct {
name string
diameter func(float64) float64
diameterN func(float64, int) float64
semidiameter func(float64) float64
semidiameterN func(float64, int) float64
}{
{"Sun", SunDiameter, SunDiameterN, SunSemidiameter, SunSemidiameterN},
{"Moon", MoonDiameter, MoonDiameterN, MoonSemidiameter, MoonSemidiameterN},
{"Mercury", MercuryDiameter, MercuryDiameterN, MercurySemidiameter, MercurySemidiameterN},
{"Venus", VenusDiameter, VenusDiameterN, VenusSemidiameter, VenusSemidiameterN},
{"Mars", MarsDiameter, MarsDiameterN, MarsSemidiameter, MarsSemidiameterN},
{"Jupiter", JupiterDiameter, JupiterDiameterN, JupiterSemidiameter, JupiterSemidiameterN},
{"Saturn", SaturnDiameter, SaturnDiameterN, SaturnSemidiameter, SaturnSemidiameterN},
{"Uranus", UranusDiameter, UranusDiameterN, UranusSemidiameter, UranusSemidiameterN},
{"Neptune", NeptuneDiameter, NeptuneDiameterN, NeptuneSemidiameter, NeptuneSemidiameterN},
}
for _, tc := range cases {
assertSameFloat(t, tc.name+".Diameter", tc.diameter(jd), tc.diameterN(jd, -1))
assertSameFloat(t, tc.name+".Semidiameter", tc.semidiameter(jd), tc.semidiameterN(jd, -1))
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package basic 提供底层天文算法、星历实例入口与内置模型口径。
// Package basic provides the low-level astronomical algorithms, ephemeris-instance entry points and built-in model conventions.
// 求值接口的 N 后缀是 n 变体:n < 0 取完整级数;半解析模型下所有 n >= 0 使用同一张快速表;Legacy 按 n 截断。
// The N suffix marks an n-variant: n < 0 uses the full series, every n >= 0 uses one fast table under the semianalytic model, and Legacy truncates by n.
package basic
+9 -8
View File
@@ -1,8 +1,9 @@
package basic
import (
. "b612.me/astro/tools"
"math"
. "b612.me/astro/tools"
)
//地球常数
@@ -10,7 +11,7 @@ import (
const (
EARTH_EQUATORIAL_RADIUS float64 = 6378137.0
EARTH_POLAR_RADIUS float64 = 6356752.3
EARTH_AVERAGE_RADIUS float64 = 6371393.0
EARTH_AVERAGE_RADIUS float64 = 6371008.8
)
// HeightDistance 高度与地平线距离的关系(单位:米)
@@ -20,22 +21,22 @@ func HeightDistance(height float64) float64 {
// HeightDistance 高度(单位:米)与地平线下角度的关系(单位:度)
func HeightDegree(height float64) float64 {
return math.Acos((EARTH_AVERAGE_RADIUS)/(EARTH_AVERAGE_RADIUS+height)) * 180 / math.Pi / 2
return math.Acos((EARTH_AVERAGE_RADIUS)/(EARTH_AVERAGE_RADIUS+height)) * 180 / math.Pi
}
// HeightDistanceByLat 不同纬度下高度与地平线距离的关系(单位:米)
func HeightDistanceByLat(height, lat float64) float64 {
raduis := GeocentricRadius(lat)
return math.Acos((raduis)/(raduis+height)) * raduis
radius := GeocentricRadius(lat)
return math.Acos((radius)/(radius+height)) * radius
}
// HeightDegreeByLat 不同纬度下高度(单位:米)与地平线下角度的关系(单位:度)
func HeightDegreeByLat(height, lat float64) float64 {
raduis := GeocentricRadius(lat)
return math.Acos((raduis)/(raduis+height)) * 180 / math.Pi / 2
radius := GeocentricRadius(lat)
return math.Acos((radius)/(radius+height)) * 180 / math.Pi
}
// GeocentricRadius 地心直径与纬度的关系
// GeocentricRadius 给定纬度处的地心半径,单位米 / geocentric radius at the given latitude, in metres.
func GeocentricRadius(lat float64) float64 {
a := (EARTH_EQUATORIAL_RADIUS * EARTH_EQUATORIAL_RADIUS * Cos(lat))
a *= a
+26 -5
View File
@@ -1,13 +1,34 @@
package basic
import (
"fmt"
"math"
"testing"
)
func Test_EarthFn(t *testing.T) {
fmt.Println(HeightDistance(10000))
//近似算法,差距在接受范围内?
fmt.Println(math.Sqrt(((EARTH_AVERAGE_RADIUS)*2 + 10000) * 10000))
func TestHeightDegreeMatchesSphericalArc(t *testing.T) {
height := 10000.0
got := HeightDegree(height)
want := HeightDistance(height) / EARTH_AVERAGE_RADIUS * 180 / math.Pi
if math.Abs(got-want) > 1e-12 {
t.Fatalf("HeightDegree mismatch: got %.15f want %.15f", got, want)
}
}
func TestHeightDegreeByLatMatchesSphericalArc(t *testing.T) {
height := 10000.0
lat := 45.0
radius := GeocentricRadius(lat)
got := HeightDegreeByLat(height, lat)
want := HeightDistanceByLat(height, lat) / radius * 180 / math.Pi
if math.Abs(got-want) > 1e-12 {
t.Fatalf("HeightDegreeByLat mismatch: got %.15f want %.15f", got, want)
}
}
func TestHeightDegreeReferenceValue(t *testing.T) {
got := HeightDegree(10000)
want := 3.208113256367627
if math.Abs(got-want) > 1e-12 {
t.Fatalf("HeightDegree(10000) = %.15f want %.15f", got, want)
}
}
+151
View File
@@ -0,0 +1,151 @@
package basic
import (
"math"
"testing"
"time"
)
func TestLunarEclipseDiagramIncludesContacts(t *testing.T) {
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)
}
if diagram.MoonRadius != 1 {
t.Fatalf("moon radius mismatch: got %.9f want 1", diagram.MoonRadius)
}
if !(diagram.PenumbraRadius > diagram.UmbraRadius && diagram.UmbraRadius > diagram.MoonRadius) {
t.Fatalf(
"unexpected radii: penumbra=%.9f umbra=%.9f moon=%.9f",
diagram.PenumbraRadius,
diagram.UmbraRadius,
diagram.MoonRadius,
)
}
labels := map[string]bool{}
for _, point := range diagram.Points {
if point.Label != "" {
labels[point.Label] = true
}
}
for _, label := range []string{"P1", "U1", "U2", "Greatest", "U3", "U4", "P4"} {
if !labels[label] {
t.Fatalf("missing label %s in diagram points", label)
}
}
}
func TestLocalSolarEclipseDiagramIncludesContacts(t *testing.T) {
diagram := LocalSolarEclipseDiagram(
UTC2TT(Date2JD(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC))),
-96.7970,
32.7767,
0,
LocalSolarEclipseDiagramOptions{StepDays: 5.0 / 1440.0},
)
if diagram.Eclipse.Type == SolarEclipseNone {
t.Fatalf("expected local solar eclipse")
}
if len(diagram.Frames) == 0 {
t.Fatalf("expected diagram frames")
}
labels := map[string]bool{}
for _, frame := range diagram.Frames {
if frame.SunRadius != 1 {
t.Fatalf("sun radius mismatch: got %.9f want 1", frame.SunRadius)
}
if !(frame.MoonRadius > 0) {
t.Fatalf("invalid moon radius: %.9f", frame.MoonRadius)
}
if math.IsNaN(frame.MoonX) || math.IsNaN(frame.MoonY) {
t.Fatalf("invalid moon position: x=%f y=%f", frame.MoonX, frame.MoonY)
}
if frame.Label != "" {
labels[frame.Label] = true
}
}
for _, label := range []string{"C1", "Greatest", "C4"} {
if !labels[label] {
t.Fatalf("missing label %s in diagram frames", label)
}
}
}
func TestLocalSolarEclipseDiagramTimesKeepCoincidentLabels(t *testing.T) {
eclipse := LocalSolarEclipseResult{
HasPartial: true,
HasCentral: true,
PartialStart: 1,
GreatestEclipse: 2,
CentralStart: 2,
CentralEnd: 2,
PartialEnd: 3,
}
times, _ := localSolarEclipseDiagramTimes(eclipse, 0.5)
var coincident localSolarEclipseDiagramTime
found := false
for _, item := range times {
if item.jde == 2 {
coincident = item
found = true
break
}
}
if !found {
t.Fatalf("missing coincident event time")
}
want := []string{"C2", "Greatest", "C3"}
if len(coincident.labels) != len(want) {
t.Fatalf("coincident labels = %v, want %v", coincident.labels, want)
}
for i, label := range want {
if coincident.labels[i] != label {
t.Fatalf("coincident labels = %v, want %v", coincident.labels, want)
}
}
if got := localSolarEclipseDiagramPrimaryLabel(coincident.labels); got != "Greatest" {
t.Fatalf("primary label = %q, want %q", got, "Greatest")
}
}
func TestLunarEclipseDiagramTimesKeepCoincidentLabels(t *testing.T) {
eclipse := LunarEclipseResult{
HasPenumbral: true,
HasPartial: true,
HasTotal: true,
PenumbralStart: 1,
PartialStart: 1.5,
TotalStart: 2,
Maximum: 2,
TotalEnd: 2,
PartialEnd: 2.5,
PenumbralEnd: 3,
}
times, _ := lunarEclipseDiagramTimes(eclipse, 0.5)
var coincident lunarEclipseDiagramTime
found := false
for _, item := range times {
if item.jde == 2 {
coincident = item
found = true
break
}
}
if !found {
t.Fatalf("missing coincident event time")
}
want := []string{"U2", "Greatest", "U3"}
if len(coincident.labels) != len(want) {
t.Fatalf("coincident labels = %v, want %v", coincident.labels, want)
}
for i, label := range want {
if coincident.labels[i] != label {
t.Fatalf("coincident labels = %v, want %v", coincident.labels, want)
}
}
if got := lunarEclipseDiagramPrimaryLabel(coincident.labels); got != "Greatest" {
t.Fatalf("primary label = %q, want %q", got, "Greatest")
}
}
+35
View File
@@ -0,0 +1,35 @@
package basic
import (
"b612.me/astro/internal/ephem"
)
// Instance 显式只读星历实例 / explicit read-only ephemeris instance.
type Instance = ephem.Instance
// EphemerisModel 几何位置所用的星历模型 / ephemeris model behind geometric positions.
type EphemerisModel int
const (
// EphemerisLegacy VSOP87 + ELP2000 截断级数,astro_legacy 构建下为默认 / truncated VSOP87 and ELP2000 series, default under astro_legacy.
EphemerisLegacy EphemerisModel = iota
// EphemerisSemianalytic VSOP2013/TOP2013 + ELP/MPP02 结构、按 DE441 重拟的半解析表 / semi-analytic tables refitted to DE441.
EphemerisSemianalytic
)
// SetEphemerisModel 切换全局星历模型,未知值返回 false / switches the global ephemeris model; unknown values return false.
func SetEphemerisModel(m EphemerisModel) bool {
if m != EphemerisLegacy && m != EphemerisSemianalytic {
return false
}
// 依赖星历的缓存都按 ΔT 世代校验,切换时一并递增。
deltaTFnMu.Lock()
if ephem.SetActive(ephem.Model(m)) {
deltaTGeneration++
}
deltaTFnMu.Unlock()
return true
}
// GetEphemerisModel 当前星历模型 / the active ephemeris model.
func GetEphemerisModel() EphemerisModel { return EphemerisModel(ephem.Active()) }
+120
View File
@@ -0,0 +1,120 @@
package basic
import (
"errors"
"math"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet"
)
func instanceValue(instances []*Instance) *Instance {
if len(instances) == 0 {
return nil
}
return instances[0]
}
func instanceModel(inst *Instance) ephem.Model {
if inst == nil {
return ephem.Active()
}
return inst.Model()
}
// PlanetCoordinateWithInstance 指定星历实例的行星日心经纬或距离 / heliocentric planetary coordinate or distance with an explicit instance.
func PlanetCoordinateWithInstance(index, coordinate int, jde float64, inst *Instance) float64 {
return PlanetCoordinateNWithInstance(index, coordinate, jde, -1, inst)
}
// PlanetCoordinateNWithInstance 指定星历实例的行星日心经纬或距离(n 变体) / n-parameterised heliocentric planetary coordinate or distance with an explicit instance.
func PlanetCoordinateNWithInstance(index, coordinate int, jde float64, n int, inst *Instance) float64 {
if inst == nil {
return planet.WherePlanetN(index, coordinate, jde, n)
}
if inst.Model() != ephem.Custom {
return planet.WherePlanetModelN(inst.Model(), index, coordinate, jde, n)
}
value, ok := planetHeliocentricCoordinateWithInstance(index, coordinate, jde, inst)
if !ok {
return math.NaN()
}
return value
}
func boundVectorApparent(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool) {
lo, bo, tau, distAU, ok, err := vectorApparentWithInstance(target, jde, n, inst)
if err != nil {
return math.NaN(), math.NaN(), math.NaN(), math.NaN(), true
}
return lo, bo, tau, distAU, ok
}
func instanceStateEcliptic(target ephem.Body, jde float64, inst *Instance) (lo, bo, distAU float64, ok bool) {
return instanceStateEclipticRelative(target, ephem.Earth, jde, jde, inst)
}
func instanceStateEclipticRelative(target, center ephem.Body, stateJDE, eclipticJDE float64, inst *Instance) (lo, bo, distAU float64, ok bool) {
// 几何状态捷径只给自定义引擎,内置模型必须走与包级相同的级数与视位置管线。
if inst == nil || inst.Model() != ephem.Custom {
return 0, 0, 0, false
}
state, _, err := inst.State(target, center, stateJDE)
if err != nil {
return 0, 0, 0, false
}
distanceKM := math.Sqrt(state[0]*state[0] + state[1]*state[1] + state[2]*state[2])
if distanceKM == 0 || math.IsNaN(distanceKM) || math.IsInf(distanceKM, 0) {
return 0, 0, 0, false
}
direction := [3]float64{state[0] / distanceKM, state[1] / distanceKM, state[2] / distanceKM}
lo, bo = icrfToMeanEcliptic(direction, eclipticJDE)
return lo, bo, distanceKM / astronomicalUnitKM, true
}
func icrfToMeanEcliptic(direction [3]float64, jde float64) (lo, bo float64) {
matrix := ltpPBMAT(2000 + (jde-2451545)/365.25)
var equatorial [3]float64
for axis := range equatorial {
equatorial[axis] = matrix[axis][0]*direction[0] + matrix[axis][1]*direction[1] + matrix[axis][2]*direction[2]
}
sine, cosine := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
eclipticY := equatorial[1]*cosine + equatorial[2]*sine
eclipticZ := -equatorial[1]*sine + equatorial[2]*cosine
lo = math.Atan2(eclipticY, equatorial[0]) * 180 / math.Pi
bo = math.Atan2(eclipticZ, math.Hypot(equatorial[0], eclipticY)) * 180 / math.Pi
return math.Mod(lo+720, 360), bo
}
func instanceApparent(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
if inst == nil || n >= 0 && inst.Model() != ephem.Custom {
return 0, 0, 0, 0, false, nil
}
return apparentState(target, jde, inst)
}
// apparentState 求视位置并校验;引擎不支持时 ok=false 且 err 为 nil,状态非法时返回 err。
func apparentState(target ephem.Body, jde float64, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
direction, distanceKM, tau, err := inst.Apparent(target, jde)
if errors.Is(err, ephem.ErrUnsupportedApparent) || inst.Model() != ephem.Custom && errors.Is(err, ephem.ErrNoEngine) {
return 0, 0, 0, 0, false, nil
}
if err != nil {
return 0, 0, 0, 0, false, err
}
norm := math.Hypot(math.Hypot(direction[0], direction[1]), direction[2])
if !isFiniteFloat(distanceKM) || distanceKM <= 0 || !isFiniteFloat(norm) || norm <= 0 || !isFiniteFloat(tau) || tau < 0 {
return 0, 0, 0, 0, false, errors.New("ephem: invalid apparent state")
}
lo, bo = icrfToTrueEcliptic(direction, jde)
// 天文单位须与 vectorApparent 一致,否则实例与包级距离不逐位相等。
return lo, bo, tau, distanceKM / 149597870.7, true, nil
}
func vectorApparentWithInstance(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
if inst != nil {
return instanceApparent(target, jde, n, inst)
}
lo, bo, tau, distAU, ok = vectorApparent(target, jde, n)
return lo, bo, tau, distAU, ok, nil
}
+121
View File
@@ -0,0 +1,121 @@
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet"
)
type p3StateEngine struct {
position [3]float64
err error
}
func (engine p3StateEngine) State(target, center ephem.Body, jde float64) ([3]float64, [3]float64, error) {
return engine.position, [3]float64{}, engine.err
}
func (engine p3StateEngine) Coverage(target ephem.Body) (float64, float64, bool) {
return 2400000, 2500000, true
}
type p3ApparentEngine struct {
p3StateEngine
direction [3]float64
distance float64
tau float64
err error
}
func (engine p3ApparentEngine) Apparent(target ephem.Body, jde float64) ([3]float64, float64, float64, error) {
return engine.direction, engine.distance, engine.tau, engine.err
}
func TestP3BuiltInGeometryPreservesTruncation(t *testing.T) {
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, jde := range []float64{2451545, 2461319.5} {
for _, terms := range []int{-1, 0, 16} {
for _, sample := range []struct {
name string
got float64
want float64
}{
{"SunLo", HSunTrueLoNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 0, jde, terms)},
{"SunBo", HSunTrueBoNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 1, jde, terms)},
{"SunDistance", EarthAwayNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 2, jde, terms)},
{"MoonLo", MoonCalcNewNWithInstance(0, jde, terms, inst), planet.WhereMoonModelN(model, 0, jde, terms)},
{"MoonBo", MoonCalcNewNWithInstance(1, jde, terms, inst), planet.WhereMoonModelN(model, 1, jde, terms)},
{"MoonDistance", HMoonAwayNWithInstance(jde, terms, inst), planet.WhereMoonModelN(model, 2, jde, terms)},
} {
if sample.got != sample.want {
t.Errorf("%s model=%d jde=%v n=%d: got %.17g, want %.17g", sample.name, model, jde, terms, sample.got, sample.want)
}
}
}
}
}
}
func TestP3CustomGeometryUsesMeanEcliptic(t *testing.T) {
const jde = 2461319.5
direction := [3]float64{1, 0, 0}
inst := ephem.NewInstance(p3StateEngine{position: [3]float64{astronomicalUnitKM, 0, 0}})
trueLo, wantBo := icrfToTrueEcliptic(direction, jde)
wantLo := trueLo - Nutation2000Bi(jde)
for _, got := range []float64{HSunTrueLoWithInstance(jde, inst), HMoonTrueLoWithInstance(jde, inst), MercuryLWithInstance(jde, inst)} {
if math.Abs(angleDifference(got, wantLo)) > 1e-12 {
t.Errorf("geometric longitude includes nutation: got %.17g, want %.17g", got, wantLo)
}
}
if got := HSunTrueBoWithInstance(jde, inst); math.Abs(got-wantBo) > 1e-12 {
t.Errorf("latitude: got %.17g, want %.17g", got, wantBo)
}
if got := EarthAwayNWithInstance(jde, 16, inst); got != 1 {
t.Errorf("distance: got %v, want 1 AU", got)
}
}
func TestP3CustomGeometryDoesNotFallback(t *testing.T) {
for _, engine := range []ephem.Engine{
nil,
p3StateEngine{err: ephem.ErrOutOfRange},
p3StateEngine{err: ephem.ErrUnsupportedBody},
p3StateEngine{},
p3StateEngine{position: [3]float64{math.NaN(), 0, 0}},
} {
inst := ephem.NewInstance(engine)
for _, got := range []float64{
HSunTrueLoWithInstance(2451545, inst), HSunTrueBoWithInstance(2451545, inst), EarthAwayNWithInstance(2451545, -1, inst),
HMoonTrueLoWithInstance(2451545, inst), HMoonTrueBoWithInstance(2451545, inst), HMoonAwayWithInstance(2451545, inst),
} {
if !math.IsNaN(got) {
t.Errorf("failed custom engine %T fell back to a built-in model: %v", engine, got)
}
}
}
}
func TestP3CustomApparentFailureDoesNotFallback(t *testing.T) {
state := p3StateEngine{position: [3]float64{astronomicalUnitKM, 0, 0}}
for _, engine := range []p3ApparentEngine{
{p3StateEngine: state, err: ephem.ErrOutOfRange},
{p3StateEngine: state, direction: [3]float64{1, 0, 0}, distance: -1},
{p3StateEngine: state, direction: [3]float64{math.NaN(), 0, 0}, distance: 1},
{p3StateEngine: state, distance: 1},
{p3StateEngine: state, direction: [3]float64{1, 0, 0}, distance: 1, tau: math.NaN()},
} {
inst := ephem.NewInstance(engine)
for _, got := range []float64{HSunApparentLoWithInstance(2451545, inst), HMoonApparentLoWithInstance(2451545, inst), MercuryApparentLoWithInstance(2451545, inst)} {
if !math.IsNaN(got) {
t.Errorf("failed apparent engine fell back to geometry: %v", got)
}
}
}
inst := ephem.NewInstance(state)
if got := HSunApparentLoWithInstance(2451545, inst); !isFiniteFloat(got) {
t.Errorf("state-only engine must retain geometric fallback: %v", got)
}
}
+384
View File
@@ -0,0 +1,384 @@
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
)
type p3ProbeEngine struct {
angle float64
distance float64
}
func (engine p3ProbeEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
angle := engine.angle + float64(target-center)*0.01
distance := engine.distance
if distance == 0 {
distance = 1
}
pos[0] = math.Cos(angle) * astronomicalUnitKM * distance
pos[1] = math.Sin(angle) * astronomicalUnitKM * distance
return pos, vel, nil
}
func (engine p3ProbeEngine) Coverage(target ephem.Body) (start, end float64, ok bool) {
return 2400000, 2500000, target != ephem.SSB
}
func (engine p3ProbeEngine) Apparent(target ephem.Body, jdTT float64) (dir [3]float64, distKM, tauDays float64, err error) {
angle := engine.angle + float64(target)*0.01
dir[0] = math.Cos(angle)
dir[1] = math.Sin(angle)
distance := engine.distance
if distance == 0 {
distance = 1
}
return dir, astronomicalUnitKM * distance, 0.005, nil
}
func TestP3SunMoonFunctionsUseExplicitInstances(t *testing.T) {
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
sunFirst := HSunApparentLoNWithInstance(jd, -1, first)
sunSecond := HSunApparentLoNWithInstance(jd, -1, second)
if math.Abs(angleDifference(sunFirst, sunSecond)) < 1 {
t.Fatalf("sun apparent longitude did not follow instance: %.6f vs %.6f", sunFirst, sunSecond)
}
moonFirst := HMoonApparentLoNWithInstance(jd, -1, first)
moonSecond := HMoonApparentLoNWithInstance(jd, -1, second)
if math.Abs(angleDifference(moonFirst, moonSecond)) < 1 {
t.Fatalf("moon apparent longitude did not follow instance: %.6f vs %.6f", moonFirst, moonSecond)
}
if got, want := HSunApparentLoN(jd, -1), HSunApparentLoNWithInstance(jd, -1, nil); math.Abs(angleDifference(got, want)) > 1e-12 {
t.Fatalf("nil instance changed legacy-compatible result: %.15f vs %.15f", got, want)
}
}
func TestP3PlanetFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
for _, sample := range []struct {
name string
first float64
second float64
}{
{"MercuryL", MercuryLWithInstance(jd, first), MercuryLWithInstance(jd, second)},
{"VenusL", VenusLWithInstance(jd, first), VenusLWithInstance(jd, second)},
{"MarsL", MarsLWithInstance(jd, first), MarsLWithInstance(jd, second)},
{"JupiterL", JupiterLWithInstance(jd, first), JupiterLWithInstance(jd, second)},
{"SaturnL", SaturnLWithInstance(jd, first), SaturnLWithInstance(jd, second)},
{"UranusL", UranusLWithInstance(jd, first), UranusLWithInstance(jd, second)},
{"NeptuneL", NeptuneLWithInstance(jd, first), NeptuneLWithInstance(jd, second)},
{"MercuryApparentLo", MercuryApparentLoWithInstance(jd, first), MercuryApparentLoWithInstance(jd, second)},
{"NeptuneApparentLo", NeptuneApparentLoWithInstance(jd, first), NeptuneApparentLoWithInstance(jd, second)},
} {
if math.Abs(angleDifference(sample.first, sample.second)) < 1 {
t.Fatalf("%s did not follow explicit instance: %.6f vs %.6f", sample.name, sample.first, sample.second)
}
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"MercuryL", MercuryLWithInstance(jd, inst)},
{"MercuryR", MercuryRWithInstance(jd, inst)},
{"MercuryApparentRa", MercuryApparentRaWithInstance(jd, inst)},
{"NeptuneApparentDec", NeptuneApparentDecWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
if got, want := MercuryLWithInstance(jd, nil), MercuryL(jd); got != want {
t.Fatalf("nil instance changed MercuryL: %.15f vs %.15f", got, want)
}
if got, want := NeptuneApparentRaWithInstance(jd, nil), NeptuneApparentRa(jd); got != want {
t.Fatalf("nil instance changed NeptuneApparentRa: %.15f vs %.15f", got, want)
}
}
func TestP3MoonPhaseFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
phaseFirst := MoonPhaseWithInstance(jd, first)
phaseSecond := MoonPhaseWithInstance(jd, second)
if math.Abs(phaseFirst-phaseSecond) < 1e-6 {
t.Fatalf("moon phase did not follow explicit instance: %.12f vs %.12f", phaseFirst, phaseSecond)
}
seekFirst := SunMoonSeekWithInstance(jd, 0, first)
seekSecond := SunMoonSeekWithInstance(jd, 0, second)
if math.Abs(angleDifference(seekFirst, seekSecond)) < 1 {
t.Fatalf("sun-moon phase residual did not follow explicit instance: %.6f vs %.6f", seekFirst, seekSecond)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"CalcMoonSHByJDE", CalcMoonSHByJDEWithInstance(jd, 0, inst)},
{"CalcMoonXHByJDE", CalcMoonXHByJDEWithInstance(jd, 0, inst)},
{"MoonPhase", MoonPhaseWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := MoonPhaseWithInstance(jd, nil), MoonPhase(jd); got != want {
t.Fatalf("nil instance changed MoonPhase: %.15f vs %.15f", got, want)
}
if got, want := CalcMoonSHByJDEWithInstance(jd, 0, nil), CalcMoonSHByJDE(jd, 0); got != want {
t.Fatalf("nil instance changed CalcMoonSHByJDE: %.15f vs %.15f", got, want)
}
}
func TestP3SolarTermFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
firstResidual := JQLospecWithInstance(jd, 0, first)
secondResidual := JQLospecWithInstance(jd, 0, second)
if math.Abs(angleDifference(firstResidual, secondResidual)) < 1 {
t.Fatalf("solar-term longitude did not follow explicit instance: %.6f vs %.6f", firstResidual, secondResidual)
}
if GetShiErCiWithInstance(jd, first) == GetShiErCiWithInstance(jd, second) {
t.Fatalf("twelve-division label did not follow explicit instance")
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"GetJQTime", GetJQTimeWithInstance(2024, 270, inst)},
{"GetWuHouTime", GetWuHouTimeWithInstance(2024, 0, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
if got := GetJieqiLoopsWithInstance(2024, 3, inst); len(got) != 3 {
t.Fatalf("GetJieqiLoops model %d returned %d values", model, len(got))
}
}
if got, want := JQLospecWithInstance(jd, 0, nil), JQLospec(jd, 0); got != want {
t.Fatalf("nil instance changed JQLospec: %.15f vs %.15f", got, want)
}
if got, want := GetJQTimeWithInstance(2024, 270, nil), GetJQTime(2024, 270); got != want {
t.Fatalf("nil instance changed GetJQTime: %.15f vs %.15f", got, want)
}
}
func TestP3SunObservationFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
firstHeight := SunHeightWithInstance(jd, 121.47, 31.23, 8, first)
secondHeight := SunHeightWithInstance(jd, 121.47, 31.23, 8, second)
if math.Abs(firstHeight-secondHeight) < 1 {
t.Fatalf("sun height did not follow explicit instance: %.6f vs %.6f", firstHeight, secondHeight)
}
firstAzimuth := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, first)
secondAzimuth := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, second)
if math.Abs(angleDifference(firstAzimuth, secondAzimuth)) < 1 {
t.Fatalf("sun azimuth did not follow explicit instance: %.6f vs %.6f", firstAzimuth, secondAzimuth)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SunTime", SunTimeWithInstance(jd, inst)},
{"CulminationTime", CulminationTimeWithInstance(jd, 121.47, 8, inst)},
{"SunTimeAngle", SunTimeAngleWithInstance(jd, 121.47, 31.23, 8, inst)},
{"SunHeight", SunHeightWithInstance(jd, 121.47, 31.23, 8, inst)},
{"SunAzimuth", SunAzimuthWithInstance(jd, 121.47, 31.23, 8, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SunHeightWithInstance(jd, 121.47, 31.23, 8, nil), SunHeight(jd, 121.47, 31.23, 8); got != want {
t.Fatalf("nil instance changed SunHeight: %.15f vs %.15f", got, want)
}
if got, want := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, nil), SunAzimuth(jd, 121.47, 31.23, 8); got != want {
t.Fatalf("nil instance changed SunAzimuth: %.15f vs %.15f", got, want)
}
}
// 显式 Legacy 实例的均时差与中天时刻必须复现包级 Legacy 结果。
func TestSunTimeWithLegacyInstanceMatchesPackageLevel(t *testing.T) {
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
inst := ephem.NewModelInstance(ephem.Legacy)
for _, jd := range []float64{2451545.0, 2460384.5, 2460569.5} {
if got, want := SunTimeWithInstance(jd, inst), SunTime(jd); math.Abs(got-want) > 1e-9 {
t.Fatalf("SunTime jd %.1f: instance %.9f h, package %.9f h", jd, got, want)
}
if got, want := SunTimeNWithInstance(jd, 20, inst), SunTimeN(jd, 20); math.Abs(got-want) > 1e-9 {
t.Fatalf("SunTimeN jd %.1f: instance %.9f h, package %.9f h", jd, got, want)
}
if got, want := CulminationTimeWithInstance(jd, 116.4, 8, inst), CulminationTime(jd, 116.4, 8); math.Abs(got-want)*86400 > 1e-3 {
t.Fatalf("CulminationTime jd %.1f: differs by %.3f s", jd, (got-want)*86400)
}
}
}
func TestP3SunMoonAliasFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
if math.Abs(angleDifference(SunApparentRaWithInstance(jd, first), SunApparentRaWithInstance(jd, second))) < 1 {
t.Fatal("SunApparentRa alias did not follow explicit instance")
}
if math.Abs(angleDifference(MoonGeocentricApparentRaWithInstance(jd, first), MoonGeocentricApparentRaWithInstance(jd, second))) < 1 {
t.Fatal("MoonGeocentricApparentRa alias did not follow explicit instance")
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SunApparentRa", SunApparentRaWithInstance(jd, inst)},
{"SunTrueDec", SunTrueDecWithInstance(jd, inst)},
{"MoonGeocentricTrueRa", MoonGeocentricTrueRaWithInstance(jd, inst)},
{"MoonTrueDec", MoonTrueDecWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SunApparentRaWithInstance(jd, nil), SunApparentRa(jd); got != want {
t.Fatalf("nil instance changed SunApparentRa: %.15f vs %.15f", got, want)
}
if got, want := MoonGeocentricTrueRaWithInstance(jd, nil), HMoonGeocentricTrueRa(jd); got != want {
t.Fatalf("nil instance changed MoonGeocentricTrueRa: %.15f vs %.15f", got, want)
}
}
func TestP3PlanetMagnitudeFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0, distance: 0.8})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2, distance: 1.2})
jd := 2451545.0
for _, sample := range []struct {
name string
first float64
second float64
}{
{"MercuryMag", MercuryMagWithInstance(jd, first), MercuryMagWithInstance(jd, second)},
{"VenusMag", VenusMagWithInstance(jd, first), VenusMagWithInstance(jd, second)},
{"MarsMag", MarsMagWithInstance(jd, first), MarsMagWithInstance(jd, second)},
{"JupiterMag", JupiterMagWithInstance(jd, first), JupiterMagWithInstance(jd, second)},
{"UranusMag", UranusMagWithInstance(jd, first), UranusMagWithInstance(jd, second)},
{"NeptuneMag", NeptuneMagWithInstance(jd, first), NeptuneMagWithInstance(jd, second)},
} {
if math.Abs(sample.first-sample.second) < 1e-6 {
t.Fatalf("%s did not follow explicit instance: %.12f vs %.12f", sample.name, sample.first, sample.second)
}
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"MercuryMag", MercuryMagWithInstance(jd, inst)},
{"JupiterMag", JupiterMagWithInstance(jd, inst)},
{"NeptuneMag", NeptuneMagWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := MercuryMagWithInstance(jd, nil), MercuryMag(jd); got != want {
t.Fatalf("nil instance changed MercuryMag: %.15f vs %.15f", got, want)
}
}
func TestP3SaturnRingFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0, distance: 0.8})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2, distance: 1.2})
jd := 2451545.0
firstRing := SaturnRingBWithInstance(jd, first)
secondRing := SaturnRingBWithInstance(jd, second)
if math.Abs(firstRing-secondRing) < 1e-6 {
t.Fatalf("Saturn ring opening angle did not follow explicit instance: %.12f vs %.12f", firstRing, secondRing)
}
firstMagnitude := SaturnMagWithInstance(jd, first)
secondMagnitude := SaturnMagWithInstance(jd, second)
if math.Abs(firstMagnitude-secondMagnitude) < 1e-6 {
t.Fatalf("Saturn magnitude did not follow explicit instance: %.12f vs %.12f", firstMagnitude, secondMagnitude)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SaturnRingB", SaturnRingBWithInstance(jd, inst)},
{"SaturnRingPositionAngle", SaturnRingPositionAngleWithInstance(jd, inst)},
{"SaturnMag", SaturnMagWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SaturnMagWithInstance(jd, nil), SaturnMag(jd); got != want {
t.Fatalf("nil instance changed SaturnMag: %.15f vs %.15f", got, want)
}
}
func angleDifference(a, b float64) float64 {
difference := math.Mod(a-b+180, 360)
if difference < 0 {
difference += 360
}
return difference - 180
}
+41
View File
@@ -0,0 +1,41 @@
// 被测契约:默认构建以旧级数开始;切换模型使缓存世代递增,重复设置同一模型不递增,未知模型被拒绝。
package basic
import (
"testing"
"b612.me/astro/internal/ephem"
)
func TestEphemerisDefaultIsBuildDefault(t *testing.T) {
if ephem.BuildDefault != ephem.Legacy && ephem.BuildDefault != ephem.Semianalytic {
t.Fatalf("build default %d", ephem.BuildDefault)
}
if GetEphemerisModel() != EphemerisModel(ephem.BuildDefault) {
t.Fatalf("default model %d, build default %d", GetEphemerisModel(), ephem.BuildDefault)
}
}
func TestSetEphemerisModelBumpsGeneration(t *testing.T) {
if SetEphemerisModel(EphemerisModel(99)) {
t.Fatal("unknown model accepted")
}
prev := GetEphemerisModel()
t.Cleanup(func() { SetEphemerisModel(prev) })
SetEphemerisModel(EphemerisLegacy)
g := deltaTGenerationValue()
SetEphemerisModel(EphemerisSemianalytic)
if deltaTGenerationValue() == g {
t.Fatal("generation did not change")
}
g = deltaTGenerationValue()
SetEphemerisModel(EphemerisSemianalytic)
if deltaTGenerationValue() != g {
t.Fatal("setting the same model changed the generation")
}
before := HMoonTrueLo(2451545.0)
SetEphemerisModel(EphemerisLegacy)
if HMoonTrueLo(2451545.0) == before {
t.Fatal("moon longitude did not follow the model switch")
}
}
+85
View File
@@ -0,0 +1,85 @@
// 事件回归基线按星历模型分文件:旧模型用 testdata/<name>.json,新模型用 testdata/semianalytic/<name>.json。
// ASTRO_UPDATE_GOLDEN=1 且当前为新模型时,以旧基线的输入时刻重算并重写新模型基线;旧模型基线从不改写。
package basic
import (
"encoding/json"
"math"
"os"
"path/filepath"
"sort"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/ephemtest"
)
type eventBaselineSample struct {
InputUTC string `json:"input_utc"`
TTJDBits uint64 `json:"tt_jd_bits"`
Events map[string]uint64 `json:"events"`
}
type eventBaselineFile struct {
Samples []eventBaselineSample `json:"samples"`
}
type namedEventFunc struct {
name string
fn func(float64) float64
}
func readEventBaseline(t *testing.T, path string) eventBaselineFile {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var b eventBaselineFile
if err := json.Unmarshal(data, &b); err != nil {
t.Fatal(err)
}
if len(b.Samples) == 0 {
t.Fatalf("empty event baseline %s", path)
}
return b
}
// eventBaselineData 当前模型的基线原始字节;需要时先重写新模型基线。
func eventBaselineData(t *testing.T, legacyPath string, cases []namedEventFunc) []byte {
t.Helper()
path := ephemtest.Golden(legacyPath)
if ephemtest.Update() && ephem.Active() == ephem.Semianalytic {
src := readEventBaseline(t, legacyPath)
out := eventBaselineFile{Samples: make([]eventBaselineSample, len(src.Samples))}
for i, s := range src.Samples {
jd := math.Float64frombits(s.TTJDBits)
ev := make(map[string]uint64, len(cases))
for _, c := range cases {
ev[c.name] = math.Float64bits(c.fn(jd))
}
out.Samples[i] = eventBaselineSample{InputUTC: s.InputUTC, TTJDBits: s.TTJDBits, Events: ev}
}
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(cases))
for _, c := range cases {
names = append(names, c.name)
}
sort.Strings(names)
t.Logf("rewrote %s: %d samples × %d events", path, len(out.Samples), len(names))
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%v (regenerate with ASTRO_UPDATE_GOLDEN=1 go test)", err)
}
return data
}
+77
View File
@@ -0,0 +1,77 @@
package basic
import "math"
const (
exactEventTolerance = 2.0 / 86400.0
exactQueryTTToleranceUT = 0.1 / 86400.0
// stationQueryToleranceUT 站(留)事件的「同刻」容差 / same-instant tolerance for station events.
//
// 站的求解本身有数值不确定度(火星 ~0.2 s、金星 ~2 s、外行星改精修步长后 <0.05 s)。
// 若与普通事件一样取 0.1 s,查询落在站前几十毫秒时会被判成「站仍在未来」,
// 于是跳到上一个会合周期。取 0.5 s 可覆盖除金星外的全部不确定度。
// 上限约束:必须明显小于 1 s,否则会破坏
// TestInnerPlanetNextEventAdvancesPastReturnedEvent 的「查询=事件+1 秒必须前进」语义。
stationQueryToleranceUT = 0.5 / 86400.0
)
func sameEventJD(a, b float64) bool {
return math.Abs(a-b) <= exactEventTolerance
}
// stationUTQueryBeforeOrEqual / stationUTQueryAfterOrEqual 站事件专用侧向判定,
// 容差比普通事件宽(见 stationQueryToleranceUT)。
func stationUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= stationQueryToleranceUT
}
func stationUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -stationQueryToleranceUT
}
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {
bestAbs := math.Abs(eventUTQueryTTDelta(best, queryTT))
for _, candidate := range candidates {
candidateAbs := math.Abs(eventUTQueryTTDelta(candidate, queryTT))
if candidateAbs < bestAbs {
best = candidate
bestAbs = candidateAbs
}
}
return best
}
type phaseEventSearchFunc func(jde, degree float64, next uint8) float64
type simpleEventSearchFunc func(jde float64) float64
// inclusive* 的唯一作用是把「查询几乎正好落在事件上」判成包含:
// 反向搜索给出的邻接事件落在同刻容差内时优先返回它,否则返回本方向的事件(可能是 NaN)。
func inclusiveLastPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
last := fn(jde, degree, 0)
if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
return last
}
func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
return fn(jde, degree, 1)
}
func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
last := lastFn(jde)
if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
return last
}
func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
return nextFn(jde)
}
+268
View File
@@ -0,0 +1,268 @@
package basic
import "math"
const (
eventNewtonMaxIterations = 24
eventDirectionalSearchIterations = 128
eventRiseSetScanStep = 1.0 / 1440
// stationDerivativeStepDay 「留」精修用的中心差分步长(天):更细的步长会被浮点相消噪声支配。
stationDerivativeStepDay = 0.01
)
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// eventNewtonRefine 执行有界牛顿迭代;修正函数返回 f(x)/f'(x),调用者保留现有导数计算 / eventNewtonRefine performs a bounded Newton iteration. The correction
// 对格式错误输入和不收敛迭代快速失败 / function returns f(x)/f'(x), so callers retain their existing derivative
// 计算 / calculation while malformed input and non-convergent iterations fail fast.
func eventNewtonRefine(seed, tolerance float64, correction func(float64) float64) (float64, bool) {
if !isFiniteFloat(seed) || !isFiniteFloat(tolerance) || tolerance <= 0 {
return math.NaN(), false
}
current := seed
for i := 0; i < eventNewtonMaxIterations; i++ {
step := correction(current)
if !isFiniteFloat(step) {
return math.NaN(), false
}
next := current - step
if !isFiniteFloat(next) {
return math.NaN(), false
}
if math.Abs(next-current) <= tolerance {
return next, true
}
current = next
}
return math.NaN(), false
}
func eventRiseSetCandidateValid(candidate, civilDayStart, slope float64, isRise bool) bool {
if !isFiniteFloat(candidate) || !isFiniteFloat(civilDayStart) || !isFiniteFloat(slope) ||
candidate < civilDayStart || candidate >= civilDayStart+1 {
return false
}
if isRise {
return slope > 0
}
return slope < 0
}
func eventDirectionalRiseSetSearch(civilDayStart float64, isRise bool, fallbackErr error,
residual func(float64) float64) (float64, error) {
if !isFiniteFloat(civilDayStart) {
return 0, ErrInvalidObservationInput
}
previousJD := civilDayStart
previousValue := residual(previousJD)
if !isFiniteFloat(previousValue) {
return 0, ErrInvalidObservationInput
}
minimum, maximum := previousValue, previousValue
steps := int(math.Round(1 / eventRiseSetScanStep))
for i := 1; i <= steps; i++ {
currentJD := civilDayStart + float64(i)*eventRiseSetScanStep
currentValue := residual(currentJD)
if !isFiniteFloat(currentValue) {
return 0, ErrInvalidObservationInput
}
minimum = math.Min(minimum, currentValue)
maximum = math.Max(maximum, currentValue)
if eventCrossesDirection(previousValue, currentValue, isRise) {
eventJD := eventDirectionalBracketRefine(previousJD, currentJD, previousValue, currentValue, residual)
if eventJD < civilDayStart+1 {
return eventJD, nil
}
}
previousJD = currentJD
previousValue = currentValue
}
switch {
case fallbackErr != nil:
return 0, fallbackErr
case maximum < 0:
return 0, ErrNeverRise
case minimum > 0:
return 0, ErrNeverSet
default:
return 0, ErrNotOnThisDate
}
}
func eventCrossesDirection(leftValue, rightValue float64, isRise bool) bool {
if isRise {
return leftValue <= 0 && rightValue >= 0 && leftValue != rightValue
}
return leftValue >= 0 && rightValue <= 0 && leftValue != rightValue
}
func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float64, residual func(float64) float64) float64 {
if leftValue == 0 {
return leftJD
}
if rightValue == 0 {
return rightJD
}
for i := 0; i < 48; i++ {
middleJD := (leftJD + rightJD) / 2
middleValue := residual(middleJD)
if middleValue == 0 {
return middleJD
}
if (leftValue < 0) == (middleValue < 0) {
leftJD = middleJD
leftValue = middleValue
} else {
rightJD = middleJD
}
}
return (leftJD + rightJD) / 2
}
// eventBracketSecantRoot 在已知异号的括号内用割线法(带中点兜底)求根。
//
// 与 eventDirectionalBracketRefine(固定 48 次二分)相比,割线法通常 5~8 次求值即可达到
// 亚秒精度,适合「括号由廉价截断级数给出、抛光必须用全项级数」的两段式搜索。
// 括号每一步都收缩,因此不会跑到括号外;括号端点同号或出现非有限值时返回 false。
func eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, tolerance float64,
fn func(float64) float64) (float64, bool) {
if leftValue == 0 {
return leftJD, true
}
if rightValue == 0 {
return rightJD, true
}
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
if !isFiniteFloat(tolerance) || tolerance <= 0 {
tolerance = 0.5 / 86400.0
}
bestJD := (leftJD + rightJD) / 2
for i := 0; i < 48; i++ {
candidateJD := (leftJD + rightJD) / 2
if rightValue != leftValue {
secantJD := rightJD - rightValue*(rightJD-leftJD)/(rightValue-leftValue)
if secantJD > leftJD && secantJD < rightJD {
candidateJD = secantJD
}
}
candidateValue := fn(candidateJD)
if !isFiniteFloat(candidateValue) {
return math.NaN(), false
}
bestJD = candidateJD
if candidateValue == 0 || math.Abs(rightJD-leftJD) <= tolerance {
return candidateJD, true
}
if (leftValue < 0) == (candidateValue < 0) {
leftJD, leftValue = candidateJD, candidateValue
continue
}
rightJD, rightValue = candidateJD, candidateValue
}
if math.Abs(rightJD-leftJD) <= tolerance {
return bestJD, true
}
// 48 次迭代后括号仍宽于容差(例如容差低于该儒略日的 ULP):调用方把 ok 当作
// “已抛光到容差”,这里必须报 false,不能返回一个精度未达标的时刻。
return bestJD, false
}
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
start := seed - halfWindow
bestJD := start
bestAbs := math.Abs(fn(start))
samples := int(math.Round((2 * halfWindow) / step))
for i := 1; i < samples; i++ {
candidateJD := start + float64(i)*step
candidateAbs := math.Abs(fn(candidateJD))
if candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = candidateJD
}
}
return bestJD
}
func eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal float64) (float64, float64, float64, float64, bool) {
if leftVal == 0 {
return leftJD, leftJD, leftVal, leftVal, true
}
if centerVal == 0 {
return centerJD, centerJD, centerVal, centerVal, true
}
if rightVal == 0 {
return rightJD, rightJD, rightVal, rightVal, true
}
if leftVal*centerVal < 0 {
return leftJD, centerJD, leftVal, centerVal, true
}
if centerVal*rightVal < 0 {
return centerJD, rightJD, centerVal, rightVal, true
}
if leftVal*rightVal < 0 {
return leftJD, rightJD, leftVal, rightVal, true
}
return 0, 0, 0, 0, false
}
// eventZeroRefine 细化 seed 附近的零点;无可用括号区间时退回固定步长扫描。
func eventZeroRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
leftJD := seed - halfWindow
centerJD := seed
rightJD := seed + halfWindow
leftVal := fn(leftJD)
centerVal := fn(centerJD)
rightVal := fn(rightJD)
bestJD := centerJD
bestAbs := math.Abs(centerVal)
if candidateAbs := math.Abs(leftVal); candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = leftJD
}
if candidateAbs := math.Abs(rightVal); candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = rightJD
}
bracketLeftJD, bracketRightJD, bracketLeftVal, bracketRightVal, ok := eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal)
if !ok {
return eventFixedScanRefine(seed, halfWindow, step, fn)
}
if bracketLeftJD == bracketRightJD {
return bracketLeftJD
}
for i := 0; i < 8; i++ {
candidateJD := (bracketLeftJD + bracketRightJD) / 2
if bracketRightVal != bracketLeftVal {
secantJD := bracketRightJD - bracketRightVal*(bracketRightJD-bracketLeftJD)/(bracketRightVal-bracketLeftVal)
if secantJD > bracketLeftJD && secantJD < bracketRightJD {
candidateJD = secantJD
}
}
candidateVal := fn(candidateJD)
candidateAbs := math.Abs(candidateVal)
if candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = candidateJD
}
if candidateVal == 0 || math.Abs(bracketRightJD-bracketLeftJD) <= step {
break
}
if bracketLeftVal*candidateVal < 0 {
bracketRightJD = candidateJD
bracketRightVal = candidateVal
continue
}
bracketLeftJD = candidateJD
bracketLeftVal = candidateVal
}
return bestJD
}
+41
View File
@@ -0,0 +1,41 @@
package basic
import (
"math"
"testing"
)
func TestEventZeroRefineFindsNearbyRoot(t *testing.T) {
root := 0.123456789
got := eventZeroRefine(root+0.00002, 0.0003, 1e-7, func(x float64) float64 {
return x - root
})
if math.Abs(got-root) > 1e-12 {
t.Fatalf("got %.15f want %.15f", got, root)
}
}
func TestEventZeroRefineFallsBackToFixedScan(t *testing.T) {
got := eventZeroRefine(0.1, 1, 0.1, func(x float64) float64 {
return x*x + 1
})
if math.Abs(got) > 1e-12 {
t.Fatalf("got %.15f want 0", got)
}
}
// 48 次迭代用尽后括号仍宽于容差时不能谎报"已抛光":调用方把 ok 当作精度保证。
func TestEventBracketSecantRootRejectsUnpolishedRoot(t *testing.T) {
linear := func(x float64) float64 { return x - 0.5 }
if _, ok := eventBracketSecantRoot(0, 1, -0.5, 0.5, 1e-9, linear); !ok {
t.Fatalf("normal tolerance should return ok=true")
}
// 无理根(sqrt(2))永远取不到精确零,容差又低于该量级的 ULP:必须报 false。
irrational := func(x float64) float64 { return x*x - 2 }
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-25, irrational); ok {
t.Fatalf("unreachable tolerance must not report a polished root")
}
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-12, irrational); !ok {
t.Fatalf("reachable tolerance should still report ok=true")
}
}
+126
View File
@@ -0,0 +1,126 @@
package basic
import (
"math"
"time"
"b612.me/astro/internal/geodata"
)
// 等时线(食甚/掩甚时刻等值线)的共享常量与工具。
// 两个等时线实现(日食、月掩)用同一套容差与时刻取值生成规则,避免口径漂移。
const (
// greatestTimeContourCoverToleranceKM 是"该点是否已被已绘等时线覆盖"的距离门限。
// 判据必须用点到折线的距离:同一条曲线被两个种子各画一次时该距离约为 0,而折线弦高在
// 1.5° 步长下最大几 km,所以 10 km 既不会漏判重复,也远小于不同支路的间距。
greatestTimeContourCoverToleranceKM = 10.0
// greatestTimeContourCorrectionBacktracking 是牛顿投影跳出定义域时按二分回退的次数。
greatestTimeContourCorrectionBacktracking = 6
// greatestTimeContourMaxLevels 是单个事件允许的等时线条数上限;超出部分按时间截断,
// 避免极长事件请求出上万条曲线。
greatestTimeContourMaxLevels = 64
// greatestTimeContourResidualTolerance 是残差零点判定的收敛容差。
greatestTimeContourResidualTolerance = 1e-9
// greatestTimeContourBisectionIterations 是二分求根的迭代上限。
greatestTimeContourBisectionIterations = 64
)
// greatestTimeContourDifference 优先用中心差分,一侧越界时退化为单侧差分。
func greatestTimeContourDifference(center, positive, negative, step float64, positiveOK, negativeOK bool) (float64, bool) {
switch {
case positiveOK && negativeOK:
return (positive - negative) / (2 * step), true
case positiveOK:
return (positive - center) / step, true
case negativeOK:
return (center - negative) / step, true
}
return 0, false
}
// greatestTimeContourBisect 在 [left,right] 上二分求残差零点;residual 在定义域外返回 NaN。
func greatestTimeContourBisect(
residual func(longitude, latitude float64) float64,
left, right, latitude, leftValue float64,
) (float64, bool) {
for iteration := 0; iteration < greatestTimeContourBisectionIterations; iteration++ {
middle := (left + right) / 2
middleValue := residual(middle, latitude)
if !finite(middleValue) {
return 0, false
}
if math.Abs(middleValue) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 {
return middle, true
}
if leftValue*middleValue <= 0 {
right = middle
} else {
left, leftValue = middle, middleValue
}
}
return (left + right) / 2, true
}
// greatestTimeContourTickTolerance 是对齐网格在窗口边界上的容差,远小于任何合法步长。
const greatestTimeContourTickTolerance = time.Millisecond
// greatestTimeContourLevel 是一条等时线对应的时刻取值:TT 儒略日与它对应的时刻必须成对传递,
// 前者用于求根,后者用于标注(按步长生成时它是原始对齐时刻,避免 JDE 往返把整分截断成前一分钟)。
type greatestTimeContourLevel struct {
tt float64
at time.Time
}
// greatestTimeContourAlignedLevels 生成覆盖 [startTT,endTT] 且对齐到 step 整刻度的时刻取值,
// 最多 maxLevels 条;起点或终点恰好落在整刻度上时该刻度只生成一次。
func greatestTimeContourAlignedLevels(startTT, endTT float64, step time.Duration, maxLevels int) []greatestTimeContourLevel {
if step <= 0 || maxLevels <= 0 || endTT <= startTT {
return nil
}
start := greatestTimeContourTTToUTC(startTT)
first := start.Truncate(step)
// TT 儒略日往返有约 40 µs(1 ULP)误差,恰好落在整刻度上的窗口边界会算到刻度外一点点,容差按 1 ms 给。
if first.Add(greatestTimeContourTickTolerance).Before(start) {
first = first.Add(step)
}
end := greatestTimeContourTTToUTC(endTT)
levels := make([]greatestTimeContourLevel, 0, maxLevels)
for current := first; !current.Add(-greatestTimeContourTickTolerance).After(end) && len(levels) < maxLevels; current = current.Add(step) {
levels = append(levels, greatestTimeContourLevel{tt: greatestTimeContourUTCToTT(current), at: current})
}
return levels
}
// greatestTimeContourTTToUTC 把 TT 儒略日换成对应的 UTC 时刻。
func greatestTimeContourTTToUTC(tt float64) time.Time {
return JD2DateByZone(TT2UTC(tt), time.UTC, false)
}
// greatestTimeContourUTCToTT 把 UTC 时刻换成对应的 TT 儒略日。
func greatestTimeContourUTCToTT(value time.Time) float64 {
return UTC2TT(Date2JD(value.UTC()))
}
// greatestTimeContourPointSegmentKM 返回点到折线段的距离,单位 km。
// 段长只有 1–2 度,用局部等距圆柱近似即可,误差远小于覆盖容差。
func greatestTimeContourPointSegmentKM(longitude, latitude, startLongitude, startLatitude, endLongitude, endLatitude float64) float64 {
cosine := math.Cos(latitude * rad)
toX := func(value float64) float64 {
return geodata.NormalizeLongitude(value-startLongitude) * rad * cosine * geodata.EarthKMPerDegree
}
toY := func(value float64) float64 { return (value - startLatitude) * geodata.EarthKMPerDegree }
pointX, pointY := toX(longitude), toY(latitude)
endX, endY := toX(endLongitude), toY(endLatitude)
lengthSquared := endX*endX + endY*endY
projection := 0.0
if lengthSquared > 0 {
projection = (pointX*endX + pointY*endY) / lengthSquared
if projection < 0 {
projection = 0
} else if projection > 1 {
projection = 1
}
}
return math.Hypot(pointX-projection*endX, pointY-projection*endY)
}
+66
View File
@@ -0,0 +1,66 @@
package basic
import (
"testing"
"time"
)
// 对齐网格是导出行为(等时线按整刻度取值)的契约,取值本身写成字面量以免与实际生成函数互相自证。
func TestGreatestTimeContourAlignedLevelsGridContracts(t *testing.T) {
tt := func(hour, minute int) float64 {
return UTC2TT(Date2JD(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC)))
}
cases := []struct {
name string
start, end float64
step time.Duration
maxLevels int
want []string
}{
{
name: "起点在刻度之间时从下一个整刻度开始", start: tt(0, 7), end: tt(2, 7),
step: 30 * time.Minute, maxLevels: 64,
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
},
{
name: "起点恰在刻度上时不重复生成该刻度", start: tt(0, 30), end: tt(2, 0),
step: 30 * time.Minute, maxLevels: 64,
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
},
{
name: "终点恰在刻度上时包含终点", start: tt(0, 7), end: tt(1, 30),
step: 30 * time.Minute, maxLevels: 64,
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
},
{
name: "上限截断", start: tt(0, 7), end: tt(5, 7),
step: 30 * time.Minute, maxLevels: 3,
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
},
{
name: "窗口内没有整刻度时为空", start: tt(0, 1), end: tt(0, 20),
step: time.Hour, maxLevels: 64,
want: nil,
},
{
name: "非正步长或倒置窗口为空", start: tt(0, 7), end: tt(2, 7),
step: 0, maxLevels: 64, want: nil,
},
}
for _, testCase := range cases {
levels := greatestTimeContourAlignedLevels(testCase.start, testCase.end, testCase.step, testCase.maxLevels)
if len(levels) != len(testCase.want) {
t.Fatalf("%s: %d levels, want %d", testCase.name, len(levels), len(testCase.want))
}
for index, level := range levels {
got := level.at.UTC().Format(time.RFC3339)
if got != testCase.want[index] {
t.Fatalf("%s: level %d at %s, want %s", testCase.name, index, got, testCase.want[index])
}
// 标注时刻与求根时刻必须成对:TT 往返 1 ULP 内一致。
if roundTrip := greatestTimeContourUTCToTT(level.at); roundTrip != level.tt {
t.Fatalf("%s: level %d tt %.9f != %.9f", testCase.name, index, level.tt, roundTrip)
}
}
}
}
+272
View File
@@ -0,0 +1,272 @@
package basic
import (
"math"
"sort"
)
const (
schaeferNightSkyNanolambert = 180.0
schaeferNanolambertToErg = 1.02e-15
schaeferSunMagnitude = -26.74
schaeferZeroMagnitudeRef = -11.05
schaeferMinSeparationDeg = 0.1
schaeferTwilightLowSunDeg = -3.0
schaeferDaylightHighSunDeg = 4.0
schaeferScotopicLimitNL = 1479.1
)
// HeliacalOptions 偕日可见计算参数;零值使用消光系数 0.25 星等/大气质量、目标最低高度 0.5° / heliacal visibility options.
type HeliacalOptions struct {
// ExtinctionCoefficient 大气消光系数(星等/大气质量)/ extinction in magnitudes per airmass.
ExtinctionCoefficient float64
// MinTargetAltitude 目标最低几何高度(度)/ minimum geometric target altitude in degrees.
MinTargetAltitude float64
// Moonlight 为 true 时计入月光散射(Krisciunas–Schaefer);默认不计 / include scattered moonlight when true.
Moonlight bool
}
func (options HeliacalOptions) normalized() HeliacalOptions {
if !(options.ExtinctionCoefficient > 0) {
options.ExtinctionCoefficient = 0.25
}
if !(options.MinTargetAltitude > 0) {
options.MinTargetAltitude = 0.5
}
return options
}
func schaeferAirmass(altitudeDeg float64) float64 {
z := math.Min(90, math.Max(0, 90-altitudeDeg)) * rad
c := math.Cos(z)
return 1 / (c + 0.025*math.Exp(-11*c))
}
func schaeferTransmission(k, airmass float64) float64 { return math.Pow(10, -0.4*k*airmass) }
func schaeferPhase(separationDeg float64) float64 {
s := math.Max(schaeferMinSeparationDeg, separationDeg)
c := math.Cos(s * rad)
return 6.2e7/(s*s) + math.Pow(10, 6.15-s/40) + math.Pow(10, 5.36)*(1.06+c*c)
}
func validExtinction(extinction float64) bool {
return extinction > 0 && !math.IsInf(extinction, 0)
}
// SkyBrightnessNanolambert 无月夜晨昏天光亮度(nL,Schaefer 模型),消光系数须为正,否则返回 NaN / twilight sky brightness without moonlight, Schaefer model.
func SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
k := extinction
targetT := schaeferTransmission(k, schaeferAirmass(targetAltitude))
sunT := schaeferTransmission(k, schaeferAirmass(sunAltitude))
targetZ := math.Min(90, math.Max(0, 90-targetAltitude))
sunZ := math.Min(180, math.Max(0, 90-sunAltitude))
sep := math.Max(schaeferMinSeparationDeg, separation)
scale := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef+43.27))
day := scale * (1 - targetT) * (schaeferPhase(sep)*sunT + 440000*(1-sunT))
twilight := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef-57.5+sunZ-targetZ/(360*k))) *
(1 - targetT) * 100 / sep
sky := math.Min(day, twilight)
switch {
case sunAltitude < schaeferTwilightLowSunDeg:
sky = twilight
case sunAltitude > schaeferDaylightHighSunDeg:
sky = day
}
sz := math.Sin(targetZ * rad)
night := schaeferNightSkyNanolambert * (0.4 + 0.6/math.Sqrt(1-0.96*sz*sz)) * targetT
return sky/schaeferNanolambertToErg + night
}
// LimitingMagnitude 肉眼极限星等(已扣除目标方向的大气消光),消光系数须为正,否则返回 NaN / naked-eye limiting magnitude after extinction at the target altitude.
func LimitingMagnitude(targetAltitude, sunAltitude, separation, extinction float64) float64 {
return limitingMagnitudeForSky(SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction), targetAltitude, extinction)
}
// MoonlightNanolambert 月光在目标方向造成的天光亮度(nL,Krisciunas–Schaefer);月在地平下为 0,消光系数非正为 NaN /
// sky brightness from scattered moonlight; zero when the Moon is below the horizon.
func MoonlightNanolambert(targetAltitude, moonAltitude, moonSeparation, moonPhaseAngle, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
if moonAltitude <= 0 || targetAltitude <= 0 {
return 0
}
alpha := math.Abs(moonPhaseAngle)
mag := -12.73 + 0.026*alpha + 4e-9*math.Pow(alpha, 4)
illuminance := math.Pow(10, -0.4*(mag+16.57))
rho := math.Max(schaeferMinSeparationDeg, moonSeparation)
c := math.Cos(rho * rad)
scatter := math.Pow(10, 5.36)*(1.06+c*c) + math.Pow(10, 6.15-rho/40)
airmass := func(alt float64) float64 {
s := math.Sin((90 - alt) * rad)
return 1 / math.Sqrt(1-0.96*s*s)
}
return scatter * illuminance * math.Pow(10, -0.4*extinction*airmass(moonAltitude)) *
(1 - math.Pow(10, -0.4*extinction*airmass(targetAltitude)))
}
func limitingMagnitudeForSky(b, targetAltitude, extinction float64) float64 {
c, k := 4.4668359215096e-9, 1.2589254117942e-6
if b < schaeferScotopicLimitNL {
c, k = 1.5848931924611e-10, 0.012589254117942
}
threshold := c * math.Pow(1+math.Sqrt(k*b), 2)
return -16.57 - 2.5*math.Log10(threshold) - extinction*schaeferAirmass(targetAltitude)
}
// HeliacalEventKind 偕日事件类型 / heliacal event kind.
type HeliacalEventKind int
const (
// HeliacalMorningFirst 晨始见(偕日升)/ morning first visibility.
HeliacalMorningFirst HeliacalEventKind = iota
// HeliacalMorningLast 晨末见 / morning last visibility.
HeliacalMorningLast
// HeliacalEveningFirst 昏始见 / evening first visibility.
HeliacalEveningFirst
// HeliacalEveningLast 昏末见(偕日落)/ evening last visibility.
HeliacalEveningLast
)
// HeliacalEvent 偕日事件:JD 为当天最佳可见时刻(UT 儒略日),Margin 为极限星等减目标星等 / heliacal event at the best moment of that twilight.
type HeliacalEvent struct {
Kind HeliacalEventKind
JD float64
Margin float64
TargetAltitude float64
SunAltitude float64
}
// heliacalTarget 返回 TT 时刻目标的视赤经、赤纬(度)与视星等。
type heliacalTarget func(jde float64) (ra, dec, magnitude float64)
type heliacalBest struct {
jd, margin, targetAlt, sunAlt float64
visible bool
}
func heliacalAltitude(ra, dec, lst, lat float64) float64 {
h := (lst - ra) * rad
d, p := dec*rad, lat*rad
return math.Asin(math.Sin(p)*math.Sin(d)+math.Cos(p)*math.Cos(d)*math.Cos(h)) / rad
}
// heliacalTwilight 扫描一次晨(morning)或昏的太阳高度 −18°..0° 时段,取可见余量最大的样本。
// 日月位置在窗口中点取一次,窗口不足两小时,引入的高度误差远小于模型本身。
func heliacalTwilight(dayJD, lon, lat float64, morning bool, target heliacalTarget, options HeliacalOptions, inst *Instance) heliacalBest {
center := dayJD - lon/360 + 0.5
if morning {
center -= 0.25
} else {
center += 0.25
}
jde := UTC2TT(center)
sunRA, sunDec := HSunApparentRaDecWithInstance(jde, inst)
ra, dec, mag := target(jde)
sep := skyAngularDistance(ra, dec, sunRA, sunDec)
best := heliacalBest{margin: math.Inf(-1)}
if math.IsNaN(ra) || math.IsNaN(mag) {
return best
}
var moonRA, moonDec, moonSep, moonPhase float64
if options.Moonlight {
lo, bo := SkyBodyApparentLoBoWithInstance(SkyMoon, jde, inst)
moonRA, moonDec = LoBoToRaDec(jde, lo, bo)
moonSep = skyAngularDistance(ra, dec, moonRA, moonDec)
moonPhase = 180 - skyAngularDistance(moonRA, moonDec, sunRA, sunDec)
}
lst0 := ApparentSiderealTime(UTC2UT1(center))*15 + lon
const step = 2.0 / 1440
for dt := -0.25; dt <= 0.25; dt += step {
lst := lst0 + dt*360.98564736629
sunAlt := heliacalAltitude(sunRA, sunDec, lst, lat)
if sunAlt > 0 || sunAlt < -18 {
continue
}
alt := heliacalAltitude(ra, dec, lst, lat)
if alt < options.MinTargetAltitude {
continue
}
sky := SkyBrightnessNanolambert(alt, sunAlt, sep, options.ExtinctionCoefficient)
if options.Moonlight {
sky += MoonlightNanolambert(alt, heliacalAltitude(moonRA, moonDec, lst, lat), moonSep, moonPhase, options.ExtinctionCoefficient)
}
margin := limitingMagnitudeForSky(sky, alt, options.ExtinctionCoefficient) - mag
if margin > best.margin {
best = heliacalBest{center + dt, margin, alt, sunAlt, margin >= 0}
}
}
return best
}
// heliacalMaxDays 逐日扫描的天数上限(约 2.3 万年),挡住非法跨度造成的近乎无界扫描。
const heliacalMaxDays = 1 << 23
// findHeliacalEvents 逐日扫描晨、昏可见性,从不可见变为可见记“始见”,反之记“末见”(取末个可见日)。
func findHeliacalEvents(startJD, endJD, lon, lat float64, target heliacalTarget, options HeliacalOptions, inst *Instance) []HeliacalEvent {
options = options.normalized()
if !finite(startJD) || !finite(endJD) || !(endJD > startJD) || math.Abs(lat) >= 90 {
return nil
}
day0 := math.Floor(startJD-0.5) + 0.5
span := math.Floor(endJD - day0)
if !(span < heliacalMaxDays) {
return nil
}
days := int(span)
var out []HeliacalEvent
for _, morning := range []bool{true, false} {
first, last := HeliacalEveningFirst, HeliacalEveningLast
if morning {
first, last = HeliacalMorningFirst, HeliacalMorningLast
}
prev := heliacalTwilight(day0-1, lon, lat, morning, target, options, inst)
for i := 0; i <= days; i++ {
cur := heliacalTwilight(day0+float64(i), lon, lat, morning, target, options, inst)
// 事件时刻是采样日内的细化值,可能越过 endJD,所以上界按事件时刻判断,不能按采样日判断。
switch {
case cur.visible && !prev.visible && cur.jd >= startJD && cur.jd <= endJD:
out = append(out, HeliacalEvent{first, cur.jd, cur.margin, cur.targetAlt, cur.sunAlt})
case !cur.visible && prev.visible && prev.jd >= startJD && prev.jd <= endJD:
out = append(out, HeliacalEvent{last, prev.jd, prev.margin, prev.targetAlt, prev.sunAlt})
}
prev = cur
}
}
sortHeliacalEvents(out)
return out
}
func sortHeliacalEvents(events []HeliacalEvent) {
sort.SliceStable(events, func(i, j int) bool { return events[i].JD < events[j].JD })
}
// FindPlanetHeliacalEventsWithInstance 行星偕日事件(UT 儒略日范围,经度东正)/ planet heliacal events in a UT JD range.
func FindPlanetHeliacalEventsWithInstance(body SkyBody, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if body < SkyMercury || body > SkyNeptune {
return nil
}
index := int(body-SkyMercury) + 1
target := func(jde float64) (float64, float64, float64) {
lo, bo := SkyBodyApparentLoBoWithInstance(body, jde, inst)
ra, dec := LoBoToRaDec(jde, lo, bo)
return ra, dec, planetMagnitude2018(index, jde, inst)
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}
// FindStarHeliacalEventsWithInstance 恒星偕日事件;magnitude 为恒星 V 星等 / star heliacal events with the star's V magnitude.
func FindStarHeliacalEventsWithInstance(star StarCoordinate, magnitude, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if math.IsNaN(magnitude) || math.IsInf(magnitude, 0) {
return nil
}
target := func(jde float64) (float64, float64, float64) {
ra, dec := starApparentRaDecGeocentric(jde, star, inst)
return ra, dec, magnitude
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}
+138
View File
@@ -0,0 +1,138 @@
// 被测契约:天狼星在开罗约 8 月初晨始见;金星下合前后依次出现昏末见与晨始见;极限星等随天光单调变化;偕日事件不得越出请求区间。
package basic
import (
"math"
"testing"
"time"
)
func TestSiriusHeliacalRisingCairo(t *testing.T) {
sirius := StarCoordinate{ID: "Sirius", RA: 101.28715533, Dec: -16.71611586, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -546.01, ProperMotionDecMasPerYear: -1223.07}
start := Date2JD(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC))
end := Date2JD(time.Date(2025, 9, 30, 0, 0, 0, 0, time.UTC))
events := FindStarHeliacalEventsWithInstance(sirius, -1.46, start, end, 31.23, 30.04, HeliacalOptions{}, nil)
var rising *HeliacalEvent
for i := range events {
if events[i].Kind == HeliacalMorningFirst {
rising = &events[i]
break
}
}
if rising == nil {
t.Fatalf("no morning first visibility: %+v", events)
}
when := JD2Date(rising.JD)
t.Logf("Sirius morning first %v margin %.2f alt %.2f sun %.2f", when, rising.Margin, rising.TargetAltitude, rising.SunAltitude)
if when.Month() != time.July && when.Month() != time.August {
t.Errorf("Sirius heliacal rising %v, expected late July to mid August", when)
}
}
func TestVenusHeliacalAroundInferiorConjunction(t *testing.T) {
// 2025-03-23 金星下合。
start := Date2JD(time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC))
end := Date2JD(time.Date(2025, 4, 30, 0, 0, 0, 0, time.UTC))
events := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, end, 116.4, 39.9, HeliacalOptions{}, nil)
var eveningLast, morningFirst float64
for _, e := range events {
t.Logf("kind %d %v margin %.2f", e.Kind, JD2Date(e.JD), e.Margin)
if e.Kind == HeliacalEveningLast && eveningLast == 0 {
eveningLast = e.JD
}
if e.Kind == HeliacalMorningFirst && morningFirst == 0 {
morningFirst = e.JD
}
}
conj := Date2JD(time.Date(2025, 3, 23, 0, 0, 0, 0, time.UTC))
if eveningLast == 0 || morningFirst == 0 || !(eveningLast < conj && morningFirst > conj) {
t.Errorf("evening last %.1f, morning first %.1f around conjunction %.1f", eveningLast, morningFirst, conj)
}
}
func TestLimitingMagnitudeDarkensWithTwilight(t *testing.T) {
prev := LimitingMagnitude(20, -2, 30, 0.25)
for _, sun := range []float64{-4, -6, -9, -12, -15, -18} {
cur := LimitingMagnitude(20, sun, 30, 0.25)
if !(cur > prev) {
t.Errorf("sun %.0f: limiting magnitude %.2f not fainter than %.2f", sun, cur, prev)
}
prev = cur
}
if prev < 5 || prev > 7.5 {
t.Errorf("dark-sky limiting magnitude at 20° altitude %.2f", prev)
}
}
func TestMoonlightBrightensSky(t *testing.T) {
if MoonlightNanolambert(30, -5, 20, 10, 0.25) != 0 {
t.Error("moon below horizon must contribute nothing")
}
full := MoonlightNanolambert(30, 40, 20, 5, 0.25)
crescent := MoonlightNanolambert(30, 40, 20, 120, 0.25)
if !(full > crescent && crescent > 0 && full > 1000) {
t.Errorf("full %.1f crescent %.1f nL", full, crescent)
}
start := 2460676.5
plain := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, start+400, 116.4, 39.9, HeliacalOptions{}, nil)
lit := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, start+400, 116.4, 39.9, HeliacalOptions{Moonlight: true}, nil)
if len(plain) == 0 || len(lit) == 0 {
t.Fatalf("events %d %d", len(plain), len(lit))
}
}
func TestHeliacalRejectsUnscannableRange(t *testing.T) {
sirius := StarCoordinate{RA: 101.287, Dec: -16.716}
for _, r := range [][2]float64{
{2451545, math.Inf(1)}, {math.Inf(-1), 2451545}, {math.NaN(), 2451545},
{2451545, math.NaN()}, {2451545, 2451545 + 1e10}, {1e300, math.MaxFloat64},
} {
if got := FindStarHeliacalEventsWithInstance(sirius, -1.46, r[0], r[1], 31.23, 30.04, HeliacalOptions{}, nil); got != nil {
t.Errorf("star %v: %d events", r, len(got))
}
if got := FindPlanetHeliacalEventsWithInstance(SkyVenus, r[0], r[1], 116.4, 39.9, HeliacalOptions{}, nil); got != nil {
t.Errorf("planet %v: %d events", r, len(got))
}
}
}
func TestSkyModelRejectsNonPositiveExtinction(t *testing.T) {
for _, k := range []float64{0, -1, math.NaN(), math.Inf(1)} {
if v := SkyBrightnessNanolambert(20, -10, 30, k); !math.IsNaN(v) {
t.Errorf("sky k=%g: %g", k, v)
}
if v := LimitingMagnitude(20, -10, 30, k); !math.IsNaN(v) {
t.Errorf("limit k=%g: %g", k, v)
}
if v := MoonlightNanolambert(30, 40, 20, 5, k); !math.IsNaN(v) {
t.Errorf("moonlight k=%g: %g", k, v)
}
}
}
func TestHeliacalEventsStayWithinRequestedRange(t *testing.T) {
lon, lat := 116.4, 39.9
narrow := 0
for _, body := range []SkyBody{SkyMercury, SkyVenus, SkyMars, SkyJupiter, SkySaturn} {
wide := FindPlanetHeliacalEventsWithInstance(body, 2459000.5, 2460000.5, lon, lat, HeliacalOptions{}, nil)
for _, event := range wide {
// 事件时刻是采样日内的细化值;把 endJD 卡在两者之间,正是越界最容易出现的窗口。
day := math.Floor(event.JD-0.5) + 0.5
offset := event.JD - day
if offset <= 0.1 {
continue
}
start, end := day, day+offset-0.05
narrow++
for _, got := range FindPlanetHeliacalEventsWithInstance(body, start, end, lon, lat, HeliacalOptions{}, nil) {
if got.JD < start || got.JD > end {
t.Fatalf("body %v: event JD %.6f outside [%.6f, %.6f]", body, got.JD, start, end)
}
}
}
}
if narrow == 0 {
t.Fatal("no narrow window was exercised")
}
}
+32
View File
@@ -0,0 +1,32 @@
package basic
import "b612.me/astro/internal/iau2006"
// RotationMatrix 3×3 旋转矩阵,按行存放,作用于列向量 / 3×3 rotation matrix, row-major, acting on column vectors.
type RotationMatrix = iau2006.Matrix
// Nutation2000A IAU 2000A 章动,返回 (黄经章动, 交角章动),单位度;不进入默认计算链 / IAU 2000A nutation in degrees, opt-in only.
func Nutation2000A(jde float64) (float64, float64) {
dpsi, deps := iau2006.Nutation(jde)
return dpsi / 3600, deps / 3600
}
// MeanObliquity2006 IAU 2006 平黄赤交角(度)/ IAU 2006 mean obliquity in degrees.
func MeanObliquity2006(jde float64) float64 { return iau2006.MeanObliquity(jde) / 3600 }
// EquationOfEquinoxes2006 春分点方程主项 Δψ·cos εA(度),按 IAU 2000A 章动 / equation of the equinoxes in degrees, IAU 2000A.
func EquationOfEquinoxes2006(jde float64) float64 { return iau2006.EquationOfEquinoxes(jde) / 3600 }
// Precession2006Matrix J2000 平赤道到历元平赤道的 IAU 2006 岁差矩阵 / IAU 2006 precession matrix.
func Precession2006Matrix(jde float64) RotationMatrix { return iau2006.Precession(jde) }
// Nutation2000AMatrix 历元平赤道到真赤道的 IAU 2000A 章动矩阵 / IAU 2000A nutation matrix.
func Nutation2000AMatrix(jde float64) RotationMatrix { return iau2006.NutationMatrix(jde) }
// FrameBiasMatrix ICRS 到 J2000 平赤道的框架偏差矩阵 / frame bias from ICRS to the J2000 mean equator.
func FrameBiasMatrix() RotationMatrix { return iau2006.FrameBias() }
// BiasPrecessionNutation2006 ICRS 到真赤道真春分点的完整旋转 N·P·B / full ICRS to true-of-date rotation.
func BiasPrecessionNutation2006(jde float64) RotationMatrix {
return iau2006.BiasPrecessionNutation(jde)
}
+25
View File
@@ -0,0 +1,25 @@
// 被测契约:2000A 与默认 2000B 章动差在 1 mas 量级,单位换算正确。
package basic
import (
"math"
"testing"
)
func TestNutation2000ANearDefault(t *testing.T) {
for _, jde := range []float64{2451545.0, 2460676.5, 2415020.0, 2488069.5} {
a1, a2 := Nutation2000A(jde)
b1, b2 := Nutation2000B(jde)
if d := math.Max(math.Abs(a1-b1), math.Abs(a2-b2)) * 3600e3; d > 2 {
t.Errorf("jde %.1f: 2000A vs 2000B differ by %.3f mas", jde, d)
}
}
if d := math.Abs(MeanObliquity2006(2451545.0)*3600 - 84381.406); d > 1e-9 {
t.Errorf("J2000 obliquity off by %g arcsec", d)
}
m := BiasPrecessionNutation2006(2460676.5)
v := m.Apply([3]float64{1, 0, 0})
if n := math.Sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); math.Abs(n-1) > 1e-14 {
t.Errorf("rotation not orthonormal: %g", n)
}
}
+189
View File
@@ -0,0 +1,189 @@
package basic
import "math"
const (
innerEventEpsilon = 0.1 / 86400.0
innerEventWindowPadding = 4.0 / 86400.0
innerEventMaximizeEpsilon = 4.0 / 86400.0
)
func eventQueryTTAsUT(queryTT float64) float64 {
return TT2UTC(queryTT)
}
func eventUTQueryTTDelta(eventUT, queryTT float64) float64 {
return eventUT - eventQueryTTAsUT(queryTT)
}
func eventUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= innerEventEpsilon
}
func eventUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -innerEventEpsilon
}
func eventUTNextQueryTT(eventUT float64) float64 {
return UTC2TT(eventUT) + 1.0
}
func eventUTLastQueryTT(eventUT float64) float64 {
return UTC2TT(eventUT) - 1.0
}
func innerNextCycleOffset(delta, period float64) float64 {
if delta <= 0 {
return -delta * period / 360.0
}
return (360.0 - delta) * period / 360.0
}
func innerLastCycleOffset(delta, period float64) float64 {
if delta >= 0 {
return delta * period / 360.0
}
return (360.0 + delta) * period / 360.0
}
func clampFloat64(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func scanWindowForMinAbs(start, end, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if step <= 0 || end == start {
return start
}
bestJD := start
bestAbs := math.Abs(fn(start))
for jd := start + step; jd < end; jd += step {
candidateAbs := math.Abs(fn(jd))
if candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = jd
}
}
endAbs := math.Abs(fn(end))
if endAbs < bestAbs {
return end
}
return bestJD
}
func scanWindowForMax(start, end, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if step <= 0 || end == start {
return start
}
bestJD := start
bestVal := fn(start)
for jd := start + step; jd < end; jd += step {
candidateVal := fn(jd)
if candidateVal > bestVal {
bestVal = candidateVal
bestJD = jd
}
}
endVal := fn(end)
if endVal > bestVal {
return end
}
return bestJD
}
func boundedEventZeroRefine(seed, start, end, halfWindow, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
maxHalfWindow := (end - start) / 2
if halfWindow > maxHalfWindow {
halfWindow = maxHalfWindow
}
if halfWindow <= 0 {
return clampFloat64(seed, start, end)
}
seed = clampFloat64(seed, start+halfWindow, end-halfWindow)
return eventZeroRefine(seed, halfWindow, step, fn)
}
func zeroEventInWindow(start, end, coarseStep, halfWindow, refineStep float64, coarseFn, exactFn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
rangeDays := end - start
if coarseStep <= 0 || coarseStep > rangeDays {
coarseStep = rangeDays / 6.0
}
if coarseStep < 0.5 {
coarseStep = 0.5
}
if refineStep <= 0 {
refineStep = 0.5 / 86400.0
}
if halfWindow <= 0 {
halfWindow = coarseStep
}
guess := scanWindowForMinAbs(start, end, coarseStep, coarseFn)
return boundedEventZeroRefine(guess, start, end, halfWindow, refineStep, exactFn)
}
func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
rangeDays := end - start
if coarseStep <= 0 || coarseStep > rangeDays {
coarseStep = rangeDays / 6.0
}
if coarseStep < 0.5 {
coarseStep = 0.5
}
guess := scanWindowForMax(start, end, coarseStep, coarseFn)
left := clampFloat64(guess-coarseStep, start, end)
right := clampFloat64(guess+coarseStep, start, end)
if right-left <= innerEventMaximizeEpsilon {
return guess
}
// 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位
// 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。
for i := 0; i < 20; i++ {
third := (right - left) / 3.0
leftThird := left + third
rightThird := right - third
if exactFn(leftThird) <= exactFn(rightThird) {
left = leftThird
continue
}
right = rightThird
}
bestJD := guess
bestVal := exactFn(bestJD)
for _, jd := range []float64{left, (left + right) / 2.0, right} {
candidateVal := exactFn(jd)
if candidateVal > bestVal {
bestVal = candidateVal
bestJD = jd
}
}
return bestJD
}
+105
View File
@@ -0,0 +1,105 @@
package basic
import (
"testing"
"time"
)
func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
name string
seed float64
lastFn func(float64) float64
nextFn func(float64) float64
}{
{name: "MercuryConjunction", seed: NextMercuryConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryConjunction, nextFn: NextMercuryConjunction},
{name: "MercuryInferior", seed: NextMercuryInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryInferiorConjunctionInclusive, nextFn: NextMercuryInferiorConjunctionInclusive},
{name: "MercurySuperior", seed: NextMercurySuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercurySuperiorConjunctionInclusive, nextFn: NextMercurySuperiorConjunctionInclusive},
{name: "MercuryRetrograde", seed: NextMercuryRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeInclusive, nextFn: NextMercuryRetrogradeInclusive},
{name: "MercuryP2R", seed: NextMercuryProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryProgradeToRetrogradeInclusive, nextFn: NextMercuryProgradeToRetrogradeInclusive},
{name: "MercuryR2P", seed: NextMercuryRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeToProgradeInclusive, nextFn: NextMercuryRetrogradeToProgradeInclusive},
{name: "MercuryGreatestElongation", seed: NextMercuryGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationInclusive, nextFn: NextMercuryGreatestElongationInclusive},
{name: "MercuryEastElongation", seed: NextMercuryGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationEastInclusive, nextFn: NextMercuryGreatestElongationEastInclusive},
{name: "MercuryWestElongation", seed: NextMercuryGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationWestInclusive, nextFn: NextMercuryGreatestElongationWestInclusive},
{name: "VenusConjunction", seed: NextVenusConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusConjunction, nextFn: NextVenusConjunction},
{name: "VenusInferior", seed: NextVenusInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusInferiorConjunctionInclusive, nextFn: NextVenusInferiorConjunctionInclusive},
{name: "VenusSuperior", seed: NextVenusSuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusSuperiorConjunctionInclusive, nextFn: NextVenusSuperiorConjunctionInclusive},
{name: "VenusRetrograde", seed: NextVenusRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeInclusive, nextFn: NextVenusRetrogradeInclusive},
{name: "VenusP2R", seed: NextVenusProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusProgradeToRetrogradeInclusive, nextFn: NextVenusProgradeToRetrogradeInclusive},
{name: "VenusR2P", seed: NextVenusRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeToProgradeInclusive, nextFn: NextVenusRetrogradeToProgradeInclusive},
{name: "VenusGreatestElongation", seed: NextVenusGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationInclusive, nextFn: NextVenusGreatestElongationInclusive},
{name: "VenusEastElongation", seed: NextVenusGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationEastInclusive, nextFn: NextVenusGreatestElongationEastInclusive},
{name: "VenusWestElongation", seed: NextVenusGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationWestInclusive, nextFn: NextVenusGreatestElongationWestInclusive},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := UTC2TT(tc.seed)
last := tc.lastFn(queryTT)
next := tc.nextFn(queryTT)
if !sameEventJD(last, tc.seed) {
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
}
if !sameEventJD(next, tc.seed) {
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
}
})
}
}
func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
}{
{name: "MercuryConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryConjunction},
{name: "MercuryInferior", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryInferiorConjunction},
{name: "MercuryP2R", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryProgradeToRetrograde},
{name: "MercuryEastElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryGreatestElongationEast},
{name: "VenusConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusConjunction},
{name: "VenusWestElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusGreatestElongationWest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
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)
}
if sameEventJD(next, first) {
t.Fatalf("next should advance past first event: first=%.12f next=%.12f", first, next)
}
})
}
}
func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
last func(float64) float64
}{
{name: "MercuryInferior", seed: NextMercuryInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercuryInferiorConjunction, last: LastMercuryInferiorConjunction},
{name: "MercurySuperior", seed: NextMercurySuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercurySuperiorConjunction, last: LastMercurySuperiorConjunction},
{name: "VenusInferior", seed: NextVenusInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusInferiorConjunction, last: LastVenusInferiorConjunction},
{name: "VenusSuperior", seed: NextVenusSuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusSuperiorConjunction, last: LastVenusSuperiorConjunction},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := UTC2TT(tc.seed)
last := tc.last(queryTT)
next := tc.next(queryTT)
if !sameEventJD(last, tc.seed) {
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
}
if !sameEventJD(next, tc.seed) {
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
}
})
}
}
+177
View File
@@ -0,0 +1,177 @@
package basic
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
type innerBaselineFile struct {
Events []innerBaselineEvent `json:"events"`
}
type innerBaselineEvent struct {
Planet string `json:"planet"`
Kind string `json:"kind"`
NAOJHintJST string `json:"naoj_hint_jst"`
Precision string `json:"precision"`
CandidateJST string `json:"candidate_jst"`
VerifiedJST string `json:"verified_jst"`
CandidateSource string `json:"candidate_source"`
}
func loadInnerBaseline(t *testing.T) innerBaselineFile {
t.Helper()
paths := [][]string{
{
"testdata/jpl_inner_event_baseline.json",
"basic/testdata/jpl_inner_event_baseline.json",
},
{
"testdata/jpl_inner_event_baseline_21c_sample.json",
"basic/testdata/jpl_inner_event_baseline_21c_sample.json",
},
{
"testdata/jpl_inner_event_baseline_20c_sample.json",
"basic/testdata/jpl_inner_event_baseline_20c_sample.json",
},
{
"testdata/jpl_inner_event_baseline_22c_sample.json",
"basic/testdata/jpl_inner_event_baseline_22c_sample.json",
},
}
var merged innerBaselineFile
for index, candidates := range paths {
var (
data []byte
err error
)
for _, path := range candidates {
data, err = os.ReadFile(path)
if err == nil {
var baseline innerBaselineFile
if err := json.Unmarshal(data, &baseline); err != nil {
t.Fatal(err)
}
merged.Events = append(merged.Events, baseline.Events...)
break
}
}
if err != nil && index == 0 {
t.Fatal(err)
}
}
if len(merged.Events) == 0 {
t.Fatal("empty inner baseline file")
}
return merged
}
func parseInnerBaselineTime(t *testing.T, value string) time.Time {
t.Helper()
loc := time.FixedZone("JST", 9*3600)
layouts := []string{
"2006-01-02 15:04:05 MST",
"2006-01-02 15:04 MST",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
}
var err error
for _, layout := range layouts {
when, parseErr := time.ParseInLocation(layout, value, loc)
if parseErr == nil {
return when
}
err = parseErr
}
t.Fatalf("parse baseline time %q: %v", value, err)
return time.Time{}
}
func innerBaselineTolerance(event innerBaselineEvent) time.Duration {
switch event.Kind {
case "IC", "SC":
// 2197 年附近双方 ΔT 外推已差约 2 分钟(校正了 EarthPI 的 0.00046·T² 项之后,
// 三个 2197 样本从 −119.5 s 移到 −121…−126 s),因此与留点同样留 3 分钟余量;
// 真实的搜索错误是小时/天级,靠这个容差仍能抓住。
// Around 2197 the two ΔT extrapolations already differ by about two minutes (after
// correcting the EarthPI 0.00046*T^2 term, three 2197 samples moved from -119.5 s to
// -121..-126 s), so conjunctions get the same 3-minute margin as the stations.
// Real search errors are hours or days, so this still catches them.
return 3 * time.Minute
case "P2R", "R2P":
// 留是 RA 的极值点:星历模型中 ~1e-6 度/天的变化率差异就会被放大成分钟级时刻差,
// 且 JPL 给出的 UT 时刻还叠加了各自的 ΔT 外推(2197 年附近两者已差 ~2 分钟)。
// 这里留 3 分钟余量,仍远小于任何真实的搜索错误(小时/天级)。
return 3 * time.Minute
case "GEE", "GEW":
return 90 * time.Minute
default:
return 2 * time.Minute
}
}
func innerEventFuncs(t *testing.T, event innerBaselineEvent) (func(float64) float64, func(float64) float64) {
t.Helper()
switch event.Planet + ":" + event.Kind {
case "Mercury:IC":
return LastMercuryInferiorConjunctionInclusive, NextMercuryInferiorConjunctionInclusive
case "Mercury:SC":
return LastMercurySuperiorConjunctionInclusive, NextMercurySuperiorConjunctionInclusive
case "Mercury:P2R":
return LastMercuryProgradeToRetrogradeInclusive, NextMercuryProgradeToRetrogradeInclusive
case "Mercury:R2P":
return LastMercuryRetrogradeToProgradeInclusive, NextMercuryRetrogradeToProgradeInclusive
case "Mercury:GEE":
return LastMercuryGreatestElongationEastInclusive, NextMercuryGreatestElongationEastInclusive
case "Mercury:GEW":
return LastMercuryGreatestElongationWestInclusive, NextMercuryGreatestElongationWestInclusive
case "Venus:IC":
return LastVenusInferiorConjunctionInclusive, NextVenusInferiorConjunctionInclusive
case "Venus:SC":
return LastVenusSuperiorConjunctionInclusive, NextVenusSuperiorConjunctionInclusive
case "Venus:P2R":
return LastVenusProgradeToRetrogradeInclusive, NextVenusProgradeToRetrogradeInclusive
case "Venus:R2P":
return LastVenusRetrogradeToProgradeInclusive, NextVenusRetrogradeToProgradeInclusive
case "Venus:GEE":
return LastVenusGreatestElongationEastInclusive, NextVenusGreatestElongationEastInclusive
case "Venus:GEW":
return LastVenusGreatestElongationWestInclusive, NextVenusGreatestElongationWestInclusive
default:
t.Fatalf("unsupported event %s:%s", event.Planet, event.Kind)
return nil, nil
}
}
func assertInnerBaselineEvent(t *testing.T, event innerBaselineEvent, lastFn, nextFn func(float64) float64) {
t.Helper()
when := parseInnerBaselineTime(t, event.VerifiedJST)
before := when.Add(-24 * time.Hour)
after := when.Add(24 * time.Hour)
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 {
t.Fatalf("%s %s next mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, next, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
}
if diff := last.Sub(when); diff < -tolerance || diff > tolerance {
t.Fatalf("%s %s last mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, last, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
}
}
func TestInnerPlanetTruthAgainstJPL(t *testing.T) {
baseline := loadInnerBaseline(t)
for _, event := range baseline.Events {
event := event
name := strings.Join([]string{event.Planet, event.Kind, event.VerifiedJST}, "_")
t.Run(name, func(t *testing.T) {
lastFn, nextFn := innerEventFuncs(t, event)
assertInnerBaselineEvent(t, event, lastFn, nextFn)
})
}
}
+185
View File
@@ -0,0 +1,185 @@
package basic
import (
"errors"
"math"
"time"
)
var ErrInvalidCivilDate = errors.New("invalid civil date")
var timeNow = time.Now
// 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 JDCalc(date.Year(), int(date.Month()), day)
}
func ValidateCivilDate(year, month int, day float64) error {
if math.IsNaN(day) || math.IsInf(day, 0) {
return ErrInvalidCivilDate
}
if month < 1 || month > 12 {
return ErrInvalidCivilDate
}
if day < 1 {
return ErrInvalidCivilDate
}
dayInt := int(math.Floor(day))
if dayInt < 1 || dayInt > daysInCivilMonth(year, month) {
return ErrInvalidCivilDate
}
if isGregorianReformGap(year, month, day) {
return ErrInvalidCivilDate
}
return nil
}
func isGregorianReformGap(year, month int, day float64) bool {
return year == 1582 && month == 10 && day >= 5 && day < 15
}
func daysInCivilMonth(year, month int) int {
switch month {
case 1, 3, 5, 7, 8, 10, 12:
return 31
case 4, 6, 9, 11:
return 30
case 2:
if isCivilLeapYear(year, month, 1) {
return 29
}
return 28
default:
return 0
}
}
func isCivilLeapYear(year, month int, day float64) bool {
if year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day <= 4))) {
return year%4 == 0
}
if year%400 == 0 {
return true
}
if year%100 == 0 {
return false
}
return year%4 == 0
}
// 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()
}
effectiveYear, effectiveMonth, effectiveDay := year, month, int(math.Floor(day))
if month == 1 || month == 2 {
year--
month += 12
}
var gregorianCorrection int
if effectiveYear < 1582 || (effectiveYear == 1582 && (effectiveMonth < 10 || (effectiveMonth == 10 && effectiveDay <= 4))) {
gregorianCorrection = 0
} else {
century := int(year / 100)
gregorianCorrection = 2 - century + int(century/4)
}
return (math.Floor(365.25*(float64(year)+4716.0)) + math.Floor(30.6001*float64(month+1)) + day + float64(gregorianCorrection) - 1524.5)
}
// 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
nowJD = JDCalc(now.Year(), int(now.Month()), float64(now.Day())+dayFraction)
return
}
// 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
var a, b, years, months, days float64
if z < 2299161.0 {
a = z
} else {
alpha := math.Floor((z - 1867216.25) / 36524.25)
a = z + 1 + alpha - math.Floor(alpha/4)
}
b = a + 1524
c := math.Floor((b - 122.1) / 365.25)
d := math.Floor(365.25 * c)
e := math.Floor((b - d) / 30.6001)
days = b - d - math.Floor(30.6001*e) + f
if e < 14 {
months = e - 1
}
if e == 14 || e == 15 {
months = e - 13
}
if months > 2 {
years = c - 4716
}
if months == 1 || months == 2 {
years = c - 4715
}
tms := (days - math.Floor(days)) * 24 * 3600
days = math.Floor(days)
tz, _ := time.LoadLocation("Local")
dates := time.Date(int(years), time.Month(int(months)), int(days), 0, 0, 0, 0, tz)
return time.Unix(dates.Unix()+int64(tms), int64((tms-math.Floor(tms))*1000000000))
}
// JD2DateByZone 儒略日转日期
// jd: 儒略日
// tz: 目标时区
// byZone: (true: 传入的儒略日视为目标时区当地时间的儒略日,false: 传入的儒略日视为UTC时间的儒略日)
// 回参:转换后的日期,时区始终为目标时区
func JD2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time {
jd = jd + 0.5
z := float64(int(jd))
f := jd - z
var a, b, years, months, days float64
if z < 2299161.0 {
a = z
} else {
alpha := math.Floor((z - 1867216.25) / 36524.25)
a = z + 1 + alpha - math.Floor(alpha/4)
}
b = a + 1524
c := math.Floor((b - 122.1) / 365.25)
d := math.Floor(365.25 * c)
e := math.Floor((b - d) / 30.6001)
days = b - d - math.Floor(30.6001*e) + f
if e < 14 {
months = e - 1
}
if e == 14 || e == 15 {
months = e - 13
}
if months > 2 {
years = c - 4716
}
if months == 1 || months == 2 {
years = c - 4715
}
tms := (days - math.Floor(days)) * 24 * 3600
days = math.Floor(days)
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 {
return date.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)
}
+34
View File
@@ -0,0 +1,34 @@
package basic
import (
"math"
"testing"
"time"
)
func TestGetNowJDUsesSingleTimestamp(t *testing.T) {
oldTimeNow := timeNow
defer func() {
timeNow = oldTimeNow
}()
calls := 0
first := time.Date(2026, 4, 29, 23, 59, 59, 0, time.FixedZone("CST", 8*3600))
second := first.Add(2 * time.Second)
timeNow = func() time.Time {
calls++
if calls == 1 {
return first
}
return second
}
got := GetNowJD()
want := Date2JD(first)
if calls != 1 {
t.Fatalf("GetNowJD should read current time once, got %d calls", calls)
}
if math.Float64bits(got) != math.Float64bits(want) {
t.Fatalf("GetNowJD mismatch: got %.15f want %.15f", got, want)
}
}
+28
View File
@@ -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)
}
}
}
}
}
+113 -369
View File
@@ -7,431 +7,175 @@ 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)
sita := Sita(JD)
ra := math.Atan2((Sin(lo)*Cos(sita) - Tan(bo)*Sin(sita)), Cos(lo))
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)
sita := Sita(JD)
dec := ArcSin(Sin(bo)*Cos(sita) + Cos(bo)*Sin(sita)*Sin(lo))
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)
sita := Sita(JD)
ra := math.Atan2((Sin(lo)*Cos(sita) - Tan(bo)*Sin(sita)), Cos(lo))
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(sita) + Cos(bo)*Sin(sita)*Sin(lo))
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
return Limit360(ra), dec
}
func EarthJupiterAway(JD float64) float64 {
x, y, z := AJupiterXYZ(JD)
to := math.Sqrt(x*x + y*y + z*z)
return to
func EarthJupiterAway(jd float64) float64 {
return planetEarthAwayExplicitN(4, jd, -1)
}
func JupiterApparentLo(JD float64) float64 {
x, y, z := AJupiterXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(JD - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo-=GXCLo(lo,bo,JD)/3600;
//bo+=GXCBo(lo,bo,JD);
lo += HJZD(JD)
return lo
func JupiterApparentLo(jd float64) float64 {
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.lo
}
func JupiterApparentBo(JD float64) float64 {
x, y, z := AJupiterXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(JD - to)
//lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
//lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
//lo+=GXCLo(lo,bo,JD);
//bo+=GXCBo(lo,bo,JD)/3600;
//lo+=HJZD(JD);
return bo
func JupiterApparentBo(jd float64) float64 {
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.bo
}
func JupiterApparentLoBo(JD float64) (float64, float64) {
x, y, z := AJupiterXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(JD - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo-=GXCLo(lo,bo,JD)/3600;
//bo+=GXCBo(lo,bo,JD);
lo += HJZD(JD)
return lo, bo
func JupiterApparentLoBo(jd float64) (float64, float64) {
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.lo, geo.bo
}
func JupiterMag(JD float64) float64 {
AwaySun := JupiterR(JD)
AwayEarth := EarthJupiterAway(JD)
Away := planet.WherePlanet(-1, 2, JD)
i := (AwaySun*AwaySun + AwayEarth*AwayEarth - Away*Away) / (2 * AwaySun * AwayEarth)
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(AwaySun*AwayEarth) + 0.0005*i
return FloatRound(Mag, 2)
mag := -9.40 + 5*math.Log10(sunDistance*earthDistance) + 0.0005*i
return FloatRound(mag, 2)
}
func JupiterHeight(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
H := Limit360(st - ra)
// 高度角、时角与天球座标三角转换公式
// sin(h)=sin(lat)*sin(dec)+cos(dec)*cos(lat)*cos(H)
sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(H)
return ArcSin(sinHeight)
// JupiterHeightWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterHeightWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetHeightN(4, localJD, lon, lat, timezone, -1, inst)
}
func JupiterAzimuth(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
H := Limit360(st - ra)
// 三角转换公式
tanAzimuth := Sin(H) / (Cos(H)*Sin(lat) - Tan(dec)*Cos(lat))
Azimuth := ArcTan(tanAzimuth)
if Azimuth < 0 {
if H/15 < 12 {
return Azimuth + 360
}
return Azimuth + 180
// JupiterAzimuthWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterAzimuthWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetAzimuthN(4, localJD, lon, lat, timezone, -1, inst)
}
// JupiterHourAngleWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterHourAngleWithInstance(jd, lon, timezone float64, inst *Instance) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
hourAngle := siderealLongitude - JupiterApparentRaWithInstance(UTC2TT(jd-timezone/24.0), inst)
if hourAngle < 0 {
hourAngle += 360
}
if H/15 < 12 {
return Azimuth + 180
}
return Azimuth
return hourAngle
}
func JupiterHourAngle(JD, Lon, TZ float64) float64 {
startime := Limit360(ApparentSiderealTime(JD-TZ/24)*15 + Lon)
timeangle := startime - JupiterApparentRa(TD2UT(JD-TZ/24.0, true))
if timeangle < 0 {
timeangle += 360
}
return timeangle
}
func JupiterCulminationTime(jde, lon, timezone float64) float64 {
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
// JupiterCulminationTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterCulminationTimeWithInstance(localJD, lon, timezone float64, inst *Instance) float64 {
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
jde = math.Floor(jde) + 0.5
JD1 := jde + Limit360(360-JupiterHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
limitHA := func(jde, lon, timezone float64) float64 {
ha := JupiterHourAngle(jde, lon, timezone)
if ha < 180 {
ha += 360
localJD = math.Floor(localJD) + 0.5
estimateJD := localJD + Limit360(360-JupiterHourAngleWithInstance(localJD, lon, timezone, inst))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
currentHourAngle := JupiterHourAngleWithInstance(localJD, lon, timezone, inst)
if currentHourAngle < 180 {
currentHourAngle += 360
}
return ha
return currentHourAngle
}
for {
JD0 := JD1
stDegree := limitHA(JD0, lon, timezone) - 360
stDegreep := (limitHA(JD0+0.000005, lon, timezone) - limitHA(JD0-0.000005, lon, timezone)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return JD1
return estimateJD
}
func JupiterRiseTime(JD, Lon, Lat, TZ, ZS, HEI float64) float64 {
return jupiterRiseDown(JD, Lon, Lat, TZ, ZS, HEI, true)
// JupiterRiseTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterRiseTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true, inst)
}
func JupiterDownTime(JD, Lon, Lat, TZ, ZS, HEI float64) float64 {
return jupiterRiseDown(JD, Lon, Lat, TZ, ZS, HEI, false)
// JupiterSetTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterSetTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false, inst)
}
func jupiterRiseDown(JD, Lon, Lat, TZ, ZS, HEI float64, isRise bool) float64 {
var An float64
JD = math.Floor(JD) + 0.5
ntz := math.Round(Lon / 15)
if ZS != 0 {
An = -0.8333
}
An = An - HeightDegreeByLat(HEI, Lat)
tztime := JupiterCulminationTime(JD, Lon, ntz)
if JupiterHeight(tztime, Lon, Lat, ntz) < An {
return -2 //极夜
}
if JupiterHeight(tztime-0.5, Lon, Lat, ntz) > An {
return -1 //极昼
}
dec := HSunApparentDec(TD2UT(tztime-ntz/24, true))
//(sin(ho)-sin(φ)*sin(δ2))/(cos(φ)*cos(δ2))
tmp := (Sin(An) - Sin(dec)*Sin(Lat)) / (Cos(dec) * Cos(Lat))
var rise float64
if math.Abs(tmp) <= 1 {
rzsc := ArcCos(tmp) / 15
if isRise {
rise = tztime - rzsc/24 - 25.0/24.0/60.0
} else {
rise = tztime + rzsc/24 - 25.0/24.0/60.0
}
} else {
rise = tztime
i := 0
//TODO:使用二分法计算
for JupiterHeight(rise, Lon, Lat, ntz) > An {
i++
if isRise {
rise -= 15.0 / 60.0 / 24.0
} else {
rise += 15.0 / 60.0 / 24.0
}
if i > 48 {
break
}
}
}
JD1 := rise
for {
JD0 := JD1
stDegree := JupiterHeight(JD0, Lon, Lat, ntz) - An
stDegreep := (JupiterHeight(JD0+0.000005, Lon, Lat, ntz) - JupiterHeight(JD0-0.000005, Lon, Lat, ntz)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
}
return JD1 - ntz/24 + TZ/24
}
// Pos
const JUPITER_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 4332.59))
func jupiterConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
decSub := func(jde float64, degree float64, filter bool) float64 {
sub := Limit360(Limit360(JupiterApparentLo(jde)-HSunApparentLo(jde)) - degree)
if filter {
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
}
return sub
}
dayCost := JUPITER_S_PERIOD / 360
nowSub := decSub(jde, degree, false)
if next == 0 {
jde -= (360 - nowSub) * dayCost
} else {
jde += dayCost * nowSub
}
JD1 := jde
for {
JD0 := JD1
stDegree := decSub(JD0, degree, true)
stDegreep := (decSub(JD0+0.000005, degree, true) - decSub(JD0-0.000005, degree, true)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
}
return TD2UT(JD1, false)
}
func LastJupiterConjunction(jde float64) float64 {
return jupiterConjunction(jde, 0, 0)
}
func NextJupiterConjunction(jde float64) float64 {
return jupiterConjunction(jde, 0, 1)
}
func LastJupiterOpposition(jde float64) float64 {
return jupiterConjunction(jde, 180, 0)
}
func NextJupiterOpposition(jde float64) float64 {
return jupiterConjunction(jde, 180, 1)
}
func NextJupiterEasternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 90, 1)
}
func LastJupiterEasternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 90, 0)
}
func NextJupiterWesternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 270, 1)
}
func LastJupiterWesternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 270, 0)
}
func jupiterRetrograde(jde float64, isLeft bool) float64 {
//0=last 1=next
decSub := func(jde float64, val float64) float64 {
sub := JupiterApparentRa(jde+val) - JupiterApparentRa(jde-val)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * val)
}
jde = NextJupiterOpposition(jde)
if isLeft {
jde -= 60
} else {
jde += 60
}
for {
nowSub := decSub(jde, 1.0/86400.0)
if math.Abs(nowSub) > 0.55 {
jde += 2
continue
}
break
}
JD1 := jde
for {
JD0 := JD1
stDegree := decSub(JD0, 2.0/86400.0)
stDegreep := (decSub(JD0+15.0/86400.0, 2.0/86400.0) - decSub(JD0-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 30.0/86400.0 {
break
}
}
JD1 = JD1 - 15.0/86400.0
min := JD1
minRa := 100.0
for i := 0.0; i < 60.0; i++ {
tmp := decSub(JD1+i*0.5/86400.0, 0.5/86400.0)
if math.Abs(tmp) < math.Abs(minRa) {
minRa = tmp
min = JD1 + i*0.5/86400.0
}
}
return TD2UT(min, false)
}
func NextJupiterRetrogradeToPrograde(jde float64) float64 {
date := jupiterRetrograde(jde, false)
if date < jde {
op := NextJupiterOpposition(jde)
return jupiterRetrograde(op+10, false)
}
return date
}
func LastJupiterRetrogradeToPrograde(jde float64) float64 {
jde = LastJupiterOpposition(jde) - 10
date := jupiterRetrograde(jde, false)
if date > jde {
op := LastJupiterOpposition(jde)
return jupiterRetrograde(op-10, false)
}
return date
}
func NextJupiterProgradeToRetrograde(jde float64) float64 {
date := jupiterRetrograde(jde, true)
if date < jde {
op := NextJupiterOpposition(jde)
return jupiterRetrograde(op+10, true)
}
return date
}
func LastJupiterProgradeToRetrograde(jde float64) float64 {
jde = LastJupiterOpposition(jde) - 10
date := jupiterRetrograde(jde, true)
if date > jde {
op := LastJupiterOpposition(jde)
return jupiterRetrograde(op-10, true)
}
return date
func jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, instances ...*Instance) (float64, error) {
inst := instanceValue(instances)
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, func(localJD, lon, timezone float64) float64 {
return JupiterCulminationTimeWithInstance(localJD, lon, timezone, inst)
}, func(localJD, lon, lat, timezone float64) float64 {
return JupiterHeightWithInstance(localJD, lon, lat, timezone, inst)
}, func(jde float64) float64 { return JupiterApparentDecWithInstance(jde, inst) })
}
+325
View File
@@ -0,0 +1,325 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
// Pos
const (
JUPITER_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 4332.59))
jupiterEventSearchN = 16
jupiterPhaseCoarseTolerance = 30.0 / 86400.0
)
func jupiterSunLongitudeDelta(jde, degree float64, filter bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(JupiterApparentLoWithInstance(jde, inst)-HSunApparentLoWithInstance(jde, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
}
return sub
}
func jupiterSunLongitudeDeltaN(jde, degree float64, filter bool, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(JupiterApparentLoNWithInstance(jde, n, inst)-HSunApparentLoNWithInstance(jde, n, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
}
return sub
}
func jupiterRADerivative(jde, delta float64, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := JupiterApparentRaWithInstance(jde+delta, inst) - JupiterApparentRaWithInstance(jde-delta, inst)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func jupiterRADerivativeN(jde, delta float64, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := JupiterApparentRaNWithInstance(jde+delta, n, inst) - JupiterApparentRaNWithInstance(jde-delta, n, inst)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func jupiterConjunctionFull(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} else {
jde += daysPerDegree * currentDelta
}
estimateJDE := jde
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJDE := estimateJDE
longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true, inst)
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJDE = nextJD
if math.Abs(nextJD-prevJDE) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TT2UTC(estimateJDE)
}
func jupiterConjunction(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} else {
jde += daysPerDegree * currentDelta
}
estimateJDE := jde
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJDE := estimateJDE
longitudeDelta := jupiterSunLongitudeDeltaN(prevJDE, degree, true, jupiterEventSearchN, inst)
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJDE+0.000005, degree, true, jupiterEventSearchN, inst) - jupiterSunLongitudeDeltaN(prevJDE-0.000005, degree, true, jupiterEventSearchN, inst)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJDE = nextJD
if math.Abs(nextJD-prevJDE) <= jupiterPhaseCoarseTolerance {
converged = true
break
}
}
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJDE := estimateJDE
longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true, inst)
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJDE = nextJD
if math.Abs(nextJD-prevJDE) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TT2UTC(estimateJDE)
}
// LastJupiterConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
if !isFiniteFloat(oppositionJD) {
return math.NaN()
}
oppositionTT := UTC2TT(oppositionJD)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
easternQuadratureUT := jupiterConjunction(oppositionTT, 90, 0, inst)
startTT = UTC2TT(easternQuadratureUT)
} else {
westernQuadratureUT := jupiterConjunction(oppositionTT, 270, 1, inst)
endTT = UTC2TT(westernQuadratureUT)
}
bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN, inst)
}, func(jd float64) float64 {
return jupiterRADerivative(jd, stationDerivativeStepDay, inst)
})
return TT2UTC(bestJDE)
}
// NextJupiterRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(nextOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
// LastJupiterRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0, inst)
if !isFiniteFloat(previousOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(previousOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
// NextJupiterProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1, inst)
if !isFiniteFloat(followingOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(followingOppositionJD, true, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
// LastJupiterProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(lastOppositionJD, true, inst)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
+121
View File
@@ -0,0 +1,121 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
type jupiterPhysicalObservationInfo struct {
DS float64
DE float64
SystemI float64
SystemII float64
}
// JupiterCentralMeridianInfo 木星中央经线 / Jupiter central meridians.
type JupiterCentralMeridianInfo struct {
// SystemI 木星 System I 照亮盘中央经线,单位度,西经为正。
SystemI float64
// SystemII 木星 System II 照亮盘中央经线,单位度,西经为正。
SystemII float64
// SystemIII 木星 System III 盘面中央经线,单位度,西经为正。
SystemIII float64
}
// JupiterCentralMeridians 木星 System I/II/III 中央经线 / Jupiter System I/II/III central meridians.
func JupiterCentralMeridians(jde float64) JupiterCentralMeridianInfo {
return JupiterCentralMeridiansN(jde, -1)
}
// JupiterCentralMeridiansNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterCentralMeridiansNWithInstance(jde float64, n int, inst *Instance) JupiterCentralMeridianInfo {
observations := jupiterPhysicalObservationsN(jde, n, inst)
physical := JupiterPhysicalNWithInstance(jde, n, inst)
return JupiterCentralMeridianInfo{
SystemI: observations.SystemI,
SystemII: observations.SystemII,
SystemIII: physical.SubEarthLongitude,
}
}
// JupiterDSDE 木星 DS/DE 行星中心赤纬 / Jupiter planetocentric declinations of Sun and Earth.
func JupiterDSDE(jde float64) (ds, de float64) {
return JupiterDSDEN(jde, -1)
}
// JupiterDSDENWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterDSDENWithInstance(jde float64, n int, inst *Instance) (ds, de float64) {
observations := jupiterPhysicalObservationsN(jde, n, inst)
return observations.DS, observations.DE
}
func jupiterPhysicalObservationsN(jde float64, n int, instances ...*Instance) jupiterPhysicalObservationInfo {
inst := instanceValue(instances)
days := jde - 2433282.5
julianCentury := days / 36525.0
poleRA := (268.0 + 0.1061*julianCentury) * rad
poleDec := (64.5 - 0.0164*julianCentury) * rad
w1 := (17.71 + 877.90003539*days) * rad
w2 := (16.838 + 870.27003539*days) * rad
earthLon := PlanetCoordinateNWithInstance(-1, 0, jde, n, inst)
earthLat := PlanetCoordinateNWithInstance(-1, 1, jde, n, inst)
earthRadius := PlanetCoordinateNWithInstance(-1, 2, jde, n, inst)
delta := 4.0
var jupiterLon float64
var jupiterLat float64
var jupiterRadius float64
var x float64
var y float64
var z float64
for i := 0; i < 2; i++ {
lightTimeDays := astronomicalUnitLightTimeDays * delta
jupiterLon = PlanetCoordinateNWithInstance(4, 0, jde-lightTimeDays, n, inst)
jupiterLat = PlanetCoordinateNWithInstance(4, 1, jde-lightTimeDays, n, inst)
jupiterRadius = PlanetCoordinateNWithInstance(4, 2, jde-lightTimeDays, n, inst)
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(jde, false)
sinMeanObliquity, cosMeanObliquity := math.Sincos(meanObliquity)
sinJupiterLat, cosJupiterLat := math.Sincos(jupiterLat * rad)
sinJupiterLon, cosJupiterLon := math.Sincos(jupiterLon * rad)
alphaSun := math.Atan2(cosMeanObliquity*sinJupiterLon-sinMeanObliquity*sinJupiterLat/cosJupiterLat, cosJupiterLon)
deltaSun := math.Asin(cosMeanObliquity*sinJupiterLat + sinMeanObliquity*cosJupiterLat*sinJupiterLon)
u := y*Cos(meanObliquity) - z*Sin(meanObliquity)
v := y*Sin(meanObliquity) + z*Cos(meanObliquity)
alpha := math.Atan2(u, x)
deltaEarth := math.Atan2(v, math.Hypot(x, u))
sinPoleDec, cosPoleDec := math.Sincos(poleDec)
sinDeltaSun, cosDeltaSun := math.Sincos(deltaSun)
ds := math.Asin(-sinPoleDec*sinDeltaSun-cosPoleDec*cosDeltaSun*math.Cos(poleRA-alphaSun)) * deg
sinDeltaEarth, cosDeltaEarth := math.Sincos(deltaEarth)
sinPoleDeltaRA, cosPoleDeltaRA := math.Sincos(poleRA - alpha)
zeta := math.Atan2(
sinPoleDec*cosDeltaEarth*cosPoleDeltaRA-sinDeltaEarth*cosPoleDec,
cosDeltaEarth*sinPoleDeltaRA,
)
de := math.Asin(-sinPoleDec*sinDeltaEarth-cosPoleDec*cosDeltaEarth*math.Cos(poleRA-alpha)) * deg
systemI := w1 - zeta - 5.07033*rad*delta
systemII := w2 - zeta - 5.02626*rad*delta
phaseCorrection := (2*jupiterRadius*delta + earthRadius*earthRadius - jupiterRadius*jupiterRadius - delta*delta) / (4 * jupiterRadius * delta)
if Sin(jupiterLon-earthLon) < 0 {
phaseCorrection = -phaseCorrection
}
return jupiterPhysicalObservationInfo{
DS: ds,
DE: de,
SystemI: Limit360((systemI + phaseCorrection) * deg),
SystemII: Limit360((systemII + phaseCorrection) * deg),
}
}
+95
View File
@@ -0,0 +1,95 @@
package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestJupiterCentralMeridiansMeeusExample43A(t *testing.T) {
ephemtest.PinLegacy(t)
got := JupiterCentralMeridians(2448972.50068)
ds, de := JupiterDSDE(2448972.50068)
assertPlanetPhaseClose(t, "Jupiter.CMI", got.SystemI, 268.06, 0.02)
assertPlanetPhaseClose(t, "Jupiter.CMII", got.SystemII, 72.74, 0.02)
assertPlanetPhaseClose(t, "Jupiter.DS.Exact", ds, -1.733360091891, 1e-9)
assertPlanetPhaseClose(t, "Jupiter.DE.Exact", de, -2.484625891057, 1e-9)
}
func TestJupiterCentralMeridianSystemIIIMatchesHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/planet_physical_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []planetPhysicalSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
for _, sample := range samples {
if sample.Body != "jupiter" {
continue
}
date, err := time.Parse(time.RFC3339, sample.InputUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
}
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 := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
got := JupiterCentralMeridians(jd)
gotN := JupiterCentralMeridiansN(jd, -1)
ds, de := JupiterDSDE(jd)
dsN, deN := JupiterDSDEN(jd, -1)
if math.Float64bits(got.SystemI) != math.Float64bits(gotN.SystemI) {
t.Fatalf("SystemI mismatch: got %.18f want %.18f", got.SystemI, gotN.SystemI)
}
if math.Float64bits(got.SystemII) != math.Float64bits(gotN.SystemII) {
t.Fatalf("SystemII mismatch: got %.18f want %.18f", got.SystemII, gotN.SystemII)
}
if math.Float64bits(got.SystemIII) != math.Float64bits(gotN.SystemIII) {
t.Fatalf("SystemIII mismatch: got %.18f want %.18f", got.SystemIII, gotN.SystemIII)
}
if math.Float64bits(ds) != math.Float64bits(dsN) {
t.Fatalf("DS mismatch: got %.18f want %.18f", ds, dsN)
}
if math.Float64bits(de) != math.Float64bits(deN) {
t.Fatalf("DE mismatch: got %.18f want %.18f", de, deN)
}
}
func TestJupiterCentralMeridianAndDSDESampleSweepFiniteAndInRange(t *testing.T) {
dates := []time.Time{
time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(1969, 7, 20, 20, 17, 40, 0, time.UTC),
time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC),
time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC),
}
for _, date := range dates {
jd := UTC2TT(Date2JD(date.UTC()))
meridians := JupiterCentralMeridians(jd)
ds, de := JupiterDSDE(jd)
physical := JupiterPhysical(jd)
prefix := date.Format(time.RFC3339)
assertFiniteRange(t, prefix+".SystemI", meridians.SystemI, 0, 360, true)
assertFiniteRange(t, prefix+".SystemII", meridians.SystemII, 0, 360, true)
assertFiniteRange(t, prefix+".SystemIII", meridians.SystemIII, 0, 360, true)
assertFiniteRange(t, prefix+".DS", ds, -90, 90, false)
assertFiniteRange(t, prefix+".DE", de, -90, 90, false)
assertSameFloat(t, prefix+".CMIII", meridians.SystemIII, physical.SubEarthLongitude)
}
}
+808
View File
@@ -0,0 +1,808 @@
package basic
import (
"math"
)
const (
jupiterGalileanContactBracketStepDays = 2.0 / 1440.0
jupiterGalileanContactBracketSpanDays = 10.0 / 24.0
)
// JupiterGalileanPhenomenonContactPhase 接触阶段 / contact phase.
type JupiterGalileanPhenomenonContactPhase string
const (
// JupiterGalileanDisappearanceContact 初亏/初入接触阶段 / disappearance ingress contact.
JupiterGalileanDisappearanceContact JupiterGalileanPhenomenonContactPhase = "disappearance"
// JupiterGalileanReappearanceContact 复圆/复出接触阶段 / reappearance egress contact.
JupiterGalileanReappearanceContact JupiterGalileanPhenomenonContactPhase = "reappearance"
)
// JupiterGalileanPhenomenonContact 伽利略卫星接触窗口 / Galilean-satellite contact window.
//
// Start/End 表示有限圆盘或有限影斑开始/结束接触的时刻;ModelCrossing 表示这套连续接触模型下,
// 零半径参考点穿越边界的时刻。
// Start/End mark the beginning/end of the finite-disk or finite-shadow contact interval.
// ModelCrossing is the zero-radius boundary crossing in this continuous contact model.
type JupiterGalileanPhenomenonContact struct {
Valid bool
Phase JupiterGalileanPhenomenonContactPhase
Start float64
ModelCrossing float64
End float64
}
// JupiterGalileanPhenomenonContactEvent IMCCE 风格的 D/F 接触事件 / IMCCE-style D/F contact event.
//
// 与 `JupiterGalileanPhenomenonEvent` 不同,这里返回的是有限圆盘/有限影斑的初亏与复圆接触窗口;
// 现有整场事件 API 返回的则是零半径几何模型处于 active 状态的整段区间。
// 对 `shadow_transit`,这里按 IMCCE 的影凌语义处理:先用半影/本影边界求出部分相持续时间,
// 再把这段持续时间中心放在旧 `shadow_transit` API 的影轴过盘时刻上。
// Unlike `JupiterGalileanPhenomenonEvent`, this returns the finite-disk / finite-shadow D/F contact windows.
// The existing full-event API returns the whole active interval of the zero-radius geometric model.
// For `shadow_transit`, the partial-phase duration comes from penumbra/umbra boundaries,
// while the reported D/F time is centered on the shadow-axis limb crossing from the existing full-event model.
type JupiterGalileanPhenomenonContactEvent struct {
Valid bool
Satellite int
Type JupiterGalileanPhenomenonType
Disappearance JupiterGalileanPhenomenonContact
Greatest float64
Reappearance JupiterGalileanPhenomenonContact
GreatestPhenomenon JupiterGalileanPhenomenon
}
type jupiterGalileanContactGeometry struct {
signedDistance float64
effectiveRadius float64
}
// LastJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
LastJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
// NextJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
NextJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
// ClosestJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
ClosestJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
func jupiterGalileanPhenomenonContactEventFromEvent(event JupiterGalileanPhenomenonEvent, instances ...*Instance) JupiterGalileanPhenomenonContactEvent {
inst := instanceValue(instances)
if !event.Valid {
return invalidJupiterGalileanPhenomenonContactEvent()
}
var (
disappearance JupiterGalileanPhenomenonContact
reappearance JupiterGalileanPhenomenonContact
ok bool
)
if event.Type == JupiterGalileanShadowTransit {
disappearance, reappearance, ok = refineJupiterGalileanShadowContactPair(event, inst)
} else if event.Type == JupiterGalileanEclipse {
disappearance, reappearance, ok = refineJupiterGalileanEclipseContactPair(event, inst)
} else {
disappearance, reappearance, ok = refineJupiterGalileanContactPair(event.Greatest, event.Satellite, event.Type, inst)
}
if !ok {
return invalidJupiterGalileanPhenomenonContactEvent()
}
return JupiterGalileanPhenomenonContactEvent{
Valid: true,
Satellite: event.Satellite,
Type: event.Type,
Disappearance: disappearance,
Greatest: event.Greatest,
Reappearance: reappearance,
GreatestPhenomenon: event.GreatestPhenomenon,
}
}
func refineJupiterGalileanContactPair(
greatestJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
signedDistance := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
return geometry.signedDistance
}
disappearanceStartTarget := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
return geometry.signedDistance - geometry.effectiveRadius
}
insideTarget := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
return geometry.signedDistance + geometry.effectiveRadius
}
disappearanceModel, ok := refineJupiterGalileanContactRoot(greatestJD, -1, signedDistance)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceModel, ok := refineJupiterGalileanContactRoot(greatestJD, 1, signedDistance)
if !ok || reappearanceModel <= disappearanceModel {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceStart, ok := refineJupiterGalileanContactRoot(disappearanceModel, -1, disappearanceStartTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceEnd, ok := refineJupiterGalileanContactRoot(disappearanceModel, 1, insideTarget)
if !ok || disappearanceEnd <= disappearanceStart {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceStart, ok := refineJupiterGalileanContactRoot(reappearanceModel, -1, insideTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceEnd, ok := refineJupiterGalileanContactRoot(reappearanceModel, 1, disappearanceStartTarget)
if !ok || reappearanceEnd <= reappearanceStart {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
return JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanDisappearanceContact,
Start: disappearanceStart,
ModelCrossing: disappearanceModel,
End: disappearanceEnd,
}, JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanReappearanceContact,
Start: reappearanceStart,
ModelCrossing: reappearanceModel,
End: reappearanceEnd,
}, true
}
func refineJupiterGalileanEclipseContactPair(
event JupiterGalileanPhenomenonEvent, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
satelliteRadius := jupiterGalileanSatelliteRadiusJupiterRadii(event.Satellite)
if !isFinite(satelliteRadius) || satelliteRadius <= 0 {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
// IMCCE 的 EC.D/EC.F 更接近“目视消失/重现”而不是纯几何接触:
// D 相用半影入段近似,F 相用本影/半影出段的中点近似。
penumbraOuterTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return signedDistance - satelliteRadius
}
penumbraInnerTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return signedDistance + satelliteRadius
}
umbraInnerTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
return signedDistance + satelliteRadius
}
umbraOuterTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
return signedDistance - satelliteRadius
}
disappearanceStart, ok := refineJupiterGalileanContactRoot(event.Start, -1, penumbraOuterTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceEnd, ok := refineJupiterGalileanContactRoot(event.Start, 1, penumbraInnerTarget)
if !ok || disappearanceEnd <= disappearanceStart {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceUmbraStart, ok := refineJupiterGalileanContactRoot(event.End, -1, umbraInnerTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearancePenumbraStart, ok := refineJupiterGalileanContactRoot(event.End, -1, penumbraInnerTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceStart := (reappearanceUmbraStart + reappearancePenumbraStart) / 2
reappearanceUmbraEnd, ok := refineJupiterGalileanContactRoot(event.End, 1, umbraOuterTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearancePenumbraEnd, ok := refineJupiterGalileanContactRoot(event.End, 1, penumbraOuterTarget)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceEnd := (reappearanceUmbraEnd + reappearancePenumbraEnd) / 2
if !ok || reappearanceEnd <= reappearanceStart {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
return JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanDisappearanceContact,
Start: disappearanceStart,
ModelCrossing: event.Start,
End: disappearanceEnd,
}, JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanReappearanceContact,
Start: reappearanceStart,
ModelCrossing: event.End,
End: reappearanceEnd,
}, true
}
func refineJupiterGalileanShadowContactPair(
event JupiterGalileanPhenomenonEvent, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
penumbraMetric := func(jd float64) float64 {
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return value
}
umbraMetric := func(jd float64) float64 {
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
return value
}
penumbraSeedStartJD, penumbraSeedStartValue, ok := findJupiterGalileanNegativeMetricSeed(event.Start, penumbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceStart, ok := refineJupiterGalileanNegativeWindowRoot(penumbraSeedStartJD, penumbraSeedStartValue, -1, penumbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
umbraSeedStartJD, umbraSeedStartValue, ok := findJupiterGalileanNegativeMetricSeed(event.Start, umbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceUmbraIn, ok := refineJupiterGalileanNegativeWindowRoot(umbraSeedStartJD, umbraSeedStartValue, 1, umbraMetric)
if !ok || disappearanceUmbraIn <= disappearanceStart {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
umbraSeedEndJD, umbraSeedEndValue, ok := findJupiterGalileanNegativeMetricSeed(event.End, umbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearanceUmbraOut, ok := refineJupiterGalileanNegativeWindowRoot(umbraSeedEndJD, umbraSeedEndValue, -1, umbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
penumbraSeedEndJD, penumbraSeedEndValue, ok := findJupiterGalileanNegativeMetricSeed(event.End, penumbraMetric)
if !ok {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
reappearancePenumbraOut, ok := refineJupiterGalileanNegativeWindowRoot(penumbraSeedEndJD, penumbraSeedEndValue, 1, penumbraMetric)
if !ok || reappearancePenumbraOut <= reappearanceUmbraOut {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceDuration := disappearanceUmbraIn - disappearanceStart
reappearanceDuration := reappearancePenumbraOut - reappearanceUmbraOut
if disappearanceDuration <= 0 || reappearanceDuration <= 0 {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
}
disappearanceCenteredStart := event.Start - disappearanceDuration/2
disappearanceCenteredEnd := event.Start + disappearanceDuration/2
reappearanceCenteredStart := event.End - reappearanceDuration/2
reappearanceCenteredEnd := event.End + reappearanceDuration/2
return JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanDisappearanceContact,
Start: disappearanceCenteredStart,
ModelCrossing: event.Start,
End: disappearanceCenteredEnd,
}, JupiterGalileanPhenomenonContact{
Valid: true,
Phase: JupiterGalileanReappearanceContact,
Start: reappearanceCenteredStart,
ModelCrossing: event.End,
End: reappearanceCenteredEnd,
}, true
}
func findJupiterGalileanNegativeMetricSeed(
seedJD float64,
metric func(jd float64) float64,
) (float64, float64, bool) {
value := metric(seedJD)
if isFinite(value) && value < 0 {
return seedJD, value, true
}
step := jupiterGalileanContactBracketStepDays
maxSteps := int(math.Ceil(jupiterGalileanContactBracketSpanDays / step))
for i := 1; i <= maxSteps; i++ {
for _, direction := range []float64{-1, 1} {
candidateJD := seedJD + direction*step*float64(i)
candidateValue := metric(candidateJD)
if isFinite(candidateValue) && candidateValue < 0 {
return candidateJD, candidateValue, true
}
}
}
return math.NaN(), math.NaN(), false
}
func refineJupiterGalileanNegativeWindowRoot(
activeJD, activeValue float64,
direction int,
metric func(jd float64) float64,
) (float64, bool) {
currentJD := activeJD
currentValue := activeValue
step := jupiterGalileanContactBracketStepDays
maxSteps := int(math.Ceil(jupiterGalileanContactBracketSpanDays / step))
for i := 1; i <= maxSteps; i++ {
candidateJD := currentJD + float64(direction)*step
candidateValue := metric(candidateJD)
if !isFinite(candidateValue) {
currentJD = candidateJD
currentValue = math.Inf(1)
continue
}
if candidateValue >= 0 {
return bisectJupiterGalileanContactRoot(currentJD, currentValue, candidateJD, candidateValue, metric)
}
currentJD = candidateJD
currentValue = candidateValue
}
return math.NaN(), false
}
func refineJupiterGalileanContactRoot(
modelCrossingJD float64,
direction int,
target func(jd float64) float64,
) (float64, bool) {
if direction != -1 && direction != 1 {
return math.NaN(), false
}
modelValue := target(modelCrossingJD)
if !isFinite(modelValue) {
return math.NaN(), false
}
step := jupiterGalileanContactBracketStepDays
maxSteps := int(math.Ceil(jupiterGalileanContactBracketSpanDays / step))
nearJD := modelCrossingJD
nearValue := modelValue
for i := 1; i <= maxSteps; i++ {
farJD := modelCrossingJD + float64(direction)*step*float64(i)
farValue := target(farJD)
if !isFinite(farValue) {
continue
}
if nearValue == 0 {
return nearJD, true
}
if farValue == 0 {
return farJD, true
}
if nearValue*farValue < 0 {
return bisectJupiterGalileanContactRoot(nearJD, nearValue, farJD, farValue, target)
}
nearJD = farJD
nearValue = farValue
}
return math.NaN(), false
}
func bisectJupiterGalileanContactRoot(
jd1, value1, jd2, value2 float64,
target func(jd float64) float64,
) (float64, bool) {
leftJD := jd1
rightJD := jd2
leftValue := value1
rightValue := value2
if rightJD < leftJD {
leftJD, rightJD = rightJD, leftJD
leftValue, rightValue = rightValue, leftValue
}
if !isFinite(leftValue) || !isFinite(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
for i := 0; i < 80 && rightJD-leftJD > jupiterGalileanEventEpsilonDays; i++ {
midJD := (leftJD + rightJD) / 2
midValue := target(midJD)
if !isFinite(midValue) {
return math.NaN(), false
}
if midValue == 0 {
return midJD, true
}
if leftValue*midValue <= 0 {
rightJD = midJD
rightValue = midValue
} else {
leftJD = midJD
leftValue = midValue
}
}
return (leftJD + rightJD) / 2, true
}
func jupiterGalileanContactGeometryAt(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) (jupiterGalileanContactGeometry, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
return jupiterGalileanContactGeometry{}, false
}
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
if context.jupiterDistance == 0 {
return jupiterGalileanContactGeometry{}, false
}
index := satellite - 1
observation := context.observationForSatellite(index)
stateVector := Vector3{observation.State.X, observation.State.Y, observation.State.Z}
satelliteRadius := jupiterGalileanSatelliteRadiusJupiterRadii(satellite)
if !isFinite(satelliteRadius) || satelliteRadius <= 0 {
return jupiterGalileanContactGeometry{}, false
}
switch phenomenonType {
case JupiterGalileanTransit, JupiterGalileanOccultation:
return jupiterGalileanContactGeometry{
signedDistance: ellipseSignedDistance(observation.OffsetXJupiterRadii, observation.OffsetYJupiterRadii, 1, context.earthMinorRadius),
effectiveRadius: satelliteRadius,
}, true
case JupiterGalileanEclipse:
xSunAU := vectorDot(stateVector, context.sunEast)
ySunAU := vectorDot(stateVector, context.sunNorth)
zSunAU := vectorDot(stateVector, context.sunLineOfSight)
umbraScale := jupiterUmbraScale(zSunAU, context.sunDistanceAU)
if zSunAU <= 0 || umbraScale <= 0 {
return jupiterGalileanContactGeometry{}, false
}
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
return jupiterGalileanContactGeometry{
signedDistance: ellipseSignedDistance(xSunAU/radiusAU, ySunAU/radiusAU, umbraScale, context.sunMinorRadius*umbraScale),
effectiveRadius: satelliteRadius,
}, true
case JupiterGalileanShadowTransit:
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
axisDenominator := vectorDot(context.sunLineOfSight, context.lineOfSight)
if math.Abs(axisDenominator) < 1e-12 {
return jupiterGalileanContactGeometry{}, false
}
axisDistanceAU := -vectorDot(stateVector, context.lineOfSight) / axisDenominator
if axisDistanceAU <= 0 {
return jupiterGalileanContactGeometry{}, false
}
axisPoint := Vector3{
stateVector[0] + axisDistanceAU*context.sunLineOfSight[0],
stateVector[1] + axisDistanceAU*context.sunLineOfSight[1],
stateVector[2] + axisDistanceAU*context.sunLineOfSight[2],
}
xAU := vectorDot(axisPoint, context.east)
yAU := vectorDot(axisPoint, context.north)
return jupiterGalileanContactGeometry{
signedDistance: ellipseSignedDistance(xAU/radiusAU, yAU/radiusAU, 1, context.earthMinorRadius),
effectiveRadius: jupiterGalileanPenumbraRadiusJupiterRadii(satellite, axisDistanceAU, context.sunDistanceAU),
}, true
default:
return jupiterGalileanContactGeometry{}, false
}
}
func jupiterGalileanEclipseSignedDistanceAt(jd float64, satellite int, penumbra bool, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 {
return math.NaN(), false
}
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
if context.jupiterDistance == 0 {
return math.NaN(), false
}
state := context.observationForSatellite(satellite - 1).State
stateVector := Vector3{state.X, state.Y, state.Z}
xSunAU := vectorDot(stateVector, context.sunEast)
ySunAU := vectorDot(stateVector, context.sunNorth)
zSunAU := vectorDot(stateVector, context.sunLineOfSight)
if zSunAU <= 0 {
return math.NaN(), false
}
scale := jupiterUmbraScale(zSunAU, context.sunDistanceAU)
if penumbra {
scale = jupiterPenumbraScale(zSunAU, context.sunDistanceAU)
}
if scale <= 0 {
return math.NaN(), false
}
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
return ellipseSignedDistance(xSunAU/radiusAU, ySunAU/radiusAU, scale, context.sunMinorRadius*scale), true
}
func jupiterGalileanSatelliteRadiusJupiterRadii(satellite int) float64 {
switch satellite {
case 1:
return 1821.6 / jupiterGalileanEquatorialRadiusKM
case 2:
return 1560.8 / jupiterGalileanEquatorialRadiusKM
case 3:
return 2634.1 / jupiterGalileanEquatorialRadiusKM
case 4:
return 2410.3 / jupiterGalileanEquatorialRadiusKM
default:
return math.NaN()
}
}
func jupiterPenumbraScale(distanceBehindAU, sunDistanceAU float64) float64 {
if distanceBehindAU <= 0 || sunDistanceAU <= 0 {
return 0
}
jupiterRadiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
return 1 + distanceBehindAU*(solarRadiusAU+jupiterRadiusAU)/(sunDistanceAU*jupiterRadiusAU)
}
func jupiterGalileanPenumbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 {
if pathLengthAU <= 0 || sunDistanceAU <= 0 {
return math.NaN()
}
satelliteRadiusAU := jupiterGalileanSatelliteRadiusJupiterRadii(satellite) * jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
penumbraRadiusAU := satelliteRadiusAU + pathLengthAU*(solarRadiusAU+satelliteRadiusAU)/sunDistanceAU
if penumbraRadiusAU <= 0 {
return math.NaN()
}
return penumbraRadiusAU / (jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM)
}
func jupiterGalileanShadowLimbMetricAt(jd float64, satellite int, penumbra bool, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 {
return math.NaN(), false
}
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
if context.jupiterDistance == 0 {
return math.NaN(), false
}
state := context.observationForSatellite(satellite - 1).State
stateVector := Vector3{state.X, state.Y, state.Z}
satelliteBody := context.toBodyCoordinates(stateVector)
axisBody := normalizeVector(context.toBodyCoordinates(context.sunLineOfSight))
if vectorMagnitude(axisBody) == 0 {
return math.NaN(), false
}
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
satelliteRadiusAU := jupiterGalileanSatelliteRadiusJupiterRadii(satellite) * radiusAU
limbU, limbV, ok := jupiterGalileanVisibleLimbBasis(context)
if !ok {
return math.NaN(), false
}
metricAtAngle := func(angle float64) float64 {
limbPointBody := jupiterGalileanVisibleLimbPoint(angle, limbU, limbV, jupiterPolarRadiusRatio())
limbPointAU := Vector3{
limbPointBody[0] * radiusAU,
limbPointBody[1] * radiusAU,
limbPointBody[2] * radiusAU,
}
return jupiterGalileanShadowConeMetricForPoint(limbPointAU, satelliteBody, axisBody, satelliteRadiusAU, context.sunDistanceAU, penumbra)
}
return minimizeJupiterGalileanPeriodicMetric(metricAtAngle)
}
func jupiterGalileanVisibleLimbBasis(context jupiterGalileanObservationContext) (Vector3, Vector3, bool) {
polar := jupiterPolarRadiusRatio()
earthBody := context.toBodyCoordinates(context.earthDirection)
planeNormal := Vector3{earthBody[0], earthBody[1], earthBody[2] / polar}
planeNormal = normalizeVector(planeNormal)
if vectorMagnitude(planeNormal) == 0 {
return Vector3{}, Vector3{}, false
}
reference := Vector3{0, 0, 1}
if math.Abs(vectorDot(reference, planeNormal)) > 0.9 {
reference = Vector3{1, 0, 0}
}
u := normalizeVector(pxp(planeNormal, reference))
if vectorMagnitude(u) == 0 {
reference = Vector3{0, 1, 0}
u = normalizeVector(pxp(planeNormal, reference))
if vectorMagnitude(u) == 0 {
return Vector3{}, Vector3{}, false
}
}
v := normalizeVector(pxp(planeNormal, u))
if vectorMagnitude(v) == 0 {
return Vector3{}, Vector3{}, false
}
return u, v, true
}
func jupiterGalileanVisibleLimbPoint(angle float64, u, v Vector3, polar float64) Vector3 {
sinAngle := math.Sin(angle)
cosAngle := math.Cos(angle)
q := Vector3{
u[0]*cosAngle + v[0]*sinAngle,
u[1]*cosAngle + v[1]*sinAngle,
u[2]*cosAngle + v[2]*sinAngle,
}
return Vector3{q[0], q[1], polar * q[2]}
}
func jupiterGalileanShadowConeMetricForPoint(
pointAU, satelliteAU, axisUnit Vector3,
satelliteRadiusAU, sunDistanceAU float64,
penumbra bool,
) float64 {
vector := Vector3{
pointAU[0] - satelliteAU[0],
pointAU[1] - satelliteAU[1],
pointAU[2] - satelliteAU[2],
}
axisDistanceAU := vectorDot(vector, axisUnit)
if axisDistanceAU <= 0 {
return math.Inf(1)
}
perpendicular := Vector3{
vector[0] - axisDistanceAU*axisUnit[0],
vector[1] - axisDistanceAU*axisUnit[1],
vector[2] - axisDistanceAU*axisUnit[2],
}
perpendicularDistanceAU := vectorMagnitude(perpendicular)
if penumbra {
penumbraRadiusAU := satelliteRadiusAU + axisDistanceAU*(solarRadiusAU+satelliteRadiusAU)/sunDistanceAU
return perpendicularDistanceAU - penumbraRadiusAU
}
umbraRadiusAU := satelliteRadiusAU - axisDistanceAU*(solarRadiusAU-satelliteRadiusAU)/sunDistanceAU
return perpendicularDistanceAU - umbraRadiusAU
}
func minimizeJupiterGalileanPeriodicMetric(metric func(angle float64) float64) (float64, bool) {
const (
samples = 144
phi = 0.6180339887498948482
)
step := 2 * math.Pi / float64(samples)
bestAngle := 0.0
bestValue := math.Inf(1)
for i := 0; i < samples; i++ {
angle := float64(i) * step
value := metric(angle)
if value < bestValue {
bestValue = value
bestAngle = angle
}
}
if !isFinite(bestValue) {
return math.NaN(), false
}
left := bestAngle - step
right := bestAngle + step
x1 := right - phi*(right-left)
x2 := left + phi*(right-left)
f1 := metric(x1)
f2 := metric(x2)
for i := 0; i < 80; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - phi*(right-left)
f1 = metric(x1)
} else {
left = x1
x1 = x2
f1 = f2
x2 = left + phi*(right-left)
f2 = metric(x2)
}
}
return math.Min(bestValue, metric((left+right)/2)), true
}
func ellipseSignedDistance(x, y, major, minor float64) float64 {
if major <= 0 || minor <= 0 {
return math.NaN()
}
targetX := math.Abs(x)
targetY := math.Abs(y)
if targetX == 0 && targetY == 0 {
if minor < major {
return -minor
}
return -major
}
left := 0.0
right := math.Pi / 2
const phi = 0.6180339887498948482
x1 := right - phi*(right-left)
x2 := left + phi*(right-left)
f1 := ellipseDistanceSquaredAtAngle(targetX, targetY, major, minor, x1)
f2 := ellipseDistanceSquaredAtAngle(targetX, targetY, major, minor, x2)
for i := 0; i < 80; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - phi*(right-left)
f1 = ellipseDistanceSquaredAtAngle(targetX, targetY, major, minor, x1)
} else {
left = x1
x1 = x2
f1 = f2
x2 = left + phi*(right-left)
f2 = ellipseDistanceSquaredAtAngle(targetX, targetY, major, minor, x2)
}
}
distance := math.Sqrt(ellipseDistanceSquaredAtAngle(targetX, targetY, major, minor, (left+right)/2))
if ellipseInside(x, y, major, minor) {
return -distance
}
return distance
}
func ellipseDistanceSquaredAtAngle(x, y, major, minor, angle float64) float64 {
ellipseX := major * math.Cos(angle)
ellipseY := minor * math.Sin(angle)
dx := ellipseX - x
dy := ellipseY - y
return dx*dx + dy*dy
}
func invalidJupiterGalileanPhenomenonContactEvent() JupiterGalileanPhenomenonContactEvent {
return JupiterGalileanPhenomenonContactEvent{
Disappearance: JupiterGalileanPhenomenonContact{
Start: math.NaN(),
ModelCrossing: math.NaN(),
End: math.NaN(),
},
Greatest: math.NaN(),
Reappearance: JupiterGalileanPhenomenonContact{
Start: math.NaN(),
ModelCrossing: math.NaN(),
End: math.NaN(),
},
GreatestPhenomenon: invalidJupiterGalileanPhenomenon(),
}
}
@@ -0,0 +1,101 @@
package basic
import (
"math"
"testing"
"time"
)
func TestJupiterGalileanPhenomenonContactEventsAgainstIMCCEBaseline(t *testing.T) {
records := loadGalileanEventBaseline(t)
maxStartDiff := 0.0
maxEndDiff := 0.0
maxStartDurationDiff := 0.0
maxEndDurationDiff := 0.0
for _, record := range records {
startUTC := mustParseRFC3339Nano(t, record.StartUTC)
endUTC := mustParseRFC3339Nano(t, record.EndUTC)
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2)
phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type)
event := ClosestJupiterGalileanPhenomenonContactEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType)
if !event.Valid {
t.Fatalf("%s invalid contact event", record.Label)
}
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)
endDurationDiff := math.Abs((event.Reappearance.End-event.Reappearance.Start)*86400 - record.EndDurationMinutes*60)
if startDiff > maxStartDiff {
maxStartDiff = startDiff
}
if endDiff > maxEndDiff {
maxEndDiff = endDiff
}
if startDurationDiff > maxStartDurationDiff {
maxStartDurationDiff = startDurationDiff
}
if endDurationDiff > maxEndDurationDiff {
maxEndDurationDiff = endDurationDiff
}
if startDiff > galileanContactTimeToleranceSeconds(phenomenonType) {
t.Fatalf("%s disappearance start mismatch: got %s want %s", record.Label, gotStart.Format(time.RFC3339Nano), startUTC.Format(time.RFC3339Nano))
}
if endDiff > galileanContactTimeToleranceSeconds(phenomenonType) {
t.Fatalf("%s reappearance start mismatch: got %s want %s", record.Label, gotEnd.Format(time.RFC3339Nano), endUTC.Format(time.RFC3339Nano))
}
if startDurationDiff > galileanContactDurationToleranceSeconds(phenomenonType) {
t.Fatalf("%s disappearance duration mismatch: got %.1fs want %.1fs", record.Label, (event.Disappearance.End-event.Disappearance.Start)*86400, record.StartDurationMinutes*60)
}
if endDurationDiff > galileanContactDurationToleranceSeconds(phenomenonType) {
t.Fatalf("%s reappearance duration mismatch: got %.1fs want %.1fs", record.Label, (event.Reappearance.End-event.Reappearance.Start)*86400, record.EndDurationMinutes*60)
}
if !(event.Disappearance.Start <= event.Disappearance.ModelCrossing && event.Disappearance.ModelCrossing <= event.Disappearance.End) {
t.Fatalf("%s contact ordering invalid", record.Label)
}
if !(event.Reappearance.Start <= event.Reappearance.ModelCrossing && event.Reappearance.ModelCrossing <= event.Reappearance.End) {
t.Fatalf("%s reappearance ordering invalid", record.Label)
}
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)
}
if math.Abs(event.Reappearance.ModelCrossing-fullEvent.End)*86400 > 2 {
t.Fatalf("%s reappearance model crossing mismatch", record.Label)
}
}
}
t.Logf(
"galilean contact baseline max diff: start=%.1fs end=%.1fs startDur=%.1fs endDur=%.1fs",
maxStartDiff,
maxEndDiff,
maxStartDurationDiff,
maxEndDurationDiff,
)
}
func galileanContactTimeToleranceSeconds(phenomenonType JupiterGalileanPhenomenonType) float64 {
switch phenomenonType {
case JupiterGalileanShadowTransit:
return 90.0
case JupiterGalileanEclipse:
return 180.0
default:
return 120.0
}
}
func galileanContactDurationToleranceSeconds(phenomenonType JupiterGalileanPhenomenonType) float64 {
switch phenomenonType {
case JupiterGalileanShadowTransit:
return 15.0
default:
return 90.0
}
}
+651
View File
@@ -0,0 +1,651 @@
package basic
import (
"math"
)
const (
jupiterGalileanEventSearchSpanDays = 8 * 365.25
jupiterGalileanEventEpsilonDays = 1.0 / 86400.0
jupiterGalileanBoundaryStepDays = 1.0 / 24.0
)
// JupiterGalileanPhenomenonType 伽利略卫星现象类型 / Galilean-satellite phenomenon type.
type JupiterGalileanPhenomenonType string
const (
// JupiterGalileanTransit 凌日 / satellite transit across Jupiter.
JupiterGalileanTransit JupiterGalileanPhenomenonType = "transit"
// JupiterGalileanOccultation 掩蔽 / occultation behind Jupiter.
JupiterGalileanOccultation JupiterGalileanPhenomenonType = "occultation"
// JupiterGalileanEclipse 食 / eclipse in Jupiter's shadow.
JupiterGalileanEclipse JupiterGalileanPhenomenonType = "eclipse"
// JupiterGalileanShadowTransit 影凌 / shadow transit across Jupiter.
JupiterGalileanShadowTransit JupiterGalileanPhenomenonType = "shadow_transit"
)
// JupiterGalileanPhenomenonEvent 伽利略卫星现象整场事件 / full Galilean-satellite phenomenon event.
//
// Start、Greatest、End 都使用 UTC/UT 对应的儒略日。
// Start, Greatest, and End are UTC/UT Julian days.
type JupiterGalileanPhenomenonEvent struct {
Valid bool
Satellite int
Type JupiterGalileanPhenomenonType
Start float64
Greatest float64
End float64
GreatestPhenomenon JupiterGalileanPhenomenon
}
type jupiterGalileanMetricSample struct {
active bool
metric float64
// signed 是该现象相对木星视面中心线的有符号偏移(木星半径),每次过零对应一次现象;未定义时为 NaN。
signed float64
phenomenon JupiterGalileanPhenomenon
}
type jupiterGalileanShadowPoint struct {
hasIntersection bool
visible bool
pathLengthAU float64
xAU float64
yAU float64
xJupiterRadii float64
yJupiterRadii float64
}
// LastJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true, inst)
return event
}
// NextJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false, inst)
return event
}
// ClosestJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true, inst)
next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false, inst)
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return invalidJupiterGalileanPhenomenonEvent()
}
if math.Abs(last.Greatest-jd) <= math.Abs(next.Greatest-jd) {
return last
}
return next
}
func searchJupiterGalileanPhenomenonEvent(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
direction int,
includeCurrent bool, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || direction == 0 || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
return invalidJupiterGalileanPhenomenonEvent(), false
}
if sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType, inst); sample.active {
current := findJupiterGalileanPhenomenonEventAround(jd, satellite, phenomenonType, inst)
if current.Valid && includeCurrent {
return current, true
}
if current.Valid {
if direction > 0 {
jd = current.End + jupiterGalileanEventEpsilonDays
} else {
jd = current.Start - jupiterGalileanEventEpsilonDays
}
}
}
stepDays := jupiterGalileanCoarseStepDays(satellite)
maxSteps := int(math.Ceil(jupiterGalileanEventSearchSpanDays / stepDays))
sign := float64(direction)
prevTime := jd
prevSample := jupiterGalileanPhenomenonMetricAt(prevTime, satellite, phenomenonType, inst)
midTime := jd + sign*stepDays
midSample := jupiterGalileanPhenomenonMetricAt(midTime, satellite, phenomenonType, inst)
for i := 2; i <= maxSteps; i++ {
nextTime := jd + sign*float64(i)*stepDays
nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType, inst)
if event, ok := jupiterGalileanIntervalCandidate(
prevTime, nextTime, prevSample, midSample, nextSample,
jd, satellite, phenomenonType, direction, includeCurrent, inst,
); ok {
return event, true
}
prevTime, prevSample = midTime, midSample
midTime, midSample = nextTime, nextSample
}
return invalidJupiterGalileanPhenomenonEvent(), false
}
// jupiterGalileanIntervalCandidate 在粗扫区间内确认现象:度量局部极小,或有符号偏移过零。
func jupiterGalileanIntervalCandidate(
prevTime, nextTime float64,
prevSample, midSample, nextSample jupiterGalileanMetricSample,
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
direction int,
includeCurrent bool, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
var best JupiterGalileanPhenomenonEvent
found := false
consider := func(event JupiterGalileanPhenomenonEvent) {
if !event.Valid ||
!jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
return
}
if !found ||
(direction > 0 && event.Greatest < best.Greatest) ||
(direction < 0 && event.Greatest > best.Greatest) {
best, found = event, true
}
}
if isFinite(midSample.metric) &&
midSample.metric <= prevSample.metric &&
midSample.metric <= nextSample.metric {
consider(findJupiterGalileanPhenomenonEventAround(
refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType, inst),
satellite, phenomenonType, inst,
))
}
if jupiterGalileanSignedCrosses(prevSample.signed, nextSample.signed) &&
jupiterGalileanNearDisk(prevSample, midSample, nextSample) {
crossing := refineJupiterGalileanSignedCrossing(prevTime, nextTime, satellite, phenomenonType, inst)
// 过零点只说明"靠近木星",只在它已贴近视面时按事件尺度细扫一次。
if sample := jupiterGalileanPhenomenonMetricAt(crossing, satellite, phenomenonType, inst); isFinite(sample.metric) &&
sample.metric <= jupiterGalileanCrossingProbeMetric {
if event, ok := jupiterGalileanFineScanAround(crossing, satellite, phenomenonType, inst); ok {
consider(event)
}
}
}
return best, found
}
const (
// jupiterGalileanCrossingProbeMetric 是细扫门限(椭球度量,1 为视面边缘)。
jupiterGalileanCrossingProbeMetric = 2.5
// jupiterGalileanCrossingProbeDays 是过零点两侧的细扫半宽。
jupiterGalileanCrossingProbeDays = 15.0 / 1440.0
// jupiterGalileanCrossingProbeStepDays 是细扫步长(30 秒),短于最短的擦边事件。
jupiterGalileanCrossingProbeStepDays = 0.5 / 1440.0
)
// jupiterGalileanNearDisk 报告区间内是否已有贴近木星视面的采样点。
func jupiterGalileanNearDisk(prevSample, midSample, nextSample jupiterGalileanMetricSample) bool {
for _, metric := range [3]float64{prevSample.metric, midSample.metric, nextSample.metric} {
if isFinite(metric) && metric <= jupiterGalileanCrossingProbeMetric {
return true
}
}
return false
}
// jupiterGalileanFineScanAround 在过零点邻域按事件尺度取样,命中后交给事件求解。
func jupiterGalileanFineScanAround(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
// 从过零点向两侧交替外扩,通常几步内就能落进事件窗口。
steps := int(math.Round(jupiterGalileanCrossingProbeDays / jupiterGalileanCrossingProbeStepDays))
for step := 0; step <= steps; step++ {
for _, sign := range [2]float64{1, -1} {
if step == 0 && sign < 0 {
continue
}
candidate := jd + sign*float64(step)*jupiterGalileanCrossingProbeStepDays
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType, inst).active {
continue
}
if event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType, inst); event.Valid {
return event, true
}
}
}
return JupiterGalileanPhenomenonEvent{}, false
}
// jupiterGalileanSignedCrosses 判断两次采样的有符号偏移是否变号(端点为 0 也算)。
func jupiterGalileanSignedCrosses(first, second float64) bool {
if !isFinite(first) || !isFinite(second) {
return false
}
if first == 0 || second == 0 {
return true
}
return (first < 0) != (second < 0)
}
// refineJupiterGalileanSignedCrossing 用二分把过零时刻收敛到事件容差内。
func refineJupiterGalileanSignedCrossing(
left, right float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
leftValue := jupiterGalileanPhenomenonMetricAt(left, satellite, phenomenonType, inst).signed
rightValue := jupiterGalileanPhenomenonMetricAt(right, satellite, phenomenonType, inst).signed
if !isFinite(leftValue) || !isFinite(rightValue) || (leftValue < 0) == (rightValue < 0) {
return (left + right) / 2
}
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
middle := (left + right) / 2
value := jupiterGalileanPhenomenonMetricAt(middle, satellite, phenomenonType, inst).signed
if !isFinite(value) {
return middle
}
if (value < 0) == (leftValue < 0) {
left, leftValue = middle, value
} else {
right, rightValue = middle, value
}
}
return (left + right) / 2
}
func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) JupiterGalileanPhenomenonEvent {
inst := instanceValue(instances)
sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType, inst)
if !sample.active {
return invalidJupiterGalileanPhenomenonEvent()
}
stepDays := jupiterGalileanBoundaryStep(satellite)
maxBoundarySteps := int(math.Ceil(jupiterGalileanOrbitPeriodDays(satellite)/stepDays)) + 4
activeStart := jd
inactiveStart := math.NaN()
for i := 0; i < maxBoundarySteps; i++ {
candidate := activeStart - stepDays
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType, inst).active {
inactiveStart = candidate
break
}
activeStart = candidate
}
if !isFinite(inactiveStart) {
return invalidJupiterGalileanPhenomenonEvent()
}
activeEnd := jd
inactiveEnd := math.NaN()
for i := 0; i < maxBoundarySteps; i++ {
candidate := activeEnd + stepDays
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType, inst).active {
inactiveEnd = candidate
break
}
activeEnd = candidate
}
if !isFinite(inactiveEnd) {
return invalidJupiterGalileanPhenomenonEvent()
}
start := refineJupiterGalileanEventStart(inactiveStart, activeStart, satellite, phenomenonType, inst)
end := refineJupiterGalileanEventEnd(activeEnd, inactiveEnd, satellite, phenomenonType, inst)
if !isFinite(start) || !isFinite(end) || end <= start {
return invalidJupiterGalileanPhenomenonEvent()
}
greatest := refineJupiterGalileanMetricMinimum(start, end, satellite, phenomenonType, inst)
greatestSample := jupiterGalileanPhenomenonMetricAt(greatest, satellite, phenomenonType, inst)
if !greatestSample.active {
return invalidJupiterGalileanPhenomenonEvent()
}
return JupiterGalileanPhenomenonEvent{
Valid: true,
Satellite: satellite,
Type: phenomenonType,
Start: start,
Greatest: greatest,
End: end,
GreatestPhenomenon: greatestSample.phenomenon,
}
}
func refineJupiterGalileanEventStart(
outsideJD, insideJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
if insideJD < outsideJD {
outsideJD, insideJD = insideJD, outsideJD
}
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
left := outsideJD
right := insideJD
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
mid := (left + right) / 2
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType, inst).active {
right = mid
} else {
left = mid
}
}
return right
}
func refineJupiterGalileanEventEnd(
insideJD, outsideJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
if outsideJD < insideJD {
insideJD, outsideJD = outsideJD, insideJD
}
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
left := insideJD
right := outsideJD
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
mid := (left + right) / 2
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType, inst).active {
left = mid
} else {
right = mid
}
}
return left
}
func refineJupiterGalileanMetricMinimum(
jd1, jd2 float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
left := math.Min(jd1, jd2)
right := math.Max(jd1, jd2)
if right-left <= jupiterGalileanEventEpsilonDays {
return (left + right) / 2
}
const phi = 0.6180339887498948482
x1 := right - phi*(right-left)
x2 := left + phi*(right-left)
f1 := jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType, inst).metric
f2 := jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType, inst).metric
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - phi*(right-left)
f1 = jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType, inst).metric
} else {
left = x1
x1 = x2
f1 = f2
x2 = left + phi*(right-left)
f2 = jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType, inst).metric
}
}
return (left + right) / 2
}
func jupiterGalileanPhenomenonMetricAt(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) jupiterGalileanMetricSample {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
return jupiterGalileanMetricSample{
metric: math.Inf(1),
signed: math.NaN(),
phenomenon: invalidJupiterGalileanPhenomenon(),
}
}
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
if context.jupiterDistance == 0 {
return jupiterGalileanMetricSample{
metric: math.Inf(1),
signed: math.NaN(),
phenomenon: invalidJupiterGalileanPhenomenon(),
}
}
index := satellite - 1
observation := context.observationForSatellite(index)
stateVector := Vector3{observation.State.X, observation.State.Y, observation.State.Z}
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
xEarth := observation.OffsetXJupiterRadii
yEarth := observation.OffsetYJupiterRadii
earthMetric := ellipseMetric(xEarth, yEarth, 1, context.earthMinorRadius)
onEarthDisk := ellipseInside(xEarth, yEarth, 1, context.earthMinorRadius)
xSunAU := vectorDot(stateVector, context.sunEast)
ySunAU := vectorDot(stateVector, context.sunNorth)
zSunAU := vectorDot(stateVector, context.sunLineOfSight)
xSun := xSunAU / radiusAU
ySun := ySunAU / radiusAU
umbraScale := jupiterUmbraScale(zSunAU, context.sunDistanceAU)
sunMetric := math.Inf(1)
eclipse := false
if umbraScale > 0 {
sunMetric = ellipseMetric(xSun, ySun, umbraScale, context.sunMinorRadius*umbraScale)
eclipse = zSunAU > 0 && sunMetric <= 1+1e-12
}
shadowPoint := context.shadowPointFor(stateVector)
shadowMetric := math.Inf(1)
shadowTransit := false
if shadowPoint.hasIntersection {
shadowMetric = ellipseMetric(shadowPoint.xJupiterRadii, shadowPoint.yJupiterRadii, 1, context.earthMinorRadius)
shadowTransit = shadowPoint.visible && shadowMetric <= 1+1e-12
}
phenomenon := JupiterGalileanPhenomenon{
Transit: onEarthDisk && observation.InFrontOfJupiter,
Occultation: onEarthDisk && !observation.InFrontOfJupiter,
Eclipse: eclipse,
ShadowTransit: shadowTransit,
ShadowOffsetXArcsec: math.NaN(),
ShadowOffsetYArcsec: math.NaN(),
ShadowOffsetXJupiterRadii: math.NaN(),
ShadowOffsetYJupiterRadii: math.NaN(),
}
if shadowTransit {
phenomenon.ShadowOffsetXArcsec = math.Atan2(shadowPoint.xAU, context.jupiterDistance) * deg * 3600
phenomenon.ShadowOffsetYArcsec = math.Atan2(shadowPoint.yAU, context.jupiterDistance) * deg * 3600
phenomenon.ShadowOffsetXJupiterRadii = shadowPoint.xJupiterRadii
phenomenon.ShadowOffsetYJupiterRadii = shadowPoint.yJupiterRadii
}
switch phenomenonType {
case JupiterGalileanTransit:
metric := earthMetric
if !observation.InFrontOfJupiter {
metric += 4
}
return jupiterGalileanMetricSample{
active: phenomenon.Transit, metric: metric, signed: xEarth, phenomenon: phenomenon,
}
case JupiterGalileanOccultation:
metric := earthMetric
if observation.InFrontOfJupiter {
metric += 4
}
return jupiterGalileanMetricSample{
active: phenomenon.Occultation, metric: metric, signed: xEarth, phenomenon: phenomenon,
}
case JupiterGalileanEclipse:
metric := sunMetric
if zSunAU <= 0 {
metric += 4
}
return jupiterGalileanMetricSample{
active: phenomenon.Eclipse, metric: metric, signed: xSun, phenomenon: phenomenon,
}
case JupiterGalileanShadowTransit:
metric := shadowMetric
if shadowPoint.hasIntersection && !shadowPoint.visible {
metric += 4
}
signed := math.NaN()
if shadowPoint.hasIntersection {
signed = shadowPoint.xJupiterRadii
}
return jupiterGalileanMetricSample{
active: phenomenon.ShadowTransit, metric: metric, signed: signed, phenomenon: phenomenon,
}
default:
return jupiterGalileanMetricSample{
metric: math.Inf(1), signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(),
}
}
}
func (context jupiterGalileanObservationContext) shadowPointFor(stateVector Vector3) jupiterGalileanShadowPoint {
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
satelliteBody := context.toBodyCoordinates(stateVector)
satelliteBody = Vector3{
satelliteBody[0] / radiusAU,
satelliteBody[1] / radiusAU,
satelliteBody[2] / radiusAU,
}
directionBody := context.toBodyCoordinates(context.sunLineOfSight)
intersectionBody, ok := ellipsoidRayIntersection(satelliteBody, directionBody, jupiterPolarRadiusRatio())
if !ok {
return jupiterGalileanShadowPoint{}
}
normalBody := Vector3{
intersectionBody[0],
intersectionBody[1],
intersectionBody[2] / (jupiterPolarRadiusRatio() * jupiterPolarRadiusRatio()),
}
earthBody := context.toBodyCoordinates(context.earthDirection)
intersection := context.fromBodyCoordinates(Vector3{
intersectionBody[0] * radiusAU,
intersectionBody[1] * radiusAU,
intersectionBody[2] * radiusAU,
})
dx := intersection[0] - stateVector[0]
dy := intersection[1] - stateVector[1]
dz := intersection[2] - stateVector[2]
xAU := vectorDot(intersection, context.east)
yAU := vectorDot(intersection, context.north)
xR := xAU / radiusAU
yR := yAU / radiusAU
return jupiterGalileanShadowPoint{
hasIntersection: true,
visible: vectorDot(normalBody, earthBody) > 0,
pathLengthAU: math.Sqrt(dx*dx + dy*dy + dz*dz),
xAU: xAU,
yAU: yAU,
xJupiterRadii: xR,
yJupiterRadii: yR,
}
}
func jupiterGalileanOrbitPeriodDays(satellite int) float64 {
switch satellite {
case 1:
return 1.769137786
case 2:
return 3.551181
case 3:
return 7.154553
case 4:
return 16.689018
default:
return math.NaN()
}
}
func jupiterGalileanCoarseStepDays(satellite int) float64 {
step := jupiterGalileanOrbitPeriodDays(satellite) / 16
maxStep := 2.0 / 24.0
if step > maxStep {
return maxStep
}
return step
}
func jupiterGalileanBoundaryStep(satellite int) float64 {
step := jupiterGalileanOrbitPeriodDays(satellite) / 32
if step > jupiterGalileanBoundaryStepDays {
return jupiterGalileanBoundaryStepDays
}
return step
}
func jupiterGalileanEventMatchesDirection(eventJD, targetJD float64, direction int, includeCurrent bool) bool {
diff := eventJD - targetJD
switch {
case direction < 0 && includeCurrent:
return diff <= jupiterGalileanEventEpsilonDays
case direction < 0:
return diff < -jupiterGalileanEventEpsilonDays
case includeCurrent:
return diff >= -jupiterGalileanEventEpsilonDays
default:
return diff > jupiterGalileanEventEpsilonDays
}
}
func ellipseMetric(x, y, major, minor float64) float64 {
if major <= 0 || minor <= 0 {
return math.Inf(1)
}
return (x*x)/(major*major) + (y*y)/(minor*minor)
}
func isValidJupiterGalileanPhenomenonType(phenomenonType JupiterGalileanPhenomenonType) bool {
switch phenomenonType {
case JupiterGalileanTransit, JupiterGalileanOccultation, JupiterGalileanEclipse, JupiterGalileanShadowTransit:
return true
default:
return false
}
}
func invalidJupiterGalileanPhenomenonEvent() JupiterGalileanPhenomenonEvent {
return JupiterGalileanPhenomenonEvent{
Start: math.NaN(),
Greatest: math.NaN(),
End: math.NaN(),
GreatestPhenomenon: invalidJupiterGalileanPhenomenon(),
}
}
+181
View File
@@ -0,0 +1,181 @@
package basic
import (
"encoding/json"
"math"
"os"
"path/filepath"
"testing"
"time"
)
const galileanEventToleranceSeconds = 480.0
type galileanEventBaselineRecord struct {
Label string `json:"label"`
Satellite int `json:"satellite"`
Type string `json:"type"`
StartUTC string `json:"start_utc"`
StartDurationMinutes float64 `json:"start_duration_minutes"`
EndUTC string `json:"end_utc"`
EndDurationMinutes float64 `json:"end_duration_minutes"`
}
func TestJupiterGalileanPhenomenonEventsAgainstIMCCEBaseline(t *testing.T) {
records := loadGalileanEventBaseline(t)
maxStartDiff := 0.0
maxEndDiff := 0.0
for _, record := range records {
startUTC := mustParseRFC3339Nano(t, record.StartUTC)
endUTC := mustParseRFC3339Nano(t, record.EndUTC)
queryBefore := startUTC.Add(-12 * time.Hour)
queryAfter := endUTC.Add(12 * time.Hour)
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2)
phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type)
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)
assertGalileanEventMatchesBaseline(t, record.Label+" closest", closest, record, startUTC, endUTC, &maxStartDiff, &maxEndDiff)
}
t.Logf("galilean event baseline max diff: start=%.1fs end=%.1fs", maxStartDiff, maxEndDiff)
}
func assertGalileanEventMatchesBaseline(
t *testing.T,
name string,
event JupiterGalileanPhenomenonEvent,
record galileanEventBaselineRecord,
startUTC, endUTC time.Time,
maxStartDiff, maxEndDiff *float64,
) {
t.Helper()
if !event.Valid {
t.Fatalf("%s invalid event", name)
}
if event.Satellite != record.Satellite {
t.Fatalf("%s satellite mismatch: got %d want %d", name, event.Satellite, record.Satellite)
}
if string(event.Type) != record.Type {
t.Fatalf("%s type mismatch: got %q want %q", name, event.Type, record.Type)
}
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 {
*maxStartDiff = startDiff
}
if endDiff > *maxEndDiff {
*maxEndDiff = endDiff
}
if startDiff > galileanEventToleranceSeconds {
t.Fatalf("%s start mismatch: got %s want %s", name, gotStart.Format(time.RFC3339Nano), startUTC.Format(time.RFC3339Nano))
}
if endDiff > galileanEventToleranceSeconds {
t.Fatalf("%s end mismatch: got %s want %s", name, gotEnd.Format(time.RFC3339Nano), endUTC.Format(time.RFC3339Nano))
}
if !(event.Start <= event.Greatest && event.Greatest <= event.End) {
t.Fatalf("%s greatest not inside event: start=%.9f greatest=%.9f end=%.9f", name, event.Start, event.Greatest, event.End)
}
if !galileanPhenomenonFlag(event.GreatestPhenomenon, event.Type) {
t.Fatalf("%s greatest state is not active", name)
}
if jupiterGalileanPhenomenonMetricAt(event.Start-5.0/86400.0, event.Satellite, event.Type).active {
t.Fatalf("%s still active 5s before start", name)
}
if jupiterGalileanPhenomenonMetricAt(event.End+5.0/86400.0, event.Satellite, event.Type).active {
t.Fatalf("%s still active 5s after end", name)
}
}
func galileanPhenomenonFlag(phenomenon JupiterGalileanPhenomenon, phenomenonType JupiterGalileanPhenomenonType) bool {
switch phenomenonType {
case JupiterGalileanTransit:
return phenomenon.Transit
case JupiterGalileanOccultation:
return phenomenon.Occultation
case JupiterGalileanEclipse:
return phenomenon.Eclipse
case JupiterGalileanShadowTransit:
return phenomenon.ShadowTransit
default:
return false
}
}
func parseBasicGalileanPhenomenonType(t *testing.T, value string) JupiterGalileanPhenomenonType {
t.Helper()
switch JupiterGalileanPhenomenonType(value) {
case JupiterGalileanTransit, JupiterGalileanOccultation, JupiterGalileanEclipse, JupiterGalileanShadowTransit:
return JupiterGalileanPhenomenonType(value)
default:
t.Fatalf("unknown galilean phenomenon type %q", value)
return ""
}
}
func loadGalileanEventBaseline(t *testing.T) []galileanEventBaselineRecord {
t.Helper()
path := filepath.Join("..", "jupiter", "testdata", "galilean_events_imcce_2026.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var records []galileanEventBaselineRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatal(err)
}
if len(records) == 0 {
t.Fatal("empty galilean event baseline")
}
return records
}
func mustParseRFC3339Nano(t *testing.T, value string) time.Time {
t.Helper()
date, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return date
}
func TestJupiterGalileanSearchFindsShortGrazingTransit(t *testing.T) {
// 2463144 附近 Callisto 有一次擦边凌日:Start 2463143.9993 / Greatest 2463144.0015 /
// End 2463144.0038,全场只有 6.4 分钟,整段落在 2 小时粗采样之间。旧实现看不到度量
// 凹陷,会把 16.85 天后的下一次凌日当成 "next"(Valid 仍为 true,属静默跳事件)。
// Callisto has a grazing transit near 2463144: Start 2463143.9993, Greatest
// 2463144.0015, End 2463144.0038 — only 6.4 minutes, entirely between two two-hour
// coarse samples. The old search saw no metric dip and reported the next transit 16.85
// days later as "next", still with Valid=true (a silent skip).
closest := ClosestJupiterGalileanPhenomenonEvent(2463144.0, 4, JupiterGalileanTransit)
if !closest.Valid {
t.Fatal("no Callisto transit found at 2463144")
}
if duration := closest.End - closest.Start; duration > 0.01 {
t.Fatalf("fixture changed: transit duration %.4f d, want the 6.4-minute grazing event", duration)
}
next := NextJupiterGalileanPhenomenonEvent(2463143.9646, 4, JupiterGalileanTransit)
if !next.Valid {
t.Fatal("NextJupiterGalileanPhenomenonEvent returned nothing")
}
if offset := next.Greatest - 2463143.9646; offset > 0.1 {
t.Fatalf("next transit is %.3f d away, want the 53-minute one (the search skipped it)", offset)
}
if math.Abs(next.Greatest-closest.Greatest) > 1e-5 {
t.Fatalf("next greatest %.9f, want the same event as closest %.9f", next.Greatest, closest.Greatest)
}
last := LastJupiterGalileanPhenomenonEvent(2463144.05, 4, JupiterGalileanTransit)
if !last.Valid {
t.Fatal("LastJupiterGalileanPhenomenonEvent returned nothing")
}
if math.Abs(last.Greatest-closest.Greatest) > 1e-5 {
t.Fatalf("last greatest %.9f, want the same event as closest %.9f", last.Greatest, closest.Greatest)
}
}
+864
View File
@@ -0,0 +1,864 @@
package basic
// Code generated by scripts/jupiter-galilean-l1/main.go; DO NOT EDIT.
var jupiterGalileanL1LongPeriodTerms = [4][4][]jupiterGalileanL1Term{
{
{
{Amp: 0.0000000006827622, Period: 462.51460581, Phase: -2.709383024651},
{Amp: 0.0000000006827622, Period: -462.51460581, Phase: 2.709383056866},
{Amp: 0.0000000003280812, Period: -482.05605144, Phase: 1.755848247185},
{Amp: 0.0000000003280812, Period: 482.05605145, Phase: -1.755848223779},
{Amp: 0.0000000001706288, Period: -403.51538063, Phase: -2.610358561262},
{Amp: 0.0000000001706288, Period: 403.51538062, Phase: 2.610358537855},
{Amp: 0.0000000001855996, Period: 485.60320176, Phase: -0.112246363060},
{Amp: 0.0000000001855996, Period: -485.60320175, Phase: 0.112246363894},
{Amp: 0.0000000001507209, Period: -254.63444650, Phase: -0.161685950186},
{Amp: 0.0000000001507209, Period: 254.63444650, Phase: 0.161685950186},
{Amp: 0.0000000000724860, Period: 2059.62104200, Phase: 2.190529222727},
{Amp: 0.0000000000724860, Period: -2059.62104200, Phase: -2.190529222727},
{Amp: 0.0000000000272746, Period: -274.02279431, Phase: -1.229708703052},
{Amp: 0.0000000000272746, Period: 274.02279431, Phase: 1.229708703058},
{Amp: 0.0000000000163051, Period: 250.13067987, Phase: 2.545191166411},
{Amp: 0.0000000000163051, Period: -250.13067987, Phase: -2.545191131299},
},
{
{Amp: 0.0000962629174333, Period: -462.51460360, Phase: 1.138619193016},
{Amp: 0.0000962629174333, Period: 462.51460361, Phase: -1.138619147350},
{Amp: 0.0000485401798038, Period: 482.05605148, Phase: -0.185051689928},
{Amp: 0.0000485401798038, Period: -482.05605148, Phase: 0.185051701631},
{Amp: 0.0000449408708250, Period: -2059.62271753, Phase: 2.521072536783},
{Amp: 0.0000449408708250, Period: 2059.62271753, Phase: -2.521072536783},
{Amp: 0.0000276550525131, Period: -485.60320179, Phase: -1.458550472778},
{Amp: 0.0000276550525131, Period: 485.60320179, Phase: 1.458550472730},
{Amp: 0.0000209039435245, Period: -403.51538158, Phase: 2.102023003408},
{Amp: 0.0000209039435245, Period: 403.51538158, Phase: -2.102022944892},
{Amp: 0.0000186178298694, Period: 4332.93914977, Phase: 2.108666755900},
{Amp: 0.0000186178298694, Period: -4332.93914965, Phase: -2.108666744160},
{Amp: 0.0000117220266508, Period: 254.63444653, Phase: 1.732483394545},
{Amp: 0.0000117220266508, Period: -254.63444653, Phase: -1.732483394547},
{Amp: 0.0000080156582120, Period: -66002.48715215, Phase: 0.073578008624},
{Amp: 0.0000080156582120, Period: 66002.48716936, Phase: -0.073578000960},
{Amp: 0.0000041114727746, Period: -11428.11256828, Phase: 2.016207414938},
{Amp: 0.0000041114727746, Period: 11428.11256829, Phase: -2.016207414823},
{Amp: 0.0000043608559052, Period: 14133.23625267, Phase: 0.738064832500},
{Amp: 0.0000043608559052, Period: -14133.23624426, Phase: -0.738064751637},
{Amp: 0.0000037682740860, Period: 9692.27262460, Phase: 1.002642850091},
{Amp: 0.0000037682740860, Period: -9692.27262347, Phase: -1.002642825467},
{Amp: 0.0000026598151336, Period: 2166.51917628, Phase: -0.118101155294},
{Amp: 0.0000026598151336, Period: -2166.51917619, Phase: 0.118101190565},
},
{
{Amp: 0.0041510849668155, Period: -486.80959494, Phase: 2.009725283179},
{Amp: 0.0000352747346169, Period: 49367.32662218, Phase: 0.253909334368},
{Amp: 0.0000198194483636, Period: 195946.92419811, Phase: 1.897179483608},
{Amp: 0.0000146399842989, Period: 2358.32875876, Phase: 1.478395862207},
{Amp: 0.0000052220588690, Period: 9267.76794016, Phase: 2.447815000610},
{Amp: 0.0000022044764663, Period: -237.17561538, Phase: -1.564292084282},
{Amp: 0.0000015410375360, Period: 2265.74291645, Phase: -1.823335732733},
{Amp: 0.0000012193607962, Period: -637.45860369, Phase: -1.957542685623},
},
{
{Amp: 0.0003142172466014, Period: -2714.00626203, Phase: 0.017009891895},
{Amp: 0.0000904169207946, Period: -11038.50115941, Phase: 2.090564874290},
{Amp: 0.0000164452324013, Period: -50300.46261866, Phase: -1.808667780761},
{Amp: 0.0000055424829091, Period: -205593.77774416, Phase: 2.907426236797},
{Amp: 0.0000035856270353, Period: -248.89302051, Phase: 1.928881950702},
{Amp: 0.0000024180760140, Period: 2166.34061401, Phase: 2.301952132272},
{Amp: 0.0000008673084930, Period: -4333.05393383, Phase: 1.875397350363},
{Amp: 0.0000003176227277, Period: -267.38514937, Phase: 0.860886171255},
{Amp: 0.0000003152816608, Period: 1444.25588657, Phase: 2.837887905087},
{Amp: 0.0000002338676726, Period: 4332.83109038, Phase: -2.991881157330},
{Amp: 0.0000001754553689, Period: -244.58835226, Phase: -0.454720421567},
{Amp: 0.0000001286319583, Period: -243.40543219, Phase: 1.572977535435},
{Amp: 0.0000000967213304, Period: -2166.50569931, Phase: -1.943113737418},
},
},
{
{
{Amp: 0.0000000090655385, Period: 482.05605605, Phase: 1.385772645301},
{Amp: 0.0000000090655385, Period: -482.05605605, Phase: -1.385772633599},
{Amp: 0.0000000047683565, Period: -485.60320520, Phase: -3.029413042035},
{Amp: 0.0000000047683565, Period: 485.60320520, Phase: 3.029413042096},
{Amp: 0.0000000035969671, Period: 462.51472501, Phase: -2.707591871848},
{Amp: 0.0000000035969671, Period: -462.51472501, Phase: 2.707591896577},
{Amp: 0.0000000008232399, Period: -403.51538403, Phase: 0.531187191370},
{Amp: 0.0000000008232399, Period: 403.51538403, Phase: -0.531187167963},
{Amp: 0.0000000005125913, Period: 2059.62196661, Phase: -0.950637805889},
{Amp: 0.0000000005125913, Period: -2059.62196661, Phase: 0.950637805889},
{Amp: 0.0000000003478592, Period: 250.13068971, Phase: -0.596195111066},
{Amp: 0.0000000003478592, Period: -250.13068971, Phase: 0.596195134473},
{Amp: 0.0000000002908957, Period: -254.63444649, Phase: 2.979903215092},
{Amp: 0.0000000002908957, Period: 254.63444649, Phase: -2.979903215092},
{Amp: 0.0000000001698689, Period: -241.91156453, Phase: 1.867830046014},
{Amp: 0.0000000001698689, Period: 241.91156453, Phase: -1.867830013805},
{Amp: 0.0000000001896089, Period: -248.89380995, Phase: 2.620073542296},
{Amp: 0.0000000001896089, Period: 248.89380995, Phase: -2.620073542267},
{Amp: 0.0000000001269077, Period: -274.02280385, Phase: 1.911888048968},
{Amp: 0.0000000001269077, Period: 274.02280385, Phase: -1.911888048527},
{Amp: 0.0000000001579746, Period: -241.02802357, Phase: -2.771389990456},
{Amp: 0.0000000001579746, Period: 241.02802357, Phase: 2.771389988517},
},
{
{Amp: 0.0004288216586468, Period: 482.05605608, Phase: 2.956569304014},
{Amp: 0.0004288216586468, Period: -482.05605608, Phase: -2.956569292310},
{Amp: 0.0002274791437543, Period: 485.60320524, Phase: -1.682976093416},
{Amp: 0.0002274791437547, Period: -485.60320524, Phase: 1.682976093476},
{Amp: 0.0001537125039667, Period: -462.51473011, Phase: 1.136719181022},
{Amp: 0.0001537125039667, Period: 462.51473011, Phase: -1.136719137618},
{Amp: 0.0000917031775902, Period: -4332.93871803, Phase: -2.108663081132},
{Amp: 0.0000917031775902, Period: 4332.93871814, Phase: 2.108663092871},
{Amp: 0.0000991193072392, Period: 2059.37736140, Phase: 0.507502870024},
{Amp: 0.0000991193072392, Period: -2059.37736140, Phase: -0.507502870018},
{Amp: 0.0000316144888598, Period: -403.51538512, Phase: -1.039617955783},
{Amp: 0.0000316144888598, Period: 403.51538512, Phase: 1.039617990892},
{Amp: 0.0000174586375148, Period: -210240.18087863, Phase: 1.750407353612},
{Amp: 0.0000174586375134, Period: 210240.18167626, Phase: -1.750407317793},
{Amp: 0.0000162854547323, Period: 50284.65590194, Phase: -0.490087477075},
{Amp: 0.0000162854547324, Period: -50284.65590078, Phase: 0.490087477963},
{Amp: 0.0000102913236930, Period: -2166.51734380, Phase: -3.053369407734},
{Amp: 0.0000102913236930, Period: 2166.51734389, Phase: 3.053369442987},
{Amp: 0.0000084014158127, Period: 250.13068976, Phase: 0.974602969007},
{Amp: 0.0000084014158127, Period: -250.13068976, Phase: -0.974602933897},
{Amp: 0.0000070356577800, Period: 254.63444650, Phase: -1.409106426418},
{Amp: 0.0000070356577800, Period: -254.63444650, Phase: 1.409106424511},
{Amp: 0.0000070814366803, Period: -9676.80000028, Phase: 2.607323588975},
{Amp: 0.0000070814366803, Period: 9676.80000028, Phase: -2.607323588975},
{Amp: 0.0000050373040117, Period: 11362.43599928, Phase: 0.577394871339},
{Amp: 0.0000050373040117, Period: -11362.43599928, Phase: -0.577394871340},
{Amp: 0.0000053299308810, Period: 2078.23892619, Phase: -0.021997688216},
{Amp: 0.0000053299308811, Period: -2078.23892619, Phase: 0.021997688222},
{Amp: 0.0000047325683320, Period: 67215.42643142, Phase: 0.141213175007},
{Amp: 0.0000047325683325, Period: -67215.42643262, Phase: -0.141213175548},
{Amp: 0.0000039214851670, Period: 241.91154490, Phase: -0.297536953260},
{Amp: 0.0000039214851670, Period: -241.91154490, Phase: 0.297536962655},
{Amp: 0.0000045554036662, Period: 248.89381141, Phase: -1.049231904996},
{Amp: 0.0000045554036661, Period: -248.89381141, Phase: 1.049231905683},
},
{
{Amp: 0.0093589104136341, Period: -486.80959494, Phase: -1.131867355050},
{Amp: 0.0001980963564781, Period: 9267.59815105, Phase: -0.700195659441},
{Amp: 0.0002139036390350, Period: 49367.31948358, Phase: 0.253903838790},
{Amp: 0.0001210388158965, Period: 196002.76969709, Phase: 1.900452411216},
{Amp: 0.0000139052321679, Period: -242.21058266, Phase: -2.517653962057},
{Amp: 0.0000073925894084, Period: -243.10282624, Phase: 2.121892237182},
{Amp: 0.0000051968296512, Period: -237.17564686, Phase: 1.575472602669},
},
{
{Amp: 0.0040404917832303, Period: -11038.50070470, Phase: 2.090572293375},
{Amp: 0.0002200421034564, Period: -50300.46217493, Phase: -1.808667829190},
{Amp: 0.0000590282470983, Period: -205590.83436962, Phase: 2.907535129258},
{Amp: 0.0000105030331400, Period: -2714.00565902, Phase: -3.124346970559},
{Amp: 0.0000102943248250, Period: -248.89302050, Phase: -1.212709792839},
{Amp: 0.0000072600013020, Period: 2166.34096750, Phase: 2.302095210918},
{Amp: 0.0000018391258758, Period: -4333.05816401, Phase: -1.266797143358},
{Amp: 0.0000014880605763, Period: -244.58836553, Phase: -0.455088252478},
{Amp: 0.0000008828196274, Period: 1810.91607041, Phase: -0.522805812968},
{Amp: 0.0000008714042768, Period: 4332.82720044, Phase: -2.991672113265},
{Amp: 0.0000008536188044, Period: 1444.25631462, Phase: 2.838189555021},
{Amp: 0.0000006846214331, Period: -243.40441644, Phase: 1.606346686085},
{Amp: 0.0000004471826348, Period: -267.38514860, Phase: -2.280679314445},
{Amp: 0.0000003034392168, Period: -243.69327303, Phase: 1.113159867990},
{Amp: 0.0000001792048645, Period: -3111.52953257, Phase: 1.563078661742},
{Amp: 0.0000001799083735, Period: 7133.00782851, Phase: -0.505644195617},
{Amp: 0.0000001062153531, Period: -11679.84994953, Phase: 1.447094472227},
{Amp: 0.0000001098546626, Period: 1277.13984095, Phase: 0.006562244228},
{Amp: 0.0000001083128732, Period: -10479.05596460, Phase: -0.636294555123},
{Amp: 0.0000000768496749, Period: 1083.20345403, Phase: -2.906284048843},
{Amp: 0.0000000692273841, Period: 2076.86504405, Phase: -2.906237638896},
{Amp: 0.0000000676969224, Period: -14141.63136873, Phase: 0.044806456994},
{Amp: 0.0000000621559952, Period: -461.88727875, Phase: 0.684245423843},
},
},
{
{
{Amp: 0.0000000054767566, Period: 462.51465961, Phase: 0.433012270674},
{Amp: 0.0000000054767566, Period: -462.51465961, Phase: -0.433012249211},
{Amp: 0.0000000056033730, Period: -482.05605692, Phase: 1.755815363396},
{Amp: 0.0000000056033730, Period: 482.05605692, Phase: -1.755815351694},
{Amp: 0.0000000028921342, Period: 485.60320600, Phase: -0.112163580985},
{Amp: 0.0000000028921342, Period: -485.60320600, Phase: 0.112163580799},
{Amp: 0.0000000003409630, Period: 254.63444667, Phase: -2.979904123822},
{Amp: 0.0000000003409630, Period: -254.63444667, Phase: 2.979904123823},
{Amp: 0.0000000002216888, Period: 250.13068507, Phase: 2.545311746332},
{Amp: 0.0000000002216888, Period: -250.13068507, Phase: -2.545311746332},
{Amp: 0.0000000001418329, Period: -241.91155085, Phase: -1.273238319314},
{Amp: 0.0000000001418329, Period: 241.91155085, Phase: 1.273238319313},
},
{
{Amp: 0.0001155398943113, Period: 4332.93886608, Phase: 2.108691866697},
{Amp: 0.0001155398943113, Period: -4332.93886574, Phase: -2.108691831480},
{Amp: 0.0000914317982059, Period: -482.05605702, Phase: 0.185017491519},
{Amp: 0.0000914317982059, Period: 482.05605703, Phase: -0.185017456411},
{Amp: 0.0000756189389102, Period: 462.51466388, Phase: 2.003875199838},
{Amp: 0.0000756189389102, Period: -462.51466387, Phase: -2.003875175333},
{Amp: 0.0000477739034923, Period: 485.60320603, Phase: 1.458631213994},
{Amp: 0.0000477739034926, Period: -485.60320603, Phase: -1.458631213811},
{Amp: 0.0000271961236501, Period: 210274.48426729, Phase: -1.785123499277},
{Amp: 0.0000271961236540, Period: -210274.48346940, Phase: 1.785123534351},
{Amp: 0.0000244626823237, Period: -50284.47674577, Phase: 0.491194059142},
{Amp: 0.0000244626823247, Period: 50284.47674762, Phase: -0.491194057704},
{Amp: 0.0000077362945254, Period: 9642.10026223, Phase: 2.580679451841},
{Amp: 0.0000077362945254, Period: -9642.10026227, Phase: -2.580679452618},
{Amp: 0.0000084883673229, Period: 2059.54227670, Phase: -2.558255736482},
{Amp: 0.0000084883673229, Period: -2059.54227670, Phase: 2.558255736428},
{Amp: 0.0000067827229369, Period: -11287.76456818, Phase: -0.945782523799},
{Amp: 0.0000067827229369, Period: 11287.76456818, Phase: 0.945782523799},
{Amp: 0.0000037731001815, Period: -67979.99326489, Phase: -0.181520301079},
{Amp: 0.0000037731001804, Period: 67979.99326458, Phase: 0.181520300800},
{Amp: 0.0000032076570609, Period: 2166.51777632, Phase: 3.080339678337},
{Amp: 0.0000032076570609, Period: -2166.51777629, Phase: -3.080339666591},
{Amp: 0.0000026285122860, Period: -254.63444659, Phase: 1.409111585164},
{Amp: 0.0000026285122860, Period: 254.63444659, Phase: -1.409111565386},
{Amp: 0.0000028282486512, Period: 14014.13678272, Phase: 2.891690797470},
{Amp: 0.0000028282486512, Period: -14014.13678272, Phase: -2.891690797471},
},
{
{Amp: 0.0014289811307319, Period: 49367.34034574, Phase: 0.253921225395},
{Amp: 0.0007710931226760, Period: 195955.54100807, Phase: 1.897688885982},
{Amp: 0.0005925911780766, Period: -486.80959494, Phase: 2.009725311237},
{Amp: 0.0000070808673396, Period: -242.21058256, Phase: 0.623939292029},
{Amp: 0.0000070804404020, Period: 9270.75474882, Phase: 2.567494996183},
{Amp: 0.0000048299146941, Period: 2265.73531869, Phase: 1.315002005009},
{Amp: 0.0000038142769361, Period: -243.10282598, Phase: -1.019683047586},
{Amp: 0.0000022651772374, Period: 4332.66561900, Phase: 0.788371189690},
{Amp: 0.0000023143850535, Period: 2190.53482611, Phase: -0.326448440973},
{Amp: 0.0000007268381420, Period: -237.17572590, Phase: -1.570723011296},
},
{
{Amp: 0.0015932721570848, Period: -50300.46317781, Phase: -1.808668110228},
{Amp: 0.0003513347911037, Period: -205594.76675742, Phase: 2.907439275438},
{Amp: 0.0001441929255483, Period: -11038.50233695, Phase: -1.051046669426},
{Amp: 0.0000157303527750, Period: 2166.34184154, Phase: 2.302490124225},
{Amp: 0.0000025161319881, Period: -4333.06554786, Phase: -1.267594020600},
{Amp: 0.0000020438305183, Period: 4332.81770831, Phase: -2.991340015519},
{Amp: 0.0000017939612784, Period: 1444.25697259, Phase: 2.838852837237},
{Amp: 0.0000013614276895, Period: -248.89302051, Phase: 1.928883308456},
{Amp: 0.0000008996109017, Period: 2076.86534892, Phase: -2.906045098558},
{Amp: 0.0000008702078430, Period: -2714.00571514, Phase: -3.123659682744},
{Amp: 0.0000004371144064, Period: -244.58835722, Phase: 2.686731305626},
{Amp: 0.0000001926397869, Period: 2142.19483368, Phase: -1.985925544377},
{Amp: 0.0000002174259374, Period: -243.40560179, Phase: -1.574163434803},
{Amp: 0.0000001589279656, Period: 1083.20364160, Phase: -2.905875929922},
{Amp: 0.0000001432228753, Period: -3989.29234760, Phase: -2.346001284423},
},
},
{
{
{Amp: 0.0000000001238821, Period: 9641.95997751, Phase: -2.136521368996},
{Amp: 0.0000000001238821, Period: -9641.95997747, Phase: 2.136521369695},
{Amp: 0.0000000000937288, Period: 11292.66166947, Phase: 2.658438162555},
{Amp: 0.0000000000937288, Period: -11292.66166870, Phase: -2.658438150854},
},
{
{Amp: 0.0002793020061912, Period: -4332.93860171, Phase: -2.108786357946},
{Amp: 0.0002793020061912, Period: 4332.93860182, Phase: 2.108786369685},
{Amp: 0.0001902906934088, Period: 211344.06814397, Phase: 1.346615691710},
{Amp: 0.0001902906934113, Period: -211344.06760662, Phase: -1.346615668368},
{Amp: 0.0000167518242157, Period: -50289.57428101, Phase: -2.654427789777},
{Amp: 0.0000167518242264, Period: 50289.57429022, Phase: 2.654427797106},
{Amp: 0.0000092970467736, Period: -2166.51555964, Phase: -2.989880275328},
{Amp: 0.0000092970467736, Period: 2166.51555970, Phase: 2.989880298849},
{Amp: 0.0000085219011443, Period: 9641.95655559, Phase: -0.565865906865},
{Amp: 0.0000085219011444, Period: -9641.95655560, Phase: 0.565865906783},
{Amp: 0.0000075696916557, Period: -11291.33721849, Phase: 2.059123798134},
{Amp: 0.0000075696916557, Period: 11291.33721925, Phase: -2.059123786437},
{Amp: 0.0000058058699386, Period: -67440.56838013, Phase: 2.926756672932},
{Amp: 0.0000058058699344, Period: 67440.56837337, Phase: -2.926756675958},
},
{
{Amp: 0.0073755808467977, Period: 195950.90846202, Phase: 1.897427359615},
{Amp: 0.0001561131605348, Period: 49367.23074528, Phase: -2.887649087328},
{Amp: 0.0000540660842731, Period: 2190.52954139, Phase: -0.328140763057},
{Amp: 0.0000090411191759, Period: 4332.68219367, Phase: 0.789967351008},
{Amp: 0.0000060406575087, Period: 1454.71609134, Phase: -0.020851153251},
{Amp: 0.0000028551622596, Period: -198930.12372162, Phase: -0.327774752201},
{Amp: 0.0000026588026505, Period: 4430.38639549, Phase: 2.290679594163},
{Amp: 0.0000016317841395, Period: 4238.97138743, Phase: 2.407098908451},
{Amp: 0.0000016159095087, Period: -4430.93037398, Phase: -1.783540964911},
{Amp: 0.0000012029942283, Period: 2265.73177106, Phase: -1.827994616498},
{Amp: 0.0000008869382117, Period: 9192.14352819, Phase: -0.159251424576},
{Amp: 0.0000004518928658, Period: 2164.87438400, Phase: 0.627199957599},
{Amp: 0.0000004739086661, Period: 10677.43163337, Phase: -1.721255818131},
{Amp: 0.0000005321318002, Period: 1089.21699649, Phase: 0.749679902895},
},
{
{Amp: 0.0022453891791894, Period: -205593.16871630, Phase: 2.907607823156},
{Amp: 0.0002604479450559, Period: -50300.45144562, Phase: 1.332921952672},
{Amp: 0.0000332112143230, Period: 2166.33408559, Phase: 2.299271304157},
{Amp: 0.0000049727136261, Period: -4333.06038571, Phase: -1.266951160328},
{Amp: 0.0000049416729114, Period: -11038.56696703, Phase: -1.052098159774},
{Amp: 0.0000043945193428, Period: 4332.82948108, Phase: -2.986950453661},
{Amp: 0.0000037630501589, Period: 1444.25618193, Phase: 2.837982671867},
{Amp: 0.0000030823418750, Period: 2143.47705615, Phase: -1.442836410158},
{Amp: 0.0000004637177865, Period: -4242.89245749, Phase: 2.432125315770},
{Amp: 0.0000004719790711, Period: 4426.19806280, Phase: 0.277692210330},
{Amp: 0.0000003467132626, Period: 1433.98920963, Phase: -0.966205311247},
{Amp: 0.0000003497224175, Period: 2076.87114473, Phase: 0.238492877349},
{Amp: 0.0000003324412570, Period: 1083.20735284, Phase: -2.900230549782},
{Amp: 0.0000001945374351, Period: 2181.03566283, Phase: 2.659258242659},
{Amp: 0.0000001727743329, Period: -2166.53579568, Phase: -1.915921390647},
{Amp: 0.0000001485176585, Period: 4243.19386437, Phase: 1.378730646509},
},
},
}
var jupiterGalileanL1CrossPeriodTerms = [4][4][]jupiterGalileanL1Term{
{
{
{Amp: 0.0028210960212903, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000000381012294, Period: 1.76273174, Phase: -2.643895074882},
{Amp: 0.0000000381012294, Period: -1.76273174, Phase: 2.643895075707},
{Amp: 0.0000000086168826, Period: 3.52546349, Phase: 1.819648782093},
{Amp: 0.0000000086168826, Period: -3.52546349, Phase: -1.819648774502},
{Amp: 0.0000000090450162, Period: -0.88136587, Phase: -0.995547070485},
{Amp: 0.0000000090450162, Period: 0.88136587, Phase: 0.995547070485},
{Amp: 0.0000000047397043, Period: -0.78343633, Phase: 2.807162813908},
{Amp: 0.0000000047397043, Period: 0.78343633, Phase: -2.807162778199},
{Amp: 0.0000000046098133, Period: -0.58757725, Phase: 1.648484811816},
{Amp: 0.0000000046098133, Period: 0.58757725, Phase: -1.648484776721},
{Amp: 0.0000000050863040, Period: 0.70509270, Phase: 2.815055976377},
{Amp: 0.0000000050863040, Period: -0.70509270, Phase: -2.815055941282},
{Amp: 0.0000000029290802, Period: -2.35030899, Phase: -1.158674633569},
{Amp: 0.0000000029290802, Period: 2.35030899, Phase: 1.158674656974},
{Amp: 0.0000000017446377, Period: 0.50363764, Phase: 0.171224706165},
{Amp: 0.0000000017446377, Period: -0.50363764, Phase: -0.171224671071},
{Amp: 0.0000000015421426, Period: 0.98945742, Phase: -2.666154170053},
{Amp: 0.0000000015421426, Period: -0.98945742, Phase: 2.666154193457},
{Amp: 0.0000000018109074, Period: -1.17515450, Phase: 0.824225124590},
{Amp: 0.0000000018109074, Period: 1.17515450, Phase: -0.824225124846},
{Amp: 0.0000000010397325, Period: -0.52701992, Phase: 1.990665563264},
{Amp: 0.0000000010397325, Period: 0.52701992, Phase: -1.990665563264},
{Amp: 0.0000000005841286, Period: 0.49036751, Phase: 0.489778750861},
{Amp: 0.0000000005841286, Period: -0.49036751, Phase: -0.489778750861},
{Amp: 0.0000000004015988, Period: 0.61965131, Phase: 2.472795335277},
{Amp: 0.0000000004015988, Period: -0.61965131, Phase: -2.472795323587},
{Amp: 0.0000000003507059, Period: -0.88493019, Phase: 1.953447599078},
{Amp: 0.0000000003507059, Period: 0.88493019, Phase: -1.953447575672},
{Amp: 0.0000000003654755, Period: 0.75178915, Phase: 0.653129619279},
{Amp: 0.0000000003654755, Period: -0.75178915, Phase: -0.653129619279},
{Amp: 0.0000000002876544, Period: 0.65963828, Phase: -0.857967356136},
{Amp: 0.0000000002876544, Period: -0.65963828, Phase: 0.857967374635},
{Amp: 0.0000000002179774, Period: 0.95555745, Phase: -1.167011088750},
{Amp: 0.0000000002179774, Period: -0.95555745, Phase: 1.167011088750},
{Amp: 0.0000000001614172, Period: 1.31085840, Phase: -2.986185416058},
{Amp: 0.0000000001614172, Period: -1.31085840, Phase: 2.986185416058},
{Amp: 0.0000000001716490, Period: 1.97891465, Phase: 1.799472338743},
{Amp: 0.0000000001716490, Period: -1.97891465, Phase: -1.799472336412},
{Amp: 0.0000000000827916, Period: 1.41018540, Phase: 2.978320583302},
{Amp: 0.0000000000827916, Period: -1.41018540, Phase: -2.978320559896},
{Amp: 0.0000000000763996, Period: 0.84151364, Phase: -1.827565110733},
{Amp: 0.0000000000763996, Period: -0.84151364, Phase: 1.827565116962},
{Amp: 0.0000000000806455, Period: 2.08677541, Phase: 1.477346039152},
{Amp: 0.0000000000806455, Period: -2.08677541, Phase: -1.477346042522},
{Amp: 0.0000000000853835, Period: 1.76920091, Phase: -1.211307445204},
{Amp: 0.0000000000853835, Period: -1.76920091, Phase: 1.211307445226},
{Amp: 0.0000000000761656, Period: 1.40607178, Phase: -1.650855579663},
{Amp: 0.0000000000761656, Period: -1.40607178, Phase: 1.650855579663},
{Amp: 0.0000000000594344, Period: -0.88511089, Phase: 0.951017241368},
{Amp: 0.0000000000594344, Period: 0.88511089, Phase: -0.951017239898},
{Amp: 0.0000000000564629, Period: 0.49472871, Phase: 0.950314977999},
{Amp: 0.0000000000564629, Period: -0.49472871, Phase: -0.950314966307},
{Amp: 0.0000000000493043, Period: -5.11358278, Phase: 0.341909441869},
{Amp: 0.0000000000493043, Period: 5.11358278, Phase: -0.341909430740},
{Amp: 0.0000000000428597, Period: 1.05181306, Phase: -2.914901885532},
{Amp: 0.0000000000428597, Period: -1.05181306, Phase: 2.914901883107},
{Amp: 0.0000000000438860, Period: 3.49879406, Phase: 0.481234140725},
{Amp: 0.0000000000438860, Period: -3.49879406, Phase: -0.481234137799},
},
{
{Amp: 1.4462132960212235, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000251792213075, Period: 1.76273177, Phase: -1.071369371364},
{Amp: 0.0000251792213075, Period: -1.76273177, Phase: 1.071369371632},
{Amp: 0.0000222206385058, Period: -3.52546349, Phase: 2.892741097493},
{Amp: 0.0000222206385058, Period: 3.52546349, Phase: -2.892741074803},
{Amp: 0.0000054507134660, Period: 2.35030899, Phase: 2.729470598014},
{Amp: 0.0000054507134660, Period: -2.35030899, Phase: -2.729470598014},
{Amp: 0.0000059524877849, Period: 0.88136587, Phase: 2.566011851738},
{Amp: 0.0000059524877849, Period: -0.88136587, Phase: -2.566011851738},
{Amp: 0.0000028787912389, Period: -0.78343633, Phase: 1.236365304804},
{Amp: 0.0000028787912389, Period: 0.78343633, Phase: -1.236365280939},
{Amp: 0.0000030726401981, Period: -0.70509270, Phase: 1.897339035434},
{Amp: 0.0000030726401981, Period: 0.70509270, Phase: -1.897339023744},
{Amp: 0.0000023908706663, Period: -2056.36375976, Phase: -0.211262261618},
{Amp: 0.0000023908713552, Period: 2056.36431648, Phase: 0.211277724557},
{Amp: 0.0000022777007661, Period: 274.03235254, Phase: 0.387713436622},
{Amp: 0.0000022777007661, Period: -274.03235254, Phase: -0.387713436591},
{Amp: 0.0000025590603468, Period: -0.58757725, Phase: 0.077681276181},
{Amp: 0.0000025590603468, Period: 0.58757725, Phase: -0.077681252776},
{Amp: 0.0000021602067349, Period: 400.78715490, Phase: 0.013492534637},
{Amp: 0.0000021602067349, Period: -400.78715480, Phase: -0.013492473587},
{Amp: 0.0000018842049141, Period: 1.17515450, Phase: 0.746568951200},
{Amp: 0.0000018842049141, Period: -1.17515450, Phase: -0.746568951200},
{Amp: 0.0000015701869124, Period: -249.77392671, Phase: 2.493981352238},
{Amp: 0.0000015701869123, Period: 249.77392671, Phase: -2.493981352263},
{Amp: 0.0000008114937768, Period: -10400.18449376, Phase: -1.567931706749},
{Amp: 0.0000008114933753, Period: 10400.18102399, Phase: 1.567928771616},
{Amp: 0.0000012168267714, Period: 2471.07130547, Phase: -0.717589824480},
{Amp: 0.0000012168291116, Period: -2471.05875738, Phase: 0.717795029776},
{Amp: 0.0000010144950846, Period: -0.98945742, Phase: 1.095489123829},
{Amp: 0.0000010144950846, Period: 0.98945742, Phase: -1.095489112139},
{Amp: 0.0000009276215519, Period: 0.50363764, Phase: 1.741664442016},
{Amp: 0.0000009276215519, Period: -0.50363764, Phase: -1.741664395199},
{Amp: 0.0000009332719352, Period: -462.34993510, Phase: 3.008175468973},
{Amp: 0.0000009332719348, Period: 462.34993512, Phase: -3.008175463874},
{Amp: 0.0000001301198829, Period: 1375.10423851, Phase: -0.137037396295},
{Amp: 0.0000001301198573, Period: -1375.10422893, Phase: 0.137037333790},
{Amp: 0.0000004035364904, Period: 1668.45604889, Phase: -1.183442165366},
{Amp: 0.0000004035364083, Period: -1668.45590610, Phase: 1.183447162176},
{Amp: 0.0000006580493802, Period: -437.59465164, Phase: -3.042608902290},
{Amp: 0.0000006580493801, Period: 437.59465164, Phase: 3.042608902840},
},
{
{Amp: 0.0006260521444113, Period: 1.76913777, Phase: 1.446188898563},
{Amp: 0.0000096819265753, Period: 3.55118107, Phase: 2.768134001457},
{Amp: 0.0000098749504021, Period: -1.75637194, Phase: 0.450761187889},
{Amp: 0.0000083063168209, Period: 7.15455323, Phase: -2.854077904786},
{Amp: 0.0000045951340101, Period: -1.40611218, Phase: -2.029833930094},
{Amp: 0.0000059689735869, Period: 0.88296443, Phase: -1.209110059349},
{Amp: 0.0000046538995236, Period: -1.17232451, Phase: -1.368864973222},
{Amp: 0.0000037405126681, Period: -0.87977305, Phase: 3.094673734781},
{Amp: 0.0000037711061757, Period: -3.50011573, Phase: 2.270416702740},
{Amp: 0.0000018698303790, Period: -2.24513352, Phase: -2.170781015720},
{Amp: 0.0000013214613496, Period: -0.70407292, Phase: 1.275017772430},
{Amp: 0.0000012707585609, Period: 16.68653361, Phase: 1.972514861645},
{Amp: 0.0000011886104747, Period: 1.17799818, Phase: 0.124242374879},
{Amp: 0.0000007689215742, Period: -2302.10434716, Phase: 0.689291076961},
{Amp: 0.0000008742035177, Period: 2493.83004733, Phase: 2.390352831189},
},
{
{Amp: 0.0000175695395780, Period: 0.00000000, Phase: 2.415080967945},
{Amp: 0.0000000000692310, Period: -193.28859060, Phase: -2.208588622047},
},
},
{
{
{Amp: 0.0044871037804314, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000002162183749, Period: 3.52546349, Phase: 1.819645606290},
{Amp: 0.0000002162183749, Period: -3.52546349, Phase: -1.819645606290},
{Amp: 0.0000000801807375, Period: -2.35030899, Phase: 1.982912654261},
{Amp: 0.0000000801807375, Period: 2.35030899, Phase: -1.982912619140},
{Amp: 0.0000000509573393, Period: -1.17515450, Phase: -2.317355432932},
{Amp: 0.0000000509573393, Period: 1.17515450, Phase: 2.317355432932},
{Amp: 0.0000000462367393, Period: -7.05092698, Phase: 0.660971402306},
{Amp: 0.0000000462367393, Period: 7.05092698, Phase: -0.660971402162},
{Amp: 0.0000000255754500, Period: -1.41018540, Phase: -2.978330737054},
{Amp: 0.0000000255754500, Period: 1.41018540, Phase: 2.978330748744},
{Amp: 0.0000000261832900, Period: 1.76273174, Phase: 0.497691978790},
{Amp: 0.0000000261832900, Period: -1.76273174, Phase: -0.497691978790},
{Amp: 0.0000000136429969, Period: -0.70509270, Phase: 0.327144622755},
{Amp: 0.0000000136429969, Period: 0.70509270, Phase: -0.327144587998},
{Amp: 0.0000000155953890, Period: -0.88136587, Phase: 2.146927076004},
{Amp: 0.0000000155953890, Period: 0.88136587, Phase: -2.146927075722},
{Amp: 0.0000000087480272, Period: 1.00727528, Phase: 1.656394163933},
{Amp: 0.0000000087480272, Period: -1.00727528, Phase: -1.656394128838},
{Amp: 0.0000000116112914, Period: 2.25553591, Phase: -0.022341870823},
{Amp: 0.0000000116112914, Period: -2.25553591, Phase: 0.022341870823},
{Amp: 0.0000000061437036, Period: 0.58757726, Phase: 1.500536383468},
{Amp: 0.0000000061437036, Period: -0.58757726, Phase: -1.500536348490},
{Amp: 0.0000000042431918, Period: -0.50363764, Phase: 2.970124107071},
{Amp: 0.0000000042431918, Period: 0.50363764, Phase: -2.970124071981},
{Amp: 0.0000000034561177, Period: -1.50369059, Phase: 0.034310693267},
{Amp: 0.0000000034561177, Period: 1.50369059, Phase: -0.034310721884},
{Amp: 0.0000000030688784, Period: 0.78343633, Phase: 0.334349762913},
{Amp: 0.0000000030688784, Period: -0.78343633, Phase: -0.334349729096},
{Amp: 0.0000000022671527, Period: 0.52701992, Phase: 1.152376957614},
{Amp: 0.0000000022671527, Period: -0.52701992, Phase: -1.152376945916},
{Amp: 0.0000000022287342, Period: 1.77703490, Phase: 0.345978042967},
{Amp: 0.0000000022287342, Period: -1.77703490, Phase: -0.345978042889},
{Amp: 0.0000000021175036, Period: 4.51107181, Phase: -0.011219786970},
{Amp: 0.0000000021175036, Period: -4.51107181, Phase: 0.011219786970},
{Amp: 0.0000000014391886, Period: -0.61965131, Phase: 0.669288476670},
{Amp: 0.0000000014391886, Period: 0.61965131, Phase: -0.669288418187},
{Amp: 0.0000000012177331, Period: 6.94927396, Phase: 0.995871908751},
{Amp: 0.0000000012177331, Period: -6.94927396, Phase: -0.995871908751},
{Amp: 0.0000000010786785, Period: 1.12776795, Phase: -0.045280251144},
{Amp: 0.0000000010786785, Period: -1.12776795, Phase: 0.045280251144},
{Amp: 0.0000000011266470, Period: 0.64099336, Phase: -0.987288717782},
{Amp: 0.0000000011266470, Period: -0.64099336, Phase: 0.987288718396},
{Amp: 0.0000000008265031, Period: -0.75178905, Phase: 2.504075950065},
{Amp: 0.0000000008265031, Period: 0.75178905, Phase: -2.504075904237},
{Amp: 0.0000000005794919, Period: -3.55143712, Phase: 3.009946487657},
{Amp: 0.0000000005794919, Period: 3.55143712, Phase: -3.009946487654},
{Amp: 0.0000000003807491, Period: 1.77777556, Phase: 1.686281241558},
{Amp: 0.0000000003807491, Period: -1.77777556, Phase: -1.686281227667},
{Amp: 0.0000000003552247, Period: -2.60797333, Phase: 0.371913588076},
{Amp: 0.0000000003552247, Period: 2.60797333, Phase: -0.371913588692},
{Amp: 0.0000000005101755, Period: -0.95555743, Phase: -1.972390618495},
{Amp: 0.0000000005101755, Period: 0.95555743, Phase: 1.972390620107},
},
{
{Amp: 0.3735263437471362, Period: 0.00000000, Phase: -3.141592653403},
{Amp: 0.0001624469912587, Period: -3.52546349, Phase: -0.248853126552},
{Amp: 0.0001624469912587, Period: 3.52546349, Phase: 0.248853148768},
{Amp: 0.0000717191594226, Period: 7.05092698, Phase: 0.909825056364},
{Amp: 0.0000717191594226, Period: -7.05092698, Phase: -0.909825056364},
{Amp: 0.0000385969570472, Period: 2.35030899, Phase: -0.412116543189},
{Amp: 0.0000385969570472, Period: -2.35030899, Phase: 0.412116578303},
{Amp: 0.0000218287365705, Period: 1.76273174, Phase: 2.068494534904},
{Amp: 0.0000218287365705, Period: -1.76273174, Phase: -2.068494534904},
{Amp: 0.0000223383238694, Period: 1.17515450, Phase: -2.395036777814},
{Amp: 0.0000223383238694, Period: -1.17515450, Phase: 2.395036777814},
{Amp: 0.0000096353043778, Period: 1.41018540, Phase: -1.734057827225},
{Amp: 0.0000096353043778, Period: -1.41018540, Phase: 1.734057862320},
{Amp: 0.0000065973457880, Period: 0.88136587, Phase: -0.574948883091},
{Amp: 0.0000065973457880, Period: -0.88136587, Phase: 0.574948883295},
{Amp: 0.0000048707009708, Period: -0.70509245, Phase: -1.160449891993},
{Amp: 0.0000048707009708, Period: 0.70509245, Phase: 1.160449916162},
{Amp: 0.0000043860283834, Period: 400.80554123, Phase: 3.108492565509},
{Amp: 0.0000043860283834, Period: -400.80554119, Phase: -3.108492540844},
{Amp: 0.0000037783019709, Period: -14526.63126285, Phase: 1.653763811537},
{Amp: 0.0000037783070079, Period: 14526.64946924, Phase: -1.653758308820},
{Amp: 0.0000052005863869, Period: 2.25553591, Phase: 1.548440639446},
{Amp: 0.0000052005863869, Period: -2.25553591, Phase: -1.548440639370},
{Amp: 0.0000033290495376, Period: -274.03305205, Phase: 2.753261920991},
{Amp: 0.0000033290495376, Period: 274.03305205, Phase: -2.753261920909},
{Amp: 0.0000029065567615, Period: -1.00727528, Phase: 3.055999599574},
{Amp: 0.0000029065567615, Period: 1.00727528, Phase: -3.055999587858},
{Amp: 0.0000032927071387, Period: -240.79911272, Phase: 2.850621646594},
{Amp: 0.0000032927071387, Period: 240.79911272, Phase: -2.850621646515},
{Amp: 0.0000027860432638, Period: 2465.81058901, Phase: 2.385773248504},
{Amp: 0.0000027860415206, Period: -2465.81062125, Phase: -2.385773134668},
{Amp: 0.0000024099254453, Period: -4.51107182, Phase: -1.559634116074},
{Amp: 0.0000024099254453, Period: 4.51107182, Phase: 1.559634116074},
{Amp: 0.0000021364215633, Period: 0.58757722, Phase: 3.051294967909},
{Amp: 0.0000021364215633, Period: -0.58757722, Phase: -3.051294932621},
{Amp: 0.0000021087772652, Period: -2082.97789867, Phase: 0.219861989989},
{Amp: 0.0000021087773173, Period: 2082.97802228, Phase: -0.219858903141},
{Amp: 0.0000018853812260, Period: 249.13840056, Phase: -2.750530921943},
{Amp: 0.0000018853812260, Period: -249.13840055, Phase: 2.750530930849},
},
{
{Amp: 0.0002988994545555, Period: 3.55118107, Phase: -0.373458788613},
{Amp: 0.0000823525166369, Period: 1.76913777, Phase: 1.446188770927},
{Amp: 0.0000837042048393, Period: -6.95025971, Phase: 1.609453836719},
{Amp: 0.0000278946698536, Period: -3.50011572, Phase: -0.871155222557},
{Amp: 0.0000315906532820, Period: 7.15455323, Phase: -2.854080409609},
{Amp: 0.0000294503681314, Period: -1.75637194, Phase: -2.690812623889},
{Amp: 0.0000144958688621, Period: -2.33901625, Phase: 2.931395664158},
{Amp: 0.0000108374431350, Period: -6.18210653, Phase: -0.351109195546},
{Amp: 0.0000082175838585, Period: -1.17232451, Phase: 1.772880355220},
{Amp: 0.0000062618381566, Period: -0.87977309, Phase: -0.057891668832},
{Amp: 0.0000041298266970, Period: -1.40611218, Phase: -2.029848170071},
{Amp: 0.0000043507065743, Period: 16.68653641, Phase: 1.973436581069},
{Amp: 0.0000042081682285, Period: 1.41426445, Phase: 3.120261383685},
{Amp: 0.0000027357551003, Period: -0.70407293, Phase: -1.868111985056},
{Amp: 0.0000036991221930, Period: 2.36171130, Phase: 2.107163763597},
{Amp: 0.0000020163445050, Period: -2.60801351, Phase: -0.434765781769},
{Amp: 0.0000026854901206, Period: 397.49502864, Phase: -2.385626020918},
{Amp: 0.0000017894118824, Period: -0.58686890, Phase: 2.596961125563},
{Amp: 0.0000023074479953, Period: 0.88296447, Phase: 1.943899853407},
{Amp: 0.0000018159137522, Period: 1.00936379, Phase: 2.604869046210},
{Amp: 0.0000011726743714, Period: -437.81569630, Phase: 1.882700621195},
{Amp: 0.0000016518864520, Period: 1.17799818, Phase: -3.017336059369},
{Amp: 0.0000018506530067, Period: 9207.93895292, Phase: -0.457770143364},
{Amp: 0.0000013196935928, Period: -1.00519540, Phase: -0.707882741855},
{Amp: 0.0000014426949422, Period: -3.55690202, Phase: -0.281777715997},
{Amp: 0.0000015660692561, Period: -548.57786416, Phase: 3.037342396932},
{Amp: 0.0000005646670312, Period: -488.13559324, Phase: 2.168360257491},
{Amp: 0.0000007495662748, Period: 3126.34340655, Phase: -0.151955418922},
{Amp: 0.0000007569857746, Period: -2303.92749369, Phase: 0.668283437067},
{Amp: 0.0000009550285338, Period: 4406.90403072, Phase: -0.300652348938},
{Amp: 0.0000007091149133, Period: -3153.42860903, Phase: 2.713933181549},
{Amp: 0.0000002004455524, Period: -1919.99999983, Phase: -1.852207712464},
{Amp: 0.0000001623489363, Period: 1408.49105459, Phase: -2.899698107364},
{Amp: 0.0000001058862562, Period: 1599.99999991, Phase: -1.747489966524},
},
{
{Amp: 0.0001662544744719, Period: 0.00000000, Phase: 2.413486237501},
{Amp: 0.0000000000608298, Period: -193.26413985, Phase: -2.230228354541},
},
},
{
{
{Amp: 0.0071566594572575, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000006965149555, Period: -2.35030899, Phase: -1.158674588527},
{Amp: 0.0000006965149555, Period: 2.35030899, Phase: 1.158674600230},
{Amp: 0.0000003224914673, Period: 7.05092698, Phase: -0.660970737035},
{Amp: 0.0000003224914673, Period: -7.05092698, Phase: 0.660970737035},
{Amp: 0.0000001149029760, Period: -6.26162437, Phase: -1.299592404339},
{Amp: 0.0000001149029760, Period: 6.26162437, Phase: 1.299592404339},
{Amp: 0.0000000610717185, Period: -3.52546349, Phase: -1.819650979517},
{Amp: 0.0000000610717185, Period: 3.52546349, Phase: 1.819651032396},
{Amp: 0.0000000547899088, Period: 4.17441617, Phase: 1.948670877782},
{Amp: 0.0000000547899088, Period: -4.17441617, Phase: -1.948670828180},
{Amp: 0.0000000350717808, Period: -12.52324882, Phase: -0.649785081154},
{Amp: 0.0000000350717808, Period: 12.52324882, Phase: 0.649785104562},
{Amp: 0.0000000273934283, Period: -3.13081218, Phase: -2.599205067142},
{Amp: 0.0000000273934283, Period: 3.13081218, Phase: 2.599205102257},
{Amp: 0.0000000181610714, Period: -1.76273174, Phase: -0.497708209553},
{Amp: 0.0000000181610714, Period: 1.76273174, Phase: 0.497708210129},
{Amp: 0.0000000197317929, Period: 1.17515450, Phase: -0.824239093090},
{Amp: 0.0000000197317929, Period: -1.17515450, Phase: 0.824239093704},
{Amp: 0.0000000140622484, Period: -2.50464974, Phase: 3.034184231000},
{Amp: 0.0000000140622484, Period: 2.50464974, Phase: -3.034184231000},
{Amp: 0.0000000103962349, Period: 1.41018540, Phase: -0.163261763515},
{Amp: 0.0000000103962349, Period: -1.41018540, Phase: 0.163261798610},
{Amp: 0.0000000145474682, Period: -3.58319300, Phase: -2.012339222997},
{Amp: 0.0000000145474682, Period: 3.58319300, Phase: 2.012339225153},
{Amp: 0.0000000073448387, Period: 2.08720812, Phase: -2.384360905770},
{Amp: 0.0000000073448387, Period: -2.08720812, Phase: 2.384360906102},
{Amp: 0.0000000059965021, Period: -1.00727528, Phase: 1.485195822039},
{Amp: 0.0000000059965021, Period: 1.00727528, Phase: -1.485195786944},
{Amp: 0.0000000049933886, Period: 10.02108773, Phase: 0.967007202582},
{Amp: 0.0000000049933886, Period: -10.02108773, Phase: -0.967007214068},
{Amp: 0.0000000038834130, Period: 1.78903553, Phase: -1.734548005475},
{Amp: 0.0000000038834130, Period: -1.78903553, Phase: 1.734548012548},
{Amp: 0.0000000033173319, Period: 0.78343633, Phase: 0.334410735342},
{Amp: 0.0000000033173319, Period: -0.78343633, Phase: -0.334410703853},
{Amp: 0.0000000037071986, Period: 50.13968535, Phase: 0.161404498578},
{Amp: 0.0000000037071986, Period: -50.13968535, Phase: -0.161404499697},
{Amp: 0.0000000024697553, Period: 3.58615746, Phase: 3.014796806223},
{Amp: 0.0000000024697553, Period: -3.58615746, Phase: -3.014796771059},
{Amp: 0.0000000027884176, Period: 5.56655476, Phase: 1.323585106216},
{Amp: 0.0000000027884176, Period: -5.56655476, Phase: -1.323585106216},
{Amp: 0.0000000020719852, Period: 1.56540608, Phase: -1.084729476400},
{Amp: 0.0000000020719852, Period: -1.56540608, Phase: 1.084729523216},
{Amp: 0.0000000018431031, Period: 0.58757726, Phase: 1.497090964345},
{Amp: 0.0000000018431031, Period: -0.58757726, Phase: -1.497090952676},
{Amp: 0.0000000016808769, Period: 7.15554098, Phase: -2.533819463034},
{Amp: 0.0000000016808769, Period: -7.15554098, Phase: 2.533819476120},
{Amp: 0.0000000020382815, Period: -0.88136587, Phase: 2.146155229321},
{Amp: 0.0000000020382815, Period: 0.88136587, Phase: -2.146155229461},
{Amp: 0.0000000016674142, Period: 3.85367596, Phase: 2.266819681887},
{Amp: 0.0000000016674142, Period: -3.85367596, Phase: -2.266819678138},
{Amp: 0.0000000012877349, Period: -3.49986546, Phase: 2.808660684496},
{Amp: 0.0000000012877349, Period: 3.49986546, Phase: -2.808660671629},
{Amp: 0.0000000012181542, Period: 55.93578462, Phase: 1.960483840747},
{Amp: 0.0000000012181542, Period: -55.93578462, Phase: -1.960483829044},
{Amp: 0.0000000010016338, Period: -2.94686240, Phase: -2.916664806186},
{Amp: 0.0000000010016338, Period: 2.94686240, Phase: 2.916664806820},
{Amp: 0.0000000009057853, Period: -6.94927480, Phase: 2.143765079507},
{Amp: 0.0000000009057853, Period: 6.94927480, Phase: -2.143765093634},
{Amp: 0.0000000011132716, Period: -1.39147207, Phase: 0.434910736795},
{Amp: 0.0000000011132716, Period: 1.39147207, Phase: -0.434910734723},
{Amp: 0.0000000007267503, Period: 0.70508749, Phase: 1.874821204178},
{Amp: 0.0000000007267503, Period: -0.70508749, Phase: -1.874821204178},
},
{
{Amp: 0.2874089391143348, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000581860484889, Period: -2.35030899, Phase: 0.412122994161},
{Amp: 0.0000581860484889, Period: 2.35030899, Phase: -0.412122959047},
{Amp: 0.0000407623427232, Period: -7.05092698, Phase: 2.231767920686},
{Amp: 0.0000407623427232, Period: 7.05092698, Phase: -2.231767920577},
{Amp: 0.0000400609839801, Period: 6.26162437, Phase: 2.870388622006},
{Amp: 0.0000400609839801, Period: -6.26162437, Phase: -2.870388622006},
{Amp: 0.0000303508630091, Period: 12.52324880, Phase: 2.220584023571},
{Amp: 0.0000303508630091, Period: -12.52324880, Phase: -2.220583988461},
{Amp: 0.0000213787490768, Period: 3.52546349, Phase: -2.892737639574},
{Amp: 0.0000213787490768, Period: -3.52546349, Phase: 2.892737684197},
{Amp: 0.0000153680208913, Period: 4.17441628, Phase: -2.762551710070},
{Amp: 0.0000153680208913, Period: -4.17441628, Phase: 2.762551710070},
{Amp: 0.0000067324310952, Period: -3.13081218, Phase: 2.113191687546},
{Amp: 0.0000067324310952, Period: 3.13081218, Phase: -2.113191675837},
{Amp: 0.0000072634431824, Period: -50.14182397, Phase: -1.759435081522},
{Amp: 0.0000072634431824, Period: 50.14182397, Phase: 1.759435084589},
{Amp: 0.0000043977562585, Period: -1.76273174, Phase: -2.068506076335},
{Amp: 0.0000043977562585, Period: 1.76273174, Phase: 2.068506076335},
{Amp: 0.0000047762008660, Period: 1.17515450, Phase: 0.746555718634},
{Amp: 0.0000047762008660, Period: -1.17515450, Phase: -0.746555718634},
{Amp: 0.0000031610812971, Period: -2.50464974, Phase: 1.463376735121},
{Amp: 0.0000031610812971, Period: 2.50464974, Phase: -1.463376734852},
{Amp: 0.0000035573097979, Period: -3.58319294, Phase: 2.700332818172},
{Amp: 0.0000035573097979, Period: 3.58319294, Phase: -2.700332827650},
{Amp: 0.0000023282599410, Period: 14399.99998683, Phase: -1.099877635453},
{Amp: 0.0000023282599359, Period: -14399.99998683, Phase: 1.099877639624},
{Amp: 0.0000023502114735, Period: -10.02107511, Phase: -2.536966831342},
{Amp: 0.0000023502114735, Period: 10.02107511, Phase: 2.536966842808},
{Amp: 0.0000023510383997, Period: 1.41018540, Phase: 1.407535452307},
{Amp: 0.0000023510383997, Period: -1.41018540, Phase: -1.407535440592},
{Amp: 0.0000021174661004, Period: 55.93578577, Phase: -2.751914513472},
{Amp: 0.0000021174661004, Period: -55.93578577, Phase: 2.751914548581},
{Amp: 0.0000016288866844, Period: -3679.84840303, Phase: -0.973793744174},
{Amp: 0.0000016288866845, Period: 3679.84840303, Phase: 0.973793744140},
{Amp: 0.0000019377870959, Period: -249.86158491, Phase: 2.450426504098},
{Amp: 0.0000019377870959, Period: 249.86158492, Phase: -2.450426503434},
},
{
{Amp: 0.0002045597496146, Period: -50.17098410, Phase: -1.011816940225},
{Amp: 0.0001785118648258, Period: 7.15455319, Phase: 0.287431567198},
{Amp: 0.0001131999784893, Period: 1.76913777, Phase: 1.446212727724},
{Amp: 0.0000658778169210, Period: -3.50011574, Phase: -0.871350254578},
{Amp: 0.0000497058888328, Period: 3.55118106, Phase: -0.373506086712},
{Amp: 0.0000316384926978, Period: -6.95025977, Phase: -1.532287159698},
{Amp: 0.0000287801237327, Period: -10.02171452, Phase: -1.661453180348},
{Amp: 0.0000181744317896, Period: 16.68901713, Phase: 2.779471484341},
{Amp: 0.0000105558175161, Period: -5.56684974, Phase: -2.311166167228},
{Amp: 0.0000057610853129, Period: -1.40611219, Phase: 1.111461108666},
{Amp: 0.0000061046181888, Period: -7.17825897, Phase: -1.726424077467},
{Amp: 0.0000057310334964, Period: -2.33901627, Phase: -0.210412331329},
{Amp: 0.0000046610005483, Period: -3.85376806, Phase: -2.960887284255},
{Amp: 0.0000034982417330, Period: 3.33918688, Phase: 1.586656801126},
{Amp: 0.0000030091617315, Period: -1.75637195, Phase: 0.450524745204},
{Amp: 0.0000024732926446, Period: 25.01011480, Phase: 2.204580355873},
{Amp: 0.0000024171568015, Period: 0.00000000, Phase: -2.832382068191},
{Amp: 0.0000022247695560, Period: -2.94691618, Phase: 2.672542463618},
{Amp: 0.0000020947921969, Period: 2.63625763, Phase: 2.235037411637},
{Amp: 0.0000024416432533, Period: -25.08296275, Phase: -1.578237604387},
{Amp: 0.0000014042712722, Period: 4.55326510, Phase: -2.204412209524},
{Amp: 0.0000011180389240, Period: -1.17232452, Phase: 1.772399331347},
{Amp: 0.0000010371714944, Period: -7.19017077, Phase: -2.734365259635},
{Amp: 0.0000011076384510, Period: -2.38555726, Phase: 2.022753854104},
{Amp: 0.0000011932531874, Period: 2.17780908, Phase: 2.886194141381},
{Amp: 0.0000007178049665, Period: 2.36171130, Phase: 2.107149695705},
{Amp: 0.0000006908412319, Period: -8.35287888, Phase: -2.223571888688},
{Amp: 0.0000006659820028, Period: 1.85518927, Phase: -2.747232277664},
{Amp: 0.0000006772314920, Period: 2.38747924, Phase: 2.301347989676},
{Amp: 0.0000008993128501, Period: -0.87977305, Phase: -0.047323465226},
{Amp: 0.0000006286307601, Period: 0.88296448, Phase: -1.197531764168},
{Amp: 0.0000006784151570, Period: 1478.51706690, Phase: 0.743089169080},
{Amp: 0.0000005660807396, Period: -2.00384437, Phase: 1.372931645641},
{Amp: 0.0000006339665249, Period: 1.41428228, Phase: 0.785273916249},
{Amp: 0.0000006128705113, Period: 1.00936322, Phase: -0.638851146096},
{Amp: 0.0000005206551413, Period: -1.00519540, Phase: 2.433337444677},
{Amp: 0.0000004583970422, Period: -0.64015046, Phase: -1.206485628814},
{Amp: 0.0000003466029660, Period: 397.49139960, Phase: 0.754124274965},
{Amp: 0.0000004577854173, Period: 3.57743657, Phase: 3.093485444125},
{Amp: 0.0000004718481418, Period: 37429.59476194, Phase: 1.395067854967},
},
{
{Amp: 0.0008533093128905, Period: 0.00000000, Phase: 2.413388168755},
{Amp: 0.0000000926213408, Period: -243.69680308, Phase: -0.936338792860},
{Amp: 0.0000000000106902, Period: -192.66710225, Phase: -1.706763976490},
},
},
{
{
{Amp: 0.0125879701715314, Period: 0.00000000, Phase: 0.000000000000},
{Amp: 0.0000013790105326, Period: -1.97891484, Phase: -1.808423578125},
{Amp: 0.0000013790105326, Period: 1.97891484, Phase: 1.808423589815},
{Amp: 0.0000017976024735, Period: 12.52324852, Phase: 0.649657760137},
{Amp: 0.0000017976024735, Period: -12.52324852, Phase: -0.649657760137},
{Amp: 0.0000006437448086, Period: -4.51107179, Phase: 0.011294478638},
{Amp: 0.0000006437448086, Period: 4.51107179, Phase: -0.011294478574},
{Amp: 0.0000002086864553, Period: -6.26162422, Phase: 1.842527624347},
{Amp: 0.0000002086864553, Period: 6.26162422, Phase: -1.842527612645},
{Amp: 0.0000001395378859, Period: 8.37677335, Phase: 0.714288700422},
{Amp: 0.0000001395378859, Period: -8.37677335, Phase: -0.714288676300},
{Amp: 0.0000000999126129, Period: 4.17441617, Phase: -1.192604552413},
{Amp: 0.0000000999126129, Period: -4.17441617, Phase: 1.192604552682},
{Amp: 0.0000000500574919, Period: -3.13081213, Phase: 0.542875780384},
{Amp: 0.0000000500574919, Period: 3.13081213, Phase: -0.542875768668},
{Amp: 0.0000000256983546, Period: 2.50464971, Phase: 0.106887217028},
{Amp: 0.0000000256983546, Period: -2.50464971, Phase: -0.106887217655},
{Amp: 0.0000000237843996, Period: -8.39299929, Phase: -1.721959538178},
{Amp: 0.0000000237843996, Period: 8.39299929, Phase: 1.721959573291},
{Amp: 0.0000000174121120, Period: 16.69021715, Phase: 0.150827135054},
{Amp: 0.0000000174121120, Period: -16.69021715, Phase: -0.150827135054},
{Amp: 0.0000000119553173, Period: 10.02131069, Phase: -1.925833362861},
{Amp: 0.0000000119553173, Period: -10.02131069, Phase: 1.925833397083},
{Amp: 0.0000000131617319, Period: -2.08719489, Phase: -0.208389567476},
{Amp: 0.0000000131617319, Period: 2.08719489, Phase: 0.208389567476},
{Amp: 0.0000000141920315, Period: -50.14242063, Phase: 1.115887970655},
{Amp: 0.0000000141920315, Period: 50.14242063, Phase: -1.115887945684},
{Amp: 0.0000000109988711, Period: 2.24510856, Phase: 1.507540480873},
{Amp: 0.0000000109988711, Period: -2.24510856, Phase: -1.507540445897},
{Amp: 0.0000000085572239, Period: -2.25553590, Phase: -3.119143532879},
{Amp: 0.0000000085572239, Period: 2.25553590, Phase: 3.119143533646},
{Amp: 0.0000000070978417, Period: -1.78903551, Phase: -1.406515118334},
{Amp: 0.0000000070978417, Period: 1.78903551, Phase: 1.406515133811},
{Amp: 0.0000000060001815, Period: -5.56672420, Phase: 1.283203905605},
{Amp: 0.0000000060001815, Period: 5.56672420, Phase: -1.283203904944},
{Amp: 0.0000000054209452, Period: -0.93228920, Phase: 0.795871427118},
{Amp: 0.0000000054209452, Period: 0.93228920, Phase: -0.795871427118},
{Amp: 0.0000000054109127, Period: 6.18191832, Phase: -0.305922255492},
{Amp: 0.0000000054109127, Period: -6.18191832, Phase: 0.305922255492},
},
{
{Amp: 0.3620341291375704, Period: 0.00000000, Phase: -3.141592653590},
{Amp: 0.0001102576431631, Period: -12.52324875, Phase: 0.920999800869},
{Amp: 0.0001102576431631, Period: 12.52324875, Phase: -0.920999800869},
{Amp: 0.0000938947575579, Period: 1.97891484, Phase: 0.237682433552},
{Amp: 0.0000938947575579, Period: -1.97891484, Phase: -0.237682433079},
{Amp: 0.0000383458487621, Period: 4.51107181, Phase: -1.581970202032},
{Amp: 0.0000383458487621, Period: -4.51107181, Phase: 1.581970202035},
{Amp: 0.0000373528427553, Period: 6.26162437, Phase: -0.271204706549},
{Amp: 0.0000373528427553, Period: -6.26162437, Phase: 0.271204706549},
{Amp: 0.0000194161648683, Period: 8.37677211, Phase: 2.283688674383},
{Amp: 0.0000194161648683, Period: -8.37677211, Phase: -2.283688674381},
{Amp: 0.0000146516338969, Period: -4.17441624, Phase: -0.378597607197},
{Amp: 0.0000146516338969, Period: 4.17441624, Phase: 0.378597607197},
{Amp: 0.0000064963948341, Period: 3.13081218, Phase: 1.028401028095},
{Amp: 0.0000064963948341, Period: -3.13081218, Phase: -1.028401016392},
{Amp: 0.0000074412818628, Period: -50.14197245, Phase: 1.380281518586},
{Amp: 0.0000074412818628, Period: 50.14197245, Phase: -1.380281518587},
{Amp: 0.0000032693721243, Period: 3796.36874430, Phase: 1.987215709322},
{Amp: 0.0000032693721243, Period: -3796.36874429, Phase: -1.987215709307},
{Amp: 0.0000033105851447, Period: -8.39299149, Phase: 2.997485814677},
{Amp: 0.0000033105851447, Period: 8.39299149, Phase: -2.997485813557},
{Amp: 0.0000030790399070, Period: 2.50464974, Phase: 1.678207462150},
{Amp: 0.0000030790399070, Period: -2.50464974, Phase: -1.678207462150},
{Amp: 0.0000023398570389, Period: -10.02107449, Phase: 0.604674627622},
{Amp: 0.0000023398570389, Period: 10.02107449, Phase: -0.604674593224},
},
{
{Amp: 0.0002065924169942, Period: 16.68901704, Phase: -0.362202150664},
{Amp: 0.0001589869764021, Period: 7.15455319, Phase: 0.287440062412},
{Amp: 0.0001486043380971, Period: 1.76913777, Phase: 1.446213430046},
{Amp: 0.0000635073108731, Period: 3.55118106, Phase: -0.373504978601},
{Amp: 0.0000599351698525, Period: -2.24513352, Phase: -2.170633548755},
{Amp: 0.0000489596900866, Period: -10.02171449, Phase: 1.480222294735},
{Amp: 0.0000333682283528, Period: -16.81857729, Phase: -1.076487783264},
{Amp: 0.0000292325461337, Period: -50.17099062, Phase: -1.012422966902},
{Amp: 0.0000295832427279, Period: -6.18210646, Phase: -0.350915517480},
{Amp: 0.0000197588369441, Period: 0.00000000, Phase: -2.951408504882},
{Amp: 0.0000183551029746, Period: -5.56684974, Phase: 0.830451671341},
{Amp: 0.0000081987970452, Period: -3.85376806, Phase: 0.180738718447},
{Amp: 0.0000039403527376, Period: -2.94691618, Phase: -0.469009428127},
{Amp: 0.0000056895636122, Period: -16.88411373, Phase: -2.084089640414},
{Amp: 0.0000040434854859, Period: -25.08297371, Phase: 1.559247963578},
{Amp: 0.0000036901291978, Period: 5.57732714, Phase: 0.352077722692},
{Amp: 0.0000019322089806, Period: 25.00957616, Phase: -1.141325761390},
{Amp: 0.0000019711212463, Period: -2.38555726, Phase: -1.118790685515},
{Amp: 0.0000018673159813, Period: 4.55326506, Phase: -2.204843732624},
{Amp: 0.0000016695689644, Period: 3.33918689, Phase: -1.554811604707},
{Amp: 0.0000014034621874, Period: -2.60801097, Phase: 2.801758550422},
{Amp: 0.0000011758260607, Period: -8.35287933, Phase: 0.916517243491},
{Amp: 0.0000016838424078, Period: 8.34481080, Phase: -0.203581964410},
{Amp: 0.0000010798624964, Period: 2.63625769, Phase: -0.905106521704},
{Amp: 0.0000010108880552, Period: -2.00384437, Phase: -1.768605450166},
{Amp: 0.0000008876681807, Period: -12.52396907, Phase: 1.911818107846},
{Amp: 0.0000008194699011, Period: -16.75354536, Phase: 3.077495182201},
{Amp: 0.0000006728737059, Period: -16.95016232, Phase: -3.092183700860},
{Amp: 0.0000006297345982, Period: 5.58451544, Phase: 1.359571973412},
{Amp: 0.0000006128899757, Period: 0.88296471, Phase: -1.142969177277},
{Amp: 0.0000007093782158, Period: -2.59408012, Phase: -1.871354043032},
{Amp: 0.0000005580987049, Period: -5.01075568, Phase: 0.270180657271},
},
{
{Amp: 0.0038422977898495, Period: 0.00000000, Phase: 2.413392208631},
{Amp: 0.0000000000666922, Period: -192.00000000, Phase: -2.221562730997},
},
},
}
var jupiterGalileanL1Chebyshev = [4][5][9]float64{
{
{8.26698820334074e-005, -5.46986318473484e-006, 5.94965634346142e-005, -1.10076009434021e-005, -5.75048718532862e-006, 5.74994024502453e-006, 1.78506935927118e-006, -2.40677412003469e-006, -1.38513209122323e-006},
{-1.33581115495368e-007, -7.58579668623469e-009, -1.73800453537518e-009, -3.02381800276162e-009, -7.21047445746571e-009, -3.92259073275152e-010, -4.16879673796892e-009, -3.29083173926104e-009, 2.88537255009037e-009},
{-4.47550080805276e-008, -5.78177122641661e-010, -5.35142525086474e-009, 1.02942385189608e-010, -8.49040607452073e-009, -2.52840670456370e-009, -6.13405545173217e-009, -3.43800069555589e-009, -3.59984191898440e-009},
{-2.91234929817703e-007, -1.62905002759586e-007, -3.68431020234090e-009, 7.77164290527916e-010, 1.59932283427177e-009, -2.28491296590504e-009, 7.03033944342130e-010, -2.89289190860300e-011, 4.86451562749639e-010},
{-3.17117027043262e-007, -1.26321744097649e-007, 6.51758518535435e-009, 2.62070675646012e-009, 2.59238370832140e-009, -7.26318061740534e-010, -1.18191642174809e-009, 5.90816038543625e-010, 1.23053999757825e-009},
},
{
{-2.34691437046728e-004, 2.59036121717763e-005, -1.73835841495123e-004, 3.80673357683932e-005, -1.03485888270120e-007, 1.17376776995138e-005, 3.19509608989603e-006, -4.83167496026174e-006, 4.47094431509368e-007},
{-1.00355398526925e-006, -5.80438343691240e-008, 3.80875695145265e-008, -2.10657418625019e-007, 3.12238251072476e-007, -1.72444072203130e-007, 2.35985832098386e-007, 6.13727340553035e-008, -1.58131098756712e-007},
{-2.61024960215820e-007, -1.82656275716032e-007, 2.78016374834476e-008, -1.58858363289506e-007, 2.31850746815330e-008, -1.10691155968765e-007, -3.69048459423356e-008, 6.78631069587260e-009, 1.90777145516695e-008},
{-2.74704767394256e-006, -1.54365304913777e-006, -3.49546762061882e-008, 2.86789414864130e-009, 1.75452766852069e-008, -1.97744662243578e-008, 3.43169676541053e-009, -1.30924950693160e-010, 1.35338072425105e-008},
{-2.98415696654187e-006, -1.19708851225481e-006, 5.67146658521585e-008, 2.68814727457189e-008, 2.61897719058852e-008, -5.11649608698392e-009, -1.27841777400320e-008, 1.19534263567129e-008, 1.15749543781328e-008},
},
{
{-4.02499890223698e-004, 4.22870514937427e-005, -2.88258753042082e-004, 6.23338370193608e-005, 1.78692873660163e-006, 1.44147643545141e-005, 6.58944353306297e-006, -6.19139500170464e-006, 7.82954615405540e-007},
{-1.11797459279082e-007, -2.78967292638245e-007, -7.04645336679784e-008, -2.38693040681653e-007, 1.22869992713930e-008, -1.53370399783488e-007, 4.47318789843126e-008, -1.65802124252890e-008, -8.28946056921499e-008},
{-1.07044201697345e-007, -2.23668695371038e-007, -2.18527598721759e-007, -2.00040954734845e-007, -3.19586844323525e-007, -2.22086683443490e-007, -2.98609138042077e-007, -1.29217448782639e-007, -1.50654590880644e-007},
{-1.41968295962213e-005, -7.93995493982020e-006, -2.08882224662785e-007, 2.09762683478441e-008, 8.35070484335148e-008, -1.07729670354460e-007, 3.06301230195125e-008, 1.12449433356374e-008, 7.08786417409988e-009},
{-1.53976109321613e-005, -6.12022141299496e-006, 3.15217558237908e-007, 1.48179418616661e-007, 1.41150094231365e-007, -2.24063936978287e-008, -3.33311708439601e-008, 1.07863442857316e-008, 6.62499119729457e-008},
},
{
{3.06204347064615e-004, -1.68321429486551e-004, 2.02182016979082e-004, -1.37641833741129e-004, -5.30404402536766e-005, -1.10474926063654e-005, -1.05634674903822e-005, 3.11526543050599e-005, 4.66495863544146e-006},
{-1.81310025984216e-007, -1.26014618556329e-006, -3.82306332383069e-007, -6.67102945644653e-007, -5.05214970641581e-008, 1.53918794369926e-007, 9.28279781805284e-008, 3.66048038576706e-007, 5.68038769950531e-008},
{-1.40782617429892e-006, -1.35394137498700e-006, -3.20084097458594e-007, -6.20618627039507e-007, -6.11742588608485e-007, -2.76537195728238e-007, -1.33810977431205e-007, 2.89677483700145e-007, 4.70713328715423e-008},
{-6.40936655172450e-005, -3.57948862513955e-005, -8.87261624128964e-007, 1.64506004407617e-007, 4.77415826267116e-007, -3.59030239515192e-007, 1.17406290303369e-007, 8.95051737231057e-008, 9.71875658565188e-009},
{-6.96069435911693e-005, -2.76636397672750e-005, 1.38328020437413e-006, 6.51631405812798e-007, 6.20307849865976e-007, -2.66081185506962e-007, -2.09003718083271e-007, -1.46372661194868e-007, -1.22390974721364e-007},
},
}
+203
View File
@@ -0,0 +1,203 @@
package basic
import (
"math"
)
const solarRadiusAU = 695700.0 / astronomicalUnitKM
// JupiterGalileanPhenomenon 木星伽利略卫星瞬时现象 / instantaneous Galilean-satellite phenomena.
//
// Transit 表示卫星本体在木星盘前;Occultation 表示卫星在木星盘后被掩蔽;Eclipse 表示卫星落入木星本影;ShadowTransit 表示卫星影心落在可见木星盘面上。
// Transit means the satellite itself is in front of Jupiter's disk; Occultation means it is hidden behind the disk; Eclipse means the satellite lies in Jupiter's umbra; ShadowTransit means the center of the satellite shadow falls on the visible Jovian disk.
type JupiterGalileanPhenomenon struct {
Transit bool
Occultation bool
Eclipse bool
ShadowTransit bool
ShadowOffsetXArcsec float64
ShadowOffsetYArcsec float64
ShadowOffsetXJupiterRadii float64
ShadowOffsetYJupiterRadii float64
}
// JupiterGalileanSatellitePhenomenon 单颗伽利略卫星瞬时现象 / instantaneous phenomena of one Galilean satellite.
func JupiterGalileanSatellitePhenomenon(jde float64, satellite int) JupiterGalileanPhenomenon {
if satellite < 1 || satellite > 4 || !isFinite(jde) {
return invalidJupiterGalileanPhenomenon()
}
context := newJupiterGalileanObservationContext(jde)
return context.phenomenonForSatellite(satellite - 1)
}
// JupiterGalileanSatellitePhenomenaWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterGalileanSatellitePhenomenaWithInstance(jde float64, inst *Instance) [4]JupiterGalileanPhenomenon {
var phenomena [4]JupiterGalileanPhenomenon
context := newJupiterGalileanObservationContext(jde, inst)
for i := range phenomena {
phenomena[i] = context.phenomenonForSatellite(i)
}
return phenomena
}
func (context jupiterGalileanObservationContext) phenomenonForSatellite(index int) JupiterGalileanPhenomenon {
if index < 0 || index >= 4 || context.jupiterDistance == 0 {
return invalidJupiterGalileanPhenomenon()
}
observation := context.observationForSatellite(index)
stateVector := Vector3{observation.State.X, observation.State.Y, observation.State.Z}
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
xEarth := observation.OffsetXJupiterRadii
yEarth := observation.OffsetYJupiterRadii
onEarthDisk := ellipseInside(xEarth, yEarth, 1, context.earthMinorRadius)
xSunAU := vectorDot(stateVector, context.sunEast)
ySunAU := vectorDot(stateVector, context.sunNorth)
zSunAU := vectorDot(stateVector, context.sunLineOfSight)
xSun := xSunAU / radiusAU
ySun := ySunAU / radiusAU
umbraScale := jupiterUmbraScale(zSunAU, context.sunDistanceAU)
eclipse := false
if zSunAU > 0 && umbraScale > 0 {
eclipse = ellipseInside(xSun, ySun, umbraScale, context.sunMinorRadius*umbraScale)
}
shadowTransit, shadowXAU, shadowYAU := context.shadowTransitFor(stateVector)
phenomenon := JupiterGalileanPhenomenon{
Transit: onEarthDisk && observation.InFrontOfJupiter,
Occultation: onEarthDisk && !observation.InFrontOfJupiter,
Eclipse: eclipse,
ShadowTransit: shadowTransit,
ShadowOffsetXArcsec: math.NaN(),
ShadowOffsetYArcsec: math.NaN(),
ShadowOffsetXJupiterRadii: math.NaN(),
ShadowOffsetYJupiterRadii: math.NaN(),
}
if shadowTransit {
phenomenon.ShadowOffsetXArcsec = math.Atan2(shadowXAU, context.jupiterDistance) * deg * 3600
phenomenon.ShadowOffsetYArcsec = math.Atan2(shadowYAU, context.jupiterDistance) * deg * 3600
phenomenon.ShadowOffsetXJupiterRadii = shadowXAU / radiusAU
phenomenon.ShadowOffsetYJupiterRadii = shadowYAU / radiusAU
}
return phenomenon
}
func (context jupiterGalileanObservationContext) shadowTransitFor(stateVector Vector3) (bool, float64, float64) {
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
satelliteBody := context.toBodyCoordinates(stateVector)
satelliteBody = Vector3{satelliteBody[0] / radiusAU, satelliteBody[1] / radiusAU, satelliteBody[2] / radiusAU}
directionBody := context.toBodyCoordinates(context.sunLineOfSight)
intersectionBody, ok := ellipsoidRayIntersection(satelliteBody, directionBody, jupiterPolarRadiusRatio())
if !ok {
return false, 0, 0
}
normalBody := Vector3{intersectionBody[0], intersectionBody[1], intersectionBody[2] / (jupiterPolarRadiusRatio() * jupiterPolarRadiusRatio())}
earthBody := context.toBodyCoordinates(context.earthDirection)
if vectorDot(normalBody, earthBody) <= 0 {
return false, 0, 0
}
intersection := context.fromBodyCoordinates(Vector3{
intersectionBody[0] * radiusAU,
intersectionBody[1] * radiusAU,
intersectionBody[2] * radiusAU,
})
xAU := vectorDot(intersection, context.east)
yAU := vectorDot(intersection, context.north)
x := xAU / radiusAU
y := yAU / radiusAU
if !ellipseInside(x, y, 1, context.earthMinorRadius) {
return false, 0, 0
}
return true, xAU, yAU
}
func (context jupiterGalileanObservationContext) toBodyCoordinates(vector Vector3) Vector3 {
return Vector3{
vectorDot(vector, context.bodyX),
vectorDot(vector, context.bodyY),
vectorDot(vector, context.bodyZ),
}
}
func (context jupiterGalileanObservationContext) fromBodyCoordinates(vector Vector3) Vector3 {
return Vector3{
context.bodyX[0]*vector[0] + context.bodyY[0]*vector[1] + context.bodyZ[0]*vector[2],
context.bodyX[1]*vector[0] + context.bodyY[1]*vector[1] + context.bodyZ[1]*vector[2],
context.bodyX[2]*vector[0] + context.bodyY[2]*vector[1] + context.bodyZ[2]*vector[2],
}
}
func jupiterProjectedMinorRadius(direction, pole Vector3) float64 {
sinBeta := vectorDot(direction, pole)
cos2Beta := 1 - sinBeta*sinBeta
if cos2Beta < 0 {
cos2Beta = 0
}
ratio := jupiterPolarRadiusRatio()
return math.Sqrt(sinBeta*sinBeta + ratio*ratio*cos2Beta)
}
func jupiterPolarRadiusRatio() float64 {
return jupiterPhysicalModel.polarRadius / jupiterPhysicalModel.equatorialRadius
}
func ellipseInside(x, y, major, minor float64) bool {
if major <= 0 || minor <= 0 {
return false
}
return (x*x)/(major*major)+(y*y)/(minor*minor) <= 1+1e-12
}
func ellipsoidRayIntersection(origin, direction Vector3, polarRatio float64) (Vector3, bool) {
invPolar2 := 1 / (polarRatio * polarRatio)
a := direction[0]*direction[0] + direction[1]*direction[1] + direction[2]*direction[2]*invPolar2
b := 2 * (origin[0]*direction[0] + origin[1]*direction[1] + origin[2]*direction[2]*invPolar2)
c := origin[0]*origin[0] + origin[1]*origin[1] + origin[2]*origin[2]*invPolar2 - 1
discriminant := b*b - 4*a*c
if discriminant < 0 {
return Vector3{}, false
}
sqrtDiscriminant := math.Sqrt(discriminant)
t1 := (-b - sqrtDiscriminant) / (2 * a)
t2 := (-b + sqrtDiscriminant) / (2 * a)
t := math.Inf(1)
if t1 > 0 {
t = t1
}
if t2 > 0 && t2 < t {
t = t2
}
if !isFinite(t) {
return Vector3{}, false
}
return Vector3{
origin[0] + t*direction[0],
origin[1] + t*direction[1],
origin[2] + t*direction[2],
}, true
}
func jupiterUmbraScale(distanceBehindAU, sunDistanceAU float64) float64 {
if distanceBehindAU <= 0 || sunDistanceAU <= 0 {
return 0
}
jupiterRadiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
umbraLength := jupiterRadiusAU * sunDistanceAU / (solarRadiusAU - jupiterRadiusAU)
if umbraLength <= 0 {
return 0
}
return 1 - distanceBehindAU/umbraLength
}
func invalidJupiterGalileanPhenomenon() JupiterGalileanPhenomenon {
nan := math.NaN()
return JupiterGalileanPhenomenon{
ShadowOffsetXArcsec: nan,
ShadowOffsetYArcsec: nan,
ShadowOffsetXJupiterRadii: nan,
ShadowOffsetYJupiterRadii: nan,
}
}
+445
View File
@@ -0,0 +1,445 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
const (
jupiterGalileanReferenceJD = 2433282.5
jupiterGalileanLongPeriodShift = 310910.16
jupiterGalileanMinSolarLPYear = 1150.0
jupiterGalileanMaxSolarLPYear = 2750.0
jupiterGalileanEquatorialRadiusKM = 71492.0
astronomicalUnitKM = 149597870.691
)
var (
jupiterGalileanBaseMeanLongitudes = [4]float64{3.55155228618240, 1.76932271112347, 0.878207923589328, 0.376486233433828}
jupiterGalileanMu = [4]float64{2.82489428433814e-07, 2.82483274392893e-07, 2.82498184184723e-07, 2.82492144889909e-07}
)
const (
jupiterGalileanFrameNode = 6.24950183065715
jupiterGalileanFrameTilt = 0.445094736497665
)
type jupiterGalileanL1Term struct {
Amp float64
Period float64
Phase float64
}
// 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 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
Z float64
VX float64
VY float64
VZ float64
}
// JupiterGalileanObservation 木星伽利略卫星视位置 / apparent Galilean-satellite geometry.
//
// 视位置相对木星中心定义:X 向天球东为正,Y 向天球北为正,Z>0 表示比木星更远、位于盘后。
// Apparent offsets are relative to Jupiter's center: X is positive to celestial east, Y to celestial north, and Z>0 means farther than Jupiter and behind the disk.
type JupiterGalileanObservation struct {
State JupiterGalileanState
RA float64
Dec float64
Distance float64
OffsetXArcsec float64
OffsetYArcsec float64
OffsetXJupiterRadii float64
OffsetYJupiterRadii float64
OffsetZJupiterRadii float64
InFrontOfJupiter bool
}
// JupiterGalileanSatelliteState 伽利略卫星木心 J2000 状态 / Jovicentric J2000 state of a Galilean satellite.
//
// satellite 取 1=Io, 2=Europa, 3=Ganymede, 4=Callisto。jd 为 TT/TDB 对应儒略日。
// satellite is 1=Io, 2=Europa, 3=Ganymede, 4=Callisto. jd is a TT/TDB Julian day.
func JupiterGalileanSatelliteState(jd float64, satellite int) JupiterGalileanState {
if satellite < 1 || satellite > 4 || !isFinite(jd) {
return invalidJupiterGalileanState()
}
et := jd - jupiterGalileanReferenceJD
includeSolarLongPeriod := jupiterGalileanUseSolarLongPeriod(jd)
return jupiterGalileanSatelliteStateAtET(et, satellite-1, includeSolarLongPeriod)
}
// JupiterGalileanSatelliteStates 四颗伽利略卫星木心 J2000 状态 / Jovicentric J2000 states of the four Galilean satellites.
//
// 返回次序固定为 Io、Europa、Ganymede、Callisto。
// The returned order is Io, Europa, Ganymede, Callisto.
func JupiterGalileanSatelliteStates(jd float64) [4]JupiterGalileanState {
var states [4]JupiterGalileanState
et := jd - jupiterGalileanReferenceJD
includeSolarLongPeriod := jupiterGalileanUseSolarLongPeriod(jd)
for i := range states {
states[i] = jupiterGalileanSatelliteStateAtET(et, i, includeSolarLongPeriod)
}
return states
}
// JupiterGalileanSatelliteObservation 伽利略卫星视位置 / apparent geometry of a Galilean satellite.
//
// 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(jde float64, satellite int) JupiterGalileanObservation {
if satellite < 1 || satellite > 4 || !isFinite(jde) {
return invalidJupiterGalileanObservation()
}
context := newJupiterGalileanObservationContext(jde)
return context.observationForSatellite(satellite - 1)
}
// JupiterGalileanSatelliteObservationsWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterGalileanSatelliteObservationsWithInstance(jde float64, inst *Instance) [4]JupiterGalileanObservation {
var observations [4]JupiterGalileanObservation
context := newJupiterGalileanObservationContext(jde, inst)
for i := range observations {
observations[i] = context.observationForSatellite(i)
}
return observations
}
type jupiterGalileanObservationContext struct {
inst *Instance
jd float64
targetJD float64
earthHelioJ2000 Vector3
jupiterGeoJ2000 Vector3
jupiterDistance float64
jupiterLightTime float64
sunDistanceAU float64
east Vector3
north Vector3
lineOfSight Vector3
earthDirection Vector3
sunDirection Vector3
sunLineOfSight Vector3
sunEast Vector3
sunNorth Vector3
earthMinorRadius float64
sunMinorRadius float64
bodyX Vector3
bodyY Vector3
bodyZ Vector3
}
func newJupiterGalileanObservationContext(jde float64, instances ...*Instance) jupiterGalileanObservationContext {
inst := instanceValue(instances)
context := jupiterGalileanObservationContext{inst: inst, jd: jde}
if !isFinite(jde) {
return context
}
context.earthHelioJ2000 = rotateEclipticToEquatorial(earthHeliocentricVectorJ2000(jde, inst), orbitJ2000Obliquity)
context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jde, context.earthHelioJ2000, inst)
context.targetJD = jde - context.jupiterLightTime
context.jupiterDistance = vectorMagnitude(context.jupiterGeoJ2000)
if context.jupiterDistance == 0 {
return context
}
context.lineOfSight = normalizeVector(context.jupiterGeoJ2000)
context.earthDirection = Vector3{-context.lineOfSight[0], -context.lineOfSight[1], -context.lineOfSight[2]}
ra, dec := vectorToRaDec(context.lineOfSight)
context.east = Vector3{-Sin(ra), Cos(ra), 0}
context.north = Vector3{-Cos(ra) * Sin(dec), -Sin(ra) * Sin(dec), Cos(dec)}
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(context.targetJD, inst), orbitJ2000Obliquity)
context.sunDistanceAU = vectorMagnitude(jupiterHelio)
context.sunDirection = normalizeVector(Vector3{-jupiterHelio[0], -jupiterHelio[1], -jupiterHelio[2]})
context.sunLineOfSight = Vector3{-context.sunDirection[0], -context.sunDirection[1], -context.sunDirection[2]}
sunRA, sunDec := vectorToRaDec(context.sunLineOfSight)
context.sunEast = Vector3{-Sin(sunRA), Cos(sunRA), 0}
context.sunNorth = Vector3{-Cos(sunRA) * Sin(sunDec), -Sin(sunRA) * Sin(sunDec), Cos(sunDec)}
poleRA, poleDec, _ := jupiterPoleRotation(context.targetJD)
context.bodyZ = raDecToVector(poleRA, poleDec)
context.bodyX = normalizeVector(Vector3{-math.Sin(poleRA * rad), math.Cos(poleRA * rad), 0})
context.bodyY = normalizeVector(pxp(context.bodyZ, context.bodyX))
context.earthMinorRadius = jupiterProjectedMinorRadius(context.earthDirection, context.bodyZ)
context.sunMinorRadius = jupiterProjectedMinorRadius(context.sunDirection, context.bodyZ)
return context
}
func (context jupiterGalileanObservationContext) observationForSatellite(index int) JupiterGalileanObservation {
if index < 0 || index >= 4 || context.jupiterDistance == 0 {
return invalidJupiterGalileanObservation()
}
state, geocentric := jupiterGalileanSatelliteAstrometricGeocentric(index, context.jd, context.jupiterLightTime, context.earthHelioJ2000, context.inst)
direction := normalizeVector(geocentric)
ra, dec := vectorToRaDec(direction)
distance := vectorMagnitude(geocentric)
relative := Vector3{
geocentric[0] - context.jupiterGeoJ2000[0],
geocentric[1] - context.jupiterGeoJ2000[1],
geocentric[2] - context.jupiterGeoJ2000[2],
}
zAU := vectorDot(relative, context.lineOfSight)
radiusAU := jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
offsetXRad, offsetYRad := tangentPlaneOffsetAngles(direction, context.lineOfSight, context.east, context.north)
jupiterSemidiameterArcsec := math.Atan2(radiusAU, context.jupiterDistance) * deg * 3600
return JupiterGalileanObservation{
State: state,
RA: ra,
Dec: dec,
Distance: distance,
OffsetXArcsec: offsetXRad * deg * 3600,
OffsetYArcsec: offsetYRad * deg * 3600,
OffsetXJupiterRadii: offsetXRad * deg * 3600 / jupiterSemidiameterArcsec,
OffsetYJupiterRadii: offsetYRad * deg * 3600 / jupiterSemidiameterArcsec,
OffsetZJupiterRadii: zAU / radiusAU,
InFrontOfJupiter: zAU < 0,
}
}
func tangentPlaneOffsetAngles(target, center, east, north Vector3) (float64, float64) {
denominator := vectorDot(target, center)
return math.Atan2(vectorDot(target, east), denominator), math.Atan2(vectorDot(target, north), denominator)
}
func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTime float64, earthHelioJ2000 Vector3, instances ...*Instance) (JupiterGalileanState, Vector3) {
inst := instanceValue(instances)
lightTime := initialLightTime
state := JupiterGalileanState{}
result := Vector3{}
includeSolarLongPeriod := jupiterGalileanUseSolarLongPeriod(jd)
for i := 0; i < 8; i++ {
targetJDE := jd - lightTime
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(targetJDE, inst), orbitJ2000Obliquity)
state = jupiterGalileanSatelliteStateAtET(targetJDE-jupiterGalileanReferenceJD, index, includeSolarLongPeriod)
result = Vector3{
jupiterHelio[0] + state.X - earthHelioJ2000[0],
jupiterHelio[1] + state.Y - earthHelioJ2000[1],
jupiterHelio[2] + state.Z - earthHelioJ2000[2],
}
nextLightTime := lightTimeDaysPerAU * vectorMagnitude(result)
if math.Abs(nextLightTime-lightTime) < 1e-12 {
break
}
lightTime = nextLightTime
}
return state, result
}
func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3, instances ...*Instance) (Vector3, float64) {
inst := instanceValue(instances)
lightTime := 0.0
result := Vector3{}
for i := 0; i < 8; i++ {
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(jd-lightTime, inst), orbitJ2000Obliquity)
result = Vector3{
jupiterHelio[0] - earthHelioJ2000[0],
jupiterHelio[1] - earthHelioJ2000[1],
jupiterHelio[2] - earthHelioJ2000[2],
}
nextLightTime := lightTimeDaysPerAU * vectorMagnitude(result)
if math.Abs(nextLightTime-lightTime) < 1e-12 {
return result, nextLightTime
}
lightTime = nextLightTime
}
return result, lightTime
}
func jupiterHeliocentricVectorJ2000(jde float64, instances ...*Instance) Vector3 {
inst := instanceValue(instances)
return eclipticVectorAtReferenceEpoch(
eclipticCartesian(
PlanetCoordinateWithInstance(4, 0, jde, inst),
PlanetCoordinateWithInstance(4, 1, jde, inst),
PlanetCoordinateWithInstance(4, 2, jde, inst),
),
jde,
orbitReferenceJD,
)
}
func jupiterGalileanSatelliteStateAtET(et float64, index int, includeSolarLongPeriod bool) JupiterGalileanState {
elements := jupiterGalileanElementsAtET(et, index, includeSolarLongPeriod)
pv := jupiterGalileanElementsToPV(jupiterGalileanMu[index], elements)
cosNode, sinNode := math.Cos(jupiterGalileanFrameNode), math.Sin(jupiterGalileanFrameNode)
cosTilt, sinTilt := math.Cos(jupiterGalileanFrameTilt), math.Sin(jupiterGalileanFrameTilt)
return JupiterGalileanState{
X: pv[0]*cosNode - pv[1]*sinNode*cosTilt + pv[2]*sinTilt*sinNode,
Y: pv[0]*sinNode + pv[1]*cosNode*cosTilt - pv[2]*sinTilt*cosNode,
Z: pv[1]*sinTilt + pv[2]*cosTilt,
VX: pv[3]*cosNode - pv[4]*sinNode*cosTilt + pv[5]*sinTilt*sinNode,
VY: pv[3]*sinNode + pv[4]*cosNode*cosTilt - pv[5]*sinTilt*cosNode,
VZ: pv[4]*sinTilt + pv[5]*cosTilt,
}
}
type jupiterGalileanElements struct {
A float64
L float64
K float64
H float64
Q float64
P float64
}
func jupiterGalileanElementsAtET(et float64, index int, includeSolarLongPeriod bool) jupiterGalileanElements {
longPeriod := jupiterGalileanEvaluateSeries(jupiterGalileanL1LongPeriodTerms[index], et+jupiterGalileanLongPeriodShift, et, includeSolarLongPeriod, index)
crossPeriod := jupiterGalileanEvaluateSeries(jupiterGalileanL1CrossPeriodTerms[index], et, et, false, index)
combined := jupiterGalileanElements{
A: longPeriod.A + crossPeriod.A,
L: longPeriod.L + crossPeriod.L + jupiterGalileanBaseMeanLongitudes[index]*et,
K: longPeriod.K + crossPeriod.K,
H: longPeriod.H + crossPeriod.H,
Q: longPeriod.Q + crossPeriod.Q,
P: longPeriod.P + crossPeriod.P,
}
combined.L = math.Atan2(math.Sin(combined.L), math.Cos(combined.L))
if combined.L < 0 {
combined.L += 2 * math.Pi
}
return combined
}
func jupiterGalileanEvaluateSeries(blocks [4][]jupiterGalileanL1Term, angleTime, et float64, includeSolarLongPeriod bool, index int) jupiterGalileanElements {
vals := [5]float64{}
if includeSolarLongPeriod {
x := (et/365.25 - 0.5*(812.721806990360-819.727638594856)) / (0.5 * (812.721806990360 - -819.727638594856))
tn := [9]float64{1, x}
for i := 2; i < len(tn); i++ {
tn[i] = 2*x*tn[i-1] - tn[i-2]
}
for variable := 0; variable < len(vals); variable++ {
sum := 0.0
for term := 0; term < len(tn); term++ {
sum += jupiterGalileanL1Chebyshev[index][variable][term] * tn[term]
}
vals[variable] = sum - 0.5*jupiterGalileanL1Chebyshev[index][variable][0]
}
}
result := jupiterGalileanElements{}
for blockIndex, terms := range blocks {
realPart, imagPart := 0.0, 0.0
for _, term := range terms {
angle := term.Phase
if term.Period != 0 {
angle += 2 * math.Pi * angleTime / term.Period
}
realPart += term.Amp * math.Cos(angle)
imagPart += term.Amp * math.Sin(angle)
}
switch blockIndex {
case 0:
result.A = realPart
case 1:
result.L = realPart + vals[0]
case 2:
result.K = realPart + vals[1]
result.H = imagPart + vals[2]
case 3:
result.Q = realPart + vals[3]
result.P = imagPart + vals[4]
}
}
return result
}
func jupiterGalileanElementsToPV(mu float64, elements jupiterGalileanElements) [6]float64 {
k := elements.K
h := elements.H
q := elements.Q
p := elements.P
a := elements.A
al := elements.L
invalid := func() [6]float64 {
nan := math.NaN()
return [6]float64{nan, nan, nan, nan, nan, nan}
}
if !isFiniteFloat(mu) || !isFiniteFloat(k) || !isFiniteFloat(h) || !isFiniteFloat(q) || !isFiniteFloat(p) || !isFiniteFloat(a) || !isFiniteFloat(al) || a == 0 {
return invalid()
}
an := math.Sqrt(mu / math.Pow(a, 3))
ee := al + k*math.Sin(al) - h*math.Cos(al)
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
ce := math.Cos(ee)
se := math.Sin(ee)
de := (al - ee + k*se - h*ce) / (1 - k*ce - h*se)
if !isFiniteFloat(de) {
return invalid()
}
ee += de
if math.Abs(de) < 1e-12 {
converged = true
break
}
}
if !converged {
return invalid()
}
ce := math.Cos(ee)
se := math.Sin(ee)
dle := h*ce - k*se
rsam1 := -k*ce - h*se
asr := 1 / (1 + rsam1)
phi := math.Sqrt(1 - k*k - h*h)
psi := 1 / (1 + phi)
x1 := a * (ce - k - psi*h*dle)
y1 := a * (se - h + psi*k*dle)
vx1 := an * asr * a * (-se - psi*h*rsam1)
vy1 := an * asr * a * (ce + psi*k*rsam1)
f2 := 2 * math.Sqrt(1-q*q-p*p)
p2 := 1 - 2*p*p
q2 := 1 - 2*q*q
pq := 2 * p * q
return [6]float64{
x1*p2 + y1*pq,
x1*pq + y1*q2,
(q*y1 - x1*p) * f2,
vx1*p2 + vy1*pq,
vx1*pq + vy1*q2,
(q*vy1 - vx1*p) * f2,
}
}
func jupiterGalileanUseSolarLongPeriod(jd float64) bool {
year := 2000.0 + (jd-2451545.0)/365.25
return year >= jupiterGalileanMinSolarLPYear && year <= jupiterGalileanMaxSolarLPYear
}
func invalidJupiterGalileanState() JupiterGalileanState {
nan := math.NaN()
return JupiterGalileanState{X: nan, Y: nan, Z: nan, VX: nan, VY: nan, VZ: nan}
}
func invalidJupiterGalileanObservation() JupiterGalileanObservation {
nan := math.NaN()
return JupiterGalileanObservation{
State: invalidJupiterGalileanState(),
RA: nan,
Dec: nan,
Distance: nan,
OffsetXArcsec: nan,
OffsetYArcsec: nan,
OffsetXJupiterRadii: nan,
OffsetYJupiterRadii: nan,
OffsetZJupiterRadii: nan,
InFrontOfJupiter: false,
}
}
+75
View File
@@ -0,0 +1,75 @@
package basic
import (
"math"
"testing"
)
const galileanSampleToleranceAU = 1e-15
const galileanSampleToleranceAUDay = 1e-15
type galileanSample struct {
name string
index int
state JupiterGalileanState
}
func TestJupiterGalileanSatelliteStateMatchesIMCCESample(t *testing.T) {
samples := []galileanSample{
{
name: "Io",
index: 1,
state: JupiterGalileanState{X: 2.671999370920431e-003, Y: 7.644018403387422e-004, Z: 4.087344808808269e-004, VX: -3.116203340625001e-003, VY: 8.645679572984422e-003, VZ: 4.066210333795641e-003},
},
{
name: "Europa",
index: 2,
state: JupiterGalileanState{X: -3.751373844521062e-003, Y: -2.136179970327756e-003, Z: -1.056765216826830e-003, VX: 4.310591732986133e-003, VY: -6.143199976514738e-003, VZ: -2.800434328620005e-003},
},
{
name: "Ganymede",
index: 3,
state: JupiterGalileanState{X: -5.490036250442612e-003, Y: -4.112229247907583e-003, Z: -2.033821277493470e-003, VX: 4.036147912130572e-003, VY: -4.364866691392988e-003, VZ: -2.037111499364415e-003},
},
{
name: "Callisto",
index: 4,
state: JupiterGalileanState{X: 2.172082907229073e-003, Y: 1.118792302205555e-002, Z: 5.322275059416266e-003, VX: -4.662583658656747e-003, VY: 7.976685330152526e-004, VZ: 3.092058747362411e-004},
},
}
const jd = 2451545.0
maxPosDiff := 0.0
maxVelDiff := 0.0
for _, sample := range samples {
got := JupiterGalileanSatelliteState(jd, sample.index)
posDiffs := []float64{
math.Abs(got.X - sample.state.X),
math.Abs(got.Y - sample.state.Y),
math.Abs(got.Z - sample.state.Z),
}
velDiffs := []float64{
math.Abs(got.VX - sample.state.VX),
math.Abs(got.VY - sample.state.VY),
math.Abs(got.VZ - sample.state.VZ),
}
for i, diff := range posDiffs {
if diff > maxPosDiff {
maxPosDiff = diff
}
if diff > galileanSampleToleranceAU {
t.Fatalf("%s position[%d] mismatch: got %.18e want %.18e", sample.name, i, []float64{got.X, got.Y, got.Z}[i], []float64{sample.state.X, sample.state.Y, sample.state.Z}[i])
}
}
for i, diff := range velDiffs {
if diff > maxVelDiff {
maxVelDiff = diff
}
if diff > galileanSampleToleranceAUDay {
t.Fatalf("%s velocity[%d] mismatch: got %.18e want %.18e", sample.name, i, []float64{got.VX, got.VY, got.VZ}[i], []float64{sample.state.VX, sample.state.VY, sample.state.VZ}[i])
}
}
}
// Official IMCCE README example for V1_1 at JD 2451545.0.
t.Logf("galilean IMCCE sample max diff: position=%.3e AU velocity=%.3e AU/day", maxPosDiff, maxVelDiff)
}
-13
View File
@@ -1,13 +0,0 @@
package basic
import (
"fmt"
"testing"
)
func TestJupiter(t *testing.T) {
jde := GetNowJDE() - 6000
for i := 0.00; i < 20; i++ {
fmt.Println(jde+i*365, JDE2Date(jde+i*365), JDE2Date(NextJupiterRetrogradeToPrograde(jde+i*365)))
}
}
+198
View File
@@ -0,0 +1,198 @@
package basic
import (
"math"
"time"
)
const (
// 月掩南北界:星迹相对月心是直线,偏离切点 δ 后月缘需高出 R(1/cosδ − 1),8° 时约 17 km,远超月面起伏。
limbGrazeSpanDeg = 8.0
limbGrazeStepDeg = 0.1
// 界线点月缘平移上限;超出视为不收敛并保留圆月面点。
limbLimitMaxShiftKM = 30.0
limbLimitProbeKM = 1.0
limbLimitIterations = 4
// 太阳 / 月球高度低于此值的界线点不做月缘平移。
limbLimitHorizonDeg = 0.5
)
// limbLimitGap 返回站点处的圆月面接触间隙与月缘要求的间隙(同一单位),二者相等即为月缘界线。
type limbLimitGap func(longitude, latitude float64) (gap, target float64, ok bool)
// limbLimitSolve 从圆月面界线点出发,沿间隙梯度做牛顿迭代,使 gap − gap0 = target;失败时返回原点。
// gap0 是原点处的圆月面间隙:界线点与站心间隙零点之间的模型差由它吸收,只平移月缘带来的部分。
func limbLimitSolve(longitude, latitude float64, evaluate limbLimitGap) (float64, float64, bool) {
kmPerDegLat := math.Pi / 180 * topocentricEarthRadiusKM
gap0, _, ok := evaluate(longitude, latitude)
if !ok {
return longitude, latitude, false
}
lon, lat := longitude, latitude
for iteration := 0; iteration < limbLimitIterations; iteration++ {
gap, target, ok := evaluate(lon, lat)
if !ok {
return longitude, latitude, false
}
residual := gap - gap0 - target
kmPerDegLon := kmPerDegLat * math.Cos(lat*rad)
if kmPerDegLon < 1 {
return longitude, latitude, false
}
dLon := limbLimitProbeKM / kmPerDegLon
dLat := limbLimitProbeKM / kmPerDegLat
east, _, okEast := evaluate(lon+dLon, lat)
north, _, okNorth := evaluate(lon, lat+dLat)
if !okEast || !okNorth {
return longitude, latitude, false
}
gx := (east - gap) / limbLimitProbeKM
gy := (north - gap) / limbLimitProbeKM
norm2 := gx*gx + gy*gy
if !finite(norm2) || norm2 <= 0 {
return longitude, latitude, false
}
stepX, stepY := -residual*gx/norm2, -residual*gy/norm2
lon += stepX / kmPerDegLon
lat += stepY / kmPerDegLat
if math.Abs(stepX) < 1e-4 && math.Abs(stepY) < 1e-4 {
break
}
}
shiftKM := math.Hypot((lon-longitude)*kmPerDegLat*math.Cos(latitude*rad), (lat-latitude)*kmPerDegLat)
if !finite(shiftKM) || shiftKM > limbLimitMaxShiftKM || lat > 90 || lat < -90 {
return longitude, latitude, false
}
if lon > 180 {
lon -= 360
} else if lon < -180 {
lon += 360
}
return lon, lat, true
}
func occultationVectorArray(vector occultationPathVector) [3]float64 {
return [3]float64{vector.x, vector.y, vector.z}
}
// occultationLimbCorrectLimitSeries 把月掩外接触南北界平移到月缘掠掩线:
// 星迹在切点附近按直线处理,有效月缘半径取 max R(θ0+δ)·cosδ。只用于外接触(掠掩)界线。
func occultationLimbCorrectLimitSeries(
points []OccultationPathPoint,
contextAt occultationRiseSetContextFunc,
limb LunarLimb,
location *time.Location,
inst *Instance,
) ([]OccultationPathPoint, bool) {
if limb == nil || len(points) == 0 || contextAt == nil {
return points, false
}
applied := false
corrected := append([]OccultationPathPoint(nil), points...)
for index, point := range points {
if math.Abs(point.MoonAltitude) < limbLimitHorizonDeg {
continue
}
tt := centerTimeTT(point.Time)
context := contextAt(tt)
if !context.valid || context.moon.distanceKM <= 0 {
continue
}
frame := newLunarLimbFrame(limb, tt, occultationVectorArray(context.moon.positionKM), inst)
if !frame.valid {
continue
}
evaluate := func(longitude, latitude float64) (float64, float64, bool) {
state := context.stateAt(longitude, latitude)
if !state.valid || !finite(state.contactMetric) {
return 0, 0, false
}
parallaxKM, distanceKM, _ := occultationRiseSetObserverVectors(context.siderealDegrees, longitude, latitude)
moonKM := occultationPathSub(context.moon.positionKM, parallaxKM)
var targetKM occultationPathVector
if context.target.distanceKM > 0 {
targetKM = occultationPathSub(context.target.positionKM, parallaxKM)
} else {
targetKM = context.target.direction
}
view, ok := frame.viewToward(occultationVectorArray(moonKM), occultationVectorArray(targetKM))
if !ok {
return 0, 0, false
}
effective := 0.0
for offset := -limbGrazeSpanDeg; offset <= limbGrazeSpanDeg+1e-9; offset += limbGrazeStepDeg {
radius, ok := frame.radiusAt(view, offset)
if !ok {
return 0, 0, false
}
if value := radius * math.Cos(offset*rad); value > effective {
effective = value
}
}
moonDistanceKM := occultationRiseSetTopocentricDistance(context.moon, distanceKM)
if !finite(moonDistanceKM) || moonDistanceKM <= effective {
return 0, 0, false
}
// 有效半径更大时掠掩线外移:需要的圆月面间隙为两者角半径之差(度)。
target := (angularSemidiameterArcsec(effective, moonDistanceKM) -
angularSemidiameterArcsec(moonEquatorialRadiusKM, moonDistanceKM)) / 3600
return state.contactMetric, target, true
}
longitude, latitude, ok := limbLimitSolve(point.Longitude, point.Latitude, evaluate)
if !ok {
continue
}
if !limbGroundMoved(point.Longitude, point.Latitude, longitude, latitude) {
continue
}
corrected[index].Longitude, corrected[index].Latitude = longitude, latitude
applied = true
state := context.stateAt(longitude, latitude)
if state.valid {
corrected[index].MoonAltitude = state.moonAltitude
}
}
return corrected, applied
}
// limbCorrectCentralLimits 把全食 / 环食带南北界平移到月缘界线。
func (solver solarEclipseSolver) limbCorrectCentralLimits(points []SolarEclipsePathPoint, limb LunarLimb) ([]SolarEclipsePathPoint, bool) {
if limb == nil || len(points) == 0 {
return points, false
}
limbSolver := solver.withLimbParams(limb)
applied := false
corrected := append([]SolarEclipsePathPoint(nil), points...)
for index, point := range points {
// 地平线上的 U1/U4 端点处地面梯度趋于零,平移不可信,保留圆月面点。
if math.Abs(point.SunAltitude) < limbLimitHorizonDeg || !finite(point.JDE) || point.JDE <= 0 {
continue
}
context := solver.localStateContextAt(point.JDE)
limbContext := limbSolver.localStateContextAt(point.JDE)
if limbContext.limbFrame == nil {
continue
}
// 与站心同一判据:月缘内切间隙取自站心状态的等效内半径,目标是让它在界线上为零。
evaluate := func(longitude, latitude float64) (float64, float64, bool) {
lonRad, latRad := longitude*rad, latitude*rad
state := context.stateAt(lonRad, latRad, 0)
limbState := limbContext.stateAt(lonRad, latRad, 0)
if !limbState.limbApplied {
return 0, 0, false
}
gap := state.movingDiskContactState().internalContactGap()
return gap, gap - limbState.movingDiskContactState().internalContactGap(), true
}
longitude, latitude, ok := limbLimitSolve(point.Longitude, point.Latitude, evaluate)
if !ok {
continue
}
corrected[index].Longitude, corrected[index].Latitude = longitude, latitude
// 牛顿迭代可能收敛回原点(常数月缘即如此),只有位移超过噪声容差才算真正修正。
if limbGroundMoved(point.Longitude, point.Latitude, corrected[index].Longitude, corrected[index].Latitude) {
applied = true
}
}
return corrected, applied
}
+35
View File
@@ -0,0 +1,35 @@
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// 月缘修正是否生效的统一判据:每种量纲只有一个容差,所有层都经下面的函数判断,避免口径漂移。
const (
// limbAppliedTimeToleranceDays 接触时刻与时长的容差(0.05 秒)。
limbAppliedTimeToleranceDays = 0.05 / 86400.0
// limbAppliedDistanceToleranceKM 地面界线点的平移容差(0.05 km)。
limbAppliedDistanceToleranceKM = 0.05
// limbAppliedMagnitudeTolerance 食分与遮蔽率的容差。
limbAppliedMagnitudeTolerance = 1e-9
)
// limbTimeChanged 报告两个儒略日(或以日为单位的时长)是否相差超过时刻容差;任一非有限时视为未变。
func limbTimeChanged(corrected, original float64) bool {
return finite(corrected) && finite(original) &&
math.Abs(corrected-original) > limbAppliedTimeToleranceDays
}
// limbMagnitudeChanged 报告食分或遮蔽率是否相差超过容差;任一非有限时视为未变。
func limbMagnitudeChanged(corrected, original float64) bool {
return finite(corrected) && finite(original) &&
math.Abs(corrected-original) > limbAppliedMagnitudeTolerance
}
// limbGroundMoved 报告地面点(度)是否平移超过距离容差。
func limbGroundMoved(originalLon, originalLat, correctedLon, correctedLat float64) bool {
longitude := signedAngleDifference(correctedLon, originalLon) * math.Cos(originalLat*rad)
return math.Hypot(longitude, correctedLat-originalLat)*geodata.EarthKMPerDegree > limbAppliedDistanceToleranceKM
}
+298
View File
@@ -0,0 +1,298 @@
package basic
import (
"math"
)
// localEphemerisVectorNode is a pair of Cartesian ephemeris samples at one
// TT. Cartesian interpolation avoids right-ascension wraparound at 0/360.
type localEphemerisVectorNode struct {
tt float64
first, next [3]float64
}
const (
solarLocalEphemerisNodeCount = 13
solarLocalEphemerisStepDays = 1.0 / 24.0
occultationLocalEphemerisNodeCount = 49
occultationLocalEphemerisStepDays = 2.0 / 24.0
)
func interpolateLocalEphemerisVectors(
nodes []localEphemerisVectorNode,
tt float64,
) ([3]float64, [3]float64, bool) {
const interpolationPoints = 6
if len(nodes) < interpolationPoints || !finite(tt) {
return [3]float64{}, [3]float64{}, false
}
step := nodes[1].tt - nodes[0].tt
if !finite(step) || step <= 0 {
return [3]float64{}, [3]float64{}, false
}
u := (tt - nodes[0].tt) / step
if u < 0 || u > float64(len(nodes)-1) {
return [3]float64{}, [3]float64{}, false
}
start := int(math.Floor(u)) - 2
if start < 0 {
start = 0
}
if start > len(nodes)-interpolationPoints {
start = len(nodes) - interpolationPoints
}
var weights [interpolationPoints]float64
for point := 0; point < interpolationPoints; point++ {
x := float64(start + point)
weight := 1.0
for other := 0; other < interpolationPoints; other++ {
if other == point {
continue
}
xOther := float64(start + other)
weight *= (u - xOther) / (x - xOther)
}
weights[point] = weight
}
var first, next [3]float64
for coordinate := 0; coordinate < 3; coordinate++ {
for point := 0; point < interpolationPoints; point++ {
first[coordinate] += weights[point] * nodes[start+point].first[coordinate]
next[coordinate] += weights[point] * nodes[start+point].next[coordinate]
}
}
return first, next, finiteVector3(first) && finiteVector3(next)
}
func finiteVector3(value [3]float64) bool {
return finite(value[0]) && finite(value[1]) && finite(value[2])
}
func occultationPathVectorRaDec(vector occultationPathVector) (float64, float64, float64, bool) {
distance := occultationPathNorm(vector)
if !finite(distance) || distance <= 0 {
return 0, 0, 0, false
}
// 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)
}
type solarEclipseLocalEphemeris struct {
nodes []localEphemerisVectorNode
}
func newSolarEclipseLocalEphemeris(center float64, instances ...*Instance) *solarEclipseLocalEphemeris {
inst := instanceValue(instances)
half := solarLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, solarLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*solarLocalEphemerisStepDays
sun, moon := solarEclipseSunMoonEquatorial(tt, inst)
nodes[index] = localEphemerisVectorNode{
tt: tt,
first: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]),
next: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]),
}
}
return &solarEclipseLocalEphemeris{nodes: nodes}
}
// solarEclipseLimbAxes 与日月局部星历同一时间网格的月球本体坐标架插值节点。
type solarEclipseLimbAxes struct {
base lunarLimbFrame
nodes []localEphemerisVectorNode
}
func newSolarEclipseLimbAxes(ephemeris *solarEclipseLocalEphemeris, limb LunarLimb, inst *Instance) *solarEclipseLimbAxes {
if ephemeris == nil || limb == nil {
return nil
}
axes := &solarEclipseLimbAxes{nodes: make([]localEphemerisVectorNode, len(ephemeris.nodes))}
for index, node := range ephemeris.nodes {
frame := newLunarLimbFrame(limb, node.tt, node.next, inst)
if !frame.valid {
return nil
}
if index == len(ephemeris.nodes)/2 {
axes.base = frame
}
axes.nodes[index] = localEphemerisVectorNode{tt: node.tt, first: frame.axisX, next: frame.axisZ}
}
return axes
}
func (axes *solarEclipseLimbAxes) frameAt(tt float64) (lunarLimbFrame, bool) {
if axes == nil {
return lunarLimbFrame{}, false
}
axisX, axisZ, ok := interpolateLocalEphemerisVectors(axes.nodes, tt)
if !ok {
return lunarLimbFrame{}, false
}
frame := axes.base
if frame.axisZ, ok = lunarLimbNormalize(axisZ); !ok {
return lunarLimbFrame{}, false
}
// 两轴分别插值后不再严格正交,按 z 轴重新正交化 x 轴。
if frame.axisX, ok = lunarLimbNormalize(lunarLimbReject(axisX, frame.axisZ)); !ok {
return lunarLimbFrame{}, false
}
frame.axisY = lunarLimbCross(frame.axisZ, frame.axisX)
return frame, true
}
func (ephemeris *solarEclipseLocalEphemeris) equatorialAt(tt float64) ([3]float64, [3]float64, bool) {
var empty [3]float64
if ephemeris == nil {
return empty, empty, false
}
sunXYZ, moonXYZ, ok := interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
if !ok {
return empty, empty, false
}
return solarEclipseXYZToLLR(sunXYZ[0], sunXYZ[1], sunXYZ[2]),
solarEclipseXYZToLLR(moonXYZ[0], moonXYZ[1], moonXYZ[2]), true
}
type starOccultationLocalEphemeris struct {
star StarCoordinate
nodes []localEphemerisVectorNode
dense bool
// distanceKM 是中心时刻的当日距离;hasDistance 为假表示恒星距离未知,几何按无穷远处理。
distanceKM float64
hasDistance bool
}
func newStarOccultationLocalEphemeris(center float64, star StarCoordinate, instances ...*Instance) *starOccultationLocalEphemeris {
inst := instanceValue(instances)
half := occultationLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
state := starOccultationEphemerisStateAt(tt, star, inst)
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
targetDistance := state.starDistanceKM
if targetDistance <= 0 {
// 恒星距离未知时只需方向:按单位球方向装配,几何只用归一化后的矢量。
targetDistance = 1
}
target := occultationPathRaDecVector(state.starRA, state.starDec, targetDistance)
nodes[index] = localEphemerisVectorNode{
tt: tt,
first: [3]float64{moon.x, moon.y, moon.z},
next: [3]float64{target.x, target.y, target.z},
}
}
return newStarOccultationLocalEphemerisFromNodes(center, star, nodes, false, inst)
}
// newStarOccultationLocalEphemerisFromNodes 装配局部星历:距离必须与节点同源,密集分支同样要带上。
func newStarOccultationLocalEphemerisFromNodes(center float64, star StarCoordinate, nodes []localEphemerisVectorNode, dense bool, instances ...*Instance) *starOccultationLocalEphemeris {
inst := instanceValue(instances)
_, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star, inst)
return &starOccultationLocalEphemeris{
star: star, nodes: nodes, dense: dense,
distanceKM: distanceAU * occultationPathAstronomicalUnitKM, hasDistance: distanceAU > 0,
}
}
func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOccultationEphemerisState, bool) {
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
if !ok {
return starOccultationEphemerisState{}, false
}
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
})
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: starDistanceKM,
valid: true,
}, true
}
func (ephemeris *starOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
if ephemeris == nil {
return [3]float64{}, [3]float64{}, false
}
if ephemeris.dense {
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
}
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
}
func (ephemeris *starOccultationLocalEphemeris) starDistanceKM() float64 {
return ephemeris.distanceKM
}
type planetOccultationLocalEphemeris struct {
nodes []localEphemerisVectorNode
dense bool
}
func newPlanetOccultationLocalEphemeris(center float64, config planetOccultationConfig, instances ...*Instance) *planetOccultationLocalEphemeris {
inst := instanceValue(instances)
half := occultationLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
state := planetOccultationEphemerisStateAt(tt, config, inst)
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
nodes[index] = localEphemerisVectorNode{
tt: tt,
first: [3]float64{moon.x, moon.y, moon.z},
next: [3]float64{target.x, target.y, target.z},
}
}
return &planetOccultationLocalEphemeris{nodes: nodes}
}
func (ephemeris *planetOccultationLocalEphemeris) stateAt(tt float64) (planetOccultationEphemerisState, bool) {
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
if !ok {
return planetOccultationEphemerisState{}, false
}
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
})
targetRA, targetDec, planetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{
x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2],
})
if !moonOK || !targetOK {
return planetOccultationEphemerisState{}, false
}
return planetOccultationEphemerisState{
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
planetRA: targetRA, planetDec: targetDec, planetDistanceKM: planetDistance,
valid: true,
}, true
}
func (ephemeris *planetOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
if ephemeris == nil {
return [3]float64{}, [3]float64{}, false
}
if ephemeris.dense {
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
}
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
}
+157
View File
@@ -0,0 +1,157 @@
package basic
import (
"math"
"testing"
"time"
)
func TestSolarEclipseLocalEphemerisBoundedError(t *testing.T) {
events := []struct {
name string
seed float64
}{
{"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) {
center := CalcMoonSHByJDE(event.seed, 0)
ephemeris := newSolarEclipseLocalEphemeris(center)
for offset := -4.5; offset <= 4.5; offset += 0.25 {
jd := center + offset/24
approxSun, approxMoon, ok := ephemeris.equatorialAt(jd)
if !ok {
t.Fatalf("interpolator rejected in-window time %.3fh", offset)
}
exactSun, exactMoon := solarEclipseSunMoonEquatorial(jd)
for name, pair := range map[string][2][3]float64{
"sun": {solarEclipseLLRToXYZ(approxSun[0], approxSun[1], approxSun[2]), solarEclipseLLRToXYZ(exactSun[0], exactSun[1], exactSun[2])},
"moon": {solarEclipseLLRToXYZ(approxMoon[0], approxMoon[1], approxMoon[2]), solarEclipseLLRToXYZ(exactMoon[0], exactMoon[1], exactMoon[2])},
} {
if errorKM := vectorDifferenceKM(pair[0], pair[1]); errorKM > 1 {
t.Fatalf("%s interpolation error at %.3fh = %.6f km", name, offset, errorKM)
}
}
}
if _, _, ok := ephemeris.equatorialAt(center + 6.1/24); ok {
t.Fatal("interpolator should reject times outside its bounded window")
}
})
}
}
func TestOccultationLocalEphemerisBoundedError(t *testing.T) {
star := hr4799OccultationCoordinateForTest()
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
for offset := -4.5; offset <= 4.5; offset += 0.25 {
jd := starTT + offset/24
got, ok := starEphemeris.stateAt(jd)
if !ok {
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
}
want := starOccultationEphemerisStateAt(jd, star)
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
}
if difference := angularSeparationDegrees(got.starRA, got.starDec, want.starRA, want.starDec); difference > 1e-5 {
t.Fatalf("star direction interpolation error at %.3fh = %.9f deg", offset, difference)
}
}
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
for offset := -4.5; offset <= 4.5; offset += 0.25 {
jd := planetTT + offset/24
got, ok := planetEphemeris.stateAt(jd)
if !ok {
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
}
want := planetOccultationEphemerisStateAt(jd, config)
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
}
if difference := angularSeparationDegrees(got.planetRA, got.planetDec, want.planetRA, want.planetDec); difference > 1e-5 {
t.Fatalf("planet direction interpolation error at %.3fh = %.9f deg", offset, difference)
}
if math.Abs(got.planetDistanceKM-want.planetDistanceKM) > 100 {
t.Fatalf("planet distance interpolation error at %.3fh = %.6f km", offset, math.Abs(got.planetDistanceKM-want.planetDistanceKM))
}
}
}
func TestOccultationLocalEphemerisFullWindowBoundedError(t *testing.T) {
star := hr4799OccultationCoordinateForTest()
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
for _, offset := range []float64{-47.5, -40, -24, 24, 40, 47.5} {
starJD := starTT + offset/24
gotStar, starOK := starEphemeris.stateAt(starJD)
wantStar := starOccultationEphemerisStateAt(starJD, star)
if !starOK {
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
}
if difference := angularSeparationDegrees(gotStar.moonRA, gotStar.moonDec, wantStar.moonRA, wantStar.moonDec); difference > 1e-5 {
t.Fatalf("star-event moon direction error at %.3fh = %.9f deg", offset, difference)
}
if difference := angularSeparationDegrees(gotStar.starRA, gotStar.starDec, wantStar.starRA, wantStar.starDec); difference > 1e-5 {
t.Fatalf("star direction error at %.3fh = %.9f deg", offset, difference)
}
planetJD := planetTT + offset/24
gotPlanet, planetOK := planetEphemeris.stateAt(planetJD)
wantPlanet := planetOccultationEphemerisStateAt(planetJD, config)
if !planetOK {
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
}
if difference := angularSeparationDegrees(gotPlanet.moonRA, gotPlanet.moonDec, wantPlanet.moonRA, wantPlanet.moonDec); difference > 1e-5 {
t.Fatalf("planet-event moon direction error at %.3fh = %.9f deg", offset, difference)
}
if difference := angularSeparationDegrees(gotPlanet.planetRA, gotPlanet.planetDec, wantPlanet.planetRA, wantPlanet.planetDec); difference > 1e-5 {
t.Fatalf("planet direction error at %.3fh = %.9f deg", offset, difference)
}
if difference := math.Abs(gotPlanet.planetDistanceKM - wantPlanet.planetDistanceKM); difference > 100 {
t.Fatalf("planet distance error at %.3fh = %.6f km", offset, difference)
}
}
}
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("零矢量应判为无效")
}
}
+413
View File
@@ -0,0 +1,413 @@
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// LunarEclipseType 表示月食类型。
type LunarEclipseType string
const (
// LunarEclipseNone 表示该次望月没有发生月食。
LunarEclipseNone LunarEclipseType = "none"
// LunarEclipsePenumbral 表示半影月食。
LunarEclipsePenumbral LunarEclipseType = "penumbral"
// LunarEclipsePartial 表示月偏食。
LunarEclipsePartial LunarEclipseType = "partial"
// LunarEclipseTotal 表示月全食。
LunarEclipseTotal LunarEclipseType = "total"
)
// LunarEclipseResult 表示一次望月附近的月食几何结果。
//
// 所有时刻字段都使用力学时儒略日(JDE, TT)。
// 输入 seedJDE 只需要落在目标望月附近,允许相差数天。
type LunarEclipseResult struct {
Type LunarEclipseType
// LunarLimbApplied 表示接触时刻已按月缘剖面修正 / reports whether lunar-limb refinement changed the contacts.
LunarLimbApplied bool
// ShadowModel 是本次使用的影半径模型,决定影半径与食分的具体数值。
// ShadowModel is the shadow-radius model used, which fixes the shadow radii and magnitudes.
ShadowModel LunarEclipseShadowModel
// Maximum 是食甚时刻;即使最终没有月食,也会返回该次望月附近
// “月面中心最接近地影中心”的几何极值时刻。
Maximum float64
// Magnitude 是本影食分。纯半影月食时可为负值;无月食时为 0。
Magnitude float64
// PenumbralMagnitude 是半影食分。无半影接触时为 0。
PenumbralMagnitude float64
// MinimumDistance 是食甚时月心到地影中心的最小角距离,单位为弧度。
MinimumDistance float64
// Contact times:
// PenumbralStart / PenumbralEnd: 半影食始 / 半影食终
// PartialStart / PartialEnd: 初亏 / 复圆
// TotalStart / TotalEnd: 食既 / 生光
//
// 该阶段不发生时为 NaN(0 是 −4713-11-24 的真实时刻);判断阶段是否存在一律用 Has*。
// A contact is NaN when that phase does not occur (JD 0 is the real instant −4713-11-24);
// decide by the Has* flags, never by comparing a contact against zero.
PenumbralStart float64
PenumbralEnd float64
PartialStart float64
PartialEnd float64
TotalStart float64
TotalEnd float64
HasPenumbral bool
HasPartial bool
HasTotal bool
}
type lunarShadowState struct {
jde float64
x float64
y float64
moonRadiusRad float64
umbraRadiusRad float64
penumbraRadiusRad float64
}
// LunarEclipseShadowModel 标识月食用的是哪套影半径模型。
// LunarEclipseShadowModel identifies which shadow-radius model produced a result.
type LunarEclipseShadowModel int
const (
// LunarEclipseShadowModelDanjon 是 Danjon 影半径模型。
LunarEclipseShadowModelDanjon LunarEclipseShadowModel = iota
// LunarEclipseShadowModelChauvenet 是 Chauvenet 影半径模型。
LunarEclipseShadowModelChauvenet
)
type lunarEclipseShadowModel int
const (
lunarEclipseShadowDanjon lunarEclipseShadowModel = iota
lunarEclipseShadowChauvenet
)
const (
// 沿用月食常量:
// - 0.2725076 用于月亮视半径和半影几何
// - 太阳视半径与日食共用标准档常量,太阳视差用常用角秒常量 8.794
lunarMoonRadiusRatio = 0.2725076
lunarMoonRadiusScale = lunarMoonRadiusRatio * geodata.EarthEquatorialRadiusKM * 1.0000036
lunarSolarParallaxArcsec = 8.794
lunarLongitudeAberration = -3.4e-6
lunarFiniteDifferenceStep = 60.0 / 86400.0
// Chauvenet 体系:
// - 地球有效半径取 0.99834 * 赤道半径
// - 再统一乘 51/50 的大气放大因子
lunarChauvenetEarthScale = 0.99834
lunarChauvenetShadowGain = 51.0 / 50.0
// Danjon 体系:
// - 影半径只对月球水平视差项乘 1.01
// - 太阳视半径与太阳视差项不再统一乘 1.02
lunarDanjonParallaxScale = 1.01
)
var lunarArcsecPerRadian = 180.0 * 3600.0 / math.Pi
// LunarEclipse 计算给定近望时刻附近的一次月食,默认使用 Danjon 影半径模型。
//
// seedJDE 为力学时儒略日(TT),只需落在目标望月附近,允许相差数天。
// 返回值中的所有接触时刻也都是力学时儒略日。
func LunarEclipse(seedJDE float64) LunarEclipseResult {
return LunarEclipseDanjon(seedJDE)
}
// LunarEclipseDanjon 计算给定近望时刻附近的一次月食,使用 Danjon 影半径模型。
func LunarEclipseDanjon(seedJDE float64) LunarEclipseResult {
return lunarEclipse(seedJDE, lunarEclipseShadowDanjon)
}
// LunarEclipseChauvenet 计算给定近望时刻附近的一次月食,使用 Chauvenet 影半径模型。
func LunarEclipseChauvenet(seedJDE float64) LunarEclipseResult {
return lunarEclipse(seedJDE, lunarEclipseShadowChauvenet)
}
func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseResult {
fullMoonJDE := CalcMoonSHByJDE(seedJDE, 1)
return lunarEclipseFromFullMoon(fullMoonJDE, shadowModel, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
type lunarShadowEvaluator func(float64) lunarShadowState
func lunarEclipseFromFullMoon(fullMoonJDE float64, shadowModel lunarEclipseShadowModel, evaluate lunarShadowEvaluator) LunarEclipseResult {
maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximumWithEvaluator(fullMoonJDE, evaluate)
// 未发生的阶段保持 NaN:Has* 是权威判据,时刻字段不能拿 0 当哨兵。
model := LunarEclipseShadowModelDanjon
if shadowModel == lunarEclipseShadowChauvenet {
model = LunarEclipseShadowModelChauvenet
}
result := LunarEclipseResult{
Type: LunarEclipseNone,
ShadowModel: model,
Maximum: maximumJDE,
MinimumDistance: minimumDistance,
PenumbralMagnitude: (state.moonRadiusRad + state.penumbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad),
PenumbralStart: math.NaN(),
PenumbralEnd: math.NaN(),
PartialStart: math.NaN(),
PartialEnd: math.NaN(),
TotalStart: math.NaN(),
TotalEnd: math.NaN(),
}
rawUmbralMagnitude := (state.moonRadiusRad + state.umbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad)
if result.PenumbralMagnitude < 0 {
result.PenumbralMagnitude = 0
}
if minimumDistance <= state.moonRadiusRad+state.penumbraRadiusRad {
result.Type = LunarEclipsePenumbral
result.HasPenumbral = true
result.Magnitude = rawUmbralMagnitude
result.PenumbralStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, false, evaluate,
)
result.PenumbralEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, true, evaluate,
)
}
if minimumDistance <= state.moonRadiusRad+state.umbraRadiusRad {
result.Type = LunarEclipsePartial
result.HasPartial = true
result.Magnitude = rawUmbralMagnitude
result.PartialStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, false, evaluate,
)
result.PartialEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, true, evaluate,
)
}
if minimumDistance <= state.umbraRadiusRad-state.moonRadiusRad {
result.Type = LunarEclipseTotal
result.HasTotal = true
result.TotalStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, false, evaluate,
)
result.TotalEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, true, evaluate,
)
}
return result
}
// refineLunarEclipseMaximum 从近望初值出发,用有限差分速度做两轮几何极值修正。
//
// 这里直接在月心相对地影中心的二维平面上求 |r| 的极小值,
func refineLunarEclipseMaximum(
seedJDE float64,
shadowModel lunarEclipseShadowModel,
) (float64, lunarShadowState, float64, float64, float64) {
return refineLunarEclipseMaximumWithEvaluator(seedJDE, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
func refineLunarEclipseMaximumWithEvaluator(seedJDE float64, evaluate lunarShadowEvaluator) (float64, lunarShadowState, float64, float64, float64) {
currentJDE := seedJDE
for i := 0; i < 2; i++ {
state := evaluate(currentJDE)
nextState := evaluate(currentJDE + lunarFiniteDifferenceStep)
dxdt := (nextState.x - state.x) / lunarFiniteDifferenceStep
dydt := (nextState.y - state.y) / lunarFiniteDifferenceStep
denominator := dxdt*dxdt + dydt*dydt
if denominator == 0 {
finalState := evaluate(currentJDE)
return currentJDE, finalState, 0, 0, math.Hypot(finalState.x, finalState.y)
}
correction := -(state.x*dxdt + state.y*dydt) / denominator
currentJDE += correction
}
linearState := evaluate(currentJDE)
nextLinearState := evaluate(currentJDE + lunarFiniteDifferenceStep)
dxdt := (nextLinearState.x - linearState.x) / lunarFiniteDifferenceStep
dydt := (nextLinearState.y - linearState.y) / lunarFiniteDifferenceStep
denominator := dxdt*dxdt + dydt*dydt
if denominator == 0 {
return currentJDE, linearState, dxdt, dydt, math.Hypot(linearState.x, linearState.y)
}
correction := -(linearState.x*dxdt + linearState.y*dydt) / denominator
maximumJDE := currentJDE + correction
finalState := evaluate(maximumJDE)
nextState := evaluate(maximumJDE + lunarFiniteDifferenceStep)
dxdt = (nextState.x - finalState.x) / lunarFiniteDifferenceStep
dydt = (nextState.y - finalState.y) / lunarFiniteDifferenceStep
return maximumJDE, finalState, dxdt, dydt, math.Hypot(finalState.x, finalState.y)
}
// refineLunarEclipseContact 先用固定速度近似求一次接触时刻,
// 再在接触点重算半径并修正一次。
func refineLunarEclipseContact(
maximumState lunarShadowState,
dxdt, dydt, boundaryRadius float64,
afterMaximum bool,
shadowModel lunarEclipseShadowModel,
) float64 {
return refineLunarEclipseContactWithEvaluator(maximumState, dxdt, dydt, boundaryRadius, afterMaximum, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
func refineLunarEclipseContactWithEvaluator(maximumState lunarShadowState, dxdt, dydt, boundaryRadius float64, afterMaximum bool, evaluate lunarShadowEvaluator) float64 {
firstGuess, ok := solveLineCircleContact(maximumState, dxdt, dydt, boundaryRadius, afterMaximum)
if !ok {
return math.NaN()
}
contactState := evaluate(firstGuess)
refinedRadius := boundaryRadius
switch {
case math.Abs(boundaryRadius-(maximumState.moonRadiusRad+maximumState.umbraRadiusRad)) < 1e-18:
refinedRadius = contactState.moonRadiusRad + contactState.umbraRadiusRad
case math.Abs(boundaryRadius-(maximumState.moonRadiusRad+maximumState.penumbraRadiusRad)) < 1e-18:
refinedRadius = contactState.moonRadiusRad + contactState.penumbraRadiusRad
case math.Abs(boundaryRadius-(maximumState.umbraRadiusRad-maximumState.moonRadiusRad)) < 1e-18:
refinedRadius = contactState.umbraRadiusRad - contactState.moonRadiusRad
}
refinedGuess, ok := solveLineCircleContact(contactState, dxdt, dydt, refinedRadius, afterMaximum)
if !ok {
return firstGuess
}
return refinedGuess
}
// solveLineCircleContact 求月心轨迹与某个影界圆的交点时刻。
func solveLineCircleContact(
state lunarShadowState,
dxdt, dydt, radius float64,
afterMaximum bool,
) (float64, bool) {
a := dxdt*dxdt + dydt*dydt
if a == 0 {
return 0, false
}
b := 2 * (state.x*dxdt + state.y*dydt)
c := state.x*state.x + state.y*state.y - radius*radius
discriminant := b*b - 4*a*c
if discriminant < 0 {
return 0, false
}
root := math.Sqrt(discriminant)
delta := (-b - root) / (2 * a)
if afterMaximum {
delta = (-b + root) / (2 * a)
}
return state.jde + delta, true
}
// lunarEclipsePlaneState 只需要月心相对地影中心的二维坐标(穿影图采样点用,跳过半径所需的距离项)。
type lunarEclipsePlaneState struct {
jde float64
x float64
y float64
}
func lunarEclipsePlaneStateAt(jde float64) lunarEclipsePlaneState {
return lunarEclipsePlaneStateAtWithInstance(jde, nil)
}
func lunarEclipsePlaneStateAtWithInstance(jde float64, inst *Instance) lunarEclipsePlaneState {
julianCentury := (jde - 2451545.0) / 36525.0
sunLongitude := HSunTrueLoWithInstance(jde, inst)*rad + sunLongitudeAberrationRad(julianCentury)
sunLatitude := HSunTrueBoWithInstance(jde, inst) * rad
moonLongitude := HMoonTrueLoWithInstance(jde, inst)*rad + lunarLongitudeAberration
moonLatitude := HMoonTrueBoWithInstance(jde, inst)*rad + moonLatitudeAberrationRad(julianCentury)
return lunarEclipsePlaneState{
jde: jde,
x: normalizeSignedRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
y: moonLatitude + sunLatitude,
}
}
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
//
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
// 其余月心轨迹与几何求交框架保持一致。
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, nil)
}
func computeLunarShadowStateWithInstance(jde float64, shadowModel lunarEclipseShadowModel, inst *Instance) lunarShadowState {
plane := lunarEclipsePlaneStateAtWithInstance(jde, inst)
moonDistanceKM := HMoonAwayWithInstance(jde, inst)
sunDistanceAU := EarthAwayNWithInstance(jde, -1, inst)
moonRadiusArcsec := lunarMoonRadiusScale * lunarArcsecPerRadian / moonDistanceKM
earthParallaxArcsec := geodata.EarthEquatorialRadiusKM / moonDistanceKM * lunarArcsecPerRadian
solarRadiusArcsec := eclipseSunRadiusStandardArcsec / sunDistanceAU
solarParallaxArcsec := lunarSolarParallaxArcsec / sunDistanceAU
umbraRadiusArcsec, penumbraRadiusArcsec := lunarEclipseShadowRadiiArcsec(
earthParallaxArcsec,
solarRadiusArcsec,
solarParallaxArcsec,
shadowModel,
)
return lunarShadowState{
jde: plane.jde,
x: plane.x,
y: plane.y,
moonRadiusRad: moonRadiusArcsec / lunarArcsecPerRadian,
umbraRadiusRad: umbraRadiusArcsec / lunarArcsecPerRadian,
penumbraRadiusRad: penumbraRadiusArcsec / lunarArcsecPerRadian,
}
}
func lunarEclipseShadowRadiiArcsec(
earthParallaxArcsec, solarRadiusArcsec, solarParallaxArcsec float64,
shadowModel lunarEclipseShadowModel,
) (float64, float64) {
switch shadowModel {
case lunarEclipseShadowDanjon:
earthTerm := lunarDanjonParallaxScale * earthParallaxArcsec
return earthTerm - solarRadiusArcsec + solarParallaxArcsec,
earthTerm + solarRadiusArcsec + solarParallaxArcsec
default:
earthTerm := lunarChauvenetEarthScale * earthParallaxArcsec
return (earthTerm - solarRadiusArcsec + solarParallaxArcsec) * lunarChauvenetShadowGain,
(earthTerm + solarRadiusArcsec + solarParallaxArcsec) * lunarChauvenetShadowGain
}
}
func sunLongitudeAberrationRad(julianCentury float64) float64 {
meanAnomaly := -0.043126 + 628.301955*julianCentury - 0.000002732*julianCentury*julianCentury
eccentricity := 0.016708634 - 0.000042037*julianCentury - 0.0000001267*julianCentury*julianCentury
return -20.49552 * (1 + eccentricity*math.Cos(meanAnomaly)) / lunarArcsecPerRadian
}
func moonLatitudeAberrationRad(julianCentury float64) float64 {
argument := 0.057 + 8433.4662*julianCentury + 0.000064*julianCentury*julianCentury
return 0.063 * math.Sin(argument) / lunarArcsecPerRadian
}
+98
View File
@@ -0,0 +1,98 @@
package basic
import (
"math"
"testing"
)
// 未发生阶段的接触时刻必须是 NaN:JD 0 是 −4713-11-24 的真实时刻,不能当“无此阶段”的哨兵。
func TestLunarEclipseAbsentPhasesAreNaN(t *testing.T) {
eclipse := LunarEclipse(2458860.0)
if eclipse.Type != LunarEclipsePenumbral {
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipsePenumbral)
}
if !eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
t.Fatalf("unexpected flags: %+v", eclipse)
}
for name, value := range map[string]float64{
"PartialStart": eclipse.PartialStart,
"PartialEnd": eclipse.PartialEnd,
"TotalStart": eclipse.TotalStart,
"TotalEnd": eclipse.TotalEnd,
} {
if !math.IsNaN(value) {
t.Fatalf("%s = %.12f, want NaN", name, value)
}
}
for name, value := range map[string]float64{
"PenumbralStart": eclipse.PenumbralStart,
"PenumbralEnd": eclipse.PenumbralEnd,
"Maximum": eclipse.Maximum,
} {
if !isFiniteFloat(value) {
t.Fatalf("%s = %v, want a finite contact", name, value)
}
}
}
func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) {
// 2025-01-13 的望月没有月食(半影食分 < 0)。
eclipse := LunarEclipse(JDCalc(2025, 1, 13))
if eclipse.Type != LunarEclipseNone {
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipseNone)
}
if eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
t.Fatalf("unexpected flags: %+v", eclipse)
}
for name, value := range map[string]float64{
"PenumbralStart": eclipse.PenumbralStart,
"PenumbralEnd": eclipse.PenumbralEnd,
"PartialStart": eclipse.PartialStart,
"PartialEnd": eclipse.PartialEnd,
"TotalStart": eclipse.TotalStart,
"TotalEnd": eclipse.TotalEnd,
} {
if !math.IsNaN(value) {
t.Fatalf("%s = %.12f, want NaN", name, value)
}
}
if !isFiniteFloat(eclipse.Maximum) {
t.Fatalf("Maximum = %v, want the geometric extremum", eclipse.Maximum)
}
}
func TestLunarEclipseTotalKeepsAllContactsFinite(t *testing.T) {
eclipse := LunarEclipse(JDCalc(2025, 3, 14))
if eclipse.Type != LunarEclipseTotal || !eclipse.HasPenumbral || !eclipse.HasPartial || !eclipse.HasTotal {
t.Fatalf("unexpected result: %+v", eclipse)
}
contacts := []float64{
eclipse.PenumbralStart, eclipse.PartialStart, eclipse.TotalStart,
eclipse.Maximum,
eclipse.TotalEnd, eclipse.PartialEnd, eclipse.PenumbralEnd,
}
for i, value := range contacts {
if !isFiniteFloat(value) {
t.Fatalf("contact %d = %v, want finite", i, value)
}
if i > 0 && value <= contacts[i-1] {
t.Fatalf("contacts not increasing at %d: %v", i, contacts)
}
}
}
func TestLunarEclipseDiagramSkipsAbsentPhases(t *testing.T) {
diagram := LunarEclipseDiagram(2458860.0, LunarEclipseDiagramOptions{})
if len(diagram.Points) == 0 {
t.Fatalf("penumbral eclipse diagram should still have path points")
}
for _, point := range diagram.Points {
if !isFiniteFloat(point.JDE) || !isFiniteFloat(point.X) || !isFiniteFloat(point.Y) {
t.Fatalf("non-finite diagram point: %+v", point)
}
}
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))
}
}
+259
View File
@@ -0,0 +1,259 @@
package basic
import (
"math"
"sort"
)
const (
lunarEclipseDiagramDefaultStepDays = 5.0 / 1440.0
lunarEclipseDiagramMinStepDays = 1.0 / 86400.0
lunarEclipseDiagramMaxSamples = 2000
lunarEclipseDiagramDuplicateDays = 1e-10
)
// LunarEclipseDiagramOptions 控制月食穿影图采样。
// LunarEclipseDiagramOptions controls lunar eclipse shadow-path diagram sampling.
type LunarEclipseDiagramOptions struct {
// StepDays 是路径采样步长,单位为日;<=0 时使用 5 分钟。
// StepDays is the path sampling step in days; values <= 0 use five minutes.
StepDays float64
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile for contacts.
LunarLimb LunarLimb
}
// LunarEclipseDiagramPoint 表示月食穿影图上的一个月心位置。
// LunarEclipseDiagramPoint is one Moon-center point in a lunar eclipse diagram.
type LunarEclipseDiagramPoint struct {
// JDE 是力学时儒略日, TT Julian ephemeris day.
JDE float64
// X / Y 是以月球半径为单位的月心相对地影中心坐标。
// X/Y are Moon-center coordinates relative to the shadow center, in Moon-radius units.
X float64
Y float64
// Label 是关键接触标签,如 P1/U1/U2/Greatest/U3/U4/P4。
// Label is a key contact label such as P1/U1/U2/Greatest/U3/U4/P4.
Label string
// Labels 是该点对应的全部关键接触标签;若事件重合,这里会有多个值。
// Labels contains all contact labels attached to this point.
Labels []string
}
// LunarEclipseDiagramResult 表示月食穿影图几何结果。
// LunarEclipseDiagramResult is the geometry result for a lunar eclipse diagram.
type LunarEclipseDiagramResult struct {
// Eclipse 是对应的月食结果。
// Eclipse is the eclipse result used for the diagram.
Eclipse LunarEclipseResult
// MoonRadius 是月球半径,单位为图上月球半径;固定为 1。
// MoonRadius is the Moon radius in diagram Moon-radius units; always 1.
MoonRadius float64
// UmbraRadius 是本影半径,单位为图上月球半径。
// UmbraRadius is the umbral shadow radius in Moon-radius units.
UmbraRadius float64
// PenumbraRadius 是半影半径,单位为图上月球半径。
// PenumbraRadius is the penumbral shadow radius in Moon-radius units.
PenumbraRadius float64
// Points 是月心路径点,包含接触点与采样点。
// Points are Moon-center path points, including contact and sampled points.
Points []LunarEclipseDiagramPoint
// StepDays 是实际采用的路径采样步长,单位为日。
// StepDays is the effective path sampling step in days.
StepDays float64
}
type lunarEclipseDiagramTime struct {
jde float64
labels []string
}
// LunarEclipseDiagram 计算月食穿影图几何数据,默认使用 Danjon 影半径模型。
// LunarEclipseDiagram computes lunar eclipse diagram geometry, using the Danjon shadow model by default.
func LunarEclipseDiagram(seedJDE float64, options LunarEclipseDiagramOptions) LunarEclipseDiagramResult {
return LunarEclipseDiagramDanjon(seedJDE, options)
}
// LunarEclipseDiagramDanjon 计算月食穿影图几何数据,使用 Danjon 影半径模型。
// LunarEclipseDiagramDanjon computes lunar eclipse diagram geometry with the Danjon shadow model.
func LunarEclipseDiagramDanjon(seedJDE float64, options LunarEclipseDiagramOptions) LunarEclipseDiagramResult {
return lunarEclipseDiagram(seedJDE, lunarEclipseShadowDanjon, options)
}
// LunarEclipseDiagramChauvenet 计算月食穿影图几何数据,使用 Chauvenet 影半径模型。
// LunarEclipseDiagramChauvenet computes lunar eclipse diagram geometry with the Chauvenet shadow model.
func LunarEclipseDiagramChauvenet(seedJDE float64, options LunarEclipseDiagramOptions) LunarEclipseDiagramResult {
return lunarEclipseDiagram(seedJDE, lunarEclipseShadowChauvenet, options)
}
func lunarEclipseDiagram(
seedJDE float64,
shadowModel lunarEclipseShadowModel,
options LunarEclipseDiagramOptions,
) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, shadowModel, options, nil)
}
func lunarEclipseDiagramWithInstance(seedJDE float64, shadowModel lunarEclipseShadowModel, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
options = normalizeLunarEclipseDiagramOptions(options)
eclipse := LunarEclipseWithLunarLimb(lunarEclipseWithInstance(seedJDE, shadowModel, inst), options.LunarLimb, inst)
result := LunarEclipseDiagramResult{
Eclipse: eclipse,
StepDays: options.StepDays,
}
if !eclipse.HasPenumbral {
return result
}
maximumState := computeLunarShadowStateWithInstance(eclipse.Maximum, shadowModel, inst)
if maximumState.moonRadiusRad <= 0 {
return result
}
result.MoonRadius = 1
result.UmbraRadius = maximumState.umbraRadiusRad / maximumState.moonRadiusRad
result.PenumbraRadius = maximumState.penumbraRadiusRad / maximumState.moonRadiusRad
times, stepDays := lunarEclipseDiagramTimes(eclipse, options.StepDays)
result.StepDays = stepDays
result.Points = make([]LunarEclipseDiagramPoint, 0, len(times))
for _, item := range times {
plane := lunarEclipsePlaneStateAtWithInstance(item.jde, inst)
result.Points = append(result.Points, LunarEclipseDiagramPoint{
JDE: item.jde,
X: plane.x / maximumState.moonRadiusRad,
Y: plane.y / maximumState.moonRadiusRad,
Label: lunarEclipseDiagramPrimaryLabel(item.labels),
Labels: append([]string(nil), item.labels...),
})
}
return result
}
func normalizeLunarEclipseDiagramOptions(options LunarEclipseDiagramOptions) LunarEclipseDiagramOptions {
if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) {
options.StepDays = lunarEclipseDiagramDefaultStepDays
}
if options.StepDays < lunarEclipseDiagramMinStepDays {
options.StepDays = lunarEclipseDiagramMinStepDays
}
return options
}
func lunarEclipseDiagramTimes(eclipse LunarEclipseResult, stepDays float64) ([]lunarEclipseDiagramTime, float64) {
startJDE := eclipse.PenumbralStart
endJDE := eclipse.PenumbralEnd
if !isFiniteFloat(startJDE) || !isFiniteFloat(endJDE) || endJDE <= startJDE {
return nil, stepDays
}
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > lunarEclipseDiagramMaxSamples {
stepDays = (endJDE - startJDE) / float64(lunarEclipseDiagramMaxSamples-1)
}
times := []lunarEclipseDiagramTime{
{jde: startJDE, labels: []string{"P1"}},
{jde: eclipse.Maximum, labels: []string{"Greatest"}},
{jde: endJDE, labels: []string{"P4"}},
}
if eclipse.HasPartial {
times = append(times,
lunarEclipseDiagramTime{jde: eclipse.PartialStart, labels: []string{"U1"}},
lunarEclipseDiagramTime{jde: eclipse.PartialEnd, labels: []string{"U4"}},
)
}
if eclipse.HasTotal {
times = append(times,
lunarEclipseDiagramTime{jde: eclipse.TotalStart, labels: []string{"U2"}},
lunarEclipseDiagramTime{jde: eclipse.TotalEnd, labels: []string{"U3"}},
)
}
for jde := startJDE + stepDays; jde < endJDE; jde += stepDays {
times = append(times, lunarEclipseDiagramTime{jde: jde})
}
sort.SliceStable(times, func(i, j int) bool {
if times[i].jde == times[j].jde {
return lunarEclipseDiagramLabelPriority(times[i].labels) < lunarEclipseDiagramLabelPriority(times[j].labels)
}
return times[i].jde < times[j].jde
})
return uniqueLunarEclipseDiagramTimes(times), stepDays
}
func uniqueLunarEclipseDiagramTimes(times []lunarEclipseDiagramTime) []lunarEclipseDiagramTime {
if len(times) < 2 {
return times
}
unique := times[:0]
for _, item := range times {
if !isFiniteFloat(item.jde) {
continue
}
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > lunarEclipseDiagramDuplicateDays {
item.labels = append([]string(nil), item.labels...)
unique = append(unique, item)
continue
}
unique[len(unique)-1].labels = mergeLunarEclipseDiagramLabels(unique[len(unique)-1].labels, item.labels)
}
return unique
}
func mergeLunarEclipseDiagramLabels(existing, incoming []string) []string {
if len(incoming) == 0 {
return existing
}
if len(existing) == 0 {
return append([]string(nil), incoming...)
}
for _, label := range incoming {
found := false
for _, current := range existing {
if current == label {
found = true
break
}
}
if !found {
existing = append(existing, label)
}
}
return existing
}
func lunarEclipseDiagramPrimaryLabel(labels []string) string {
for _, label := range labels {
if label == "Greatest" {
return label
}
}
if len(labels) == 0 {
return ""
}
return labels[0]
}
func lunarEclipseDiagramLabelPriority(labels []string) int {
if len(labels) == 0 {
return 99
}
switch labels[0] {
case "P1":
return 0
case "U1":
return 1
case "U2":
return 2
case "Greatest":
return 3
case "U3":
return 4
case "U4":
return 5
case "P4":
return 6
default:
return 99
}
}
+61
View File
@@ -0,0 +1,61 @@
package basic
import ()
// LunarEclipseWithInstance 指定星历实例的月食(TT,Danjon),计算失败时食甚为 NaN / lunar eclipse with an explicit instance; TT maximum is NaN on failure.
func LunarEclipseWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return LunarEclipseDanjonWithInstance(seedJDE, inst)
}
// LunarEclipseDanjonWithInstance 指定星历实例的 Danjon 月食 / Danjon lunar eclipse with an explicit ephemeris instance.
func LunarEclipseDanjonWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return lunarEclipseWithInstance(seedJDE, lunarEclipseShadowDanjon, inst)
}
// LunarEclipseChauvenetWithInstance 指定星历实例的 Chauvenet 月食 / Chauvenet lunar eclipse with an explicit ephemeris instance.
func LunarEclipseChauvenetWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return lunarEclipseWithInstance(seedJDE, lunarEclipseShadowChauvenet, inst)
}
func lunarEclipseWithInstance(seedJDE float64, shadowModel lunarEclipseShadowModel, inst *Instance) LunarEclipseResult {
if inst == nil {
return lunarEclipse(seedJDE, shadowModel)
}
fullMoonJDE := CalcMoonSHByJDEWithInstance(seedJDE, 1, inst)
return lunarEclipseFromFullMoon(fullMoonJDE, shadowModel, func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
})
}
// LunarEclipseShadowGeometryAtWithInstance 指定星历实例的 Danjon 地影几何 / Danjon shadow geometry with an explicit ephemeris instance.
func LunarEclipseShadowGeometryAtWithInstance(maximumJDE float64, inst *Instance) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, lunarEclipseShadowDanjon, inst)
}
// LunarEclipseShadowGeometryChauvenetAtWithInstance 指定星历实例的 Chauvenet 地影几何 / Chauvenet shadow geometry with an explicit ephemeris instance.
func LunarEclipseShadowGeometryChauvenetAtWithInstance(maximumJDE float64, inst *Instance) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, lunarEclipseShadowChauvenet, inst)
}
// LunarEclipseShadowGeometryAtModelWithInstance 指定星历实例及影半径模型的地影几何 / shadow geometry with an explicit instance and shadow-radius model.
func LunarEclipseShadowGeometryAtModelWithInstance(maximumJDE float64, model LunarEclipseShadowModel, inst *Instance) LunarEclipseShadowGeometry {
if model == LunarEclipseShadowModelChauvenet {
return LunarEclipseShadowGeometryChauvenetAtWithInstance(maximumJDE, inst)
}
return LunarEclipseShadowGeometryAtWithInstance(maximumJDE, inst)
}
// LunarEclipseDiagramWithInstance 指定星历实例的 Danjon 月食穿影图 / Danjon lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return LunarEclipseDiagramDanjonWithInstance(seedJDE, options, inst)
}
// LunarEclipseDiagramDanjonWithInstance 指定星历实例的 Danjon 月食穿影图 / Danjon lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramDanjonWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, lunarEclipseShadowDanjon, options, inst)
}
// LunarEclipseDiagramChauvenetWithInstance 指定星历实例的 Chauvenet 月食穿影图 / Chauvenet lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramChauvenetWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, lunarEclipseShadowChauvenet, options, inst)
}
@@ -0,0 +1,162 @@
package basic
import (
"math"
"reflect"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/geodata"
)
type p3LunarEngine struct {
epoch float64
moonDistance float64
}
func (engine p3LunarEngine) State(target, center ephem.Body, jde float64) ([3]float64, [3]float64, error) {
if center != ephem.Earth || target != ephem.Sun && target != ephem.Moon {
return [3]float64{}, [3]float64{}, ephem.ErrUnsupportedBody
}
longitude, latitude, distance := 0.0, 0.0, astronomicalUnitKM
if target == ephem.Moon {
longitude = math.Pi + 0.23*(jde-engine.epoch)
latitude = 0.002
distance = engine.moonDistance
}
ecliptic := [3]float64{distance * math.Cos(latitude) * math.Cos(longitude), distance * math.Cos(latitude) * math.Sin(longitude), distance * math.Sin(latitude)}
sine, cosine := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
equatorial := [3]float64{ecliptic[0], ecliptic[1]*cosine - ecliptic[2]*sine, ecliptic[1]*sine + ecliptic[2]*cosine}
matrix := ltpPBMAT(2000 + (jde-2451545)/365.25)
var position [3]float64
for axis := range position {
position[axis] = matrix[0][axis]*equatorial[0] + matrix[1][axis]*equatorial[1] + matrix[2][axis]*equatorial[2]
}
return position, [3]float64{}, nil
}
func (engine p3LunarEngine) Coverage(target ephem.Body) (float64, float64, bool) {
return engine.epoch - 20, engine.epoch + 20, target == ephem.Sun || target == ephem.Moon
}
func p3AssertLunarResultEqual(t *testing.T, got, want LunarEclipseResult) {
t.Helper()
gotValue, wantValue := reflect.ValueOf(got), reflect.ValueOf(want)
for field := 0; field < gotValue.NumField(); field++ {
gotField, wantField := gotValue.Field(field), wantValue.Field(field)
if gotField.Kind() == reflect.Float64 && math.IsNaN(gotField.Float()) && math.IsNaN(wantField.Float()) {
continue
}
if !reflect.DeepEqual(gotField.Interface(), wantField.Interface()) {
t.Errorf("%s: got %v, want %v", gotValue.Type().Field(field).Name, gotField.Interface(), wantField.Interface())
}
}
}
func TestP3LunarEclipseBuiltInCompatibility(t *testing.T) {
previous := ephem.Active()
t.Cleanup(func() { ephem.SetActive(previous) })
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
ephem.SetActive(model)
inst := ephem.NewModelInstance(model)
for _, date := range [][3]int{{2022, 11, 8}, {2023, 5, 5}, {2023, 10, 28}, {2024, 4, 23}} {
seed := JDCalc(date[0], date[1], float64(date[2]))
for _, calculate := range []struct {
old func(float64) LunarEclipseResult
new func(float64, *ephem.Instance) LunarEclipseResult
}{
{LunarEclipse, LunarEclipseWithInstance},
{LunarEclipseDanjon, LunarEclipseDanjonWithInstance},
{LunarEclipseChauvenet, LunarEclipseChauvenetWithInstance},
} {
want := calculate.old(seed)
p3AssertLunarResultEqual(t, calculate.new(seed, nil), want)
p3AssertLunarResultEqual(t, calculate.new(seed, inst), want)
}
}
}
}
func TestP3LunarEclipseInstancesStayIsolated(t *testing.T) {
const seed = 2459891.9
first := ephem.NewInstance(p3LunarEngine{epoch: seed, moonDistance: 384400})
second := ephem.NewInstance(p3LunarEngine{epoch: seed + 0.25, moonDistance: 370000})
global, model, generation := ephem.Global(), ephem.Active(), ephem.Generation()
firstResult := LunarEclipseWithInstance(seed, first)
secondResult := LunarEclipseWithInstance(seed, second)
if !firstResult.HasTotal || !secondResult.HasTotal {
t.Fatalf("synthetic eclipses must be total: %+v / %+v", firstResult, secondResult)
}
if math.Abs(secondResult.Maximum-firstResult.Maximum-0.25) > 1e-6 {
t.Errorf("maximum did not follow instance: %.12f / %.12f", firstResult.Maximum, secondResult.Maximum)
}
if firstResult.TotalEnd-firstResult.TotalStart == secondResult.TotalEnd-secondResult.TotalStart {
t.Error("contact durations did not follow instance distances")
}
for _, result := range []LunarEclipseResult{firstResult, secondResult} {
contacts := []float64{result.PenumbralStart, result.PartialStart, result.TotalStart, result.Maximum, result.TotalEnd, result.PartialEnd, result.PenumbralEnd}
for index := 1; index < len(contacts); index++ {
if !isFiniteFloat(contacts[index]) || contacts[index] <= contacts[index-1] {
t.Errorf("invalid contact order: %v", contacts)
}
}
}
p3AssertLunarResultEqual(t, LunarEclipseWithInstance(seed, first), firstResult)
if ephem.Global() != global || ephem.Active() != model || ephem.Generation() != generation {
t.Error("explicit lunar eclipse changed the global ephemeris")
}
}
func TestP3LunarEclipseGeometryAndDiagramUseInstance(t *testing.T) {
const seed = 2459891.9
options := LunarEclipseDiagramOptions{StepDays: 0.02}
for _, shadowModel := range []LunarEclipseShadowModel{LunarEclipseShadowModelDanjon, LunarEclipseShadowModelChauvenet} {
inst := ephem.NewInstance(p3LunarEngine{epoch: seed + 0.15, moonDistance: 380000})
calculate := LunarEclipseDiagramDanjonWithInstance
if shadowModel == LunarEclipseShadowModelChauvenet {
calculate = LunarEclipseDiagramChauvenetWithInstance
}
diagram := calculate(seed, options, inst)
geometry := LunarEclipseShadowGeometryAtModelWithInstance(diagram.Eclipse.Maximum, shadowModel, inst)
if len(diagram.Points) == 0 || math.Abs(geometry.MoonDistanceEarthRadii-380000/geodata.EarthEquatorialRadiusKM) > 1e-10 {
t.Fatalf("diagram/geometry lost instance: %+v", geometry)
}
if math.Abs(diagram.Eclipse.Maximum-seed-0.15) > 0.001 {
t.Errorf("diagram eclipse lost instance: %v", diagram.Eclipse.Maximum)
}
maximumState := computeLunarShadowStateWithInstance(diagram.Eclipse.Maximum, lunarEclipseShadowModel(shadowModel), inst)
for _, point := range diagram.Points {
plane := lunarEclipsePlaneStateAtWithInstance(point.JDE, inst)
if point.X != plane.x/maximumState.moonRadiusRad || point.Y != plane.y/maximumState.moonRadiusRad {
t.Errorf("diagram sample lost instance: %+v", point)
}
}
}
for _, calculate := range []struct {
old func(float64, LunarEclipseDiagramOptions) LunarEclipseDiagramResult
new func(float64, LunarEclipseDiagramOptions, *ephem.Instance) LunarEclipseDiagramResult
}{
{LunarEclipseDiagram, LunarEclipseDiagramWithInstance},
{LunarEclipseDiagramDanjon, LunarEclipseDiagramDanjonWithInstance},
{LunarEclipseDiagramChauvenet, LunarEclipseDiagramChauvenetWithInstance},
} {
got, want := calculate.new(seed, options, nil), calculate.old(seed, options)
p3AssertLunarResultEqual(t, got.Eclipse, want.Eclipse)
got.Eclipse, want.Eclipse = LunarEclipseResult{}, LunarEclipseResult{}
if !reflect.DeepEqual(got, want) {
t.Error("nil changed diagram geometry or samples")
}
}
}
func TestP3LunarEclipseFailureDoesNotFallback(t *testing.T) {
inst := ephem.NewInstance(p3StateEngine{err: ephem.ErrOutOfRange})
result := LunarEclipseWithInstance(2459891.9, inst)
if !math.IsNaN(result.Maximum) || result.HasPenumbral || result.HasPartial || result.HasTotal {
t.Errorf("failed engine produced an eclipse: %+v", result)
}
diagram := LunarEclipseDiagramWithInstance(2459891.9, LunarEclipseDiagramOptions{}, inst)
if !math.IsNaN(diagram.Eclipse.Maximum) || len(diagram.Points) != 0 {
t.Error("failed engine produced diagram samples")
}
}
+80
View File
@@ -0,0 +1,80 @@
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// LunarEclipseShadowGeometry 是食甚时刻的地影几何。
// 注意单位口径:Gamma 用地球赤道半径,而两个影半径用度——后者是 NASA 月食图上 P./U. Radius 的口径,
// 换成地球赤道半径要乘以月球处的地球视差(弧度)。
// LunarEclipseShadowGeometry is the terrestrial-shadow geometry at maximum eclipse. Gamma is in Earth
// equatorial radii while the two shadow radii are in degrees, matching the P./U. Radius convention of
// 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 is the geocentric lunar distance at greatest eclipse, in Earth equatorial radii.
MoonDistanceEarthRadii float64
// AxisDegrees 是食甚时月心到地影轴的角距,即 NASA 月食图上那列 Axis。
// 它与 Gamma 是同一个量的两种刻度:Gamma 除以月球处的地球视差就是它。
// AxisDegrees is the angular distance from the Moon's centre to the shadow axis at greatest eclipse,
// the column NASA lunar-eclipse charts print as Axis. It is the same quantity as Gamma on a different
// scale: divide Gamma by the Earth's parallax at the Moon.
AxisDegrees float64
}
// LunarEclipseShadowGeometryAt 用 Danjon 影半径模型计算食甚时刻的地影几何。
// LunarEclipseShadowGeometryAt computes the shadow geometry at maximum eclipse with the Danjon shadow model.
func LunarEclipseShadowGeometryAt(maximumJDE float64) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowDanjon)
}
// LunarEclipseShadowGeometryChauvenetAt 用 Chauvenet 影半径模型计算食甚时刻的地影几何。
// LunarEclipseShadowGeometryChauvenetAt computes the shadow geometry with the Chauvenet shadow model.
func LunarEclipseShadowGeometryChauvenetAt(maximumJDE float64) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowChauvenet)
}
// LunarEclipseShadowGeometryAtModel 按结果里记录的影半径模型取地影几何。
// LunarEclipseShadowGeometryAtModel picks the shadow geometry by the model recorded in a result.
func LunarEclipseShadowGeometryAtModel(maximumJDE float64, model LunarEclipseShadowModel) LunarEclipseShadowGeometry {
if model == LunarEclipseShadowModelChauvenet {
return LunarEclipseShadowGeometryChauvenetAt(maximumJDE)
}
return LunarEclipseShadowGeometryAt(maximumJDE)
}
func lunarEclipseShadowGeometryAt(maximumJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, shadowModel, nil)
}
func lunarEclipseShadowGeometryAtWithInstance(maximumJDE float64, shadowModel lunarEclipseShadowModel, inst *Instance) LunarEclipseShadowGeometry {
evaluate := func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
}
state := evaluate(maximumJDE)
moonDistanceKM := HMoonAwayWithInstance(maximumJDE, inst)
// 影半径是以地心为顶点的角量,除以月球处的地球视差就换成地球赤道半径。
earthParallax := geodata.EarthEquatorialRadiusKM / moonDistanceKM
_, _, _, _, minimumDistance := refineLunarEclipseMaximumWithEvaluator(maximumJDE, evaluate)
if earthParallax <= 0 {
return LunarEclipseShadowGeometry{}
}
return LunarEclipseShadowGeometry{
Gamma: minimumDistance / earthParallax,
PenumbralRadiusDegrees: state.penumbraRadiusRad * 180 / math.Pi,
UmbralRadiusDegrees: state.umbraRadiusRad * 180 / math.Pi,
MoonDistanceEarthRadii: moonDistanceKM / geodata.EarthEquatorialRadiusKM,
// 平面 x 向东(黄经差)、y 向北(黄纬和),方位角自北向东量。
AxisDegrees: minimumDistance * 180 / math.Pi,
}
}
+53
View File
@@ -0,0 +1,53 @@
package basic
import (
"math"
"testing"
"time"
)
// Axis 与 Gamma 是同一个量的两种刻度:Gamma × 月球处的地球视差 = Axis。
func TestLunarEclipseShadowGeometryAxisMatchesGamma(t *testing.T) {
for _, date := range []time.Time{
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
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(UTC2TT(Date2JD(date)))
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
// 影几何内部用的是小角近似的地球视差(R⊕/月距),这里必须用同一形式,否则二级差会露出来。
earthParallaxDegrees := (1 / geometry.MoonDistanceEarthRadii) * 180 / math.Pi
if delta := geometry.Gamma*earthParallaxDegrees - geometry.AxisDegrees; math.Abs(delta) > 1e-9 {
t.Fatalf("%s: Gamma×parallax - Axis = %g", date.Format("2006-01-02"), delta)
}
}
}
// 食分公式反解出的月心到影轴距离必须等于 Axis:这条把影几何与食分求解器对起来。
// 食分 = (影半径 + 月视半径 − Axis) / (2 × 月视半径)。
func TestLunarEclipseMagnitudeInvertsToAxis(t *testing.T) {
for _, date := range []time.Time{
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC),
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(UTC2TT(Date2JD(date)))
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
moonSemidiameter := MoonSemidiameter(result.Maximum) / 3600
fromUmbral := geometry.UmbralRadiusDegrees + moonSemidiameter -
2*moonSemidiameter*result.Magnitude
fromPenumbral := geometry.PenumbralRadiusDegrees + moonSemidiameter -
2*moonSemidiameter*result.PenumbralMagnitude
t.Logf("%s %s: Axis=%.4f 本影反推=%.4f 半影反推=%.4f",
date.Format("2006-01-02"), result.Type, geometry.AxisDegrees, fromUmbral, fromPenumbral)
if math.Abs(fromUmbral-geometry.AxisDegrees) > 0.002 {
t.Fatalf("%s: umbral magnitude implies axis %.4f, geometry gives %.4f",
date.Format("2006-01-02"), fromUmbral, geometry.AxisDegrees)
}
if math.Abs(fromPenumbral-geometry.AxisDegrees) > 0.002 {
t.Fatalf("%s: penumbral magnitude implies axis %.4f, geometry gives %.4f",
date.Format("2006-01-02"), fromPenumbral, geometry.AxisDegrees)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
package basic
import "math"
// lunarEclipseLimbMaxShiftDays 是月缘精修允许偏离圆月面接触的上限(10 分钟),超出视为不收敛并保留原值。
const lunarEclipseLimbMaxShiftDays = 600.0 / 86400.0
// lunarEclipseLimbStepDays 是割线法的初始步长(1 秒)。
const lunarEclipseLimbStepDays = 1.0 / 86400.0
// LunarEclipseWithLunarLimb 用月缘剖面精修月食接触时刻,食甚、食分与食型仍按圆月面 / refine lunar eclipse contacts with a lunar limb profile.
func LunarEclipseWithLunarLimb(result LunarEclipseResult, limb LunarLimb, inst *Instance) LunarEclipseResult {
if limb == nil {
return result
}
original := result
shadowModel := lunarEclipseShadowDanjon
if result.ShadowModel == LunarEclipseShadowModelChauvenet {
shadowModel = lunarEclipseShadowChauvenet
}
evaluate := func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
}
refine := func(seed float64, kind lunarEclipseLimbContact) float64 {
if !finite(seed) {
return seed
}
return refineLunarEclipseLimbContact(seed, kind, limb, evaluate, inst)
}
if result.HasPenumbral {
result.PenumbralStart = refine(result.PenumbralStart, lunarEclipseLimbPenumbral)
result.PenumbralEnd = refine(result.PenumbralEnd, lunarEclipseLimbPenumbral)
}
if result.HasPartial {
result.PartialStart = refine(result.PartialStart, lunarEclipseLimbUmbral)
result.PartialEnd = refine(result.PartialEnd, lunarEclipseLimbUmbral)
}
if result.HasTotal {
result.TotalStart = refine(result.TotalStart, lunarEclipseLimbTotal)
result.TotalEnd = refine(result.TotalEnd, lunarEclipseLimbTotal)
}
result.LunarLimbApplied = lunarEclipseLimbChanged(result, original)
return result
}
func lunarEclipseLimbChanged(result, original LunarEclipseResult) bool {
for _, pair := range [][2]float64{
{result.PenumbralStart, original.PenumbralStart},
{result.PenumbralEnd, original.PenumbralEnd},
{result.PartialStart, original.PartialStart},
{result.PartialEnd, original.PartialEnd},
{result.TotalStart, original.TotalStart},
{result.TotalEnd, original.TotalEnd},
} {
if limbTimeChanged(pair[0], pair[1]) {
return true
}
}
return false
}
type lunarEclipseLimbContact int
const (
lunarEclipseLimbPenumbral lunarEclipseLimbContact = iota
lunarEclipseLimbUmbral
lunarEclipseLimbTotal
)
// refineLunarEclipseLimbContact 以圆月面接触为初值用割线法求月缘间隙的零点,不收敛时返回初值。
func refineLunarEclipseLimbContact(seed float64, kind lunarEclipseLimbContact, limb LunarLimb, evaluate lunarShadowEvaluator, inst *Instance) float64 {
t0, t1 := seed, seed+lunarEclipseLimbStepDays
g0, ok0 := lunarEclipseLimbGap(t0, kind, limb, evaluate, inst)
g1, ok1 := lunarEclipseLimbGap(t1, kind, limb, evaluate, inst)
if !ok0 || !ok1 {
return seed
}
for i := 0; i < 12; i++ {
if g1 == g0 {
return seed
}
t2 := t1 - g1*(t1-t0)/(g1-g0)
if !finite(t2) || math.Abs(t2-seed) > lunarEclipseLimbMaxShiftDays {
return seed
}
if math.Abs(t2-t1) < 1e-9 {
return t2
}
g2, ok := lunarEclipseLimbGap(t2, kind, limb, evaluate, inst)
if !ok {
return seed
}
t0, g0, t1, g1 = t1, g1, t2, g2
}
return seed
}
// lunarEclipseLimbGap 是月缘版接触间隙(弧度),零点即接触。
func lunarEclipseLimbGap(jde float64, kind lunarEclipseLimbContact, limb LunarLimb, evaluate lunarShadowEvaluator, inst *Instance) (float64, bool) {
state := evaluate(jde)
moonRA, moonDec := HMoonGeocentricApparentRaDecWithInstance(jde, inst)
sunRA, sunDec := HSunApparentRaDecWithInstance(jde, inst)
moon := lunarEclipseLimbVector(moonRA, moonDec, HMoonAwayWithInstance(jde, inst))
shadow := lunarEclipseLimbVector(sunRA+180, -sunDec, 1)
if !finite(state.moonRadiusRad) || state.moonRadiusRad <= 0 {
return 0, false
}
frame := newLunarLimbFrame(limb, jde, moon, inst)
view, ok := frame.viewToward(moon, shadow)
if !ok {
return 0, false
}
separation := math.Hypot(state.x, state.y)
contact := lunarLimbContact{
frame: &frame,
view: view,
separation: separation,
anglePerKM: state.moonRadiusRad / lunarMoonRadiusScale,
}
switch kind {
case lunarEclipseLimbPenumbral:
contact.targetRadius = state.penumbraRadiusRad
default:
contact.targetRadius = state.umbraRadiusRad
}
// 半影、本影外切取全方向最外的月缘;全食内切取全方向最靠外、最后进入本影的月缘。
var limbRad float64
if kind == lunarEclipseLimbTotal {
limbRad, ok = contact.innerRadius(false)
} else {
limbRad, ok = contact.outerRadius()
}
if !ok {
return 0, false
}
if kind == lunarEclipseLimbTotal {
return separation - math.Abs(limbRad-contact.targetRadius), true
}
return separation - limbRad - contact.targetRadius, true
}
func lunarEclipseLimbVector(raDeg, decDeg, distance float64) [3]float64 {
sinRA, cosRA := math.Sincos(raDeg * rad)
sinDec, cosDec := math.Sincos(decDeg * rad)
return [3]float64{distance * cosDec * cosRA, distance * cosDec * sinRA, distance * sinDec}
}
+341
View File
@@ -0,0 +1,341 @@
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephemtest"
)
type lunarEclipseBaseline struct {
name string
jde float64
expectedType LunarEclipseType
expectedMax float64
expectedMag float64
expectedPenumbralStart float64
expectedPenumbralEnd float64
expectedPartialStart float64
expectedPartialEnd float64
expectedTotalStart float64
expectedTotalEnd float64
}
func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
// 这些基准值来自历史本地月食基线,
// 其阴影口径对应当前保留的 Chauvenet 模型。
testCases := []lunarEclipseBaseline{
{
name: "2022-11-08 total",
jde: JDCalc(2022, 11, 8),
expectedType: LunarEclipseTotal,
expectedMax: 2459891.9585873615,
expectedMag: 1.3635170051692678,
expectedPenumbralStart: 2459891.8346063416,
expectedPenumbralEnd: 2459892.0826413140,
expectedPartialStart: 2459891.8820205650,
expectedPartialEnd: 2459892.0351211606,
expectedTotalStart: 2459891.9288277230,
expectedTotalEnd: 2459891.9883249460,
},
{
name: "2023-05-05 penumbral",
jde: JDCalc(2023, 5, 5),
expectedType: LunarEclipsePenumbral,
expectedPenumbralStart: 2460070.1342392800,
expectedPenumbralEnd: 2460070.3159191823,
},
{
name: "2023-10-28 partial",
jde: JDCalc(2023, 10, 28),
expectedType: LunarEclipsePartial,
expectedMax: 2460246.3439460830,
expectedMag: 0.12723850274626405,
expectedPenumbralStart: 2460246.2507697106,
expectedPenumbralEnd: 2460246.4370874465,
expectedPartialStart: 2460246.3164327650,
expectedPartialEnd: 2460246.3713359070,
},
{
name: "2024-03-25 penumbral",
jde: JDCalc(2024, 3, 25),
expectedType: LunarEclipsePenumbral,
expectedPenumbralStart: 2460394.7028870000,
expectedPenumbralEnd: 2460394.8999071894,
},
{
name: "2024-09-18 partial",
jde: JDCalc(2024, 9, 18),
expectedType: LunarEclipsePartial,
expectedMax: 2460571.6148748010,
expectedMag: 0.09042791952817894,
expectedPenumbralStart: 2460571.5281155687,
expectedPenumbralEnd: 2460571.7016473800,
expectedPartialStart: 2460571.5923644140,
expectedPartialEnd: 2460571.6374154520,
},
{
name: "2025-03-14 total",
jde: JDCalc(2025, 3, 14),
expectedType: LunarEclipseTotal,
expectedMax: 2460748.7916214615,
expectedMag: 1.1828107517800281,
expectedPenumbralStart: 2460748.6645233813,
expectedPenumbralEnd: 2460748.9187600957,
expectedPartialStart: 2460748.7156107454,
expectedPartialEnd: 2460748.8676076555,
expectedTotalStart: 2460748.7685903380,
expectedTotalEnd: 2460748.8146345600,
},
{
name: "2025-09-07 total",
jde: JDCalc(2025, 9, 7),
expectedType: LunarEclipseTotal,
expectedMax: 2460926.2590034613,
expectedMag: 1.3672329695760280,
expectedPenumbralStart: 2460926.1445036167,
expectedPenumbralEnd: 2460926.3734498024,
expectedPartialStart: 2460926.1860739910,
expectedPartialEnd: 2460926.3319619163,
expectedTotalStart: 2460926.2302397094,
expectedTotalEnd: 2460926.2877871464,
},
{
name: "2026-03-03 total",
jde: JDCalc(2026, 3, 3),
expectedType: LunarEclipseTotal,
expectedMax: 2461102.9825476190,
expectedMag: 1.1556387222746651,
expectedPenumbralStart: 2461102.8638708987,
expectedPenumbralEnd: 2461103.1012840020,
expectedPartialStart: 2461102.9103626400,
expectedPartialEnd: 2461103.0546894810,
expectedTotalStart: 2461102.9619182530,
expectedTotalEnd: 2461103.0031447060,
},
}
const timeTolerance = 1e-6
const magnitudeTolerance = 2e-5
values := ephemtest.NewValues(t, "lunar_eclipse_chauvenet")
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := LunarEclipseChauvenet(tc.jde)
w := func(field string, legacy, got float64) float64 { return values.Want(tc.name+"/"+field, legacy, got) }
tc.expectedMax = w("Maximum", tc.expectedMax, result.Maximum)
tc.expectedMag = w("Magnitude", tc.expectedMag, result.Magnitude)
tc.expectedPenumbralStart = w("PenumbralStart", tc.expectedPenumbralStart, result.PenumbralStart)
tc.expectedPenumbralEnd = w("PenumbralEnd", tc.expectedPenumbralEnd, result.PenumbralEnd)
tc.expectedPartialStart = w("PartialStart", tc.expectedPartialStart, result.PartialStart)
tc.expectedPartialEnd = w("PartialEnd", tc.expectedPartialEnd, result.PartialEnd)
tc.expectedTotalStart = w("TotalStart", tc.expectedTotalStart, result.TotalStart)
tc.expectedTotalEnd = w("TotalEnd", tc.expectedTotalEnd, result.TotalEnd)
if result.Type != tc.expectedType {
t.Fatalf("Type mismatch: got %s want %s", result.Type, tc.expectedType)
}
if tc.expectedMax != 0 && math.Abs(result.Maximum-tc.expectedMax) > timeTolerance {
t.Fatalf("Maximum mismatch: got %.12f want %.12f", result.Maximum, tc.expectedMax)
}
if tc.expectedMag != 0 && math.Abs(result.Magnitude-tc.expectedMag) > magnitudeTolerance {
t.Fatalf("Magnitude mismatch: got %.12f want %.12f", result.Magnitude, tc.expectedMag)
}
assertContactJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance)
assertContactJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance)
assertContactJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance)
assertContactJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance)
assertContactJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance)
assertContactJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance)
if result.HasTotal && !(result.TotalStart < result.Maximum && result.Maximum < result.TotalEnd) {
t.Fatalf("total contact order invalid: start=%.12f max=%.12f end=%.12f", result.TotalStart, result.Maximum, result.TotalEnd)
}
if result.HasPartial && !(result.PartialStart < result.Maximum && result.Maximum < result.PartialEnd) {
t.Fatalf("partial contact order invalid: start=%.12f max=%.12f end=%.12f", result.PartialStart, result.Maximum, result.PartialEnd)
}
if result.HasPenumbral && !(result.PenumbralStart < result.Maximum && result.Maximum < result.PenumbralEnd) {
t.Fatalf("penumbral contact order invalid: start=%.12f max=%.12f end=%.12f", result.PenumbralStart, result.Maximum, result.PenumbralEnd)
}
})
}
}
func TestLunarEclipseDefaultUsesDanjon(t *testing.T) {
jde := JDCalc(2025, 3, 14)
defaultResult := LunarEclipse(jde)
danjonResult := LunarEclipseDanjon(jde)
chauvenetResult := LunarEclipseChauvenet(jde)
assertCloseJD(t, "Maximum", defaultResult.Maximum, danjonResult.Maximum, 1e-12)
assertCloseJD(t, "PenumbralStart", defaultResult.PenumbralStart, danjonResult.PenumbralStart, 1e-12)
assertCloseJD(t, "PenumbralEnd", defaultResult.PenumbralEnd, danjonResult.PenumbralEnd, 1e-12)
if math.Abs(defaultResult.PenumbralMagnitude-danjonResult.PenumbralMagnitude) > 1e-12 {
t.Fatalf("default penumbral magnitude mismatch: got %.12f want %.12f", defaultResult.PenumbralMagnitude, danjonResult.PenumbralMagnitude)
}
if math.Abs(defaultResult.PenumbralMagnitude-chauvenetResult.PenumbralMagnitude) < 1e-4 {
t.Fatalf("default model should not collapse to Chauvenet: default=%.12f chauvenet=%.12f", defaultResult.PenumbralMagnitude, chauvenetResult.PenumbralMagnitude)
}
}
func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
testCases := []struct {
name string
jde float64
calc func(float64) LunarEclipseResult
}{
{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 {
t.Run(tc.name, func(t *testing.T) {
result := tc.calc(tc.jde)
if result.Type != LunarEclipsePenumbral {
t.Fatalf("type mismatch: got %s want %s", result.Type, LunarEclipsePenumbral)
}
if !result.HasPenumbral || result.HasPartial || result.HasTotal {
t.Fatalf("unexpected eclipse flags: %+v", result)
}
if !(result.Magnitude < 0) {
t.Fatalf("expected negative umbral magnitude for penumbral eclipse, got %.12f", result.Magnitude)
}
if !(result.PenumbralMagnitude > 0) {
t.Fatalf("expected positive penumbral magnitude, got %.12f", result.PenumbralMagnitude)
}
})
}
}
func TestLunarEclipseDanjonMagnitudesCloserToNASA(t *testing.T) {
testCases := []struct {
name string
jde float64
expectedType LunarEclipseType
nasaPenumbralMagnitude float64
nasaUmbralMagnitude float64
}{
{
name: "2023-10-28 partial",
jde: JDCalc(2023, 10, 28),
expectedType: LunarEclipsePartial,
nasaPenumbralMagnitude: 1.1181,
nasaUmbralMagnitude: 0.1220,
},
{
name: "2025-03-14 total",
jde: JDCalc(2025, 3, 14),
expectedType: LunarEclipseTotal,
nasaPenumbralMagnitude: 2.2595,
nasaUmbralMagnitude: 1.1784,
},
{
name: "2026-03-03 total",
jde: JDCalc(2026, 3, 3),
expectedType: LunarEclipseTotal,
nasaPenumbralMagnitude: 2.1838,
nasaUmbralMagnitude: 1.1507,
},
{
name: "2026-08-28 partial",
jde: JDCalc(2026, 8, 28),
expectedType: LunarEclipsePartial,
nasaPenumbralMagnitude: 1.9645,
nasaUmbralMagnitude: 0.9299,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
danjonResult := LunarEclipseDanjon(tc.jde)
chauvenetResult := LunarEclipseChauvenet(tc.jde)
if danjonResult.Type != tc.expectedType {
t.Fatalf("Danjon type mismatch: got %s want %s", danjonResult.Type, tc.expectedType)
}
if chauvenetResult.Type != tc.expectedType {
t.Fatalf("Chauvenet type mismatch: got %s want %s", chauvenetResult.Type, tc.expectedType)
}
danjonPenumbralError := math.Abs(danjonResult.PenumbralMagnitude - tc.nasaPenumbralMagnitude)
chauvenetPenumbralError := math.Abs(chauvenetResult.PenumbralMagnitude - tc.nasaPenumbralMagnitude)
if !(danjonPenumbralError < chauvenetPenumbralError) {
t.Fatalf("Danjon penumbral magnitude should be closer to NASA: danjon=%.6f chauvenet=%.6f nasa=%.6f", danjonResult.PenumbralMagnitude, chauvenetResult.PenumbralMagnitude, tc.nasaPenumbralMagnitude)
}
danjonUmbralError := math.Abs(danjonResult.Magnitude - tc.nasaUmbralMagnitude)
chauvenetUmbralError := math.Abs(chauvenetResult.Magnitude - tc.nasaUmbralMagnitude)
if !(danjonUmbralError < chauvenetUmbralError) {
t.Fatalf("Danjon umbral magnitude should be closer to NASA: danjon=%.6f chauvenet=%.6f nasa=%.6f", danjonResult.Magnitude, chauvenetResult.Magnitude, tc.nasaUmbralMagnitude)
}
})
}
}
func TestLunarEclipseNoEvent(t *testing.T) {
testCases := []struct {
name string
calc func(float64) LunarEclipseResult
}{
{name: "default", calc: LunarEclipse},
{name: "danjon", calc: LunarEclipseDanjon},
{name: "chauvenet", calc: LunarEclipseChauvenet},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.calc(JDCalc(2023, 6, 4))
if result.Type != LunarEclipseNone {
t.Fatalf("Type mismatch: got %s want %s", result.Type, LunarEclipseNone)
}
if result.HasPenumbral || result.HasPartial || result.HasTotal {
t.Fatalf("unexpected contacts: %+v", result)
}
for name, value := range map[string]float64{
"PenumbralStart": result.PenumbralStart,
"PenumbralEnd": result.PenumbralEnd,
"PartialStart": result.PartialStart,
"PartialEnd": result.PartialEnd,
"TotalStart": result.TotalStart,
"TotalEnd": result.TotalEnd,
} {
if !math.IsNaN(value) {
t.Fatalf("%s = %.12f, want NaN for a non-eclipse: %+v", name, value, result)
}
}
if result.Magnitude != 0 || result.PenumbralMagnitude != 0 {
t.Fatalf("expected zero magnitudes for non-eclipse, got %+v", result)
}
})
}
}
func assertCloseJD(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
if want == 0 {
if got != 0 {
t.Fatalf("%s mismatch: got %.12f want 0", name, got)
}
return
}
if math.Abs(got-want) > tolerance {
t.Fatalf("%s mismatch: got %.12f want %.12f", name, got, want)
}
}
// assertContactJD 未发生阶段的期望值是 0(基准表写法),此时实际值必须是 NaN 而不是 JD 0。
func assertContactJD(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
if want == 0 {
if !math.IsNaN(got) {
t.Fatalf("%s = %.12f, want NaN for an absent phase", name, got)
}
return
}
assertCloseJD(t, name, got, want, tolerance)
}
+424
View File
@@ -0,0 +1,424 @@
package basic
import "math"
// LunarLimb 月缘剖面,nil 为圆月面,内置数据表在 moon/limb / lunar limb profile; nil means a circular limb.
type LunarLimb interface {
// LimbRadiusKM 按观测者总天平动经纬(度,东正北正)与自投影月北极起算的位置角(度)返回月心到月缘距离(km),无值时第二个返回 false / limb radius at a position angle for the given libration.
LimbRadiusKM(librationLongitude, librationLatitude, positionAngle float64) (float64, bool)
}
// lunarLimbFrame 是某一瞬间的月球本体坐标架在真赤道坐标系下的表示,
// 由该时刻的地心天平动与自转轴位置角反解,可对任意站心方向复用。
type lunarLimbFrame struct {
limb LunarLimb
// stepDeg 是剖面位置角网格步长;ranged 为真时 minKM/maxKM 是剖面可能取到的半径范围。
stepDeg float64
minKM, maxKM float64
ranged bool
// axisX / axisY / axisZ 分别是月面经纬 (0°,0°)、(90°,0°) 与北极方向的单位矢量。
axisX [3]float64
axisY [3]float64
axisZ [3]float64
valid bool
}
// newLunarLimbFrame 用地心月球方向与该时刻的物理天平动构造本体坐标架。
// moonGeocentric 是真赤道坐标系下的地心月球矢量(km)。
func newLunarLimbFrame(limb LunarLimb, jdTT float64, moonGeocentric [3]float64, instances ...*Instance) lunarLimbFrame {
if limb == nil {
return lunarLimbFrame{}
}
inst := instanceValue(instances)
toEarth, ok := lunarLimbNormalize([3]float64{-moonGeocentric[0], -moonGeocentric[1], -moonGeocentric[2]})
if !ok {
return lunarLimbFrame{}
}
physical := MoonPhysicalWithInstance(jdTT, inst)
north, east, ok := lunarLimbSkyBasis([3]float64{-toEarth[0], -toEarth[1], -toEarth[2]})
if !ok {
return lunarLimbFrame{}
}
// 月球北极在天空平面上的投影自天北起向东量 P,于是极轴方向 = sinB·(月心→地心) + cosB·N。
sinP, cosP := math.Sincos(physical.PositionAngle * rad)
projected := lunarLimbCombine(cosP, north, sinP, east)
sinB, cosB := math.Sincos(physical.LibrationLatitude * rad)
axisZ, ok := lunarLimbNormalize(lunarLimbCombine(sinB, toEarth, cosB, projected))
if !ok {
return lunarLimbFrame{}
}
// 地心方向在赤道面内的分量对应本体系的 (cosL, sinL, 0),据此旋回 x 轴。
meridian, ok := lunarLimbNormalize(lunarLimbReject(toEarth, axisZ))
if !ok {
return lunarLimbFrame{}
}
sinL, cosL := math.Sincos(physical.LibrationLongitude * rad)
axisX, ok := lunarLimbNormalize(lunarLimbCombine(cosL, meridian, -sinL, lunarLimbCross(axisZ, meridian)))
if !ok {
return lunarLimbFrame{}
}
frame := lunarLimbFrame{
limb: limb,
stepDeg: lunarLimbDefaultStepDeg,
axisX: axisX,
axisY: lunarLimbCross(axisZ, axisX),
axisZ: axisZ,
valid: true,
}
if ranged, ok := limb.(lunarLimbRangeProvider); ok {
low, high := ranged.RadiusRangeKM()
if step := ranged.AngleStepDeg(); finite(step) && step >= lunarLimbMinStepDeg && step <= 1 {
frame.stepDeg = step
}
if finite(low) && finite(high) && low > 0 && high >= low {
frame.minKM, frame.maxKM, frame.ranged = low, high, true
}
}
return frame
}
// radiusKM 返回站心所见月缘在指定方向上的月心距(km);awayFromTarget 为真时取背向目标的一侧。
func (frame lunarLimbFrame) radiusKM(moonTopocentric, targetTopocentric [3]float64, awayFromTarget bool) (float64, bool) {
if !frame.valid {
return 0, false
}
moon, ok := lunarLimbNormalize(moonTopocentric)
if !ok {
return 0, false
}
target, ok := lunarLimbNormalize(targetTopocentric)
if !ok {
return 0, false
}
north, east, ok := lunarLimbSkyBasis(moon)
if !ok {
return 0, false
}
toObserver := [3]float64{-moon[0], -moon[1], -moon[2]}
longitude := math.Atan2(lunarLimbDot(toObserver, frame.axisY), lunarLimbDot(toObserver, frame.axisX)) / rad
latitude := math.Asin(clampUnit(lunarLimbDot(toObserver, frame.axisZ))) / rad
// 自转轴位置角 P 与目标方向位置角 Q 都自天北向东量;剖面表的 θ 自月球北极向月面东经一侧
// (从地球看是天西)量,所以 θ = P − Q。
axisAngle := math.Atan2(lunarLimbDot(frame.axisZ, east), lunarLimbDot(frame.axisZ, north))
targetPlane, ok := lunarLimbNormalize(lunarLimbReject(target, moon))
if !ok {
return 0, false
}
targetAngle := math.Atan2(lunarLimbDot(targetPlane, east), lunarLimbDot(targetPlane, north))
positionAngle := (axisAngle - targetAngle) / rad
if awayFromTarget {
positionAngle += 180
}
positionAngle = math.Mod(positionAngle, 360)
if positionAngle < 0 {
positionAngle += 360
}
radius, ok := frame.limb.LimbRadiusKM(longitude, latitude, positionAngle)
if !ok || !finite(radius) || radius <= 0 {
return 0, false
}
return radius, true
}
// lunarLimbView 是站心所见的月面经纬与指向目标方向的剖面位置角(度)。
type lunarLimbView struct {
longitude, latitude, angle float64
}
// viewToward 求站心所见天平动与目标方向的剖面位置角,约定与 radiusKM 相同。
func (frame lunarLimbFrame) viewToward(moonTopocentric, targetTopocentric [3]float64) (lunarLimbView, bool) {
if !frame.valid {
return lunarLimbView{}, false
}
moon, ok := lunarLimbNormalize(moonTopocentric)
if !ok {
return lunarLimbView{}, false
}
target, ok := lunarLimbNormalize(targetTopocentric)
if !ok {
return lunarLimbView{}, false
}
north, east, ok := lunarLimbSkyBasis(moon)
if !ok {
return lunarLimbView{}, false
}
toObserver := [3]float64{-moon[0], -moon[1], -moon[2]}
axisAngle := math.Atan2(lunarLimbDot(frame.axisZ, east), lunarLimbDot(frame.axisZ, north))
targetPlane, ok := lunarLimbNormalize(lunarLimbReject(target, moon))
if !ok {
return lunarLimbView{}, false
}
targetAngle := math.Atan2(lunarLimbDot(targetPlane, east), lunarLimbDot(targetPlane, north))
return lunarLimbView{
longitude: math.Atan2(lunarLimbDot(toObserver, frame.axisY), lunarLimbDot(toObserver, frame.axisX)) / rad,
latitude: math.Asin(clampUnit(lunarLimbDot(toObserver, frame.axisZ))) / rad,
angle: (axisAngle - targetAngle) / rad,
}, true
}
// radiusAt 返回剖面位置角偏离目标方向 offset 度处的月缘月心距(km)。
func (frame lunarLimbFrame) radiusAt(view lunarLimbView, offset float64) (float64, bool) {
angle := math.Mod(view.angle+offset, 360)
if angle < 0 {
angle += 360
}
radius, ok := frame.limb.LimbRadiusKM(view.longitude, view.latitude, angle)
if !ok || !finite(radius) || radius <= 0 {
return 0, false
}
return radius, true
}
// lunarLimbSkyBasis 返回视方向 direction(观测者指向天体)处的天球北、东单位矢量;
// 东向为赤经增加方向,e = n × direction。
func lunarLimbSkyBasis(direction [3]float64) ([3]float64, [3]float64, bool) {
north, ok := lunarLimbNormalize(lunarLimbReject([3]float64{0, 0, 1}, direction))
if !ok {
return [3]float64{}, [3]float64{}, false
}
return north, lunarLimbCross(north, direction), true
}
func lunarLimbNormalize(vector [3]float64) ([3]float64, bool) {
norm := math.Sqrt(vector[0]*vector[0] + vector[1]*vector[1] + vector[2]*vector[2])
if !finite(norm) || norm <= 0 {
return [3]float64{}, false
}
return [3]float64{vector[0] / norm, vector[1] / norm, vector[2] / norm}, true
}
// lunarLimbReject 返回 vector 中垂直于单位矢量 axis 的分量。
func lunarLimbReject(vector, axis [3]float64) [3]float64 {
projection := lunarLimbDot(vector, axis)
return [3]float64{
vector[0] - projection*axis[0],
vector[1] - projection*axis[1],
vector[2] - projection*axis[2],
}
}
func lunarLimbCombine(scaleA float64, a [3]float64, scaleB float64, b [3]float64) [3]float64 {
return [3]float64{
scaleA*a[0] + scaleB*b[0],
scaleA*a[1] + scaleB*b[1],
scaleA*a[2] + scaleB*b[2],
}
}
func lunarLimbCross(a, b [3]float64) [3]float64 {
return [3]float64{
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0],
}
}
func lunarLimbDot(a, b [3]float64) float64 {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
}
// lunarLimbDefaultStepDeg 剖面不报告网格步长时的位置角采样步长(度)。
const lunarLimbDefaultStepDeg = 0.1
// lunarLimbMinStepDeg 剖面报告的步长低于此值时退回默认步长,防止全圈扫描次数失控。
const lunarLimbMinStepDeg = 0.001
// lunarLimbRangeProvider 可选接口:报告剖面半径范围与位置角网格步长,用于门控与剪枝;步长不在 [0.001°, 1°] 内时改用默认 0.1°。
type lunarLimbRangeProvider interface {
RadiusRangeKM() (float64, float64)
AngleStepDeg() float64
}
// lunarLimbContact 是某一站心时刻按全部位置角求日面接触余量的上下文,角量均为弧度,在天空平面上按平直几何计。
type lunarLimbContact struct {
frame *lunarLimbFrame
view lunarLimbView
distanceKM float64
separation float64
targetRadius float64
// anglePerKM 非零时月缘半径按线性比例换算成角量(月食影面坐标);为零时按 asin(r/distanceKM)。
anglePerKM float64
}
// radius 返回该方向月缘角半径;exact 为假时用 r/距离 的线性近似,只供扫描选方向,极值处再按 exact 重算。
func (contact lunarLimbContact) radius(offsetDeg float64, exact bool) (float64, bool) {
radiusKM, ok := contact.frame.radiusAt(contact.view, offsetDeg)
if !ok {
return 0, false
}
if contact.anglePerKM != 0 {
return radiusKM * contact.anglePerKM, true
}
if !exact {
return radiusKM / contact.distanceKM, true
}
return math.Asin(localSolarEclipseClampUnit(radiusKM / contact.distanceKM)), true
}
// bounds 返回剖面半径的角量上下界;剖面不报告范围时 ok 为假,此时不门控、不剪枝。
func (contact lunarLimbContact) bounds() (float64, float64, bool) {
if !contact.frame.ranged {
return 0, 0, false
}
if contact.anglePerKM != 0 {
return contact.frame.minKM * contact.anglePerKM, contact.frame.maxKM * contact.anglePerKM, true
}
return contact.frame.minKM / contact.distanceKM, contact.frame.maxKM / contact.distanceKM, true
}
// rayChord 返回自月心沿偏离日心方向 δ 的射线与日面边界的近、远交点距离;射线不交日面时 ok 为假。
func (contact lunarLimbContact) rayChord(offsetDeg float64) (float64, float64, bool) {
sine, cosine := math.Sincos(offsetDeg * rad)
d, s := contact.separation, contact.targetRadius
discriminant := s*s - d*d*sine*sine
if discriminant < 0 {
return 0, 0, false
}
root := math.Sqrt(discriminant)
return d*cosine - root, d*cosine + root, true
}
// scan 自 baseDeg 向两侧按剖面位置角网格节点求余量极值并在极值方向按精确半径重算;maximize 为真取最大。剖面在节点间按位置角线性插值,
// 只取节点与 baseDeg 即不会漏掉剖面峰谷。bound(δ) 给出该方向余量可能达到的最好值,越过它不可能再刷新极值即停止该侧。
func (contact lunarLimbContact) scan(
baseDeg float64,
maximize bool,
margin func(offsetDeg float64, exact bool) (float64, bool, bool),
bound func(offsetDeg float64) (float64, bool),
) (float64, bool) {
best, bestOffset, found := 0.0, baseDeg, false
visit := func(offset float64) bool {
value, hit, ok := margin(offset, false)
if !ok {
return false
}
if hit && (!found || (maximize && value > best) || (!maximize && value < best)) {
best, bestOffset, found = value, offset, true
}
return true
}
if !visit(baseDeg) {
return 0, false
}
step := contact.frame.stepDeg
absolute := contact.view.angle + baseDeg
first := [2]float64{math.Floor(absolute/step)*step - absolute, math.Ceil(absolute/step)*step - absolute}
steps := int(math.Ceil(180/step)) + 1
for side, sign := range [2]float64{-1, 1} {
for index := 0; index <= steps; index++ {
delta := first[side] + sign*float64(index)*step
if delta == 0 || math.Abs(delta) > 180 {
continue
}
offset := baseDeg + delta
if limit, ok := bound(offset); ok && found &&
((maximize && limit <= best) || (!maximize && limit >= best)) {
break
}
if !visit(offset) {
return 0, false
}
}
}
if !found {
return 0, false
}
exact, _, ok := margin(bestOffset, true)
return exact, ok
}
// outerRadius 返回外切等效月半径(弧度):取全部方向中月缘超出日面近侧边界最多者,使 d − R − s 为零即外切。
// 只有剖面范围保证间隙离零至少一个起伏幅度时才跳过扫描,免得门控两侧的间隙在接触根附近不连续。
func (contact lunarLimbContact) outerRadius() (float64, bool) {
d, s := contact.separation, contact.targetRadius
low, high, ranged := contact.bounds()
if d <= s || (ranged && (high <= d-s-(high-low) || low >= d-s+(high-low))) {
return contact.radius(0, true)
}
best, ok := contact.scan(0, true,
func(offset float64, exact bool) (float64, bool, bool) {
near, _, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return radius - near, true, ok
},
func(offset float64) (float64, bool) {
near, _, hit := contact.rayChord(offset)
if !hit {
return math.Inf(-1), true
}
return high - near, ranged
})
if !ok {
return 0, false
}
return d - s + best, true
}
// innerRadius 返回内切等效月半径(弧度),使内切间隙 d − |R − s| 等于 min(−余量, d)。total 为真按全食求月缘盖住日面
// 远侧边界的最小余量,否则按环食求日面远侧边界超出月缘的最小余量;门控条件同 outerRadius。
func (contact lunarLimbContact) innerRadius(total bool) (float64, bool) {
d, s := contact.separation, contact.targetRadius
low, high, ranged := contact.bounds()
relief := high - low
if total {
if ranged && (low >= d+s+relief || high <= d+s-relief) {
radius, ok := contact.radius(0, true)
return s + math.Max(radius-s, 0), ok
}
best, ok := contact.scan(0, false,
func(offset float64, exact bool) (float64, bool, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return radius - far, true, ok
},
func(offset float64) (float64, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return math.Inf(1), true
}
return low - far, ranged
})
if !ok {
return 0, false
}
return s + math.Max(d+best, 0), true
}
// 月心在日面外时环食不可能,内切间隙恰为 d。
if d >= s {
return s, true
}
if ranged && (high <= s-d-relief || low >= s-d+relief) {
radius, ok := contact.radius(180, true)
return math.Min(radius, s), ok
}
best, ok := contact.scan(180, false,
func(offset float64, exact bool) (float64, bool, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return far - radius, true, ok
},
func(offset float64) (float64, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return math.Inf(1), true
}
return far - high, ranged
})
if !ok {
return 0, false
}
return s - math.Max(d+best, 0), true
}
+105
View File
@@ -0,0 +1,105 @@
package basic
// 月缘路径基准:与同一调用的圆月面版本成对,改动扫描步长或剪枝时对照两者倍率。
import (
"testing"
"time"
"b612.me/astro/moon/limb"
)
func benchmarkLocalSolarEclipseLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
LocalSolarEclipseWithOptionsWithInstance(seed, 116.2310, 39.70, 0, options, nil)
}
}
func BenchmarkLocalSolarEclipseCircular20350902(b *testing.B) { benchmarkLocalSolarEclipseLimb(b, nil) }
func BenchmarkLocalSolarEclipseLunarLimb20350902(b *testing.B) {
benchmarkLocalSolarEclipseLimb(b, limb.Default())
}
func benchmarkStarOccultationLimb(b *testing.B, profile LunarLimb) {
start := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC)
options := OccultationSearchOptions{LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := FindStarOccultationsWithInstance(start, start.Add(36*time.Hour), limbTestAntares, 41.5907, -55.07036, 0, options, nil); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkStarOccultationCircularAntares(b *testing.B) { benchmarkStarOccultationLimb(b, nil) }
func BenchmarkStarOccultationLunarLimbAntares(b *testing.B) {
benchmarkStarOccultationLimb(b, limb.Default())
}
func benchmarkPlanetOccultationLimb(b *testing.B, profile LunarLimb) {
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
options := OccultationSearchOptions{LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn, -30.072, 43.562592, 0, options, nil); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkPlanetOccultationCircularSaturnGraze(b *testing.B) {
benchmarkPlanetOccultationLimb(b, nil)
}
func BenchmarkPlanetOccultationLunarLimbSaturnGraze(b *testing.B) {
benchmarkPlanetOccultationLimb(b, limb.Default())
}
func BenchmarkStarOccultationLimbProfileAntares(b *testing.B) {
info := StarOccultationInfo{Greatest: time.Date(2026, 2, 11, 3, 47, 15, 0, time.UTC), Observer: Observer{Longitude: 41.5907, Latitude: -55.07036}}
profile := limb.Default()
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := StarOccultationLimbProfileWithInstance(info, limbTestAntares, profile, StarOccultationLimbProfileOptions{}, nil); err != nil {
b.Fatal(err)
}
}
}
func benchmarkSolarEclipseCentralPathLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
b.ResetTimer()
for index := 0; index < b.N; index++ {
SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
}
}
func BenchmarkSolarEclipseCentralPathCircular20350902(b *testing.B) {
benchmarkSolarEclipseCentralPathLimb(b, nil)
}
func BenchmarkSolarEclipseCentralPathLunarLimb20350902(b *testing.B) {
benchmarkSolarEclipseCentralPathLimb(b, limb.Default())
}
func benchmarkSolarEclipsePartialFootprintsLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
b.ResetTimer()
for index := 0; index < b.N; index++ {
SolarEclipsePartialFootprintsNASABulletinSplitKWithInstance(seed, SolarEclipsePartialFootprintOptions{LunarLimb: profile}, nil)
}
}
func BenchmarkSolarEclipsePartialFootprintsCircular20350902(b *testing.B) {
benchmarkSolarEclipsePartialFootprintsLimb(b, nil)
}
func BenchmarkSolarEclipsePartialFootprintsLunarLimb20350902(b *testing.B) {
benchmarkSolarEclipsePartialFootprintsLimb(b, limb.Default())
}
+277
View File
@@ -0,0 +1,277 @@
package basic
// 月缘等效半径必须与全部位置角的暴力判据一致,且站心全食与中心带多边形对同一地点结论一致。
import (
"math"
"testing"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon/limb"
)
func limbBruteMargins(contact lunarLimbContact) (outer, cover, inside float64) {
outer, cover, inside = math.Inf(-1), math.Inf(1), math.Inf(1)
step := contact.frame.stepDeg / 4
start := math.Ceil(contact.view.angle/step)*step - contact.view.angle
for offset := start - 180; offset < 180; offset += step {
radius, ok := contact.radius(offset, true)
if !ok {
continue
}
// 远交点为负的射线背向日面,不是真实交点;月心在日面外时远交点为正即近交点为正。
near, far, hit := contact.rayChord(offset)
if hit && far > 0 {
outer = math.Max(outer, radius-near)
cover = math.Min(cover, radius-far)
}
if _, far, hit := contact.rayChord(offset + 180); hit && far > 0 {
if radius, ok := contact.radius(offset+180, true); ok {
inside = math.Min(inside, far-radius)
}
}
}
return
}
func TestLunarLimbContactMatchesBruteForce(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
params.lunarLimb = limb.Default()
greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params)
const tolerance = 1e-4 / 3600 * rad
for _, dtSec := range []float64{-60, -20, -5, 0, 5, 20, 60} {
context := newLocalSolarEclipseStateContext(greatest+dtSec/86400, params)
for _, latDeg := range []float64{39.70, 39.7215, 39.73, 39.74} {
observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, latDeg*rad, 0)
moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]}
sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]}
view, ok := context.limbFrame.viewToward(moon, sun)
if !ok {
t.Fatal("view failed")
}
state := context.stateAt(116.2310*rad, latDeg*rad, 0)
contact := lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)),
separation: state.separationRad, targetRadius: state.sunRadiusRad}
outer, cover, _ := limbBruteMargins(contact)
low, high, _ := contact.bounds()
relief := high - low
d, sunRadius := state.separationRad, state.sunRadiusRad
// 门控区只保证间隙符号,门控外须与暴力判据逐值一致。
gotOuter, _ := contact.outerRadius()
wantOuterGap := -outer
if gotGap := d - gotOuter - sunRadius; d > sunRadius && math.Abs(wantOuterGap) < relief {
if math.Abs(gotGap-wantOuterGap) > tolerance {
t.Errorf("dt %.0f lat %.4f: outer gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600)
}
} else if (gotGap > 0) != (wantOuterGap > 0) {
t.Errorf("dt %.0f lat %.4f: outer gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600)
}
gotInner, _ := contact.innerRadius(true)
gotGap := d - math.Abs(gotInner-sunRadius)
wantGap := math.Min(-cover, d)
if math.Abs(cover) < relief {
if math.Abs(gotGap-wantGap) > tolerance {
t.Errorf("dt %.0f lat %.4f: inner gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600)
}
} else if (gotGap > 0) != (wantGap > 0) {
t.Errorf("dt %.0f lat %.4f: inner gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600)
}
}
}
}
func limbPointInBand(segments [][]SolarEclipsePathPoint, lon, lat float64) bool {
inside := false
for _, ring := range segments {
for i, j := 0, len(ring)-1; i < len(ring); j, i = i, i+1 {
a, b := ring[i], ring[j]
if (a.Latitude > lat) != (b.Latitude > lat) &&
lon < (b.Longitude-a.Longitude)*(lat-a.Latitude)/(b.Latitude-a.Latitude)+a.Longitude {
inside = !inside
}
}
}
return inside
}
func TestLimbCentralBandMatchesLocalTotality(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
profile := limb.Default()
path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
const lon, marginDeg = 116.2310, 0.5 / 111.2
for lat := 39.60; lat <= 39.85; lat += 0.005 {
inBand := limbPointInBand(path.CentralBandSegments, lon, lat)
if inBand != limbPointInBand(path.CentralBandSegments, lon, lat-marginDeg) ||
inBand != limbPointInBand(path.CentralBandSegments, lon, lat+marginDeg) {
continue
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
if local.HasCentral != inBand {
t.Errorf("lat %.4f: local central %v, band %v", lat, local.HasCentral, inBand)
}
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, 39.7215, 0, options, nil)
if local.HasCentral || limbPointInBand(path.CentralBandSegments, lon, 39.7215) {
t.Errorf("39.7215: local central %v must agree with band (outside)", local.HasCentral)
}
}
// limbTestContact 2035-09-02 北京附近某一时刻的月缘接触上下文,日面半径与角距可替换以构造环食。
func limbTestContact(t *testing.T) lunarLimbContact {
t.Helper()
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
params.lunarLimb = limb.Default()
greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params)
context := newLocalSolarEclipseStateContext(greatest, params)
observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, 39.7215*rad, 0)
moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]}
sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]}
view, ok := context.limbFrame.viewToward(moon, sun)
if !ok {
t.Fatal("view failed")
}
state := context.stateAt(116.2310*rad, 39.7215*rad, 0)
return lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)),
separation: state.separationRad, targetRadius: state.sunRadiusRad}
}
func TestLunarLimbAnnularAndUnrangedMatchBruteForce(t *testing.T) {
base := limbTestContact(t)
const tolerance = 1e-4 / 3600 * rad
meanRadius := math.Asin(1737.4 / base.distanceKM)
unranged := *base.frame
unranged.ranged = false
for _, ranged := range []bool{true, false} {
for _, separationArcsec := range []float64{0, 1, 5, 12, 20} {
contact := base
if !ranged {
contact.frame = &unranged
}
contact.targetRadius = meanRadius + 6/3600.0*rad
contact.separation = separationArcsec / 3600 * rad
_, _, inside := limbBruteMargins(contact)
got, ok := contact.innerRadius(false)
if !ok {
t.Fatalf("ranged %v d %.0f\": annular radius failed", ranged, separationArcsec)
}
// 环食等效内半径必须小于日面半径,否则食分会大于 1;只比间隙看不出 R 关于 s 的镜像错误。
if got > contact.targetRadius {
t.Errorf("ranged %v d %.0f\": annular radius %.4f\" exceeds the solar radius %.4f\"", ranged, separationArcsec, got/rad*3600, contact.targetRadius/rad*3600)
}
gotGap := contact.separation - math.Abs(got-contact.targetRadius)
wantGap := math.Min(-inside, contact.separation)
if math.Abs(gotGap-wantGap) > tolerance && !(gotGap > 0 && wantGap > 0) {
t.Errorf("ranged %v d %.0f\": annular gap %.4f\" want %.4f\"", ranged, separationArcsec, gotGap/rad*3600, wantGap/rad*3600)
}
if !ranged {
contact.targetRadius = meanRadius - 6/3600.0*rad
contact.separation = (separationArcsec + 1900) / 3600 * rad
outer, _, _ := limbBruteMargins(contact)
got, ok := contact.outerRadius()
if !ok {
t.Fatal("unranged outer failed")
}
if gap := contact.separation - got - contact.targetRadius; math.Abs(gap+outer) > tolerance {
t.Errorf("unranged d %.0f\": outer gap %.4f\" want %.4f\"", separationArcsec, gap/rad*3600, -outer/rad*3600)
}
}
}
}
}
func TestLimbCentralLimitMatchesLocalLimit(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
profile := limb.Default()
path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
const lon = 116.2310
bisect := func(inside func(float64) bool) float64 {
low, high := 39.5, 39.9
for i := 0; i < 40; i++ {
mid := (low + high) / 2
if inside(mid) {
high = mid
} else {
low = mid
}
}
return (low + high) / 2
}
local := bisect(func(lat float64) bool {
return LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil).HasCentral
})
band := bisect(func(lat float64) bool { return limbPointInBand(path.CentralBandSegments, lon, lat) })
if d := math.Abs(local-band) * 111.2; d > 0.5 {
t.Errorf("southern limit: local %.5f, band %.5f, %.2f km apart", local, band, d)
}
}
func TestLocalSolarEclipseLimbCanFlipTotalToAnnular(t *testing.T) {
seed := Date2JD(time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC))
const lon, lat = 170.1481, 4.6940
circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK}, nil)
if circular.Type != SolarEclipseTotal {
t.Fatalf("circular type %v, want total near the hybrid transition", circular.Type)
}
// 常数月缘比圆月面内半径小 2 km:圆月面判全食,月缘下日面在各方向都露出,应为环食。
innerKM := geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale
smaller := &recordingLunarLimb{radiusKM: innerKM - 2}
limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0,
SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: smaller}, nil)
if limbed.Type != SolarEclipseAnnular || !limbed.HasCentral || !limbed.HasAnnular || limbed.HasTotal {
t.Fatalf("smaller constant limb: type %v central %v annular %v total %v, want annular", limbed.Type, limbed.HasCentral, limbed.HasAnnular, limbed.HasTotal)
}
if limbed.CentralEnd <= limbed.CentralStart {
t.Errorf("annular phase has no duration: %.8f–%.8f", limbed.CentralStart, limbed.CentralEnd)
}
}
// sectorLunarLimb 只在以 center 为中心、半宽 halfWidth 的位置角扇区内给出剖面值,并报告整张表的半径范围。
type sectorLunarLimb struct {
center, halfWidth float64
profile *limb.Profile
}
func (sector sectorLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
if math.Abs(math.Mod(angle-sector.center+540, 360)-180) > sector.halfWidth {
return 0, false
}
return sector.profile.LimbRadiusKM(lon, lat, angle)
}
func (sector sectorLunarLimb) RadiusRangeKM() (float64, float64) {
return sector.profile.RadiusRangeKM()
}
func (sector sectorLunarLimb) AngleStepDeg() float64 { return sector.profile.AngleStepDeg() }
func TestLocalSolarEclipseStateSeparatesOuterAndInnerLimb(t *testing.T) {
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
const lon, lat = 116.2310 * rad, 39.70 * rad
// 食甚附近月盘深入日面:外切间隙远离零点被门控,只查日心方向;内切接近零点必须全方向扫描。
tt := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), lon, lat, 0, params)
circular := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0)
params.lunarLimb = limb.Default()
full := newLocalSolarEclipseStateContext(tt, params)
observer := localSolarEclipseObserverXYZ(full.gst, lon, lat, 0)
view, ok := full.limbFrame.viewToward(
[3]float64{full.moonXYZ[0] - observer[0], full.moonXYZ[1] - observer[1], full.moonXYZ[2] - observer[2]},
[3]float64{full.sunXYZ[0] - observer[0], full.sunXYZ[1] - observer[1], full.sunXYZ[2] - observer[2]})
if !ok {
t.Fatal("view failed")
}
params.lunarLimb = sectorLunarLimb{center: view.angle, halfWidth: 2, profile: limb.Default()}
state := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0)
if !state.outerLimbApplied || state.limbApplied {
t.Fatalf("outerLimbApplied %v limbApplied %v, want outer only", state.outerLimbApplied, state.limbApplied)
}
if state.moonInnerRadiusRad != circular.moonInnerRadiusRad || state.moonOuterRadiusRad == circular.moonOuterRadiusRad {
t.Errorf("inner radius must stay circular and outer must move: inner %.9g vs %.9g, outer %.9g vs %.9g",
state.moonInnerRadiusRad, circular.moonInnerRadiusRad, state.moonOuterRadiusRad, circular.moonOuterRadiusRad)
}
}
+489
View File
@@ -0,0 +1,489 @@
package basic
import (
"math"
"testing"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon/limb"
)
// recordingLunarLimb 记录最后一次查询参数,返回固定半径。
type recordingLunarLimb struct {
lon, lat, angle float64
radiusKM float64
}
func (limb *recordingLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
limb.lon, limb.lat, limb.angle = lon, lat, angle
return limb.radiusKM, true
}
func lunarLimbAngleDiff(a, b float64) float64 {
d := math.Mod(a-b, 360)
if d > 180 {
d -= 360
}
if d < -180 {
d += 360
}
return d
}
// 地心观测者所见的月面经纬必须还原该时刻的总天平动;目标位于月球北极投影方向时位置角为 0,
// 向天东转 90° 时为 90°。
func TestLunarLimbFrameRoundTrip(t *testing.T) {
for _, jde := range []float64{2460409.25, 2451545.0, 2462000.7} {
_, moon := solarEclipseSunMoonEquatorial(jde)
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
limb := &recordingLunarLimb{radiusKM: 1737.4}
frame := newLunarLimbFrame(limb, jde, moonXYZ)
if !frame.valid {
t.Fatalf("jde %v: frame invalid", jde)
}
physical := MoonPhysical(jde)
moonUnit, _ := lunarLimbNormalize(moonXYZ)
north, east, _ := lunarLimbSkyBasis(moonUnit)
for _, offset := range []float64{0, 90, 200} {
pa := (physical.PositionAngle + offset) * rad
// 目标放在月心旁 0.5° 处、位置角为 P + offset。
sky := lunarLimbCombine(math.Cos(pa), north, math.Sin(pa), east)
target := lunarLimbCombine(math.Cos(0.5*rad), moonUnit, math.Sin(0.5*rad), sky)
target = [3]float64{target[0] * 1.5e8, target[1] * 1.5e8, target[2] * 1.5e8}
if _, ok := frame.radiusKM(moonXYZ, target, false); !ok {
t.Fatalf("jde %v: radiusKM failed", jde)
}
if d := lunarLimbAngleDiff(limb.lon, physical.LibrationLongitude); math.Abs(d) > 1e-6 {
t.Errorf("jde %v: libration longitude %.9f, want %.9f", jde, limb.lon, physical.LibrationLongitude)
}
if d := limb.lat - physical.LibrationLatitude; math.Abs(d) > 1e-6 {
t.Errorf("jde %v: libration latitude %.9f, want %.9f", jde, limb.lat, physical.LibrationLatitude)
}
if d := lunarLimbAngleDiff(limb.angle, -offset); math.Abs(d) > 1e-6 {
t.Errorf("jde %v offset %v: position angle %.9f", jde, offset, limb.angle)
}
if _, ok := frame.radiusKM(moonXYZ, target, true); !ok {
t.Fatalf("jde %v: away radiusKM failed", jde)
}
if d := lunarLimbAngleDiff(limb.angle, 180-offset); math.Abs(d) > 1e-6 {
t.Errorf("jde %v offset %v: away position angle %.9f", jde, offset, limb.angle)
}
}
}
}
// 参考球半径等于 k·R⊕ 的常数月缘只能让结果在末位附近变化,用来确认替换路径本身不引入偏差。
func TestLocalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
if !circular.HasTotal {
t.Fatalf("expected total eclipse, got %v", circular.Type)
}
options.LunarLimb = &recordingLunarLimb{
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
}
limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
for _, pair := range [][2]float64{
{circular.PartialStart, limbed.PartialStart},
{circular.CentralStart, limbed.CentralStart},
{circular.CentralEnd, limbed.CentralEnd},
{circular.PartialEnd, limbed.PartialEnd},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 1e-3 {
t.Errorf("contact moved by %.6f s with a constant limb", d)
}
}
}
func TestLunarEclipseConstantLimbMatchesCircular(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: lunarMoonRadiusScale}
for _, chauvenet := range []bool{false, true} {
circular := LunarEclipseDanjonWithInstance(2460926.0, nil)
if chauvenet {
circular = LunarEclipseChauvenetWithInstance(2460926.0, nil)
}
limbed := LunarEclipseWithLunarLimb(circular, limb, nil)
for _, pair := range [][2]float64{
{circular.PenumbralStart, limbed.PenumbralStart},
{circular.PartialStart, limbed.PartialStart},
{circular.TotalStart, limbed.TotalStart},
{circular.TotalEnd, limbed.TotalEnd},
{circular.PartialEnd, limbed.PartialEnd},
{circular.PenumbralEnd, limbed.PenumbralEnd},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
t.Errorf("chauvenet=%v: contact moved by %.4f s with a constant limb", chauvenet, d)
}
}
if limbed.Maximum != circular.Maximum || limbed.Magnitude != circular.Magnitude {
t.Error("maximum or magnitude changed")
}
}
if got := LunarEclipseWithLunarLimb(LunarEclipseDanjonWithInstance(2460926.0, nil), nil, nil); got != LunarEclipseDanjonWithInstance(2460926.0, nil) {
t.Error("nil limb must return the input unchanged")
}
}
func TestOccultationConstantLimbMatchesCircular(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
observer := Observer{Longitude: -30.072, Latitude: 16.21}
plain, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{}, nil)
if err != nil || len(plain) != 1 {
t.Fatalf("circular planet search: %v, %d events", err, len(plain))
}
limbed, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{LunarLimb: limb}, nil)
if err != nil || len(limbed) != 1 {
t.Fatalf("limb planet search: %v, %d events", err, len(limbed))
}
a, b := plain[0], limbed[0]
for i, pair := range [][2]time.Time{
{a.ExternalImmersion, b.ExternalImmersion}, {a.InternalImmersion, b.InternalImmersion},
{a.InternalEmersion, b.InternalEmersion}, {a.ExternalEmersion, b.ExternalEmersion},
} {
if d := math.Abs(pair[0].Sub(pair[1]).Seconds()); d > 0.01 {
t.Errorf("planet contact %d moved by %.4f s with a constant limb", i, d)
}
}
if !a.Greatest.Equal(b.Greatest) || a.Type != b.Type {
t.Error("greatest or type changed")
}
}
func TestGlobalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
// IAU Single-K 的内外 k 相同,常数月缘可同时复现外切与内切半径。
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseWithOptionsWithInstance(seed, options, nil)
withLimb := options
withLimb.LunarLimb = limb
limbed := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
for i, pair := range [][2]float64{
{circular.PartialBeginOnEarth, limbed.PartialBeginOnEarth},
{circular.PartialEndOnEarth, limbed.PartialEndOnEarth},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
t.Errorf("seed %.0f contact %d moved by %.4f s with a constant limb", seed, i, d)
}
}
if limbed.GreatestEclipse != circular.GreatestEclipse || limbed.Type != circular.Type ||
limbed.CentralBeginOnEarth != circular.CentralBeginOnEarth || limbed.PathWidthKM != circular.PathWidthKM {
t.Errorf("seed %.0f: non-contact fields changed", seed)
}
if d := math.Abs(limbed.CentralDurationDays-circular.CentralDurationDays) * 86400; d > 0.05 {
t.Errorf("seed %.0f: central duration moved by %.4f s with a constant limb", seed, d)
}
}
}
func TestCentralPathConstantLimbKeepsLimits(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
if len(circular.NorthernLimit) == 0 || len(limbed.NorthernLimit) != len(circular.NorthernLimit) ||
len(limbed.SouthernLimit) != len(circular.SouthernLimit) {
t.Fatalf("seed %.0f: limit lengths %d/%d vs %d/%d", seed,
len(limbed.NorthernLimit), len(limbed.SouthernLimit), len(circular.NorthernLimit), len(circular.SouthernLimit))
}
worst := 0.0
for _, pair := range [][2][]SolarEclipsePathPoint{{circular.NorthernLimit, limbed.NorthernLimit}, {circular.SouthernLimit, limbed.SouthernLimit}} {
for i := range pair[0] {
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
worst = math.Max(worst, d)
}
}
if worst > 0.05 {
t.Errorf("seed %.0f: constant limb moved a limit by %.3f km", seed, worst)
}
}
}
func TestOccultationPathConstantLimbKeepsLimits(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
star := StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
}
start := time.Date(2025, 6, 4, 16, 0, 0, 0, time.UTC)
end := time.Date(2025, 6, 5, 16, 0, 0, 0, time.UTC)
options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}
circular, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
if err != nil || len(circular) != 1 {
t.Fatalf("circular: %v %d", err, len(circular))
}
options.LunarLimb = limb
limbed, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
if err != nil || len(limbed) != 1 {
t.Fatalf("limbed: %v %d", err, len(limbed))
}
worst := 0.0
for _, pair := range [][2][]OccultationPathPoint{{circular[0].NorthernLimit, limbed[0].NorthernLimit}, {circular[0].SouthernLimit, limbed[0].SouthernLimit}} {
if len(pair[0]) == 0 || len(pair[0]) != len(pair[1]) {
t.Fatalf("limit lengths %d vs %d", len(pair[0]), len(pair[1]))
}
for i := range pair[0] {
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
worst = math.Max(worst, d)
}
}
if worst > 0.05 {
t.Errorf("constant limb moved a graze limit by %.3f km", worst)
}
if limbed[0].LunarLimbApplied {
t.Fatal("constant circular limb set LunarLimbApplied = true")
}
}
func TestCentralPathConstantLimbKeepsWidthsAndBand(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
if len(limbed.CenterLine) != len(circular.CenterLine) || len(limbed.CentralBandSegments) != len(circular.CentralBandSegments) {
t.Fatalf("seed %.0f: sample counts changed", seed)
}
worstWidth := 0.0
for i := range circular.CenterLine {
worstWidth = math.Max(worstWidth, math.Abs(limbed.CenterLine[i].WidthKM-circular.CenterLine[i].WidthKM))
}
if worstWidth > 0.1 {
t.Errorf("seed %.0f: constant limb moved a width by %.3f km", seed, worstWidth)
}
if d := math.Abs(limbed.Greatest.WidthKM - circular.Greatest.WidthKM); d > 0.1 {
t.Errorf("seed %.0f: greatest width moved by %.3f km", seed, d)
}
if d := math.Abs(limbed.MaxCentralDurationDays-circular.MaxCentralDurationDays) * 86400; d > 0.05 {
t.Errorf("seed %.0f: max duration moved by %.4f s", seed, d)
}
worstBand := 0.0
for s := range circular.CentralBandSegments {
if len(limbed.CentralBandSegments[s]) != len(circular.CentralBandSegments[s]) {
t.Fatalf("seed %.0f: band segment %d length changed", seed, s)
}
for i, a := range circular.CentralBandSegments[s] {
b := limbed.CentralBandSegments[s][i]
d := math.Hypot((a.Longitude-b.Longitude)*math.Cos(a.Latitude*rad), a.Latitude-b.Latitude) * 111.2
worstBand = math.Max(worstBand, d)
}
}
if worstBand > 0.1 {
t.Errorf("seed %.0f: constant limb moved a band vertex by %.3f km", seed, worstBand)
}
}
}
// failingLunarLimb 处处无值:月缘修正必须整体退回圆月面且不置 LunarLimbApplied。
type failingLunarLimb struct{}
func (failingLunarLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestFailingLimbKeepsEclipsesCircular(t *testing.T) {
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
limb := failingLunarLimb{}
for _, model := range []SolarEclipseRadiusModel{SolarEclipseModelNASABulletinSplitK, SolarEclipseModelIAUSingleK} {
options := SolarEclipseOptions{RadiusModel: model}
withLimb := options
withLimb.LunarLimb = limb
circularLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
limbedLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, withLimb, nil)
if limbedLocal != circularLocal {
t.Errorf("model %v: failing limb changed the local result", model)
}
circularGlobal := SolarEclipseWithOptionsWithInstance(seed, options, nil)
limbedGlobal := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
if limbedGlobal.LunarLimbApplied ||
limbedGlobal.PartialBeginOnEarth != circularGlobal.PartialBeginOnEarth ||
limbedGlobal.PartialEndOnEarth != circularGlobal.PartialEndOnEarth ||
limbedGlobal.CentralDurationDays != circularGlobal.CentralDurationDays {
t.Errorf("model %v: failing limb changed the global result or set LunarLimbApplied", model)
}
}
circularLunar := LunarEclipseDanjonWithInstance(2460926.0, nil)
if got := LunarEclipseWithLunarLimb(circularLunar, limb, nil); got != circularLunar {
t.Error("failing limb changed the lunar eclipse result")
}
}
func TestPartialBoundaryLimbCorrection(t *testing.T) {
const seed = 2460409.25 // 2024-04-08
circularOptions := SolarEclipsePartialFootprintOptions{}
circular := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, circularOptions, nil)
if len(circular.PartialBandContours) == 0 || len(circular.RiseSetCurves) == 0 {
t.Fatal("circular partial boundary missing")
}
samePoints := func(a, b []SolarEclipsePathPoint, toleranceKM float64) float64 {
worst := 0.0
for index := range a {
km := math.Hypot(signedAngleDifference(a[index].Longitude, b[index].Longitude)*111.19*math.Cos(a[index].Latitude*rad),
(a[index].Latitude-b[index].Latitude)*111.19)
worst = math.Max(worst, km)
}
return worst
}
compare := func(name string, limb LunarLimb, wantApplied bool, maxShiftKM float64) {
options := circularOptions
options.LunarLimb = limb
limbed := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, options, nil)
if limbed.PartialBoundaryLimbApplied != wantApplied {
t.Errorf("%s: PartialBoundaryLimbApplied = %v, want %v", name, limbed.PartialBoundaryLimbApplied, wantApplied)
}
if limbed.RiseSetTopologyDegraded {
t.Errorf("%s: rise/set topology degraded", name)
}
if len(limbed.PartialBandContours) != len(circular.PartialBandContours) || len(limbed.RiseSetCurves) != len(circular.RiseSetCurves) {
t.Fatalf("%s: boundary structure changed", name)
}
worst := 0.0
for index := range circular.PartialBandContours {
worst = math.Max(worst, samePoints(circular.PartialBandContours[index], limbed.PartialBandContours[index], 0))
}
for index, curve := range circular.RiseSetCurves {
for segment := range curve.Segments {
shift := samePoints(curve.Segments[segment], limbed.RiseSetCurves[index].Segments[segment], 0)
if curve.Phase == RiseSetPhaseGreatest && shift != 0 {
t.Errorf("%s: greatest rise/set curve moved by %.3f km", name, shift)
}
worst = math.Max(worst, shift)
}
}
if worst > maxShiftKM {
t.Errorf("%s: boundary moved by %.3f km, want <= %.3f km", name, worst, maxShiftKM)
}
if wantApplied && worst < 0.5 {
t.Errorf("%s: boundary moved only %.3f km", name, worst)
}
}
compare("failing", failingLunarLimb{}, false, 0)
compare("constant", &recordingLunarLimb{
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
}, false, 0.05)
compare("profile", sawtoothLunarLimb{}, true, limbLimitMaxShiftKM)
}
// sawtoothLunarLimb 以位置角 10° 为周期在参考半径上叠加 ±3 km 起伏,模拟月缘山谷。
type sawtoothLunarLimb struct{}
func (sawtoothLunarLimb) LimbRadiusKM(_, _, angle float64) (float64, bool) {
phase := math.Mod(angle, 10) / 10
return geodata.EarthEquatorialRadiusKM*solarEclipseIAUSingleRadiusK*localSolarMoonRadiusScale + 6*math.Abs(phase-0.5) - 1.5, true
}
// TestGlobalSolarEclipseLimbContactMatchesTime 检查月缘修正后的偏食始末接触点与修正时刻同源:
// 接触点必须落在该时刻重解的半影外切点上,而不是沿地球边缘扫描网格上的样点。
func TestGlobalSolarEclipseLimbContactMatchesTime(t *testing.T) {
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: limb.Default()}
result := SolarEclipseWithOptionsWithInstance(seed, options, nil)
if !result.LunarLimbApplied {
t.Fatalf("seed %.2f: the default limb profile changed nothing", seed)
}
solver := newSolarEclipseSolverWithOptions(CalcMoonSHByJDE(seed, 0), options, nil)
for _, pair := range []struct {
name string
jde float64
contact SolarEclipseLimbContact
}{
{"P1", result.PartialBeginOnEarth, result.PartialBeginContact},
{"P4", result.PartialEndOnEarth, result.PartialEndContact},
} {
if !pair.contact.Defined {
t.Errorf("seed %.2f %s: the corrected contact location is missing", seed, pair.name)
continue
}
point, ok := solver.shadowContactPointAt(pair.jde, solarEclipsePenumbralShadow, false)
if !ok {
t.Errorf("seed %.2f %s: no penumbral tangent point at the corrected time", seed, pair.name)
continue
}
longitude := signedAngleDifference(pair.contact.Longitude, point.Longitude) * math.Cos(point.Latitude*rad)
if d := math.Hypot(longitude, pair.contact.Latitude-point.Latitude) * geodata.EarthKMPerDegree; d > 0.05 {
t.Errorf("seed %.2f %s: contact location is %.3f km from the tangent point at its own time", seed, pair.name, d)
}
}
}
}
// TestLimbFlagsIgnoreNoiseWithConstantLimb 检查常数月缘等价于圆月面时,各层月缘标志都不因离散残差误报。
func TestLimbFlagsIgnoreNoiseWithConstantLimb(t *testing.T) {
iau := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
umbral := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
global := SolarEclipseWithOptionsWithInstance(seed, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
if global.LunarLimbApplied || global.PartialBeginContact.Defined || global.PartialEndContact.Defined {
t.Errorf("seed %.2f: constant limb reported a global correction", seed)
}
central := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: umbral}, nil)
if central.CentralLimitsLimbApplied {
t.Errorf("seed %.2f: constant limb reported moved central limits", seed)
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, -96.8, 32.8, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
if local.LunarLimbApplied {
t.Errorf("seed %.2f: constant limb reported a local correction", seed)
}
}
real := SolarEclipseCentralPathNASABulletinSplitKWithInstance(2460409.25, SolarEclipsePathOptions{LunarLimb: limb.Default()}, nil)
if !real.CentralLimitsLimbApplied {
t.Error("default limb profile must move the central limits")
}
}
// TestLocalSolarEclipseLimbFlagCoversMagnitude 检查只改变食分、遮蔽率而不改变接触时刻时站心标志仍为真。
func TestLocalSolarEclipseLimbFlagCoversMagnitude(t *testing.T) {
circular := LocalSolarEclipseResult{Type: SolarEclipsePartial, PartialStart: 1, PartialEnd: 2, Magnitude: 0.5, Obscuration: 0.4}
limbed := circular
if localSolarEclipseLimbChanged(limbed, circular) {
t.Fatal("identical results reported a change")
}
limbed.Magnitude += 1e-12
if localSolarEclipseLimbChanged(limbed, circular) {
t.Error("floating-point noise in magnitude reported a change")
}
limbed.Magnitude = circular.Magnitude + 1e-6
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a visible magnitude change was not reported")
}
limbed = circular
limbed.Obscuration += 1e-6
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a visible obscuration change was not reported")
}
limbed = circular
limbed.Type = SolarEclipseAnnular
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a type change was not reported")
}
}
// steppedLunarLimb 报告指定网格步长的常半径剖面。
type steppedLunarLimb struct{ step float64 }
func (limb steppedLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
return 1737.4, true
}
func (limb steppedLunarLimb) RadiusRangeKM() (float64, float64) { return 1737.4, 1737.4 }
func (limb steppedLunarLimb) AngleStepDeg() float64 { return limb.step }
func TestLunarLimbFrameRejectsTinyStep(t *testing.T) {
_, moon := solarEclipseSunMoonEquatorial(2460409.25)
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
for _, c := range []struct{ step, want float64 }{
{1e-300, lunarLimbDefaultStepDeg}, {1e-4, lunarLimbDefaultStepDeg}, {0.001, 0.001}, {0.5, 0.5}, {2, lunarLimbDefaultStepDeg},
} {
frame := newLunarLimbFrame(steppedLunarLimb{c.step}, 2460409.25, moonXYZ)
if !frame.valid || frame.stepDeg != c.want {
t.Errorf("step %g: got %g valid=%v, want %g", c.step, frame.stepDeg, frame.valid, c.want)
}
}
}
+217
View File
@@ -0,0 +1,217 @@
package basic
import "math"
func GetLunar(year, month, day int, tz float64) (lyear, lmonth, lday int, leap bool, result string) {
julianDayEpoch := JDCalc(year, month, float64(day))
// 确定农历年份
lyear = year
adjustedYear := year
if month == 11 || month == 12 {
winterSolsticeDay := GetJQTime(year, 270) + tz
firstNewMoonDay := TT2UTC(CalcMoonSHByJDE(winterSolsticeDay-16, 0)) + tz
nextNewMoonDay := TT2UTC(CalcMoonSHByJDE(firstNewMoonDay+28, 0)) + tz
firstNewMoonDay = normalizeTimePoint(firstNewMoonDay)
nextNewMoonDay = normalizeTimePoint(nextNewMoonDay)
if winterSolsticeDay >= firstNewMoonDay && winterSolsticeDay < nextNewMoonDay && julianDayEpoch < firstNewMoonDay {
adjustedYear--
}
if winterSolsticeDay >= nextNewMoonDay && julianDayEpoch < nextNewMoonDay {
adjustedYear--
}
} else {
adjustedYear--
}
// 获取节气和朔望月数据
solarTerms := GetJieqiLoops(adjustedYear, 25)
newMoonDays := GetMoonLoops(float64(adjustedYear), 17)
// 计算冬至日期
winterSolsticeFirst := normalizeTimePoint(solarTerms[0] - 8.0/24 + tz)
winterSolsticeSecond := normalizeTimePoint(solarTerms[24] - 8.0/24 + tz)
// 规范化时间点
normalizeTimeArray(newMoonDays, tz)
normalizeTimeArray(solarTerms, tz)
// 计算朔望月范围
minMoonIndex, maxMoonIndex := 20, 0
moonCount := 0
for i := 0; i < len(newMoonDays)-1; i++ {
if (newMoonDays[i] <= winterSolsticeFirst && newMoonDays[i+1] > winterSolsticeFirst) ||
(newMoonDays[i] > winterSolsticeFirst && newMoonDays[i] < winterSolsticeSecond && newMoonDays[i+1] <= winterSolsticeSecond) {
if i <= minMoonIndex {
minMoonIndex = i
}
if i >= maxMoonIndex {
maxMoonIndex = i
}
moonCount++
}
}
// 确定闰月位置
leapMonthPos := 20
if moonCount >= 13 {
solarTermIndex, i := 0, 0
for i = minMoonIndex; i <= maxMoonIndex; i++ {
if !(newMoonDays[i] <= solarTerms[solarTermIndex] && newMoonDays[i+1] > solarTerms[solarTermIndex]) {
break
}
solarTermIndex += 2
}
leapMonthPos = i - minMoonIndex
}
// 找到当前月相索引
currentMoonIndex := 0
for currentMoonIndex = minMoonIndex; currentMoonIndex <= maxMoonIndex; currentMoonIndex++ {
if newMoonDays[currentMoonIndex] > julianDayEpoch {
break
}
}
// 计算农历月份
lmonth = currentMoonIndex - minMoonIndex - 1
shouldAdjustLeap := false
leap = false
if lmonth >= leapMonthPos {
shouldAdjustLeap = true
}
if lmonth == leapMonthPos {
leap = true
}
if lmonth < 2 {
lmonth += 11
} else {
lmonth--
}
if shouldAdjustLeap {
lmonth--
}
if lmonth <= 0 {
lmonth += 12
}
// 计算农历日期
lday = int(julianDayEpoch-newMoonDays[currentMoonIndex-1]) + 1
// 生成农历日期字符串
result = formatLunarDateString(lmonth, lday, leap)
if lmonth >= 10 && month < 3 {
lyear--
}
return
}
func GetSolar(year, month, day int, leap bool, tz float64) float64 {
adjustedYear := year
if month < 11 {
adjustedYear--
}
// 获取节气和朔望月数据
solarTerms := GetJieqiLoops(adjustedYear, 25)
newMoonDays := GetMoonLoops(float64(adjustedYear), 17)
// 计算冬至日期
winterSolsticeFirst := normalizeTimePoint(solarTerms[0] - 8.0/24 + tz)
winterSolsticeSecond := normalizeTimePoint(solarTerms[24] - 8.0/24 + tz)
// 规范化时间点
normalizeTimeArray(newMoonDays, tz)
normalizeTimeArray(solarTerms, tz)
// 计算朔望月范围
minMoonIndex, maxMoonIndex := 20, 0
moonCount := 0
for i := 0; i < 15; i++ {
if (newMoonDays[i] <= winterSolsticeFirst && newMoonDays[i+1] > winterSolsticeFirst) ||
(newMoonDays[i] > winterSolsticeFirst && newMoonDays[i] < winterSolsticeSecond && newMoonDays[i+1] <= winterSolsticeSecond) {
if i <= minMoonIndex {
minMoonIndex = i
}
if i >= maxMoonIndex {
maxMoonIndex = i
}
moonCount++
}
}
// 确定闰月位置
leapMonthPos := 20
if moonCount >= 13 {
solarTermIndex, i := 0, 0
for i = minMoonIndex; i <= maxMoonIndex; i++ {
if !(newMoonDays[i] <= solarTerms[solarTermIndex] && newMoonDays[i+1] > solarTerms[solarTermIndex]) {
break
}
solarTermIndex += 2
}
leapMonthPos = i - minMoonIndex
}
actualMonth := month
if actualMonth > 10 {
actualMonth -= 11
} else {
actualMonth++
}
// 计算实际月份索引
if leap {
actualMonth++
}
if actualMonth >= leapMonthPos && !leap {
actualMonth++
}
return newMoonDays[minMoonIndex+actualMonth] + float64(day) - 1
}
func normalizeTimeArray(timeArray []float64, tz float64) {
for idx, timeValue := range timeArray {
adjustedTime := timeValue
if tz != 8.0/24 {
adjustedTime = timeValue - 8.0/24 + tz
}
timeArray[idx] = normalizeTimePoint(adjustedTime)
}
}
func normalizeTimePoint(timePoint float64) float64 {
if timePoint-math.Floor(timePoint) > 0.5 {
return math.Floor(timePoint) + 0.5
}
return math.Floor(timePoint) - 0.5
}
func formatLunarDateString(lunarMonth, lunarDay int, isLeap bool) string {
monthNames := []string{"十", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"}
dayPrefixes := []string{"初", "十", "廿", "三"}
var dateString string
if isLeap {
dateString += "闰"
}
if lunarMonth == 1 {
dateString += "正月"
} else {
dateString += monthNames[lunarMonth] + "月"
}
if lunarDay == 20 {
dateString += "二十"
} else if lunarDay == 10 {
dateString += "初十"
} else {
dateString += dayPrefixes[lunarDay/10] + monthNames[lunarDay%10]
}
return dateString
}
+119 -384
View File
@@ -7,450 +7,185 @@ 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)
sita := Sita(JD)
ra := math.Atan2((Sin(lo)*Cos(sita) - Tan(bo)*Sin(sita)), Cos(lo))
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)
sita := Sita(JD)
dec := ArcSin(Sin(bo)*Cos(sita) + Cos(bo)*Sin(sita)*Sin(lo))
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)
sita := Sita(JD)
ra := math.Atan2((Sin(lo)*Cos(sita) - Tan(bo)*Sin(sita)), Cos(lo))
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(sita) + Cos(bo)*Sin(sita)*Sin(lo))
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
return Limit360(ra), dec
}
func EarthMarsAway(JD float64) float64 {
x, y, z := AMarsXYZ(JD)
to := math.Sqrt(x*x + y*y + z*z)
return to
func EarthMarsAway(jd float64) float64 {
return planetEarthAwayExplicitN(3, jd, -1)
}
func MarsApparentLo(JD float64) float64 {
x, y, z := AMarsXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(JD - to)
lo := math.Atan2(y, x)
//bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180.0 / math.Pi
//bo = bo * 180 / math.Pi
lo = Limit360(lo) + HJZD(JD)
//lo-=GXCLo(lo,bo,JD)/3600;
//bo+=GXCBo(lo,bo,JD);
return lo
func MarsApparentLo(jd float64) float64 {
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.lo
}
func MarsApparentBo(JD float64) float64 {
x, y, z := AMarsXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(JD - to)
//lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
//lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
//lo+=GXCLo(lo,bo,JD);
//bo+=GXCBo(lo,bo,JD)/3600;
//lo+=HJZD(JD);
return bo
func MarsApparentBo(jd float64) float64 {
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.bo
}
func MarsApparentLoBo(JD float64) (float64, float64) {
x, y, z := AMarsXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(JD - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo -= GXCLo(lo, bo, JD) / 3600
//bo += GXCBo(lo, bo, JD)
lo += HJZD(JD)
return lo, bo
func MarsApparentLoBo(jd float64) (float64, float64) {
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.lo, geo.bo
}
func MarsTrueLoBo(JD float64) (float64, float64) {
x, y, z := AMarsXYZ(JD)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(JD - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
return lo, bo
func MarsTrueLoBo(jd float64) (float64, float64) {
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
return geo.lo, geo.bo
}
func MarsTrueLo(JD float64) float64 {
x, y, _ := AMarsXYZ(JD)
lo := math.Atan2(y, x)
lo = lo * 180 / math.Pi
lo = Limit360(lo)
return lo
func MarsTrueLo(jd float64) float64 {
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
return geo.lo
}
func MarsMag(JD float64) float64 {
AwaySun := MarsR(JD)
AwayEarth := EarthMarsAway(JD)
Away := planet.WherePlanet(-1, 2, JD)
i := (AwaySun*AwaySun + AwayEarth*AwayEarth - Away*Away) / (2 * AwaySun * AwayEarth)
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(AwaySun*AwayEarth) + 0.016*i
return FloatRound(Mag, 2)
mag := -1.52 + 5*math.Log10(sunDistance*earthDistance) + 0.016*i
return FloatRound(mag, 2)
}
func MarsHeight(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
H := Limit360(st - ra)
// 高度角、时角与天球座标三角转换公式
// sin(h)=sin(lat)*sin(dec)+cos(dec)*cos(lat)*cos(H)
sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(H)
return ArcSin(sinHeight)
// MarsHeightWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsHeightWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetHeightN(3, localJD, lon, lat, timezone, -1, inst)
}
func MarsAzimuth(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
H := Limit360(st - ra)
// 三角转换公式
tanAzimuth := Sin(H) / (Cos(H)*Sin(lat) - Tan(dec)*Cos(lat))
Azimuth := ArcTan(tanAzimuth)
if Azimuth < 0 {
if H/15 < 12 {
return Azimuth + 360
}
return Azimuth + 180
// MarsAzimuthWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsAzimuthWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetAzimuthN(3, localJD, lon, lat, timezone, -1, inst)
}
// MarsHourAngleWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsHourAngleWithInstance(jd, lon, timezone float64, inst *Instance) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
hourAngle := siderealLongitude - MarsApparentRaWithInstance(UTC2TT(jd-timezone/24.0), inst)
if hourAngle < 0 {
hourAngle += 360
}
if H/15 < 12 {
return Azimuth + 180
}
return Azimuth
return hourAngle
}
func MarsHourAngle(JD, Lon, TZ float64) float64 {
startime := Limit360(ApparentSiderealTime(JD-TZ/24)*15 + Lon)
timeangle := startime - MarsApparentRa(TD2UT(JD-TZ/24.0, true))
if timeangle < 0 {
timeangle += 360
}
return timeangle
}
func MarsCulminationTime(jde, lon, timezone float64) float64 {
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
// MarsCulminationTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsCulminationTimeWithInstance(localJD, lon, timezone float64, inst *Instance) float64 {
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
jde = math.Floor(jde) + 0.5
JD1 := jde + Limit360(360-MarsHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
limitHA := func(jde, lon, timezone float64) float64 {
ha := MarsHourAngle(jde, lon, timezone)
if ha < 180 {
ha += 360
localJD = math.Floor(localJD) + 0.5
estimateJD := localJD + Limit360(360-MarsHourAngleWithInstance(localJD, lon, timezone, inst))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
currentHourAngle := MarsHourAngleWithInstance(localJD, lon, timezone, inst)
if currentHourAngle < 180 {
currentHourAngle += 360
}
return ha
return currentHourAngle
}
for {
JD0 := JD1
stDegree := limitHA(JD0, lon, timezone) - 360
stDegreep := (limitHA(JD0+0.000005, lon, timezone) - limitHA(JD0-0.000005, lon, timezone)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return JD1
return estimateJD
}
func MarsRiseTime(JD, Lon, Lat, TZ, ZS, HEI float64) float64 {
return marsRiseDown(JD, Lon, Lat, TZ, ZS, HEI, true)
// MarsRiseTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsRiseTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true, inst)
}
func MarsDownTime(JD, Lon, Lat, TZ, ZS, HEI float64) float64 {
return marsRiseDown(JD, Lon, Lat, TZ, ZS, HEI, false)
// MarsSetTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsSetTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false, inst)
}
func marsRiseDown(JD, Lon, Lat, TZ, ZS, HEI float64, isRise bool) float64 {
var An float64
JD = math.Floor(JD) + 0.5
ntz := math.Round(Lon / 15)
if ZS != 0 {
An = -0.8333
}
An = An - HeightDegreeByLat(HEI, Lat)
tztime := MarsCulminationTime(JD, Lon, ntz)
if MarsHeight(tztime, Lon, Lat, ntz) < An {
return -2 //极夜
}
if MarsHeight(tztime-0.5, Lon, Lat, ntz) > An {
return -1 //极昼
}
dec := HSunApparentDec(TD2UT(tztime-ntz/24, true))
//(sin(ho)-sin(φ)*sin(δ2))/(cos(φ)*cos(δ2))
tmp := (Sin(An) - Sin(dec)*Sin(Lat)) / (Cos(dec) * Cos(Lat))
var rise float64
if math.Abs(tmp) <= 1 {
rzsc := ArcCos(tmp) / 15
if isRise {
rise = tztime - rzsc/24 - 25.0/24.0/60.0
} else {
rise = tztime + rzsc/24 - 25.0/24.0/60.0
}
} else {
rise = tztime
i := 0
//TODO:使用二分法计算
for MarsHeight(rise, Lon, Lat, ntz) > An {
i++
if isRise {
rise -= 15.0 / 60.0 / 24.0
} else {
rise += 15.0 / 60.0 / 24.0
}
if i > 48 {
break
}
}
}
JD1 := rise
for {
JD0 := JD1
stDegree := MarsHeight(JD0, Lon, Lat, ntz) - An
stDegreep := (MarsHeight(JD0+0.000005, Lon, Lat, ntz) - MarsHeight(JD0-0.000005, Lon, Lat, ntz)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
}
return JD1 - ntz/24 + TZ/24
}
// Pos
const MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
func marsConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
decSub := func(jde float64, degree float64, filter bool) float64 {
sub := Limit360(Limit360(MarsApparentLo(jde)-HSunApparentLo(jde)) - degree)
if filter {
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
}
return sub
}
dayCost := MARS_S_PERIOD / 360
nowSub := decSub(jde, degree, false)
if next == 0 {
jde -= (360 - nowSub) * dayCost
} else {
jde += dayCost * nowSub
}
JD1 := jde
for {
JD0 := JD1
stDegree := decSub(JD0, degree, true)
stDegreep := (decSub(JD0+0.000005, degree, true) - decSub(JD0-0.000005, degree, true)) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
break
}
}
return TD2UT(JD1, false)
}
func LastMarsConjunction(jde float64) float64 {
return marsConjunction(jde, 0, 0)
}
func NextMarsConjunction(jde float64) float64 {
return marsConjunction(jde, 0, 1)
}
func LastMarsOpposition(jde float64) float64 {
return marsConjunction(jde, 180, 0)
}
func NextMarsOpposition(jde float64) float64 {
return marsConjunction(jde, 180, 1)
}
func NextMarsEasternQuadrature(jde float64) float64 {
return marsConjunction(jde, 90, 1)
}
func LastMarsEasternQuadrature(jde float64) float64 {
return marsConjunction(jde, 90, 0)
}
func NextMarsWesternQuadrature(jde float64) float64 {
return marsConjunction(jde, 270, 1)
}
func LastMarsWesternQuadrature(jde float64) float64 {
return marsConjunction(jde, 270, 0)
}
func marsRetrograde(jde float64, isLeft bool) float64 {
//0=last 1=next
decSub := func(jde float64, val float64) float64 {
sub := MarsApparentRa(jde+val) - MarsApparentRa(jde-val)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * val)
}
jde = NextMarsOpposition(jde)
if isLeft {
jde -= 60
} else {
jde += 60
}
for {
nowSub := decSub(jde, 1.0/86400.0)
if math.Abs(nowSub) > 0.55 {
jde += 2
continue
}
break
}
JD1 := jde
for {
JD0 := JD1
stDegree := decSub(JD0, 2.0/86400.0)
stDegreep := (decSub(JD0+15.0/86400.0, 2.0/86400.0) - decSub(JD0-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 30.0/86400.0 {
break
}
}
JD1 = JD1 - 15.0/86400.0
min := JD1
minRa := 100.0
for i := 0.0; i < 60.0; i++ {
tmp := decSub(JD1+i*0.5/86400.0, 0.5/86400.0)
if math.Abs(tmp) < math.Abs(minRa) {
minRa = tmp
min = JD1 + i*0.5/86400.0
}
}
return TD2UT(min, false)
}
func NextMarsRetrogradeToPrograde(jde float64) float64 {
date := marsRetrograde(jde, false)
if date < jde {
op := NextMarsOpposition(jde)
return marsRetrograde(op+10, false)
}
return date
}
func LastMarsRetrogradeToPrograde(jde float64) float64 {
jde = LastMarsOpposition(jde) - 10
date := marsRetrograde(jde, false)
if date > jde {
op := LastMarsOpposition(jde)
return marsRetrograde(op-10, false)
}
return date
}
func NextMarsProgradeToRetrograde(jde float64) float64 {
date := marsRetrograde(jde, true)
if date < jde {
op := NextMarsOpposition(jde)
return marsRetrograde(op+10, true)
}
return date
}
func LastMarsProgradeToRetrograde(jde float64) float64 {
jde = LastMarsOpposition(jde) - 10
date := marsRetrograde(jde, true)
if date > jde {
op := LastMarsOpposition(jde)
return marsRetrograde(op-10, true)
}
return date
func marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, instances ...*Instance) (float64, error) {
inst := instanceValue(instances)
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, func(localJD, lon, timezone float64) float64 {
return MarsCulminationTimeWithInstance(localJD, lon, timezone, inst)
}, func(localJD, lon, lat, timezone float64) float64 {
return MarsHeightWithInstance(localJD, lon, lat, timezone, inst)
}, func(jde float64) float64 { return MarsApparentDecWithInstance(jde, inst) })
}
+35
View File
@@ -0,0 +1,35 @@
package basic
import "testing"
func BenchmarkMarsPhaseFamily(b *testing.B) {
cases := marsEventCases()[:8]
samples := marsEventSamples()
var sink float64
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, sample := range samples {
jd := marsEventSampleTTJD(sample)
for _, event := range cases {
sink += event.fn(jd)
}
}
}
_ = sink
}
func BenchmarkMarsRetrogradeFamily(b *testing.B) {
cases := marsEventCases()[8:]
samples := marsEventSamples()
var sink float64
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, sample := range samples {
jd := marsEventSampleTTJD(sample)
for _, event := range cases {
sink += event.fn(jd)
}
}
}
_ = sink
}

Some files were not shown because too many files have changed in this diff Show More