Compare commits

...

4 Commits

Author SHA1 Message Date
a8e7513683
• feat(calendar): 扩展先秦至秦汉古历支持
- 新增显式古历 API,支持先秦古历与秦汉颛顼历选择
- 将默认公农历转换范围扩展至 -721..3000
- 支持后九月解析、负年份干支日和古历法相符节气
- 补充秦汉、先秦、交接边界和节气回归测试
2026-06-09 19:35:18 +08:00
c8dd777a7b
docs: 统一公开 API 的中英双语注释
- 补齐公开接口说明段的英文描述,保持签名注释和详细说明均为中英双语结构
- 规范农历、坐标、公式、轨道、日晷、太阳、恒星及行星事件等 API 的注释口径
2026-05-27 16:08:11 +08:00
46b555cd49
fix: 修复天象事件 API 在事件边界附近的重复返回问题
- 修正合月、合日、留等Last/Next/Closest接口在精确命中和事件后秒级查询时仍返回当前事件的问题,避免应用侧枚举卡死
- 收紧事件边界判定,并补强水星、金星、火星近事件路径的稳定化,保证公开API的单调前进语义
- 补充公开wrapper、跨世纪样本和外部基线回归,覆盖合月邻近事件与边界场景
2026-05-23 23:08:05 +08:00
be3af3884c
feat(moon): 新增行星合月查询并修正月球地心赤经赤纬接口
- 修正月球地心真/视赤经赤纬接口口径
- 新增月球与七大行星合月时刻查询
2026-05-23 19:00:53 +08:00
51 changed files with 3815 additions and 95 deletions

View File

@ -8,7 +8,7 @@ A personal astronomy library developed over years for calendrical work, amateur
> This project is mainly for learning and validating astronomical algorithms. The results are intended for serious amateur use.
The implementation follows *Astronomical Algorithms* and provides calendar conversion, Sun/Moon/planet positions, eclipses, rise/set/transit times, lunar phases, stars, coordinate transforms, physical ephemerides, research formulas, and generic small-body orbit propagation. The Sun and planets use built-in VSOP87-style analytical terms, while the Moon uses a built-in ELP2000/82-style series. No external JPL ephemeris files are required.
The implementation follows *Astronomical Algorithms* and provides calendar conversion, Sun/Moon/planet positions, eclipses, rise/set/transit times, lunar phases, stars, coordinate transforms, physical ephemerides, research formulas, and generic small-body orbit propagation. The Sun and planets use built-in VSOP87-style analytical terms, while the Moon uses a built-in ELP/MPP02 DE405-style analytical series. No external JPL ephemeris files are required.
Unless noted otherwise, coordinates are apparent-of-date coordinates. Angles are in degrees, apparent diameters and semidiameters are in arcseconds, and distances use the unit implied by the function name, usually `AU` or `km`.
@ -41,7 +41,7 @@ go get b612.me/astro
## Highlights
- Calendar conversion between Gregorian dates and the traditional Chinese lunisolar calendar, including solar terms
- Calendar conversion between Gregorian dates and the traditional Chinese lunisolar calendar, from 721 BCE through 3000 CE or later, including solar terms
- Solar position, rise/set, Earth distance, apparent solar time, apparent altitude, parallactic angle, solar `P/B0/L0`, apparent diameter
- Lunar position, rise/set, Earth distance, phase, new/full/quarter times, apparent diameter, bright-limb angle, parallactic angle, geocentric/topocentric libration, apsides, nodes, maximum declination
- `lite/sun` and `lite/moon` lightweight approximation chains for watches, frontends, mini programs, and other resource-constrained environments
@ -103,7 +103,7 @@ This is suitable for ordinary calendrical work, observing support, outreach, and
### Moon
The Moon uses a built-in truncated ELP2000/82-style series retaining the major periodic terms. The package stays lightweight and does not require external ephemeris files. It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, and ordinary positional work. For lunar laser ranging, long-term physical libration, or professional occultation work, use JPL or a dedicated lunar ephemeris.
The Moon uses a built-in truncated ELP/MPP02 DE405-style analytical series retaining the major periodic terms. The package stays lightweight and does not require external ephemeris files. It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, and ordinary positional work. For lunar laser ranging, long-term physical libration, or professional occultation work, use JPL or a dedicated lunar ephemeris.
The four principal phases keep the historical pinyin names and also expose English aliases:
@ -116,7 +116,7 @@ The matching `Next*`, `Last*`, and `Closest*` helpers are available in both nami
### Lite lightweight chains
`lite/sun` and `lite/moon` are independent approximation chains. They do not depend on the VSOP87 or ELP2000/82 engines used by `sun` / `moon`, and are intended for CPU- or memory-constrained environments.
`lite/sun` and `lite/moon` are independent approximation chains. They do not depend on the VSOP87 or ELP/MPP02 DE405 engines used by `sun` / `moon`, and are intended for CPU- or memory-constrained environments.
- `lite/sun`: simplified true/apparent solar longitude formulas plus lightweight equatorial conversion
- `lite/moon`: Schlyter-style lunar approximation with about 15 perturbation terms plus lightweight topocentric correction
@ -163,6 +163,7 @@ The following areas have been checked against JPL Horizons, NASA GSFC, IMCCE, an
- solar physical ephemerides `P/B0/L0`
- planetary rise, transit, and set events
- Earth perihelion and aphelion
- main-chain lunar position: the current algorithm is a truncated ELP/MPP02 DE405-style analytical series; across four JPL/Horizons `JDTT` samples in year `-2000`, the maximum difference from JPL/Horizons is about `219.6"` in longitude, `25.8"` in latitude, and `34.3 km` in distance
- Moon perigee and apogee
- maximum lunar declinations
- solar and lunar eclipses
@ -174,15 +175,16 @@ The README examples are illustrative. The repository tests contain the exact bas
### Calendar And Solar Terms
The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from the first lunisolar month of 104 BCE through year 3000. The calendar is lunisolar in the strict sense, but public function names use `Lunar` for readability and convention.
The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from 721 BCE through year 3000, with some modern algorithm paths usable beyond that. The calendar is lunisolar in the strict sense, but public function names use `Lunar` for readability and convention.
For historical input, Chinese era names stay in Chinese. This is part of the API surface, because historical Chinese dates are normally written that way.
#### Data sources and checks
#### Calendar notes
- **[-103, 1912]**: based on 《寿星天文历》 and corrected against the tables maintained by [Professor ytliu0](https://ytliu0.github.io/ChineseCalendar/index_simp.html). This range contains lunisolar data only, not historical solar-term records.
- **[1913, 3000]**: uses VSOP87 for apparent solar longitude and ELP2000 for new moons, following the modern Chinese-calendar rule set in GB/T 33661-2017. Solar terms and new moons use Beijing time (UTC+08:00).
- Solar terms before 1912 are computed with modern astronomical methods and may differ from historical almanac dates by 1-2 days.
- **Default routing**: the package selects by year automatically. The pre-Qin range uses reconstructed Chunqiu and ancient-six-calendar systems, `-220..-104` uses the Qin/Han Zhuanxu calendar, `-103..1912` uses calendar tables, and `1913` onward uses the modern algorithm.
- **Explicit ancient calendars**: use APIs such as `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` when a specific ancient calendar system is required.
- **Data sources**: ancient-calendar support mainly references 《寿星天文历》; [Professor ytliu0's ChineseCalendar data](https://ytliu0.github.io/ChineseCalendar/index_simp.html) is used for validation. The modern range follows GB/T 33661-2017 and uses VSOP87/ELP computations for solar terms and new moons.
- **Solar terms**: `JieQi` returns modern astronomical solar-term instants; `CalendricalJieQi` returns calendar-compatible solar-term dates.
#### Usage notes
@ -315,6 +317,8 @@ Output:
#### Solar terms
`JieQi(year, term)` returns the exact solar-term instant computed by the modern astronomical algorithm. `CalendricalJieQi(year, term)` returns the date on which the solar term falls under the default calendar, fixed at 00:00 Beijing time for that day. Use `CalendricalJieQiWithCalendar(year, term, system)` when a specific ancient calendar system is required.
```go
package main
@ -345,6 +349,23 @@ Output:
2020-03-20 11:49:37.149532735 +0800 CST // same result from direct longitude input
```
Calendrical solar-term example:
```go
date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至)
fmt.Println(date, err)
date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan)
fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err)
```
Output:
```text
1582-12-22 00:00:00 +0800 CST <nil>
-202-12-25 <nil>
```
### Sun And Moon
#### Observing-angle semantics
@ -1829,7 +1850,7 @@ Notes:
- Earth eccentricity, Sun-Earth distance, perihelion, aphelion
- Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates
- Seven major-planet coordinates, Sun/body and Earth/body distances, special events, Mercury/Venus geocentric transits, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes
- Chinese lunisolar calendar conversion from 104 BCE to 3000 CE
- Chinese lunisolar calendar conversion from 721 BCE to 3000 CE
- 9100+ star catalog
- Generic small-body orbit propagation, H-G apparent magnitude, visual-binary position angle and separation
- Blackbody radiation, synodic periods, magnitudes, telescope formulas, airmass formulas

View File

@ -39,7 +39,7 @@ go get b612.me/astro
## 功能概览
- 📅 **历法转换**公历与农历互转公元前104年-公元3000年或更久、节气时刻
- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年或更久、节气时刻
- 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等
- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等
- 🪶 **轻量链路**`lite/sun``lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相
@ -101,7 +101,7 @@ go get b612.me/astro
### 月球
月球使用内置的 ELP2000/82 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。
月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。
### Lite 轻量链路
@ -154,6 +154,7 @@ go get b612.me/astro
- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°`
- 行星升/中天/落:已用 JPL Horizons 电视事件TVH, Time-Varying Hourly做对比校验该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐
- 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU`
- 月球主链位置:当前算法为 ELP/MPP02 DE405 解析级数截断版;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km`
- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km`
- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°`
@ -161,14 +162,15 @@ go get b612.me/astro
### 历法转换与节气
本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前104年正月至公元3000年即公历年份 -103 到 3000)。
本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年部分现代算法可更久)。
农历本质上是阴阳合历Lunisolar Calendar但为兼顾大众习惯与代码简洁性相关函数命名采用 `Lunar` 而非更学术的 `Lunisolar`
#### 数据来源与校对
#### 历法说明
- **[-103, 1912] 年**:基于《寿星天文历》数据,并依据 [ytliu0 教授整理的历表](https://ytliu0.github.io/ChineseCalendar/index_simp.html) 进行修正,已完成校对,只包含农历信息,暂不包含节气信息。
- **[1913, 3000] 年**:依据 VSOP87 计算定气按太阳实际视位置确定节气时刻、ELP2000 计算定朔(按月球实际位置确定合朔时刻),按照现行标准 GB/T 33661-2017 中农历算法计算定气、定朔均使用北京时间UTC+08:00
- 公元1912年前的**节气**信息采用现代天文学方法VSOP87计算得到与历书中实际记录的日期可能相差1-2天后续将完善。
- **默认路由**:按年份自动选择,先秦段使用春秋/古六历重建,`-220..-104` 使用秦汉颛顼历,`-103..1912` 使用历表,`1913` 年后使用现代算法。
- **显式古历**:如果需要指定某一古历系统,请使用 `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` 这类 API。
- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证现代段依据GB/T 33661-2017编排通过 VSOP87、ELP定气定朔 。
- **节气**`JieQi` 返回现代天文计算的节气时刻;`CalendricalJieQi` 返回历法相符节气日期。
---
@ -379,26 +381,27 @@ func main() {
#### 节气
```go
`JieQi(year, term)` 返回现代天文算法计算出的节气精确时刻;`CalendricalJieQi(year, term)` 返回默认历法下节气落在的日期,时间固定为北京时间当天 0 点。需要指定古历系统时,使用 `CalendricalJieQiWithCalendar(year, term, system)`
```go
package main
import (
"fmt"
"b612.me/astro/calendar"
)
func main() {
// 计算 2020 年立春时刻;节气常量本质上对应太阳视黄经。
// 计算 2020 年立春时刻;节气常量本质上对应太阳视黄经。
fmt.Println(calendar.JieQi(2020, calendar.JQ_立春))
// 计算 2020 年冬至时刻。
// 计算 2020 年冬至时刻。
fmt.Println(calendar.JieQi(2020, calendar.JQ_冬至))
// 计算 2020 年春分时刻。
// 计算 2020 年春分时刻。
fmt.Println(calendar.JieQi(2020, calendar.JQ_春分))
// 也可直接传入黄经数值;春分对应太阳视黄经 0°。
// 也可直接传入黄经数值;春分对应太阳视黄经 0°。
fmt.Println(calendar.JieQi(2020, 0))
}
```
输出结果
@ -411,6 +414,23 @@ func main() {
```
历法相符节气示例:
```go
date, err := calendar.CalendricalJieQi(1582, calendar.JQ_冬至)
fmt.Println(date, err)
date, err = calendar.CalendricalJieQiWithCalendar(-202, calendar.JQ_冬至, calendar.AncientCalendarQinHan)
fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err)
```
输出结果
```
1582-12-22 00:00:00 +0800 CST <nil>
-202-12-25 <nil>
```
### 太阳与月亮

View File

@ -2,14 +2,17 @@ package basic
import "math"
const exactEventTolerance = 2.0 / 86400.0
const (
exactEventTolerance = 2.0 / 86400.0
exactQueryTTToleranceUT = 0.1 / 86400.0
)
func sameEventJD(a, b float64) bool {
return math.Abs(a-b) <= exactEventTolerance
}
func sameEventUTQueryTT(eventUT, queryTT float64) bool {
return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactEventTolerance
return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactQueryTTToleranceUT
}
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {

View File

@ -2,7 +2,11 @@ package basic
import "math"
const innerEventEpsilon = 4.0 / 86400.0
const (
innerEventEpsilon = 0.1 / 86400.0
innerEventWindowPadding = 4.0 / 86400.0
innerEventMaximizeEpsilon = 4.0 / 86400.0
)
func eventQueryTTAsUT(queryTT float64) float64 {
return TD2UT(queryTT, false)
@ -157,7 +161,7 @@ func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(flo
guess := scanWindowForMax(start, end, coarseStep, coarseFn)
left := clampFloat64(guess-coarseStep, start, end)
right := clampFloat64(guess+coarseStep, start, end)
if right-left <= innerEventEpsilon {
if right-left <= innerEventMaximizeEpsilon {
return guess
}
for i := 0; i < 20; i++ {

View File

@ -1,6 +1,9 @@
package basic
import "testing"
import (
"testing"
"time"
)
func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
@ -43,3 +46,60 @@ func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
})
}
}
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 := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true)
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 := TD2UT(tc.seed, true)
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)
}
})
}
}

View File

@ -186,12 +186,29 @@ func marsOppositionFromAfter(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0)
}
func stabilizeMarsStationNearQuery(jde, date float64, searchBeforeOpposition bool) float64 {
if math.Abs(eventUTQueryTTDelta(date, jde)) > exactEventTolerance {
return date
}
if searchBeforeOpposition {
stableOppositionJD := NextMarsOpposition(jde)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, true), marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
stableOppositionJD := LastMarsOpposition(jde)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, false), marsRetrogradeAroundOpposition(sameOppositionJD, false))
}
func NextMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromBefore(lastOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, false))
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
}
if !eventUTQueryAfterOrEqual(date, jde) {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
@ -203,9 +220,12 @@ func NextMarsRetrogradeToPrograde(jde float64) float64 {
func LastMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromBefore(lastOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, false))
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
}
if !eventUTQueryBeforeOrEqual(date, jde) {
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
@ -217,9 +237,12 @@ func LastMarsRetrogradeToPrograde(jde float64) float64 {
func NextMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromAfter(nextOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, true))
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
if !eventUTQueryAfterOrEqual(date, jde) {
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
@ -231,9 +254,12 @@ func NextMarsProgradeToRetrograde(jde float64) float64 {
func LastMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
sameOppositionJD := marsOppositionFromAfter(nextOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(sameOppositionJD, true))
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
if !eventUTQueryBeforeOrEqual(date, jde) {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)

View File

@ -168,6 +168,26 @@ func mercuryConjunctionLegacy(jde float64, next uint8) float64 {
func mercuryConjunction(jde float64, next uint8) float64 {
//0=last 1=next
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
best := math.NaN()
consider := func(inferior bool) {
eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false)
if next == 0 && !eventUTQueryBeforeOrEqual(eventUT, jde) {
return
}
if next == 1 && !eventUTQueryAfterOrEqual(eventUT, jde) {
return
}
if math.IsNaN(best) || math.Abs(eventUTQueryTTDelta(eventUT, jde)) < math.Abs(eventUTQueryTTDelta(best, jde)) {
best = eventUT
}
}
consider(true)
consider(false)
if !math.IsNaN(best) {
return best
}
}
currentDelta := mercuryConjunctionExactDelta(jde)
// pos 大于0:远离太阳 小于0:靠近太阳
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
@ -417,12 +437,12 @@ func mercuryGreatestElongationInWindow(start, end float64) float64 {
func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) {
lastSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(inferior))
return lastSuperior + innerEventEpsilon, inferior - innerEventEpsilon
return lastSuperior + innerEventWindowPadding, inferior - innerEventWindowPadding
}
func mercuryWestElongationWindowEndingAt(superior float64) (float64, float64) {
lastInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(superior))
return lastInferior + innerEventEpsilon, superior - innerEventEpsilon
return lastInferior + innerEventWindowPadding, superior - innerEventWindowPadding
}
func mercuryEastElongationWindowContaining(jde float64) (float64, float64) {

View File

@ -0,0 +1,64 @@
package basic
import (
"encoding/json"
"os"
"testing"
"time"
)
type moonGeocentricApparentSample struct {
InputUTC string `json:"input_utc"`
RightAscension float64 `json:"right_ascension"`
Declination float64 `json:"declination"`
EclipticLongitude float64 `json:"ecliptic_longitude"`
EclipticLatitude float64 `json:"ecliptic_latitude"`
}
func TestMoonGeocentricApparentCoordinatesMatchHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/moon_geocentric_apparent_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []moonGeocentricApparentSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
if len(samples) == 0 {
t.Fatal("empty moon apparent baseline")
}
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 := TD2UT(Date2JDE(date.UTC()), true)
prefix := "moon." + sample.InputUTC
assertPlanetApparentAngleClose(t, prefix+".RightAscension", HMoonGeocentricApparentRa(jd), sample.RightAscension, 0.001)
assertPlanetPhaseClose(t, prefix+".Declination", HMoonGeocentricApparentDec(jd), sample.Declination, 0.001)
assertPlanetApparentAngleClose(t, prefix+".EclipticLongitude", HMoonApparentLo(jd), sample.EclipticLongitude, 0.001)
assertPlanetPhaseClose(t, prefix+".EclipticLatitude", HMoonTrueBo(jd), sample.EclipticLatitude, 0.001)
}
}
func TestMoonGeocentricTrueCoordinatesFollowDefinition(t *testing.T) {
samples := []time.Time{
time.Date(1900, 1, 14, 12, 0, 0, 0, time.UTC),
time.Date(1950, 6, 3, 0, 0, 0, 0, time.UTC),
time.Date(2000, 2, 29, 18, 0, 0, 0, time.UTC),
time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC),
time.Date(2100, 8, 17, 9, 0, 0, 0, time.UTC),
}
for _, sample := range samples {
jd := TD2UT(Date2JDE(sample.UTC()), true)
wantRA, wantDec := LoBoToRaDec(jd, HMoonTrueLo(jd), HMoonTrueBo(jd))
gotRA, gotDec := HMoonGeocentricTrueRaDec(jd)
assertPlanetApparentAngleClose(t, sample.Format(time.RFC3339)+".TrueRightAscension", gotRA, wantRA, 1e-12)
assertPlanetPhaseClose(t, sample.Format(time.RFC3339)+".TrueDeclination", gotDec, wantDec, 1e-12)
}
}

View File

@ -0,0 +1,30 @@
package basic
import (
"math"
"testing"
)
func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) {
jd := TD2UT(JDECalc(2026, 1, 1.25), true)
ra, dec := HMoonGeocentricApparentRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricApparentRa(jd)); diff > 1e-12 {
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricApparentRa(jd))
}
if diff := math.Abs(dec - HMoonGeocentricApparentDec(jd)); diff > 1e-12 {
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricApparentDec(jd))
}
}
func TestHMoonGeocentricTrueRaDecComponentsMatch(t *testing.T) {
jd := TD2UT(JDECalc(2026, 1, 1.25), true)
ra, dec := HMoonGeocentricTrueRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricTrueRa(jd)); diff > 1e-12 {
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricTrueRa(jd))
}
if diff := math.Abs(dec - HMoonGeocentricTrueDec(jd)); diff > 1e-12 {
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricTrueDec(jd))
}
}

View File

@ -0,0 +1,397 @@
package basic
import "math"
const (
moonPlanetConjunctionEstimateN = 8
moonPlanetConjunctionNearQueryDeltaDeg = 3.0
moonPlanetConjunctionDirectionEpsilon = 0.1 / 86400.0
moonPlanetConjunctionBracketStepDays = 0.5
moonPlanetConjunctionNearQueryStepDays = 0.25
moonPlanetConjunctionNearQueryHalfSpan = 1.5
moonPlanetConjunctionBracketHalfSpan = 2.0
moonPlanetConjunctionBracketGrowth = 2.0
moonPlanetConjunctionBracketAttempts = 3
moonPlanetConjunctionRefineStepDays = 0.5 / 86400.0
moonPlanetConjunctionEventTolerance = 0.01
moonPlanetConjunctionFallbackSpanScale = 1.5
)
type moonPlanetConjunctionLocalResult struct {
lastUT float64
nextUT float64
}
func emptyMoonPlanetConjunctionLocalResult() moonPlanetConjunctionLocalResult {
return moonPlanetConjunctionLocalResult{
lastUT: math.NaN(),
nextUT: math.NaN(),
}
}
// MoonPlanetConjunctionPlanet 月球合月目标行星 / target planet for Moon-planet conjunction events.
type MoonPlanetConjunctionPlanet int
const (
MoonPlanetConjunctionMercury MoonPlanetConjunctionPlanet = iota + 1
MoonPlanetConjunctionVenus
MoonPlanetConjunctionMars
MoonPlanetConjunctionJupiter
MoonPlanetConjunctionSaturn
MoonPlanetConjunctionUranus
MoonPlanetConjunctionNeptune
)
func moonPlanetConjunctionWrappedDelta(diff float64) float64 {
diff = math.Mod(diff+180, 360)
if diff < 0 {
diff += 360
}
return diff - 180
}
func moonPlanetConjunctionDeltaAt(jdTT float64, planet MoonPlanetConjunctionPlanet, n int) float64 {
moonRA := HMoonGeocentricApparentRaN(jdTT, n)
var planetRA float64
switch planet {
case MoonPlanetConjunctionMercury:
planetRA = MercuryApparentRaN(jdTT, n)
case MoonPlanetConjunctionVenus:
planetRA = VenusApparentRaN(jdTT, n)
case MoonPlanetConjunctionMars:
planetRA = MarsApparentRaN(jdTT, n)
case MoonPlanetConjunctionJupiter:
planetRA = JupiterApparentRaN(jdTT, n)
case MoonPlanetConjunctionSaturn:
planetRA = SaturnApparentRaN(jdTT, n)
case MoonPlanetConjunctionUranus:
planetRA = UranusApparentRaN(jdTT, n)
case MoonPlanetConjunctionNeptune:
planetRA = NeptuneApparentRaN(jdTT, n)
default:
return math.NaN()
}
return moonPlanetConjunctionWrappedDelta(moonRA - planetRA)
}
func moonPlanetConjunctionPeriodDays(planet MoonPlanetConjunctionPlanet) float64 {
switch planet {
case MoonPlanetConjunctionMercury:
return 28.1
case MoonPlanetConjunctionVenus:
return 28.4
case MoonPlanetConjunctionMars:
return 29.2
case MoonPlanetConjunctionJupiter:
return 28.0
case MoonPlanetConjunctionSaturn:
return 27.4
case MoonPlanetConjunctionUranus:
return 27.3
case MoonPlanetConjunctionNeptune:
return 27.3
default:
return math.NaN()
}
}
func moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= moonPlanetConjunctionDirectionEpsilon
}
func moonPlanetConjunctionAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -moonPlanetConjunctionDirectionEpsilon
}
func moonPlanetConjunctionInDirection(eventUT, queryTT float64, direction int) bool {
switch direction {
case -1:
return moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT)
case 1:
return moonPlanetConjunctionAfterOrEqual(eventUT, queryTT)
default:
return true
}
}
func moonPlanetConjunctionFindBracket(centerTT, halfSpan, step float64, planet MoonPlanetConjunctionPlanet) (float64, float64, bool) {
if math.IsNaN(centerTT) || math.IsNaN(halfSpan) || math.IsNaN(step) || halfSpan <= 0 || step <= 0 {
return 0, 0, false
}
start := centerTT - halfSpan
end := centerTT + halfSpan
samples := int(math.Ceil((end-start)/step)) + 1
prevTT := start
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
if math.IsNaN(prevVal) {
return 0, 0, false
}
if prevVal == 0 {
return prevTT, prevTT, true
}
bestLeft := 0.0
bestRight := 0.0
bestDistance := math.Inf(1)
for i := 1; i <= samples; i++ {
tt := start + float64(i)*step
if tt > end {
tt = end
}
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
if math.IsNaN(val) {
return 0, 0, false
}
if val == 0 {
return tt, tt, true
}
if prevVal*val < 0 {
mid := (prevTT + tt) / 2.0
distance := math.Abs(mid - centerTT)
if distance < bestDistance {
bestLeft = prevTT
bestRight = tt
bestDistance = distance
}
}
if tt == end {
break
}
prevTT = tt
prevVal = val
}
if math.IsInf(bestDistance, 1) {
return 0, 0, false
}
return bestLeft, bestRight, true
}
func moonPlanetConjunctionRefineBracket(leftTT, rightTT float64, planet MoonPlanetConjunctionPlanet) float64 {
if leftTT > rightTT {
leftTT, rightTT = rightTT, leftTT
}
if leftTT == rightTT {
return leftTT
}
center := (leftTT + rightTT) / 2.0
halfWindow := (rightTT - leftTT) / 2.0
return eventZeroRefine(center, halfWindow, moonPlanetConjunctionRefineStepDays, func(sampleTT float64) float64 {
return moonPlanetConjunctionDeltaAt(sampleTT, planet, -1)
})
}
func moonPlanetConjunctionEventUT(leftTT, rightTT float64, planet MoonPlanetConjunctionPlanet) float64 {
eventTT := moonPlanetConjunctionRefineBracket(leftTT, rightTT, planet)
if math.Abs(moonPlanetConjunctionDeltaAt(eventTT, planet, -1)) > moonPlanetConjunctionEventTolerance {
return math.NaN()
}
return TD2UT(eventTT, false)
}
func moonPlanetConjunctionCollectLocalEvent(result *moonPlanetConjunctionLocalResult, queryTT, eventUT float64) {
if math.IsNaN(eventUT) {
return
}
if moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT) {
if math.IsNaN(result.lastUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.lastUT, queryTT)) {
result.lastUT = eventUT
}
}
if moonPlanetConjunctionAfterOrEqual(eventUT, queryTT) {
if math.IsNaN(result.nextUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.nextUT, queryTT)) {
result.nextUT = eventUT
}
}
}
func moonPlanetConjunctionShouldCheckLocal(queryTT float64, planet MoonPlanetConjunctionPlanet) bool {
delta := moonPlanetConjunctionDeltaAt(queryTT, planet, moonPlanetConjunctionEstimateN)
if math.IsNaN(delta) {
return false
}
return math.Abs(delta) <= moonPlanetConjunctionNearQueryDeltaDeg
}
func moonPlanetConjunctionLocalEvents(queryTT float64, planet MoonPlanetConjunctionPlanet) moonPlanetConjunctionLocalResult {
result := emptyMoonPlanetConjunctionLocalResult()
start := queryTT - moonPlanetConjunctionNearQueryHalfSpan
end := queryTT + moonPlanetConjunctionNearQueryHalfSpan
step := moonPlanetConjunctionNearQueryStepDays
prevTT := start
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
if math.IsNaN(prevVal) {
return result
}
samples := int(math.Ceil((end-start)/step)) + 1
for i := 1; i <= samples; i++ {
tt := start + float64(i)*step
if tt > end {
tt = end
}
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
if math.IsNaN(val) {
return emptyMoonPlanetConjunctionLocalResult()
}
if prevVal == 0 || val == 0 || prevVal*val < 0 {
moonPlanetConjunctionCollectLocalEvent(&result, queryTT, moonPlanetConjunctionEventUT(prevTT, tt, planet))
}
if tt == end {
break
}
prevTT = tt
prevVal = val
}
return result
}
func moonPlanetConjunctionMaybeLocalEvents(queryTT float64, planet MoonPlanetConjunctionPlanet) moonPlanetConjunctionLocalResult {
if !moonPlanetConjunctionShouldCheckLocal(queryTT, planet) {
return emptyMoonPlanetConjunctionLocalResult()
}
return moonPlanetConjunctionLocalEvents(queryTT, planet)
}
func moonPlanetConjunctionGuessTT(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
delta := moonPlanetConjunctionDeltaAt(queryTT, planet, moonPlanetConjunctionEstimateN)
if math.IsNaN(delta) {
return math.NaN()
}
period := moonPlanetConjunctionPeriodDays(planet)
if math.IsNaN(period) {
return math.NaN()
}
switch direction {
case -1:
return queryTT - innerLastCycleOffset(delta, period)
case 1:
return queryTT + innerNextCycleOffset(delta, period)
default:
return math.NaN()
}
}
func moonPlanetConjunctionDirectionalFallback(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
period := moonPlanetConjunctionPeriodDays(planet)
if math.IsNaN(period) {
return math.NaN()
}
span := period * moonPlanetConjunctionFallbackSpanScale
if span <= 0 {
return math.NaN()
}
step := moonPlanetConjunctionNearQueryStepDays
start := queryTT
end := queryTT
switch direction {
case -1:
start -= span
case 1:
end += span
default:
return math.NaN()
}
prevTT := start
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
if math.IsNaN(prevVal) {
return math.NaN()
}
bestEventUT := math.NaN()
for tt := start + step; ; tt += step {
if tt > end {
tt = end
}
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
if math.IsNaN(val) {
return math.NaN()
}
if prevVal == 0 || val == 0 || prevVal*val < 0 {
eventUT := moonPlanetConjunctionEventUT(prevTT, tt, planet)
if !math.IsNaN(eventUT) && moonPlanetConjunctionInDirection(eventUT, queryTT, direction) {
if direction == 1 {
return eventUT
}
bestEventUT = eventUT
}
}
if tt == end {
break
}
prevTT = tt
prevVal = val
}
return bestEventUT
}
func moonPlanetConjunctionDirectionalEventWithLocal(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int, local moonPlanetConjunctionLocalResult) float64 {
switch direction {
case -1:
if !math.IsNaN(local.lastUT) {
return local.lastUT
}
case 1:
if !math.IsNaN(local.nextUT) {
return local.nextUT
}
}
guessTT := moonPlanetConjunctionGuessTT(queryTT, planet, direction)
if math.IsNaN(guessTT) {
return math.NaN()
}
halfSpan := moonPlanetConjunctionBracketHalfSpan
for attempt := 0; attempt < moonPlanetConjunctionBracketAttempts; attempt++ {
left, right, ok := moonPlanetConjunctionFindBracket(guessTT, halfSpan, moonPlanetConjunctionBracketStepDays, planet)
if ok {
eventUT := moonPlanetConjunctionEventUT(left, right, planet)
if math.IsNaN(eventUT) {
halfSpan *= moonPlanetConjunctionBracketGrowth
continue
}
if moonPlanetConjunctionInDirection(eventUT, queryTT, direction) {
return eventUT
}
}
halfSpan *= moonPlanetConjunctionBracketGrowth
}
return moonPlanetConjunctionDirectionalFallback(queryTT, planet, direction)
}
func moonPlanetConjunctionDirectionalEvent(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
return moonPlanetConjunctionDirectionalEventWithLocal(queryTT, planet, direction, moonPlanetConjunctionMaybeLocalEvents(queryTT, planet))
}
// LastMoonPlanetConjunction 指定时刻之前最近一次行星合月(赤经合) / previous Moon-planet conjunction at or before jde.
func LastMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
return moonPlanetConjunctionDirectionalEvent(jde, planet, -1)
}
// NextMoonPlanetConjunction 指定时刻之后最近一次行星合月(赤经合) / next Moon-planet conjunction at or after jde.
func NextMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
return moonPlanetConjunctionDirectionalEvent(jde, planet, 1)
}
// ClosestMoonPlanetConjunction 离指定时刻最近一次行星合月(赤经合) / closest Moon-planet conjunction to jde.
func ClosestMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
local := moonPlanetConjunctionMaybeLocalEvents(jde, planet)
if !math.IsNaN(local.lastUT) && !math.IsNaN(local.nextUT) {
if sameEventJD(local.lastUT, local.nextUT) {
return local.lastUT
}
return closestEventUTToQueryTT(jde, local.lastUT, local.nextUT)
}
if !math.IsNaN(local.lastUT) {
return local.lastUT
}
if !math.IsNaN(local.nextUT) {
return local.nextUT
}
last := moonPlanetConjunctionDirectionalEventWithLocal(jde, planet, -1, local)
next := moonPlanetConjunctionDirectionalEventWithLocal(jde, planet, 1, local)
if math.IsNaN(last) {
return next
}
if math.IsNaN(next) {
return last
}
return closestEventUTToQueryTT(jde, last, next)
}

View File

@ -0,0 +1,284 @@
package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
type moonPlanetConjunctionBaselineSample struct {
Planet string `json:"planet"`
Year int `json:"year"`
Month int `json:"month"`
TimeUTC string `json:"time_utc"`
}
type moonPlanetConjunctionBaseline struct {
Samples []moonPlanetConjunctionBaselineSample `json:"samples"`
}
func loadMoonPlanetConjunctionBaseline(t *testing.T) moonPlanetConjunctionBaseline {
t.Helper()
paths := [][]string{
{
"testdata/moon_planet_conjunction_baseline.json",
"basic/testdata/moon_planet_conjunction_baseline.json",
},
{
"testdata/moon_planet_conjunction_baseline_samples.json",
"basic/testdata/moon_planet_conjunction_baseline_samples.json",
},
}
var merged moonPlanetConjunctionBaseline
for index, candidates := range paths {
var (
data []byte
err error
)
for _, path := range candidates {
data, err = os.ReadFile(path)
if err == nil {
var baseline moonPlanetConjunctionBaseline
if err := json.Unmarshal(data, &baseline); err != nil {
t.Fatalf("decode baseline %s: %v", path, err)
}
merged.Samples = append(merged.Samples, baseline.Samples...)
break
}
}
if err != nil && index == 0 {
t.Fatalf("read baseline: %v", err)
}
}
if len(merged.Samples) == 0 {
t.Fatal("empty moon-planet conjunction baseline")
}
return merged
}
func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) {
baseline := loadMoonPlanetConjunctionBaseline(t)
type conjunctionCase struct {
planet MoonPlanetConjunctionPlanet
next func(float64, MoonPlanetConjunctionPlanet) float64
}
cases := map[string]conjunctionCase{
"mercury": {planet: MoonPlanetConjunctionMercury, next: NextMoonPlanetConjunction},
"venus": {planet: MoonPlanetConjunctionVenus, next: NextMoonPlanetConjunction},
"mars": {planet: MoonPlanetConjunctionMars, next: NextMoonPlanetConjunction},
"jupiter": {planet: MoonPlanetConjunctionJupiter, next: NextMoonPlanetConjunction},
"saturn": {planet: MoonPlanetConjunctionSaturn, next: NextMoonPlanetConjunction},
"uranus": {planet: MoonPlanetConjunctionUranus, next: NextMoonPlanetConjunction},
"neptune": {planet: MoonPlanetConjunctionNeptune, next: NextMoonPlanetConjunction},
}
const tolerance = 20 * time.Second
var maxDiff time.Duration
seen := make(map[string]int, len(cases))
for _, sample := range baseline.Samples {
tc, ok := cases[sample.Planet]
if !ok {
t.Fatalf("unknown planet %q", sample.Planet)
}
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
queryTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true)
gotUT := tc.next(queryTT, tc.planet)
gotTime := JDE2DateByZone(gotUT, time.UTC, false)
diff := gotTime.Sub(wantTime)
if diff < 0 {
diff = -diff
}
if diff > maxDiff {
maxDiff = diff
}
if diff > tolerance {
t.Fatalf("%s %04d-%02d time mismatch: got %s want %s tolerance %v", sample.Planet, sample.Year, sample.Month, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, tolerance)
}
delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), tc.planet, -1))
if delta > 0.01 {
t.Fatalf("%s %04d-%02d event not near conjunction: delta=%.8f deg", sample.Planet, sample.Year, sample.Month, delta)
}
seen[sample.Planet]++
}
for planet := range cases {
if seen[planet] == 0 {
t.Fatalf("missing baseline samples for %s", planet)
}
}
t.Logf("moon-planet conjunction max diff: time=%v", maxDiff)
}
func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T) {
baseline := loadMoonPlanetConjunctionBaseline(t)
planets := map[string]MoonPlanetConjunctionPlanet{
"mercury": MoonPlanetConjunctionMercury,
"venus": MoonPlanetConjunctionVenus,
"mars": MoonPlanetConjunctionMars,
"jupiter": MoonPlanetConjunctionJupiter,
"saturn": MoonPlanetConjunctionSaturn,
"uranus": MoonPlanetConjunctionUranus,
"neptune": MoonPlanetConjunctionNeptune,
}
for _, sample := range baseline.Samples {
planet, ok := planets[sample.Planet]
if !ok {
t.Fatalf("unknown planet %q", sample.Planet)
}
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
seedTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true)
eventUT := NextMoonPlanetConjunction(seedTT, planet)
eventTime := JDE2DateByZone(eventUT, time.UTC, false)
queryAtTT := TD2UT(Date2JDE(eventTime.UTC()), true)
queryAfterTT := TD2UT(Date2JDE(eventTime.Add(time.Hour).UTC()), true)
exactNext := NextMoonPlanetConjunction(queryAtTT, planet)
exactClosest := ClosestMoonPlanetConjunction(queryAtTT, planet)
exactLastAfter := LastMoonPlanetConjunction(queryAfterTT, planet)
for name, gotUT := range map[string]float64{
"exactNext": exactNext,
"exactClosest": exactClosest,
"lastAfterEvent": exactLastAfter,
} {
gotTime := JDE2DateByZone(gotUT, time.UTC, false)
if diff := math.Abs(gotUT - eventUT); diff > 1e-9 {
t.Fatalf("%s %s mismatch: got %s want %s diff=%v", sample.Planet, name, gotTime.Format(time.RFC3339Nano), eventTime.Format(time.RFC3339Nano), diff*86400)
}
}
}
}
func TestMoonPlanetConjunctionRejectsOppositionBranchJump(t *testing.T) {
query := time.Date(1900, 11, 10, 12, 0, 0, 0, time.UTC)
queryTT := TD2UT(Date2JDE(query), true)
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
if math.Abs(lastUT-Date2JDE(query)) <= 5.0/86400.0 {
t.Fatalf("last returned query time on branch jump: got %s", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
}
if math.Abs(nextUT-Date2JDE(query)) <= 5.0/86400.0 {
t.Fatalf("next returned query time on branch jump: got %s", JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano))
}
for name, gotUT := range map[string]float64{
"last": lastUT,
"next": nextUT,
} {
delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), MoonPlanetConjunctionSaturn, -1))
if delta > moonPlanetConjunctionEventTolerance {
t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
}
}
}
func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) {
samples := []struct {
planet MoonPlanetConjunctionPlanet
query time.Time
}{
{planet: MoonPlanetConjunctionSaturn, query: time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionMercury, query: time.Date(1900, 1, 14, 12, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionVenus, query: time.Date(1950, 6, 3, 12, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionMars, query: time.Date(2000, 2, 29, 18, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionJupiter, query: time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionSaturn, query: time.Date(2100, 8, 17, 6, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionUranus, query: time.Date(2200, 11, 2, 9, 0, 0, 0, time.UTC)},
{planet: MoonPlanetConjunctionNeptune, query: time.Date(2300, 4, 24, 3, 0, 0, 0, time.UTC)},
}
for _, sample := range samples {
queryTT := TD2UT(Date2JDE(sample.query.UTC()), true)
lastUT := LastMoonPlanetConjunction(queryTT, sample.planet)
nextUT := NextMoonPlanetConjunction(queryTT, sample.planet)
closestUT := ClosestMoonPlanetConjunction(queryTT, sample.planet)
if math.IsNaN(lastUT) || math.IsNaN(nextUT) || math.IsNaN(closestUT) {
t.Fatalf("planet=%v query=%s returned NaN event(s): last=%v next=%v closest=%v", sample.planet, sample.query.Format(time.RFC3339), lastUT, nextUT, closestUT)
}
if !eventUTQueryBeforeOrEqual(lastUT, queryTT) {
t.Fatalf("planet=%v last after query: last=%s query=%s", sample.planet, JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
}
if !eventUTQueryAfterOrEqual(nextUT, queryTT) {
t.Fatalf("planet=%v next before query: next=%s query=%s", sample.planet, JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
}
if closestUT != closestEventUTToQueryTT(queryTT, lastUT, nextUT) {
t.Fatalf("planet=%v closest mismatch: got=%s want=%s", sample.planet, JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(closestEventUTToQueryTT(queryTT, lastUT, nextUT), time.UTC, false).Format(time.RFC3339Nano))
}
for name, gotUT := range map[string]float64{
"last": lastUT,
"next": nextUT,
"closest": closestUT,
} {
delta := math.Abs(moonPlanetConjunctionDeltaAt(TD2UT(gotUT, true), sample.planet, -1))
if delta > moonPlanetConjunctionEventTolerance {
t.Fatalf("planet=%v %s returned non-event candidate: delta=%.8f event=%s", sample.planet, name, delta, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
}
}
}
}
func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) {
query := time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC)
queryTT := TD2UT(Date2JDE(query.UTC()), true)
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
closestUT := ClosestMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
wantLast := time.Date(1700, 4, 15, 11, 55, 59, 115569293, time.UTC)
wantNext := time.Date(1700, 5, 13, 0, 35, 5, 981616675, time.UTC)
const tolerance = 5.0 / 86400.0
if diff := math.Abs(lastUT - Date2JDE(wantLast)); diff > tolerance {
t.Fatalf("last mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), wantLast.Format(time.RFC3339Nano), diff*86400)
}
if diff := math.Abs(nextUT - Date2JDE(wantNext)); diff > tolerance {
t.Fatalf("next mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), wantNext.Format(time.RFC3339Nano), diff*86400)
}
if !sameEventJD(closestUT, lastUT) {
t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
}
}
func TestMoonPlanetConjunctionNextAdvancesPastReturnedEvent(t *testing.T) {
seed := TD2UT(Date2JDE(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)), true)
eventUT := NextMoonPlanetConjunction(seed, MoonPlanetConjunctionMercury)
query := JDE2DateByZone(eventUT, time.UTC, false).Add(time.Second)
queryTT := TD2UT(Date2JDE(query.UTC()), true)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionMercury)
if eventUTQueryTTDelta(nextUT, queryTT) <= 0 {
t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%.6fs",
query.Format(time.RFC3339Nano),
JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
eventUTQueryTTDelta(nextUT, queryTT)*86400,
)
}
if sameEventJD(nextUT, eventUT) {
t.Fatalf("next conjunction should advance to a later event: event=%s next=%s",
JDE2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano),
JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
)
}
}

View File

@ -126,6 +126,68 @@ func HMoonApparentLoN(jd float64, n int) float64 {
return HMoonTrueLoN(jd, n) + Nutation2000Bi(jd)
}
// HMoonGeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension of the Moon.
func HMoonGeocentricApparentRa(jd float64) float64 {
return HMoonGeocentricApparentRaN(jd, -1)
}
// HMoonGeocentricApparentRaN 月亮地心视赤经(截断版) / truncated apparent geocentric right ascension of the Moon.
func HMoonGeocentricApparentRaN(jd float64, n int) float64 {
return LoToRa(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n))
}
// HMoonGeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination of the Moon.
func HMoonGeocentricApparentDec(jd float64) float64 {
return HMoonGeocentricApparentDecN(jd, -1)
}
// HMoonGeocentricApparentDecN 月亮地心视赤纬(截断版) / truncated apparent geocentric declination of the Moon.
func HMoonGeocentricApparentDecN(jd float64, n int) float64 {
return ArcSin(Sin(HMoonTrueBoN(jd, n))*Cos(TrueObliquity(jd)) +
Cos(HMoonTrueBoN(jd, n))*Sin(TrueObliquity(jd))*Sin(HMoonApparentLoN(jd, n)))
}
// HMoonGeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination of the Moon.
func HMoonGeocentricApparentRaDec(jd float64) (float64, float64) {
return HMoonGeocentricApparentRaDecN(jd, -1)
}
// HMoonGeocentricApparentRaDecN 月亮地心视赤经、视赤纬(截断版) / truncated apparent geocentric right ascension and declination of the Moon.
func HMoonGeocentricApparentRaDecN(jd float64, n int) (float64, float64) {
return LoBoToRaDec(jd, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n))
}
// HMoonGeocentricTrueRa 月亮地心真赤经 / true geocentric right ascension of the Moon.
func HMoonGeocentricTrueRa(jd float64) float64 {
return HMoonGeocentricTrueRaN(jd, -1)
}
// HMoonGeocentricTrueRaN 月亮地心真赤经(截断版) / truncated true geocentric right ascension of the Moon.
func HMoonGeocentricTrueRaN(jd float64, n int) float64 {
return LoToRa(jd, HMoonTrueLoN(jd, n), HMoonTrueBoN(jd, n))
}
// HMoonGeocentricTrueDec 月亮地心真赤纬 / true geocentric declination of the Moon.
func HMoonGeocentricTrueDec(jd float64) float64 {
return HMoonGeocentricTrueDecN(jd, -1)
}
// HMoonGeocentricTrueDecN 月亮地心真赤纬(截断版) / truncated true geocentric declination of the Moon.
func HMoonGeocentricTrueDecN(jd float64, n int) float64 {
return ArcSin(Sin(HMoonTrueBoN(jd, n))*Cos(TrueObliquity(jd)) +
Cos(HMoonTrueBoN(jd, n))*Sin(TrueObliquity(jd))*Sin(HMoonTrueLoN(jd, n)))
}
// HMoonGeocentricTrueRaDec 月亮地心真赤经、真赤纬 / true geocentric right ascension and declination of the Moon.
func HMoonGeocentricTrueRaDec(jd float64) (float64, float64) {
return HMoonGeocentricTrueRaDecN(jd, -1)
}
// HMoonGeocentricTrueRaDecN 月亮地心真赤经、真赤纬(截断版) / truncated true geocentric right ascension and declination of the Moon.
func HMoonGeocentricTrueRaDecN(jd float64, n int) (float64, float64) {
return LoBoToRaDec(jd, HMoonTrueLoN(jd, n), HMoonTrueBoN(jd, n))
}
func HMoonTrueRaDec(jd float64) (float64, float64) {
return HMoonTrueRaDecN(jd, -1)
}

View File

@ -32,7 +32,7 @@ func TestOuterPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
{name: "MarsEasternQuadrature", seed: NextMarsEasternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsEasternQuadrature, nextFn: NextMarsEasternQuadrature},
{name: "MarsWesternQuadrature", seed: NextMarsWesternQuadrature(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsWesternQuadrature, nextFn: NextMarsWesternQuadrature},
{name: "MarsP2R", seed: NextMarsProgradeToRetrograde(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsProgradeToRetrograde, nextFn: NextMarsProgradeToRetrograde},
{name: "MarsR2P", seed: NextMarsRetrogradeToPrograde(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMarsRetrogradeToPrograde, nextFn: NextMarsRetrogradeToPrograde},
{name: "MarsR2P", seed: NextMarsRetrogradeToPrograde(ttjdUTC(2025, 1, 1, 0, 0, 0)), lastFn: LastMarsRetrogradeToPrograde, nextFn: NextMarsRetrogradeToPrograde},
}
for _, tc := range cases {
@ -50,6 +50,35 @@ func TestOuterPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
}
}
func TestOuterPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
}{
{name: "MarsOpposition", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextMarsOpposition},
{name: "MarsP2R", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextMarsProgradeToRetrograde},
{name: "JupiterOpposition", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextJupiterOpposition},
{name: "SaturnConjunction", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextSaturnConjunction},
{name: "UranusP2R", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextUranusProgradeToRetrograde},
{name: "NeptuneR2P", seed: ttjdUTC(2026, 1, 1, 0, 0, 0), next: NextNeptuneRetrogradeToPrograde},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true)
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 ttjdUTC(year, month, day, hour, min, sec int) float64 {
return TD2UT(Date2JDE(time.Date(year, time.Month(month), day, hour, min, sec, 0, time.UTC)), true)
}

View File

@ -0,0 +1,34 @@
[
{
"input_utc": "2026-01-01T00:00:00Z",
"right_ascension": 63.920306258,
"declination": 26.403701421,
"ecliptic_longitude": 66.7156363,
"ecliptic_latitude": 5.0490966
}
,
{
"input_utc": "2026-05-01T00:00:00Z",
"right_ascension": 208.849626532,
"declination": -16.256039871,
"ecliptic_longitude": 212.5315932,
"ecliptic_latitude": -4.1622995
}
,
{
"input_utc": "2026-08-29T00:00:00Z",
"right_ascension": 346.062573698,
"declination": -4.416718092,
"ecliptic_longitude": 345.4608613,
"ecliptic_latitude": 1.4247855
}
,
{
"input_utc": "2026-12-27T00:00:00Z",
"right_ascension": 139.195692903,
"declination": 16.272137989,
"ecliptic_longitude": 136.6058459,
"ecliptic_latitude": 0.4338603
}
]

View File

@ -0,0 +1,94 @@
{
"samples": [
{"planet":"mercury","year":2026,"month":1,"time_utc":"2026-01-18T15:06:38Z"},
{"planet":"mercury","year":2026,"month":2,"time_utc":"2026-02-18T23:02:34Z"},
{"planet":"mercury","year":2026,"month":3,"time_utc":"2026-03-17T14:07:16Z"},
{"planet":"mercury","year":2026,"month":4,"time_utc":"2026-04-15T19:11:09Z"},
{"planet":"mercury","year":2026,"month":5,"time_utc":"2026-05-17T02:50:17Z"},
{"planet":"mercury","year":2026,"month":6,"time_utc":"2026-06-16T19:32:07Z"},
{"planet":"mercury","year":2026,"month":7,"time_utc":"2026-07-14T04:37:03Z"},
{"planet":"mercury","year":2026,"month":8,"time_utc":"2026-08-11T12:47:22Z"},
{"planet":"mercury","year":2026,"month":9,"time_utc":"2026-09-12T07:28:45Z"},
{"planet":"mercury","year":2026,"month":10,"time_utc":"2026-10-12T20:08:25Z"},
{"planet":"mercury","year":2026,"month":11,"time_utc":"2026-11-08T16:33:09Z"},
{"planet":"mercury","year":2026,"month":12,"time_utc":"2026-12-07T22:03:14Z"},
{"planet":"venus","year":2026,"month":1,"time_utc":"2026-01-19T01:01:00Z"},
{"planet":"venus","year":2026,"month":2,"time_utc":"2026-02-18T09:20:04Z"},
{"planet":"venus","year":2026,"month":3,"time_utc":"2026-03-20T12:37:37Z"},
{"planet":"venus","year":2026,"month":4,"time_utc":"2026-04-19T08:47:17Z"},
{"planet":"venus","year":2026,"month":5,"time_utc":"2026-05-19T01:49:30Z"},
{"planet":"venus","year":2026,"month":6,"time_utc":"2026-06-17T20:20:35Z"},
{"planet":"venus","year":2026,"month":7,"time_utc":"2026-07-17T16:31:26Z"},
{"planet":"venus","year":2026,"month":8,"time_utc":"2026-08-16T08:47:10Z"},
{"planet":"venus","year":2026,"month":9,"time_utc":"2026-09-14T11:10:57Z"},
{"planet":"venus","year":2026,"month":10,"time_utc":"2026-10-12T02:31:37Z"},
{"planet":"venus","year":2026,"month":11,"time_utc":"2026-11-07T11:33:28Z"},
{"planet":"venus","year":2026,"month":12,"time_utc":"2026-12-05T10:44:42Z"},
{"planet":"mars","year":2026,"month":1,"time_utc":"2026-01-18T14:09:59Z"},
{"planet":"mars","year":2026,"month":2,"time_utc":"2026-02-16T17:40:45Z"},
{"planet":"mars","year":2026,"month":3,"time_utc":"2026-03-17T21:52:35Z"},
{"planet":"mars","year":2026,"month":4,"time_utc":"2026-04-16T00:46:20Z"},
{"planet":"mars","year":2026,"month":5,"time_utc":"2026-05-15T00:44:20Z"},
{"planet":"mars","year":2026,"month":6,"time_utc":"2026-06-12T21:15:20Z"},
{"planet":"mars","year":2026,"month":7,"time_utc":"2026-07-11T14:38:57Z"},
{"planet":"mars","year":2026,"month":8,"time_utc":"2026-08-09T05:31:55Z"},
{"planet":"mars","year":2026,"month":9,"time_utc":"2026-09-06T18:25:43Z"},
{"planet":"mars","year":2026,"month":10,"time_utc":"2026-10-05T05:31:58Z"},
{"planet":"mars","year":2026,"month":11,"time_utc":"2026-11-02T14:25:02Z"},
{"planet":"mars","year":2026,"month":11,"time_utc":"2026-11-30T19:34:48Z"},
{"planet":"mars","year":2026,"month":12,"time_utc":"2026-12-28T17:44:28Z"},
{"planet":"jupiter","year":2026,"month":1,"time_utc":"2026-01-03T21:59:20Z"},
{"planet":"jupiter","year":2026,"month":1,"time_utc":"2026-01-31T02:29:07Z"},
{"planet":"jupiter","year":2026,"month":2,"time_utc":"2026-02-27T06:24:21Z"},
{"planet":"jupiter","year":2026,"month":3,"time_utc":"2026-03-26T12:11:13Z"},
{"planet":"jupiter","year":2026,"month":4,"time_utc":"2026-04-22T22:03:39Z"},
{"planet":"jupiter","year":2026,"month":5,"time_utc":"2026-05-20T12:36:55Z"},
{"planet":"jupiter","year":2026,"month":6,"time_utc":"2026-06-17T06:51:28Z"},
{"planet":"jupiter","year":2026,"month":7,"time_utc":"2026-07-15T03:03:33Z"},
{"planet":"jupiter","year":2026,"month":8,"time_utc":"2026-08-11T23:22:55Z"},
{"planet":"jupiter","year":2026,"month":9,"time_utc":"2026-09-08T18:11:18Z"},
{"planet":"jupiter","year":2026,"month":10,"time_utc":"2026-10-06T10:16:20Z"},
{"planet":"jupiter","year":2026,"month":11,"time_utc":"2026-11-02T23:09:31Z"},
{"planet":"jupiter","year":2026,"month":11,"time_utc":"2026-11-30T09:16:19Z"},
{"planet":"jupiter","year":2026,"month":12,"time_utc":"2026-12-27T17:30:35Z"},
{"planet":"saturn","year":2026,"month":1,"time_utc":"2026-01-23T12:40:10Z"},
{"planet":"saturn","year":2026,"month":2,"time_utc":"2026-02-20T00:03:21Z"},
{"planet":"saturn","year":2026,"month":3,"time_utc":"2026-03-19T14:12:20Z"},
{"planet":"saturn","year":2026,"month":4,"time_utc":"2026-04-16T06:08:13Z"},
{"planet":"saturn","year":2026,"month":5,"time_utc":"2026-05-13T21:58:07Z"},
{"planet":"saturn","year":2026,"month":6,"time_utc":"2026-06-10T11:41:01Z"},
{"planet":"saturn","year":2026,"month":7,"time_utc":"2026-07-07T21:49:36Z"},
{"planet":"saturn","year":2026,"month":8,"time_utc":"2026-08-04T04:10:47Z"},
{"planet":"saturn","year":2026,"month":8,"time_utc":"2026-08-31T08:07:26Z"},
{"planet":"saturn","year":2026,"month":9,"time_utc":"2026-09-27T12:00:54Z"},
{"planet":"saturn","year":2026,"month":10,"time_utc":"2026-10-24T17:41:49Z"},
{"planet":"saturn","year":2026,"month":11,"time_utc":"2026-11-21T01:26:46Z"},
{"planet":"saturn","year":2026,"month":12,"time_utc":"2026-12-18T10:18:35Z"},
{"planet":"uranus","year":2026,"month":1,"time_utc":"2026-01-27T18:46:51Z"},
{"planet":"uranus","year":2026,"month":2,"time_utc":"2026-02-24T00:35:51Z"},
{"planet":"uranus","year":2026,"month":3,"time_utc":"2026-03-23T07:40:49Z"},
{"planet":"uranus","year":2026,"month":4,"time_utc":"2026-04-19T17:35:45Z"},
{"planet":"uranus","year":2026,"month":5,"time_utc":"2026-05-17T05:58:41Z"},
{"planet":"uranus","year":2026,"month":6,"time_utc":"2026-06-13T19:08:48Z"},
{"planet":"uranus","year":2026,"month":7,"time_utc":"2026-07-11T07:07:53Z"},
{"planet":"uranus","year":2026,"month":8,"time_utc":"2026-08-07T16:30:03Z"},
{"planet":"uranus","year":2026,"month":9,"time_utc":"2026-09-03T23:05:03Z"},
{"planet":"uranus","year":2026,"month":10,"time_utc":"2026-10-01T04:18:41Z"},
{"planet":"uranus","year":2026,"month":10,"time_utc":"2026-10-28T10:24:46Z"},
{"planet":"uranus","year":2026,"month":11,"time_utc":"2026-11-24T18:39:41Z"},
{"planet":"uranus","year":2026,"month":12,"time_utc":"2026-12-22T04:19:54Z"},
{"planet":"neptune","year":2026,"month":1,"time_utc":"2026-01-23T15:49:28Z"},
{"planet":"neptune","year":2026,"month":2,"time_utc":"2026-02-19T23:30:09Z"},
{"planet":"neptune","year":2026,"month":3,"time_utc":"2026-03-19T09:34:27Z"},
{"planet":"neptune","year":2026,"month":4,"time_utc":"2026-04-15T21:23:14Z"},
{"planet":"neptune","year":2026,"month":5,"time_utc":"2026-05-13T09:11:35Z"},
{"planet":"neptune","year":2026,"month":6,"time_utc":"2026-06-09T19:13:52Z"},
{"planet":"neptune","year":2026,"month":7,"time_utc":"2026-07-07T02:37:58Z"},
{"planet":"neptune","year":2026,"month":8,"time_utc":"2026-08-03T07:56:14Z"},
{"planet":"neptune","year":2026,"month":8,"time_utc":"2026-08-30T12:50:11Z"},
{"planet":"neptune","year":2026,"month":9,"time_utc":"2026-09-26T19:04:28Z"},
{"planet":"neptune","year":2026,"month":10,"time_utc":"2026-10-24T03:16:37Z"},
{"planet":"neptune","year":2026,"month":11,"time_utc":"2026-11-20T12:35:40Z"},
{"planet":"neptune","year":2026,"month":12,"time_utc":"2026-12-17T21:26:40Z"}
]
}

View File

@ -0,0 +1,92 @@
{
"samples": [
{"planet":"mercury","year":1996,"month":1,"time_utc":"1996-01-20T07:48:44Z"},
{"planet":"mercury","year":1996,"month":2,"time_utc":"1996-02-17T05:58:27Z"},
{"planet":"mercury","year":1996,"month":3,"time_utc":"1996-03-18T21:54:14Z"},
{"planet":"mercury","year":1996,"month":4,"time_utc":"1996-04-19T10:29:52Z"},
{"planet":"mercury","year":1996,"month":5,"time_utc":"1996-05-17T04:02:30Z"},
{"planet":"mercury","year":1996,"month":6,"time_utc":"1996-06-14T00:19:49Z"},
{"planet":"mercury","year":1996,"month":7,"time_utc":"1996-07-16T08:03:39Z"},
{"planet":"mercury","year":1996,"month":8,"time_utc":"1996-08-16T18:24:11Z"},
{"planet":"mercury","year":1996,"month":9,"time_utc":"1996-09-13T13:17:46Z"},
{"planet":"mercury","year":1996,"month":10,"time_utc":"1996-10-11T09:44:59Z"},
{"planet":"mercury","year":1996,"month":11,"time_utc":"1996-11-11T12:53:54Z"},
{"planet":"mercury","year":1996,"month":12,"time_utc":"1996-12-12T05:10:41Z"},
{"planet":"venus","year":1996,"month":1,"time_utc":"1996-01-23T08:25:31Z"},
{"planet":"venus","year":1996,"month":2,"time_utc":"1996-02-22T04:36:35Z"},
{"planet":"venus","year":1996,"month":3,"time_utc":"1996-03-22T23:53:11Z"},
{"planet":"venus","year":1996,"month":4,"time_utc":"1996-04-21T14:13:39Z"},
{"planet":"venus","year":1996,"month":5,"time_utc":"1996-05-20T00:40:14Z"},
{"planet":"venus","year":1996,"month":6,"time_utc":"1996-06-15T09:13:17Z"},
{"planet":"venus","year":1996,"month":7,"time_utc":"1996-07-12T08:38:52Z"},
{"planet":"venus","year":1996,"month":8,"time_utc":"1996-08-10T03:59:30Z"},
{"planet":"venus","year":1996,"month":9,"time_utc":"1996-09-08T23:10:01Z"},
{"planet":"venus","year":1996,"month":10,"time_utc":"1996-10-09T04:07:49Z"},
{"planet":"venus","year":1996,"month":11,"time_utc":"1996-11-08T09:31:53Z"},
{"planet":"venus","year":1996,"month":12,"time_utc":"1996-12-08T13:14:51Z"},
{"planet":"mars","year":1996,"month":1,"time_utc":"1996-01-21T07:43:41Z"},
{"planet":"mars","year":1996,"month":2,"time_utc":"1996-02-19T07:57:56Z"},
{"planet":"mars","year":1996,"month":3,"time_utc":"1996-03-19T07:08:23Z"},
{"planet":"mars","year":1996,"month":4,"time_utc":"1996-04-17T05:16:03Z"},
{"planet":"mars","year":1996,"month":5,"time_utc":"1996-05-16T02:56:01Z"},
{"planet":"mars","year":1996,"month":6,"time_utc":"1996-06-14T00:47:52Z"},
{"planet":"mars","year":1996,"month":7,"time_utc":"1996-07-12T23:06:35Z"},
{"planet":"mars","year":1996,"month":8,"time_utc":"1996-08-10T21:29:02Z"},
{"planet":"mars","year":1996,"month":9,"time_utc":"1996-09-08T19:05:05Z"},
{"planet":"mars","year":1996,"month":10,"time_utc":"1996-10-07T14:58:13Z"},
{"planet":"mars","year":1996,"month":11,"time_utc":"1996-11-05T08:08:58Z"},
{"planet":"mars","year":1996,"month":12,"time_utc":"1996-12-03T21:11:34Z"},
{"planet":"jupiter","year":1996,"month":1,"time_utc":"1996-01-18T19:46:08Z"},
{"planet":"jupiter","year":1996,"month":2,"time_utc":"1996-02-15T15:00:36Z"},
{"planet":"jupiter","year":1996,"month":3,"time_utc":"1996-03-14T06:11:13Z"},
{"planet":"jupiter","year":1996,"month":4,"time_utc":"1996-04-10T16:55:53Z"},
{"planet":"jupiter","year":1996,"month":5,"time_utc":"1996-05-08T00:11:28Z"},
{"planet":"jupiter","year":1996,"month":6,"time_utc":"1996-06-04T05:31:30Z"},
{"planet":"jupiter","year":1996,"month":7,"time_utc":"1996-07-01T10:21:43Z"},
{"planet":"jupiter","year":1996,"month":7,"time_utc":"1996-07-28T15:41:14Z"},
{"planet":"jupiter","year":1996,"month":8,"time_utc":"1996-08-24T22:04:24Z"},
{"planet":"jupiter","year":1996,"month":9,"time_utc":"1996-09-21T05:58:04Z"},
{"planet":"jupiter","year":1996,"month":10,"time_utc":"1996-10-18T16:05:44Z"},
{"planet":"jupiter","year":1996,"month":11,"time_utc":"1996-11-15T05:27:06Z"},
{"planet":"jupiter","year":1996,"month":12,"time_utc":"1996-12-12T22:38:11Z"},
{"planet":"saturn","year":1996,"month":1,"time_utc":"1996-01-24T03:47:15Z"},
{"planet":"saturn","year":1996,"month":2,"time_utc":"1996-02-20T19:10:42Z"},
{"planet":"saturn","year":1996,"month":3,"time_utc":"1996-03-19T10:59:03Z"},
{"planet":"saturn","year":1996,"month":4,"time_utc":"1996-04-16T01:04:52Z"},
{"planet":"saturn","year":1996,"month":5,"time_utc":"1996-05-13T12:31:53Z"},
{"planet":"saturn","year":1996,"month":6,"time_utc":"1996-06-09T21:39:17Z"},
{"planet":"saturn","year":1996,"month":7,"time_utc":"1996-07-07T05:32:21Z"},
{"planet":"saturn","year":1996,"month":8,"time_utc":"1996-08-03T13:13:36Z"},
{"planet":"saturn","year":1996,"month":8,"time_utc":"1996-08-30T21:01:02Z"},
{"planet":"saturn","year":1996,"month":9,"time_utc":"1996-09-27T04:20:31Z"},
{"planet":"saturn","year":1996,"month":10,"time_utc":"1996-10-24T10:21:42Z"},
{"planet":"saturn","year":1996,"month":11,"time_utc":"1996-11-20T15:05:45Z"},
{"planet":"saturn","year":1996,"month":12,"time_utc":"1996-12-17T20:17:06Z"},
{"planet":"uranus","year":1996,"month":1,"time_utc":"1996-01-20T15:54:24Z"},
{"planet":"uranus","year":1996,"month":2,"time_utc":"1996-02-17T05:20:18Z"},
{"planet":"uranus","year":1996,"month":3,"time_utc":"1996-03-15T16:05:29Z"},
{"planet":"uranus","year":1996,"month":4,"time_utc":"1996-04-11T23:42:31Z"},
{"planet":"uranus","year":1996,"month":5,"time_utc":"1996-05-09T05:36:22Z"},
{"planet":"uranus","year":1996,"month":6,"time_utc":"1996-06-05T11:50:19Z"},
{"planet":"uranus","year":1996,"month":7,"time_utc":"1996-07-02T19:33:47Z"},
{"planet":"uranus","year":1996,"month":7,"time_utc":"1996-07-30T04:29:08Z"},
{"planet":"uranus","year":1996,"month":8,"time_utc":"1996-08-26T13:21:06Z"},
{"planet":"uranus","year":1996,"month":9,"time_utc":"1996-09-22T20:54:56Z"},
{"planet":"uranus","year":1996,"month":10,"time_utc":"1996-10-20T03:02:32Z"},
{"planet":"uranus","year":1996,"month":11,"time_utc":"1996-11-16T09:17:15Z"},
{"planet":"uranus","year":1996,"month":12,"time_utc":"1996-12-13T17:54:51Z"},
{"planet":"neptune","year":1996,"month":1,"time_utc":"1996-01-20T07:21:06Z"},
{"planet":"neptune","year":1996,"month":2,"time_utc":"1996-02-16T19:38:46Z"},
{"planet":"neptune","year":1996,"month":3,"time_utc":"1996-03-15T05:08:50Z"},
{"planet":"neptune","year":1996,"month":4,"time_utc":"1996-04-11T11:48:33Z"},
{"planet":"neptune","year":1996,"month":5,"time_utc":"1996-05-08T17:24:20Z"},
{"planet":"neptune","year":1996,"month":6,"time_utc":"1996-06-04T23:59:10Z"},
{"planet":"neptune","year":1996,"month":7,"time_utc":"1996-07-02T08:22:46Z"},
{"planet":"neptune","year":1996,"month":7,"time_utc":"1996-07-29T17:55:53Z"},
{"planet":"neptune","year":1996,"month":8,"time_utc":"1996-08-26T03:10:20Z"},
{"planet":"neptune","year":1996,"month":9,"time_utc":"1996-09-22T10:48:58Z"},
{"planet":"neptune","year":1996,"month":10,"time_utc":"1996-10-19T16:49:59Z"},
{"planet":"neptune","year":1996,"month":11,"time_utc":"1996-11-15T22:54:41Z"},
{"planet":"neptune","year":1996,"month":12,"time_utc":"1996-12-13T07:18:13Z"}
]
}

View File

@ -202,10 +202,14 @@ func venusConjunction(jde float64, next uint8) float64 {
}
left := queryTT
leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN)
if math.Abs(leftVal) <= 30.0/86400.0 {
if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 {
exact := eventZeroRefine(left, 1.0, 0.000005, venusSunLongitudeDelta)
if math.Abs(exact-queryTT) <= 1.0 {
return TD2UT(exact, false)
eventUT := TD2UT(exact, false)
if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
return eventUT
}
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
return eventUT
}
}
const step = 8.0
@ -432,12 +436,12 @@ func venusGreatestElongationInWindow(start, end float64) float64 {
func venusEastElongationWindowEndingAt(inferior float64) (float64, float64) {
lastSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(inferior))
return lastSuperior + innerEventEpsilon, inferior - innerEventEpsilon
return lastSuperior + innerEventWindowPadding, inferior - innerEventWindowPadding
}
func venusWestElongationWindowEndingAt(superior float64) (float64, float64) {
lastInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(superior))
return lastInferior + innerEventEpsilon, superior - innerEventEpsilon
return lastInferior + innerEventWindowPadding, superior - innerEventWindowPadding
}
func venusEastElongationWindowContaining(jde float64) (float64, float64) {
@ -539,35 +543,19 @@ func LastVenusGreatestElongation(jde float64) float64 {
}
func LastVenusInferiorConjunctionInclusive(jde float64) float64 {
date := LastVenusConjunction(jde)
if venusConjunctionTypeAt(date) {
return date
}
return LastVenusConjunction(eventUTLastQueryTT(date))
return inclusiveLastSimpleEvent(jde, LastVenusInferiorConjunction, NextVenusInferiorConjunction)
}
func NextVenusInferiorConjunctionInclusive(jde float64) float64 {
date := NextVenusConjunction(jde)
if venusConjunctionTypeAt(date) {
return date
}
return NextVenusConjunction(eventUTNextQueryTT(date))
return inclusiveNextSimpleEvent(jde, LastVenusInferiorConjunction, NextVenusInferiorConjunction)
}
func LastVenusSuperiorConjunctionInclusive(jde float64) float64 {
date := LastVenusConjunction(jde)
if !venusConjunctionTypeAt(date) {
return date
}
return LastVenusConjunction(eventUTLastQueryTT(date))
return inclusiveLastSimpleEvent(jde, LastVenusSuperiorConjunction, NextVenusSuperiorConjunction)
}
func NextVenusSuperiorConjunctionInclusive(jde float64) float64 {
date := NextVenusConjunction(jde)
if !venusConjunctionTypeAt(date) {
return date
}
return NextVenusConjunction(eventUTNextQueryTT(date))
return inclusiveNextSimpleEvent(jde, LastVenusSuperiorConjunction, NextVenusSuperiorConjunction)
}
func LastVenusRetrogradeInclusive(jde float64) float64 {

View File

@ -50,6 +50,10 @@ const (
// 返回 农历月,日,是否闰月以及文字描述
// 按现行农历GB/T 33661-2017算法计算推荐使用年限为[1929-3000]年
// 古代由于定朔定气误差此处计算会与古时不符
// Inputs are civil year, month, day, and timezone offset in hours.
// Returns the lunar year, month, day, leap-month flag, and text description.
// The current GB/T 33661-2017 lunar-calendar convention is recommended for years 1929-3000.
// For ancient dates, the result may differ from historical practice because computed new-moon and solar-term reconstructions are approximate for ancient dates.
func Lunar(year, month, day int, timezone float64) (int, int, int, bool, string) {
return basic.GetLunar(year, month, day, timezone/24.0)
}
@ -62,6 +66,12 @@ func Lunar(year, month, day int, timezone float64) (int, int, int, bool, string)
// 由于农历还未到鼠年故应当传入Solar(2019,12,30,false)
// 按现行农历GB/T 33661-2017算法计算推荐使用年限为[1929-3000]年
// 古代由于定朔定气误差此处计算会与古时不符
// Inputs are the civil-year proxy of the lunar year, lunar month, lunar day, leap-month flag, and timezone offset in hours.
// Returns the corresponding civil time.
// The lunar year parameter follows the civil year containing the lunar New Year of that cycle.
// For example, the last day of the Ji-Hai year corresponds to 2020-01-24, but should still be passed as `Solar(2019, 12, 30, false, ...)`.
// The current GB/T 33661-2017 lunar-calendar convention is recommended for years 1929-3000.
// For ancient dates, the result may differ from historical practice because computed new-moon and solar-term reconstructions are approximate for ancient dates.
func Solar(year, month, day int, leap bool, timezone float64) time.Time {
jde := basic.GetSolar(year, month, day, leap, timezone/24.0)
zone := time.FixedZone("CST", int(timezone*3600))
@ -71,9 +81,16 @@ func Solar(year, month, day int, leap bool, timezone float64) time.Time {
// SolarToLunar 公历转农历 / solar to lunar calendar.
// 传入 公历年月日
// 返回 包含农历信息的Time结构体
// 支持年份:[-103,3000]
// [-103,1912] 按照古代历法提供的农历信息
// 支持年份:[-721,3000]
// [-721,-221] 按默认先秦古历,[-220,-104] 秦汉颛顼历有效日期按复原算法,-104年交接后及[-103,1912]按照古代历法提供的农历信息
// (1912,3000]按现行农历GB/T 33661-2017算法计算
// Input is a civil `time.Time`.
// Returns a `Time` value carrying the lunar-calendar information.
// Supported civil years are [-721, 3000].
// Years [-721, -221] use the default pre-Qin ancient calendars.
// Years [-220, -104] use the reconstructed Qin and early-Han Zhuanxu calendar where that calendar has data.
// Late -104 and years [-103, 1912] use the historical-calendar tables included in this package.
// Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention.
func SolarToLunar(date time.Time) (Time, error) {
return innerSolarToLunar(date)
}
@ -81,21 +98,48 @@ func SolarToLunar(date time.Time) (Time, error) {
// SolarToLunarByYMD 公历转农历(按年月日) / solar to lunar calendar by year, month, and day.
// 传入 公历年月日
// 返回 包含农历信息的Time结构体
// 支持年份:[-103,3000]
// [-103,1912] 按照古代历法提供的农历信息
// 支持年份:[-721,3000]
// [-721,-221] 按默认先秦古历,[-220,-104] 秦汉颛顼历有效日期按复原算法,-104年交接后及[-103,1912]按照古代历法提供的农历信息
// (1912,3000]按现行农历GB/T 33661-2017算法计算
// Inputs are the civil year, month, and day.
// Returns a `Time` value carrying the lunar-calendar information.
// Supported civil years are [-721, 3000].
// Years [-721, -221] use the default pre-Qin ancient calendars.
// Years [-220, -104] use the reconstructed Qin and early-Han Zhuanxu calendar where that calendar has data.
// Late -104 and years [-103, 1912] use the historical-calendar tables included in this package.
// Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention.
func SolarToLunarByYMD(year, month, day int) (Time, error) {
return innerSolarToLunarByYMD(year, month, day)
}
func innerSolarToLunar(date time.Time) (Time, error) {
date = date.In(getCst())
if date.Year() < -103 || date.Year() > 9999 {
if date.Year() < ancientMinYear || date.Year() > 9999 {
return Time{}, fmt.Errorf("日期超出范围")
}
if err := basic.ValidateCivilDate(date.Year(), int(date.Month()), float64(date.Day())); err != nil {
return Time{}, fmt.Errorf("公历日期不存在")
}
if date.Year() < qinHanMinSolarYear {
if result, ok := innerSolarToLunarAncientByYMD(date.Year(), int(date.Month()), date.Day(), date); ok {
return result, nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if date.Year() <= qinHanMaxYear {
if result, ok := innerSolarToLunarQinHan(date); ok {
return tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), nil
}
if date.Year() == qinHanMinSolarYear {
if result, ok := innerSolarToLunarAncientByYMD(date.Year(), int(date.Month()), date.Day(), date); ok {
return result, nil
}
}
if date.Year() == qinHanMaxYear {
return innerSolarToLunarHanQing(date), nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if date.Year() <= 1912 {
return innerSolarToLunarHanQing(date), nil
}
@ -111,7 +155,7 @@ func innerSolarToLunar(date time.Time) (Time, error) {
}
func innerSolarToLunarByYMD(year, month, day int) (Time, error) {
if year < -103 || year > 9999 {
if year < ancientMinYear || year > 9999 {
return Time{}, fmt.Errorf("日期超出范围")
}
if month < 1 || month > 12 {
@ -123,6 +167,26 @@ func innerSolarToLunarByYMD(year, month, day int) (Time, error) {
if err := basic.ValidateCivilDate(year, month, float64(day)); err != nil {
return Time{}, fmt.Errorf("公历日期不存在")
}
if year < qinHanMinSolarYear {
if result, ok := innerSolarToLunarAncientByYMD(year, month, day, time.Time{}); ok {
return result, nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if year <= qinHanMaxYear {
if result, ok := innerSolarToLunarQinHanByYMD(year, month, day); ok {
return tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), nil
}
if year == qinHanMinSolarYear {
if result, ok := innerSolarToLunarAncientByYMD(year, month, day, time.Time{}); ok {
return result, nil
}
}
if year == qinHanMaxYear {
return innerSolarToLunarHanQingByYMD(year, month, day, time.Time{}), nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if year <= 1912 {
return innerSolarToLunarHanQingByYMD(year, month, day, time.Time{}), nil
}
@ -164,7 +228,15 @@ func transformModenLunar2Time(date time.Time, year, month, day int, leap bool, d
// 农历年中文描述+农历月中文描述+干支日中文描述
// 年号+农历月中文描述+农历日中文描述
// 年号+农历月中文描述+干支日中文描述
// 支持年份:[-103,3000]
// 支持年份:[-721,3000]
// Input is a lunar-date description such as `二零二零年正月初一`, `元丰六年十月十二`, or `元嘉二十七年七月庚午日`.
// Returns all matching `Time` results with both civil and lunar information.
// The parser accepts these forms:
// lunar year text + lunar month text + lunar day text
// lunar year text + lunar month text + sexagenary day text
// era name + lunar month text + lunar day text
// era name + lunar month text + sexagenary day text
// Supported civil result years are [-721, 3000]. Boundary lunar year -722 may be accepted when the result still falls inside that civil range.
func LunarToSolar(desc string) ([]Time, error) {
dates, err := innerParseLunar(desc)
if err != nil {
@ -186,9 +258,18 @@ func LunarToSolar(desc string) ([]Time, error) {
// Deprecated: 推荐使用LunarToSolarByYMD
// 传入 农历年月日,是否闰月
// 传出 包含公里农历信息的Time结构体
// 支持年份:[-103,3000]
// [-103,1912] 按照古代历法提供的农历信息,注意这里农历月份代表的是以当时的历法推定的农历月与正月的距离正月为1二月为2依次类推闰月显示所闰月
// 支持年份:公历结果在[-721,3000]范围内,边界农历年可回溯到-722
// [-721,-221] 按默认先秦古历,[-220,-105] 按秦汉颛顼历复原算法,-104年重叠日期按默认公历交接选择[-103,1912] 按照古代历法提供的农历信息,注意这里农历月份代表的是以当时的历法推定的农历月与正月的距离正月为1二月为2依次类推闰月显示所闰月
// (1912,3000]按现行农历GB/T 33661-2017算法计算
// Deprecated: use LunarToSolarByYMD.
// Inputs are lunar year, month, day, and the leap-month flag.
// Returns a `Time` value carrying both civil and lunar information.
// Supported civil result years are [-721, 3000]. Boundary lunar year -722 may be accepted when the result still falls inside that civil range.
// Years [-721, -221] use the default pre-Qin ancient calendars.
// Years [-220, -105] use the reconstructed Qin and early-Han Zhuanxu calendar.
// Ambiguous -104 lunar dates follow the default civil handoff; use LunarToSolarByYMDWithCalendar for a specific ancient calendar.
// For years [-103, 1912], the lunar month index follows the historical calendar in force at that time, counted from the first month of that year.
// Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention.
func LunarToSolarSingle(year, month, day int, leap bool) (Time, error) {
return LunarToSolarByYMD(year, month, day, leap)
}
@ -196,13 +277,38 @@ func LunarToSolarSingle(year, month, day int, leap bool) (Time, error) {
// LunarToSolarByYMD 农历转公历(按年月日) / lunar to solar calendar by year, month, and day.
// 传入 农历年月日,是否闰月
// 传出 包含公里农历信息的Time结构体
// 支持年份:[-103,3000]
// [-103,1912] 按照古代历法提供的农历信息,注意这里农历月份代表的是以当时的历法推定的农历月与正月的距离正月为1二月为2依次类推闰月显示所闰月
// 支持年份:公历结果在[-721,3000]范围内,边界农历年可回溯到-722
// [-721,-221] 按默认先秦古历,[-220,-105] 按秦汉颛顼历复原算法,-104年重叠日期按默认公历交接选择[-103,1912] 按照古代历法提供的农历信息,注意这里农历月份代表的是以当时的历法推定的农历月与正月的距离正月为1二月为2依次类推闰月显示所闰月
// (1912,3000]按现行农历GB/T 33661-2017算法计算
// Inputs are lunar year, month, day, and the leap-month flag.
// Returns a `Time` value carrying both civil and lunar information.
// Supported civil result years are [-721, 3000]. Boundary lunar year -722 may be accepted when the result still falls inside that civil range.
// Years [-721, -221] use the default pre-Qin ancient calendars.
// Years [-220, -105] use the reconstructed Qin and early-Han Zhuanxu calendar.
// Ambiguous -104 lunar dates follow the default civil handoff; use LunarToSolarByYMDWithCalendar for a specific ancient calendar.
// For years [-103, 1912], the lunar month index follows the historical calendar in force at that time, counted from the first month of that year.
// Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention.
func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) {
if year < -103 || year > 9999 {
if year < ancientBoundaryMinYear || year > 9999 {
return Time{}, fmt.Errorf("年份超出范围")
}
if year < qinHanMinYear {
if result, ok := lunarToSolarAncientDefault(year, month, day, leap); ok {
return result, nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if year <= qinHanMaxYear {
if year == qinHanMaxYear {
if result, ok := lunarToSolarHanQingDefault(year, month, day, leap); ok {
return result, nil
}
}
if result, ok := lunarToSolarQinHan(year, month, day, leap); ok {
return tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if year <= 1912 {
date := rapidSolarHan2Qing(year, month, day, leap, yearDiffLunar(year, month, day), nil)
return SolarToLunar(date)
@ -215,18 +321,61 @@ func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) {
return SolarToLunar(date)
}
func lunarToSolarHanQingDefault(year, month, day int, leap bool) (Time, bool) {
date := rapidSolarHan2Qing(year, month, day, leap, yearDiffLunar(year, month, day), nil)
if date.IsZero() {
return Time{}, false
}
result, err := SolarToLunar(date)
if err != nil {
return Time{}, false
}
lunar := result.Lunar()
if lunar.CalendarSystem() == AncientCalendarQinHan {
return Time{}, false
}
if lunar.LunarYear() != year || lunar.LunarMonth() != month || lunar.LunarDay() != day || lunar.IsLeap() != leap {
return Time{}, false
}
return result, true
}
// JieQi 节气时刻(北京时间) / solar term instant in Beijing time.
//
// 返回传入年份、节气对应的北京时间节气时间。
// Returns the Beijing-time instant of the requested solar term in the supplied year.
func JieQi(year, term int) time.Time {
calcJde := basic.GetJQTime(year, term)
zone := time.FixedZone("CST", 8*3600)
return basic.JDE2DateByZone(calcJde, zone, false)
}
// CalendricalJieQi 历法相符节气日期(北京时间当天 0 点) / calendrical solar-term date at Beijing midnight.
//
// 返回默认历法下指定公历年、节气落在的日期,时间固定为北京时间当天 0 点。
// 该函数沿用 `JieQi` 的节气编号,但结果是历法日期,不是现代天文学计算出的精确节气时刻。
// Returns the date on which the requested solar term falls in the default calendrical system,
// normalized to 00:00:00 at UTC+08:00. The term numbering is the same as `JieQi`, but the
// result is a calendrical date rather than the exact modern astronomical instant.
func CalendricalJieQi(year, term int) (time.Time, error) {
return CalendricalJieQiWithCalendar(year, term, AncientCalendarDefault)
}
// CalendricalJieQiWithCalendar 历法相符节气日期(显式历法) / calendrical solar-term date with an explicit calendar.
//
// 返回指定古历系统中某公历年、节气落在的日期,时间固定为北京时间当天 0 点。
// 春秋历及缺少历法节气资料的年份会返回错误。
// Returns the date on which the requested solar term falls in the specified ancient
// calendar system, normalized to 00:00:00 at UTC+08:00. Calendars or years without
// calendrical solar-term data return an error.
func CalendricalJieQiWithCalendar(year, term int, system AncientCalendarSystem) (time.Time, error) {
return calendricalJieQiWithCalendar(year, term, system)
}
// WuHou 物候时刻(北京时间) / pentad instant in Beijing time.
//
// 返回传入年份、物候对应的北京时间物候时间。
// Returns the Beijing-time instant of the requested pentad in the supplied year.
func WuHou(year, term int) time.Time {
calcJde := basic.GetWuHouTime(year, term)
zone := time.FixedZone("CST", 8*3600)
@ -371,7 +520,7 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
result.desc = dateStr
dateStr = "公元" + dateStr
// 正则表达式匹配日期格式
re := regexp.MustCompile(`^([\p{Han}]+?)([一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`)
re := regexp.MustCompile(`^([\p{Han}]+?)([-负負一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`)
matches := re.FindStringSubmatch(dateStr)
if len(matches) < 5 {
return result, fmt.Errorf("无效的日期格式: %s", dateStr)
@ -388,14 +537,21 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
}
} else {
// 直接转换年份
if m, _ := regexp.MatchString("\\d+", matches[2]); m {
result.year, err = strconv.Atoi(matches[2])
yearStr := matches[2]
sign := 1
if strings.HasPrefix(yearStr, "负") || strings.HasPrefix(yearStr, "負") {
sign = -1
yearStr = strings.TrimPrefix(strings.TrimPrefix(yearStr, "负"), "負")
}
if m, _ := regexp.MatchString("\\d+", yearStr); m {
result.year, err = strconv.Atoi(yearStr)
if err != nil {
return result, fmt.Errorf("无效的年份: %s", matches[2])
}
} else {
result.year = transfer(matches[2], true)
result.year = transfer(yearStr, true)
}
result.year *= sign
}
// 转换月份
@ -404,6 +560,15 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
result.leap = true
monthStr = strings.TrimPrefix(monthStr, "闰")
}
if strings.HasPrefix(monthStr, "后") {
result.leap = true
result.houMonth = true
monthStr = strings.TrimPrefix(monthStr, "后")
} else if strings.HasPrefix(monthStr, "後") {
result.leap = true
result.houMonth = true
monthStr = strings.TrimPrefix(monthStr, "後")
}
if month, ok := chineseMonths[monthStr]; ok {
result.month = month
} else {
@ -417,6 +582,9 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
return result, fmt.Errorf("无效的月份: %s", monthStr)
}
}
if result.houMonth && result.month != 9 {
return result, fmt.Errorf("无效的月份: %s", matches[3])
}
// 转换日期
dayStr := matches[4]
@ -458,16 +626,15 @@ func convertChineseNumber(chineseNum string) (int, error) {
func number2Chinese(num int, isDirectTrans bool) string {
chs := []string{"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"}
if isDirectTrans {
if num < 0 {
return "负" + number2Chinese(-num, true)
}
var res string
for i := 0; i < 4; i++ {
tmp := num / (int(math.Pow10(3 - i)))
if tmp == 0 && i == 0 {
continue
}
if tmp < 0 {
res = "负"
num = -num
}
res += chs[tmp]
num = num % (int(math.Pow10(3 - i)))
}
@ -574,5 +741,5 @@ func ganZhiOfDayIndex(t time.Time) (int, int) {
if diff >= 0 {
return diff % 10, diff % 12
}
return (diff%10 + 10) % 10, (diff%12 + 12) % 10
return (diff%10 + 10) % 10, (diff%12 + 12) % 12
}

681
calendar/chineseAncient.go Normal file
View File

@ -0,0 +1,681 @@
package calendar
import (
"fmt"
"math"
"time"
"b612.me/astro/basic"
)
// AncientCalendarSystem 古六历系统 / ancient calendar system.
//
// 用于显式选择先秦古历或秦汉颛顼历。
// It identifies an explicitly selected pre-Qin or Qin/Early-Han calendar.
type AncientCalendarSystem string
const (
AncientCalendarDefault AncientCalendarSystem = ""
AncientCalendarChunqiu AncientCalendarSystem = "chunqiu"
AncientCalendarZhou AncientCalendarSystem = "zhou"
AncientCalendarLu AncientCalendarSystem = "lu"
AncientCalendarHuangdi AncientCalendarSystem = "huangdi"
AncientCalendarYin AncientCalendarSystem = "yin"
AncientCalendarXia1 AncientCalendarSystem = "xia1"
AncientCalendarXia2 AncientCalendarSystem = "xia2"
AncientCalendarZhuanxu AncientCalendarSystem = "zhuanxu"
AncientCalendarQinHan AncientCalendarSystem = "qin_han"
)
const (
ancientMinYear = -721
ancientMaxYear = -221
ancientBoundaryMinYear = ancientMinYear - 1
ancientBoundaryMaxYear = qinHanMinYear
ancientLunarMonth = 29.0 + 499.0/940.0
ancientSolarYear = 365.25
chunqiuLunarMonth = 30328.0 / 1027.0
chunqiuYearEpoch = -721
chunqiuJDEpoch = 1457727.761054236
chunqiuLeapYearCount = 244
ancientDateEpsilon = 1e-9
)
type ancientMonth struct {
lunarYear int
month int
day int
leap bool
startJDN int
endJDN int
system AncientCalendarSystem
name string
}
type ancientSixParameters struct {
yEpoch int
jdEpoch float64
jdEpochMoon float64
ziOffset int
name string
}
var chunqiuLeapYearBitmap = []byte{
82, 73, 82, 164, 8, 155, 72, 201, 160, 138, 162, 144, 37, 73, 162, 73,
145, 164, 81, 146, 34, 19, 163, 148, 168, 34, 67, 69, 37, 37, 1,
}
// SolarToLunarWithCalendar 公历转农历(显式古历) / solar to lunar calendar with an explicit ancient calendar.
//
// 传入公历日期和古历系统,返回该古历系统下的农历结果。
// Input is a civil date and an ancient calendar system. The result uses that explicit calendar.
func SolarToLunarWithCalendar(date time.Time, system AncientCalendarSystem) (Time, error) {
if system == AncientCalendarDefault {
return SolarToLunar(date)
}
date = date.In(getCst())
return innerSolarToLunarByYMDWithCalendar(date.Year(), int(date.Month()), date.Day(), date, system)
}
// SolarToLunarByYMDWithCalendar 公历转农历(按年月日,显式古历) / solar to lunar calendar by YMD with an explicit ancient calendar.
//
// 传入公历年月日和古历系统,返回该古历系统下的农历结果。
// Inputs are civil year, month, day, and an ancient calendar system.
func SolarToLunarByYMDWithCalendar(year, month, day int, system AncientCalendarSystem) (Time, error) {
if system == AncientCalendarDefault {
return SolarToLunarByYMD(year, month, day)
}
return innerSolarToLunarByYMDWithCalendar(year, month, day, time.Time{}, system)
}
// LunarToSolarWithCalendar 农历描述转公历(显式古历) / lunar description to solar date with an explicit ancient calendar.
//
// 传入农历日期描述和古历系统,返回该古历系统下匹配的公历日期。
// Input is a lunar-date description and an ancient calendar system.
func LunarToSolarWithCalendar(desc string, system AncientCalendarSystem) ([]Time, error) {
if system == AncientCalendarDefault {
return LunarToSolar(desc)
}
date, err := parseChineseDate(desc)
if err != nil {
return nil, err
}
if date.year == 0 || date.comment != "" {
return nil, fmt.Errorf("显式古历暂不支持年号日期")
}
if date.houMonth && system != AncientCalendarQinHan && system != AncientCalendarZhuanxu {
return nil, fmt.Errorf("未找到对应日期")
}
result, err := LunarToSolarByYMDWithCalendar(date.year, date.month, date.day, date.leap, system)
if err != nil {
return nil, err
}
return []Time{result}, nil
}
// LunarToSolarByYMDWithCalendar 农历转公历(按年月日,显式古历) / lunar to solar calendar by YMD with an explicit ancient calendar.
//
// 传入农历年月日、闰月标记和古历系统,返回该古历系统下匹配的公历日期。
// Inputs are lunar year, month, day, leap-month flag, and an ancient calendar system.
func LunarToSolarByYMDWithCalendar(year, month, day int, leap bool, system AncientCalendarSystem) (Time, error) {
if system == AncientCalendarDefault {
return LunarToSolarByYMD(year, month, day, leap)
}
if system == AncientCalendarQinHan {
if result, ok := lunarToSolarQinHan(year, month, day, leap); ok {
return tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), nil
}
return Time{}, fmt.Errorf("未找到对应日期")
}
lmonth, ok := ancientMonthByLunar(year, month, leap, system)
if !ok {
return Time{}, fmt.Errorf("未找到对应日期")
}
if day < 1 || day > lmonth.endJDN-lmonth.startJDN {
return Time{}, fmt.Errorf("日期超出范围")
}
lmonth.day = day
date := ancientJDNToDate(lmonth.startJDN + day - 1)
if !ancientSolarYearInRange(date.Year()) {
return Time{}, fmt.Errorf("未找到对应日期")
}
return ancientTime(date, lmonth), nil
}
func calendricalJieQiWithCalendar(year, term int, system AncientCalendarSystem) (time.Time, error) {
if _, err := calendricalJieQiTermIndex(term); err != nil {
return time.Time{}, err
}
if system == AncientCalendarDefault {
return defaultCalendricalJieQi(year, term)
}
return calendricalJieQiBySystem(year, term, system)
}
func defaultCalendricalJieQi(year, term int) (time.Time, error) {
if year < ancientMinYear || year > hanQingJieQiMaxYear {
return time.Time{}, fmt.Errorf("该年份暂不支持历法节气")
}
if year < -479 {
return time.Time{}, fmt.Errorf("历法 %s 暂不支持历法节气", AncientCalendarChunqiu)
}
if year < qinHanMinSolarYear {
return calendricalJieQiBySystem(year, term, AncientCalendarZhou)
}
if year == qinHanMinSolarYear {
qinHanDate, qinHanErr := calendricalJieQiBySystem(year, term, AncientCalendarQinHan)
if qinHanErr == nil {
return qinHanDate, nil
}
return calendricalJieQiBySystem(year, term, AncientCalendarZhou)
}
if year < qinHanMaxYear {
return calendricalJieQiBySystem(year, term, AncientCalendarQinHan)
}
if year == qinHanMaxYear {
qinHanDate, qinHanErr := calendricalJieQiBySystem(year, term, AncientCalendarQinHan)
if qinHanErr == nil {
return qinHanDate, nil
}
return hanQingCalendricalJieQiDate(year, term)
}
return hanQingCalendricalJieQiDate(year, term)
}
func calendricalJieQiBySystem(year, term int, system AncientCalendarSystem) (time.Time, error) {
switch system {
case AncientCalendarQinHan:
if year < qinHanMinSolarYear || year > qinHanMaxYear {
return time.Time{}, fmt.Errorf("历法 %s 不支持该年份", system)
}
date, err := ancientSixCalendricalJieQiDate(year, term, AncientCalendarZhuanxu)
if err != nil {
return time.Time{}, err
}
if !qinHanCalendricalDateSupported(date) {
return time.Time{}, fmt.Errorf("历法 %s 不支持该年份", system)
}
return date, nil
case AncientCalendarChunqiu:
return time.Time{}, fmt.Errorf("历法 %s 暂不支持历法节气", system)
case AncientCalendarZhou, AncientCalendarLu, AncientCalendarHuangdi, AncientCalendarYin, AncientCalendarXia1, AncientCalendarXia2, AncientCalendarZhuanxu:
if !ancientSolarYearInRange(year) {
return time.Time{}, fmt.Errorf("历法 %s 不支持该年份", system)
}
return ancientSixCalendricalJieQiDate(year, term, system)
default:
return time.Time{}, fmt.Errorf("不支持的古历系统: %s", system)
}
}
func calendricalJieQiTermIndex(term int) (int, error) {
if term < 0 || term >= 360 || term%15 != 0 {
return 0, fmt.Errorf("节气参数超出范围")
}
return ((term - JQ_冬至 + 360) % 360) / 15, nil
}
func ancientSixCalendricalJieQiDate(year, term int, system AncientCalendarSystem) (time.Time, error) {
termIndex, err := calendricalJieQiTermIndex(term)
if err != nil {
return time.Time{}, err
}
param, ok := ancientSixCalendarParameters(system)
if !ok {
return time.Time{}, fmt.Errorf("不支持的古历系统: %s", system)
}
dy := year - param.yEpoch - 1
winterSolstice := param.jdEpoch + float64(dy)*ancientSolarYear
if termIndex == 0 {
winterSolstice += ancientSolarYear
}
jd := winterSolstice + float64(termIndex)*ancientSolarYear/24
return calendricalJieQiDateFromJD(jd), nil
}
func calendricalJieQiDateFromJD(jd float64) time.Time {
jdn := int(math.Floor(jd + 0.5 + ancientDateEpsilon))
date := ancientJDNToDate(jdn)
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, getCst())
}
func qinHanStartDate() time.Time {
return qinHanJDNToDate(qinHanMonthStartJDNs(qinHanMinYear)[0])
}
func qinHanEndDate() time.Time {
months := qinHanMonthsForYear(qinHanMaxYear)
if len(months) == 0 {
return time.Time{}
}
return qinHanJDNToDate(months[len(months)-1].endJDN)
}
func qinHanCalendricalDateSupported(date time.Time) bool {
if date.Before(qinHanStartDate()) {
return false
}
end := qinHanEndDate()
if end.IsZero() {
return false
}
return date.Before(end)
}
func innerSolarToLunarAncientByYMD(year, month, day int, hmi time.Time) (Time, bool) {
system, ok := defaultAncientCalendarSystemForYear(year)
if !ok {
return Time{}, false
}
result, err := innerSolarToLunarByYMDWithCalendar(year, month, day, hmi, system)
if err != nil {
return Time{}, false
}
return result, true
}
func lunarToSolarAncientDefault(year, month, day int, leap bool) (Time, bool) {
system, ok := defaultAncientCalendarSystemForLunarYear(year)
if !ok {
return Time{}, false
}
result, err := LunarToSolarByYMDWithCalendar(year, month, day, leap, system)
if err != nil {
return Time{}, false
}
if !ancientSolarYearInRange(result.Solar().In(getCst()).Year()) {
return Time{}, false
}
return result, true
}
func innerSolarToLunarByYMDWithCalendar(year, month, day int, hmi time.Time, system AncientCalendarSystem) (Time, error) {
if system == AncientCalendarQinHan {
if err := validateQinHanCalendarSolarInput(year, month, day); err != nil {
return Time{}, err
}
if year > qinHanMaxYear {
return Time{}, fmt.Errorf("历法 %s 不支持该年份", system)
}
if result, ok := innerSolarToLunarQinHanByYMD(year, month, day); ok {
if !hmi.IsZero() {
result.solarTime = hmi
for i := range result.lunars {
result.lunars[i].solarDate = hmi
}
}
return tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), nil
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
if !isPreQinSystem(system) {
return Time{}, fmt.Errorf("不支持的古历系统: %s", system)
}
if err := validatePreQinCalendarSolarInput(year, month, day, system); err != nil {
return Time{}, err
}
targetJDN := ancientDateJDN(year, month, day)
for lunarYear := year - 1; lunarYear <= year+1; lunarYear++ {
months, ok := ancientMonthsForYear(lunarYear, system)
if !ok {
continue
}
for _, m := range months {
if targetJDN >= m.startJDN && targetJDN < m.endJDN {
m.day = targetJDN - m.startJDN + 1
date := hmi
if date.IsZero() {
date = time.Date(year, time.Month(month), day, 0, 0, 0, 0, getCst())
}
return ancientTime(date, m), nil
}
}
}
return Time{}, fmt.Errorf("无法获取农历信息")
}
func validatePreQinCalendarSolarInput(year, month, day int, system AncientCalendarSystem) error {
if !ancientSolarYearInRange(year) {
return fmt.Errorf("历法 %s 不支持该年份", system)
}
return validateAncientCivilDate(year, month, day)
}
func ancientSolarYearInRange(year int) bool {
return year >= ancientMinYear && year <= qinHanMinSolarYear
}
func validateQinHanCalendarSolarInput(year, month, day int) error {
if year < qinHanMinSolarYear || year > qinHanMaxYear {
return fmt.Errorf("历法 %s 不支持该年份", AncientCalendarQinHan)
}
return validateAncientCivilDate(year, month, day)
}
func validateAncientCivilDate(year, month, day int) error {
if month < 1 || month > 12 {
return fmt.Errorf("月份超出范围")
}
if day < 1 || day > 31 {
return fmt.Errorf("日期超出范围")
}
if err := basic.ValidateCivilDate(year, month, float64(day)); err != nil {
return fmt.Errorf("公历日期不存在")
}
return nil
}
func defaultAncientCalendarSystemForYear(year int) (AncientCalendarSystem, bool) {
if year < ancientMinYear || year > qinHanMinSolarYear {
return AncientCalendarDefault, false
}
if year < -479 {
return AncientCalendarChunqiu, true
}
return AncientCalendarZhou, true
}
func defaultAncientCalendarSystemForLunarYear(year int) (AncientCalendarSystem, bool) {
if year < ancientBoundaryMinYear || year > ancientMaxYear {
return AncientCalendarDefault, false
}
if year < -479 {
return AncientCalendarChunqiu, true
}
return AncientCalendarZhou, true
}
func ancientMonthsForYear(year int, system AncientCalendarSystem) ([]ancientMonth, bool) {
if !ancientSystemSupportsTableYear(year, system) {
return nil, false
}
switch system {
case AncientCalendarChunqiu:
return chunqiuMonthsForYear(year)
case AncientCalendarZhou, AncientCalendarLu, AncientCalendarHuangdi, AncientCalendarYin, AncientCalendarXia1, AncientCalendarXia2, AncientCalendarZhuanxu:
return ancientSixMonthsForYear(year, system)
default:
return nil, false
}
}
func ancientSystemSupportsTableYear(year int, system AncientCalendarSystem) bool {
if year < ancientBoundaryMinYear {
return false
}
if system == AncientCalendarChunqiu {
return year <= -479
}
if !isPreQinSystem(system) {
return false
}
return year <= ancientBoundaryMaxYear
}
func isPreQinSystem(system AncientCalendarSystem) bool {
switch system {
case AncientCalendarChunqiu, AncientCalendarZhou, AncientCalendarLu, AncientCalendarHuangdi, AncientCalendarYin, AncientCalendarXia1, AncientCalendarXia2, AncientCalendarZhuanxu:
return true
default:
return false
}
}
func chunqiuLeapYear(index int) int {
if index < 0 || index >= chunqiuLeapYearCount {
return 0
}
if chunqiuLeapYearBitmap[index/8]&(1<<uint(index%8)) != 0 {
return 1
}
return 0
}
func chunqiuAccLeapsBefore(index int) int {
if index <= 0 {
return 0
}
if index > chunqiuLeapYearCount {
index = chunqiuLeapYearCount
}
count := 0
fullBytes := index / 8
for i := 0; i < fullBytes; i++ {
count += bitCount(chunqiuLeapYearBitmap[i])
}
for i := fullBytes * 8; i < index; i++ {
count += chunqiuLeapYear(i)
}
return count
}
func bitCount(v byte) int {
count := 0
for v != 0 {
v &= v - 1
count++
}
return count
}
func chunqiuMonthsForYear(year int) ([]ancientMonth, bool) {
i := year - chunqiuYearEpoch
if i < -1 || i >= chunqiuLeapYearCount {
return nil, false
}
leap := 0
accLeaps := 0
if i >= 0 {
leap = chunqiuLeapYear(i)
accLeaps = chunqiuAccLeapsBefore(i)
}
accMonths := 12*i + accLeaps
monthCount := 12 + leap
m0 := chunqiuJDEpoch + float64(accMonths)*chunqiuLunarMonth
jd0 := ancientJDAtLocalMidnight(year-1, 12, 31)
jdn0 := int(math.Floor(jd0 + 0.6))
starts := make([]int, monthCount+1)
for idx := 0; idx <= monthCount; idx++ {
starts[idx] = jdn0 + int(math.Floor(m0+float64(idx)*chunqiuLunarMonth-jd0+ancientDateEpsilon))
}
months := make([]ancientMonth, 0, monthCount)
for idx := 0; idx < monthCount; idx++ {
month := idx + 1
isLeap := false
if monthCount == 13 && idx == 12 {
month = 12
isLeap = true
}
months = append(months, ancientMonth{
lunarYear: year,
month: month,
leap: isLeap,
startJDN: starts[idx],
endJDN: starts[idx+1],
system: AncientCalendarChunqiu,
name: ancientCalendarName(AncientCalendarChunqiu),
})
}
return months, true
}
func ancientSixMonthsForYear(year int, system AncientCalendarSystem) ([]ancientMonth, bool) {
param, ok := ancientSixCalendarParameters(system)
if !ok {
return nil, false
}
dy := year - param.yEpoch - 1
w0 := param.jdEpoch + float64(dy)*ancientSolarYear
w1 := w0 + ancientSolarYear
i := math.Floor((math.Floor(w0+1.5) - 0.5 - param.jdEpochMoon) / ancientLunarMonth)
m0 := param.jdEpochMoon + i*ancientLunarMonth
m1 := m0 + 13*ancientLunarMonth
monthCount := 12
if math.Floor(m1+0.5) < math.Floor(w1+0.5)+0.1 {
monthCount = 13
}
monthOffset := param.ziOffset
if param.ziOffset > 0 {
if monthCount == 13 {
monthOffset++
}
m1 = m0 + float64(monthCount+13)*ancientLunarMonth
w2 := w1 + ancientSolarYear
monthCount = 12
if math.Floor(m1+0.5) < math.Floor(w2+0.5)+0.1 {
monthCount = 13
}
}
m0 += float64(monthOffset) * ancientLunarMonth
jd0 := ancientJDAtLocalMidnight(year-1, 12, 31)
jdn0 := int(math.Floor(jd0 + 0.6))
months := make([]ancientMonth, 0, monthCount)
for idx := 0; idx < monthCount; idx++ {
m := m0 + float64(idx)*ancientLunarMonth
startOffset := int(math.Floor(m - jd0 + ancientDateEpsilon))
endOffset := int(math.Floor(m + ancientLunarMonth - jd0 + ancientDateEpsilon))
start := jdn0 + startOffset
end := jdn0 + endOffset
month, isLeap := ancientSixMonthNumber(system, idx, monthCount)
months = append(months, ancientMonth{
lunarYear: year,
month: month,
leap: isLeap,
startJDN: start,
endJDN: end,
system: system,
name: param.name,
})
}
return months, true
}
func ancientSixMonthNumber(system AncientCalendarSystem, index, monthCount int) (int, bool) {
if monthCount == 13 && index == 12 {
if system == AncientCalendarZhuanxu {
return 9, true
}
return 12, true
}
if system == AncientCalendarZhuanxu {
return 1 + ((index + 9) % 12), false
}
return index + 1, false
}
func ancientSixCalendarParameters(system AncientCalendarSystem) (ancientSixParameters, bool) {
switch system {
case AncientCalendarZhou:
return ancientSixParameters{-104, 1683430.5001, 1683430.5001, 0, ancientCalendarName(system)}, true
case AncientCalendarHuangdi:
return ancientSixParameters{170, 1783510.5001, 1783510.5001, 0, ancientCalendarName(system)}, true
case AncientCalendarYin:
return ancientSixParameters{-47, 1704250.5001, 1704250.5001, 1, ancientCalendarName(system)}, true
case AncientCalendarLu:
jdEpoch := 1545730.5001
return ancientSixParameters{-481, jdEpoch, jdEpoch - ancientLunarMonth/19.0, 0, ancientCalendarName(system)}, true
case AncientCalendarZhuanxu:
jdEpochMoon := 1726575.5001
return ancientSixParameters{14, jdEpochMoon - ancientSolarYear/8.0, jdEpochMoon, -1, ancientCalendarName(system)}, true
case AncientCalendarXia1:
return ancientSixParameters{444, 1883590.5001, 1883590.5001, 2, ancientCalendarName(system)}, true
case AncientCalendarXia2:
jdEpochMoon := 1883650.5001
return ancientSixParameters{444, jdEpochMoon - ancientSolarYear/6.0, jdEpochMoon, 2, ancientCalendarName(system)}, true
default:
return ancientSixParameters{}, false
}
}
func ancientMonthByLunar(year, month int, leap bool, system AncientCalendarSystem) (ancientMonth, bool) {
if !ancientSystemSupportsTableYear(year, system) {
return ancientMonth{}, false
}
months, ok := ancientMonthsForYear(year, system)
if !ok {
return ancientMonth{}, false
}
for _, m := range months {
if m.month == month && m.leap == leap {
return m, true
}
}
return ancientMonth{}, false
}
func ancientTime(date time.Time, month ancientMonth) Time {
return Time{
solarTime: date,
lunars: []LunarTime{
{
solarDate: date,
year: month.lunarYear,
month: month.month,
day: month.day,
leap: month.leap,
desc: formatAncientLunarDateString(month.month, month.day, month.leap, month.system),
calendarSystem: month.system,
calendarName: month.name,
},
},
}
}
func tagCalendar(date Time, system AncientCalendarSystem, name string) Time {
for i := range date.lunars {
date.lunars[i].calendarSystem = system
date.lunars[i].calendarName = name
}
return date
}
func formatAncientLunarDateString(month, day int, leap bool, system AncientCalendarSystem) string {
if leap {
if system == AncientCalendarZhuanxu {
return "后九月" + formatLunarDayString(day)
}
return "闰" + formatAncientMonthName(month) + "月" + formatLunarDayString(day)
}
return formatAncientMonthName(month) + "月" + formatLunarDayString(day)
}
func formatAncientMonthName(month int) string {
return ancientMonthNames[month]
}
func ancientCalendarName(system AncientCalendarSystem) string {
switch system {
case AncientCalendarChunqiu:
return "春秋历"
case AncientCalendarZhou:
return "周历"
case AncientCalendarLu:
return "鲁历"
case AncientCalendarHuangdi:
return "黄帝历"
case AncientCalendarYin:
return "殷历"
case AncientCalendarXia1:
return "夏历(冬至版)"
case AncientCalendarXia2:
return "夏历(雨水版)"
case AncientCalendarZhuanxu:
return "颛顼历"
case AncientCalendarQinHan:
return "秦汉颛顼历"
default:
return ""
}
}
func ancientDateJDN(year, month, day int) int {
return int(math.Floor(basic.JDECalc(year, month, float64(day)) + 0.5))
}
func ancientJDAtLocalMidnight(year, month, day int) float64 {
return basic.JDECalc(year, month, float64(day))
}
func ancientJDNToDate(jdn int) time.Time {
return basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true)
}

View File

@ -0,0 +1,301 @@
package calendar
import (
"fmt"
"math"
"time"
"b612.me/astro/basic"
)
const (
hanQingJieQiMinYear = -104
hanQingJieQiMaxYear = 1912
hanQingJieQiPatternCount = 97
hanQingJieQiPatternLength = 23
)
func hanQingCalendricalJieQiDate(year, term int) (time.Time, error) {
termIndex, err := calendricalJieQiTableTermIndex(term)
if err != nil {
return time.Time{}, err
}
if year < hanQingJieQiMinYear || year > hanQingJieQiMaxYear {
return time.Time{}, fmt.Errorf("该年份暂不支持历法节气")
}
return hanQingCalendricalJieQiDateInRow(year, termIndex)
}
func hanQingCalendricalJieQiDateInRow(rowYear, termIndex int) (time.Time, error) {
yearIndex := rowYear - hanQingJieQiMinYear
offset := packedBits(hanQingJieQiFirstPacked, yearIndex*4, 4) - 4
patternID := packedBits(hanQingJieQiPatternIndexPacked, yearIndex*7, 7)
if patternID >= hanQingJieQiPatternCount {
return time.Time{}, fmt.Errorf("历法节气表数据异常")
}
for i := 0; i < termIndex; i++ {
offset += hanQingJieQiPatternDelta(patternID, i)
}
baseJDN := int(math.Floor(basic.JDECalc(rowYear-1, 12, 31) + 0.5))
return basic.JDE2DateByZone(float64(baseJDN+offset)-0.5, getCst(), true), nil
}
func calendricalJieQiTableTermIndex(term int) (int, error) {
if term < 0 || term >= 360 || term%15 != 0 {
return 0, fmt.Errorf("节气参数超出范围")
}
return ((term - JQ_小寒 + 360) % 360) / 15, nil
}
func hanQingJieQiPatternDelta(patternID, pos int) int {
key := patternID*hanQingJieQiPatternLength + pos
if delta, ok := hanQingJieQiPatternExceptionDelta(key); ok {
return delta
}
if packedBits(hanQingJieQiPatternBits, key, 1) == 1 {
return 16
}
return 15
}
func hanQingJieQiPatternExceptionDelta(key int) (int, bool) {
for _, item := range hanQingJieQiPatternExceptions {
if int(item&0x0FFF) != key {
continue
}
switch item >> 12 {
case 0:
return 12, true
case 1:
return 14, true
case 2:
return 17, true
}
return 15, true
}
return 0, false
}
func packedBits(data []byte, offset, width int) int {
value := 0
for i := 0; i < width; i++ {
bit := offset + i
if data[bit/8]&(1<<uint(bit%8)) != 0 {
value |= 1 << uint(i)
}
}
return value
}
var hanQingJieQiFirstPacked = []byte{
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221,
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221,
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221,
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221,
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221,
221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 204,
205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204,
205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204,
205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204,
205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204,
205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 205, 204, 188, 204, 188, 204,
188, 204, 188, 204, 188, 204, 188, 204, 188, 204, 188, 203, 188, 203, 188, 203,
188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203,
188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203, 188, 203,
188, 203, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187,
188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187,
188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 188, 187, 187, 187, 187, 187,
187, 187, 187, 136, 120, 136, 120, 136, 120, 136, 120, 136, 120, 136, 120, 135,
120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135,
120, 135, 120, 135, 120, 136, 120, 136, 120, 136, 120, 136, 120, 136, 120, 136,
120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135, 120, 135,
120, 135, 120, 119, 120, 119, 120, 119, 120, 119, 120, 119, 103, 118, 103, 135,
120, 135, 120, 135, 120, 119, 120, 119, 120, 119, 120, 119, 120, 119, 120, 119,
120, 119, 120, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119,
119, 119, 103, 118, 103, 118, 103, 118, 103, 118, 103, 118, 103, 118, 103, 102,
103, 102, 103, 102, 103, 102, 103, 102, 103, 102, 103, 102, 103, 102, 103, 102,
103, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102,
102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 86, 102, 86, 102,
86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101,
86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101, 86, 101,
86, 101, 86, 101, 86, 85, 86, 85, 86, 85, 86, 85, 86, 85, 86, 85,
86, 85, 86, 85, 86, 85, 86, 85, 85, 85, 85, 85, 85, 85, 85, 85,
85, 85, 85, 85, 85, 85, 85, 85, 85, 85, 85, 85, 85, 85, 69, 85,
69, 85, 69, 85, 69, 85, 69, 85, 69, 85, 69, 85, 69, 85, 69, 85,
69, 85, 69, 85, 69, 85, 69, 85, 69, 84, 69, 84, 69, 84, 69, 84,
69, 84, 69, 84, 69, 84, 69, 84, 69, 84, 69, 84, 69, 84, 69, 84,
69, 68, 69, 68, 69, 68, 69, 68, 53, 68, 68, 68, 69, 68, 52, 68,
52, 68, 52, 68, 52, 67, 52, 67, 52, 67, 52, 67, 52, 67, 52, 67,
52, 67, 52, 67, 52, 51, 52, 51, 52, 51, 52, 51, 52, 51, 52, 51,
52, 51, 52, 51, 52, 51, 52, 51, 51, 51, 51, 51, 51, 51, 51, 51,
51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 35, 51, 35, 51,
35, 51, 35, 51, 35, 51, 35, 51, 35, 51, 35, 50, 35, 50, 35, 50,
35, 50, 35, 50, 35, 50, 35, 34, 35, 34, 35, 34, 35, 34, 35, 34,
35, 34, 35, 34, 35, 34, 35, 34, 34, 34, 34, 34, 34, 34, 34, 34,
34, 34, 34, 34, 34, 34, 34, 34, 18, 34, 18, 34, 18, 34, 18, 34,
18, 34, 18, 34, 18, 34, 18, 34, 18, 33, 18, 33, 18, 33, 18, 33,
18, 33, 18, 33, 18, 33, 18, 33, 18, 17, 18, 17, 18, 17, 18, 17,
18, 17, 18, 17, 18, 17, 18, 17, 18, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 1, 17, 1, 17, 1, 17,
1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 16, 1, 16, 1, 16,
1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 0, 1, 0, 1, 0,
1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 160, 154, 170, 154, 170,
154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 169,
154, 169, 154, 169, 154, 169, 154, 169, 154, 169, 154, 153, 153, 153, 153, 153,
153, 153, 153, 153, 137, 153, 137, 153, 137, 153, 137, 153, 137, 153, 137, 153,
137, 153, 137, 153, 137, 152, 153, 169, 154, 169, 154, 169, 154, 169, 154, 169,
154, 169, 154, 169, 154, 169, 154, 169, 154, 153, 154, 153, 154, 153, 154, 153,
154, 153, 154, 153, 154, 153, 154, 153, 154, 153, 154, 153, 153, 153, 153, 153,
153, 153, 153, 153, 153, 153, 153, 153, 169, 170, 170, 170, 154, 170, 154, 170,
154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 170, 154, 169,
154, 169, 154, 169, 154, 169, 154, 169, 154, 169, 154, 169, 154, 169, 154, 169,
154, 153, 154, 153, 154, 153, 154, 153, 154, 153, 170, 170, 171, 170, 171, 170,
11,
}
var hanQingJieQiPatternIndexPacked = []byte{
218, 95, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97,
114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250,
29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38,
167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223,
97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114,
250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29,
38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167,
223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97,
114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250,
29, 38, 167, 223, 97, 114, 250, 29, 38, 167, 223, 97, 114, 250, 29, 38,
167, 223, 97, 114, 250, 205, 77, 191, 195, 228, 244, 59, 76, 78, 191, 195,
228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244,
59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76,
78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191,
195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228,
244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59,
76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78,
191, 195, 228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 76, 78, 191, 195,
228, 244, 59, 76, 78, 191, 195, 228, 244, 59, 212, 245, 138, 79, 109, 175,
248, 212, 246, 138, 207, 44, 175, 184, 204, 242, 138, 203, 44, 143, 184, 156,
238, 136, 203, 233, 142, 152, 156, 126, 135, 201, 233, 119, 152, 156, 122, 135,
72, 169, 119, 136, 148, 122, 135, 200, 104, 87, 136, 140, 118, 133, 200, 104,
23, 120, 140, 110, 129, 199, 232, 22, 120, 132, 110, 123, 70, 232, 182, 103,
132, 106, 123, 197, 167, 182, 87, 124, 106, 123, 197, 101, 150, 87, 92, 102,
121, 197, 101, 118, 71, 92, 78, 119, 196, 229, 116, 71, 76, 78, 191, 195,
228, 244, 59, 76, 78, 189, 67, 164, 212, 59, 68, 74, 189, 66, 100, 180,
43, 68, 70, 187, 66, 100, 116, 11, 60, 70, 183, 192, 99, 116, 11, 52,
66, 183, 61, 35, 116, 219, 51, 66, 181, 189, 226, 83, 219, 43, 62, 181,
189, 226, 50, 203, 43, 46, 179, 188, 226, 114, 186, 35, 46, 167, 59, 226,
114, 186, 27, 38, 167, 223, 97, 114, 250, 29, 38, 165, 222, 33, 82, 234,
109, 244, 138, 79, 45, 175, 184, 204, 242, 138, 203, 44, 143, 184, 156, 238,
136, 203, 233, 142, 152, 156, 126, 135, 201, 233, 119, 152, 156, 122, 135, 72,
169, 119, 136, 148, 122, 133, 200, 104, 87, 136, 140, 118, 133, 200, 232, 22,
120, 140, 110, 129, 199, 232, 182, 103, 132, 130, 145, 209, 174, 240, 24, 221,
2, 141, 208, 109, 207, 248, 212, 246, 138, 203, 44, 175, 184, 156, 238, 136,
201, 233, 119, 152, 148, 122, 135, 200, 104, 87, 136, 140, 110, 129, 71, 232,
182, 103, 132, 106, 123, 197, 167, 150, 87, 92, 102, 119, 196, 229, 116, 55,
76, 78, 189, 67, 164, 212, 43, 68, 70, 187, 192, 99, 116, 11, 52, 58,
135, 72, 169, 119, 136, 172, 106, 123, 197, 101, 150, 87, 92, 78, 119, 196,
229, 244, 59, 76, 74, 189, 67, 164, 116, 219, 51, 62, 181, 189, 226, 83,
203, 43, 46, 179, 188, 226, 114, 186, 35, 46, 167, 223, 97, 114, 250, 29,
34, 165, 222, 33, 50, 218, 21, 34, 163, 93, 224, 49, 186, 5, 30, 161,
219, 158, 14, 104, 132, 110, 123, 70, 168, 182, 87, 124, 106, 123, 197, 101,
150, 87, 92, 102, 119, 196, 229, 116, 55, 76, 78, 191, 67, 37, 214, 59,
68, 74, 189, 66, 100, 180, 43, 68, 70, 183, 192, 99, 116, 11, 52, 66,
183, 61, 227, 83, 235, 99, 62, 181, 60, 33, 50, 218, 21, 30, 163, 91,
224, 17, 186, 237, 25, 161, 219, 94, 241, 169, 237, 21, 151, 89, 94, 113,
153, 221, 17, 151, 211, 221, 48, 186, 237, 25, 161, 219, 94, 241, 169, 237,
21, 159, 89, 94, 113, 153, 229, 17, 151, 203, 233, 142, 184, 156, 238, 134,
201, 233, 119, 152, 148, 122, 135, 72, 105, 87, 136, 140, 118, 133, 199, 40,
23, 120, 140, 110, 123, 70, 232, 182, 103, 124, 126, 135, 72, 169, 119, 136,
148, 118, 133, 200, 104, 87, 120, 140, 110, 129, 71, 232, 182, 103, 132, 110,
123, 197, 167, 182, 87, 124, 102, 121, 197, 101, 118, 71, 92, 78, 119, 196,
228, 244, 59, 76, 78, 189, 67, 164, 212, 43, 68, 70, 187, 66, 100, 116,
11, 60, 70, 183, 61, 35, 116, 219, 43, 46, 179, 59, 226, 114, 186, 27,
38, 167, 223, 97, 82, 234, 29, 34, 165, 93, 33, 50, 218, 21, 30, 163,
91, 224, 17, 186, 237, 25, 161, 219, 94, 241, 169, 237, 21, 159, 89, 94,
113, 153, 221, 17, 151, 211, 29, 49, 57, 253, 14, 147, 82, 239, 16, 57,
253, 14, 145, 82, 239, 16, 41, 245, 14, 145, 210, 174, 16, 25, 237, 10,
143, 209, 45, 240, 8, 221, 246, 140, 79, 109, 175, 248, 204, 242, 138, 203,
44, 143, 184, 156, 238, 136, 203, 233, 119, 152, 156, 126, 135, 72, 169, 119,
136, 140, 118, 133, 200, 104, 23, 120, 140, 110, 129, 70, 232, 182, 103, 132,
106, 123, 197, 167, 182, 87, 92, 102, 121, 197, 229, 116, 71, 92, 78, 119,
195, 228, 244, 59, 68, 74, 189, 67, 164, 180, 43, 68, 70, 187, 193, 45,
240, 72, 61, 66, 183, 61, 35, 244, 250, 212, 246, 138, 203, 44, 175, 184,
156, 238, 136, 203, 233, 119, 152, 156, 110, 129, 71, 232, 182, 103, 132, 106,
129, 199, 168, 182, 87, 124, 106, 121, 197, 101, 118, 71, 92, 78, 119, 195,
228, 244, 59, 68, 74, 189, 67, 100, 180, 43, 68, 70, 183, 192, 99, 116,
11, 52, 66, 183, 61, 227, 83, 219, 43, 62, 179, 188, 226, 114, 186, 35,
46, 167, 223, 97, 114, 234, 29, 34, 165, 94, 33, 50, 218, 21, 30, 163,
91, 160, 17, 186, 237, 25, 159, 218, 94, 241, 153, 229, 21, 151, 89, 30,
113, 57, 221, 17, 143, 208, 109, 207, 8, 213, 246, 138, 209, 109, 207, 8,
221, 246, 138, 79, 109, 175, 184, 204, 242, 136, 203, 233, 142, 152, 156, 126,
135, 73, 169, 119, 136, 140, 118, 133, 200, 232, 22, 120, 140, 110, 123, 70,
168, 118, 71, 92, 78, 191, 195, 164, 212, 59, 68, 74, 187, 66, 100, 116,
11, 60, 70, 183, 61, 227, 83, 219, 43, 62, 179, 67, 226, 114, 186, 27,
38, 167, 223, 33, 82, 234, 21, 34, 163, 93, 224, 49, 186, 5, 26, 161,
219, 94, 241, 169, 237, 21, 151, 89, 30, 113, 57, 221, 13, 147, 211, 239,
16, 41, 245, 10, 145, 209, 174, 240, 24, 221, 2, 141, 208, 109, 175, 248,
212, 246, 138, 203, 44, 143, 184, 156, 238, 136, 201, 233, 119, 136, 148, 122,
133, 200, 104, 87, 120, 140, 110, 129, 70, 232, 182, 103, 124, 106, 123, 197,
101, 150, 71, 92, 78, 119, 196, 228, 244, 59, 68, 74, 189, 67, 100, 180,
43, 60, 70, 183, 64, 35, 116, 219, 51, 62, 181, 189, 226, 50, 203, 43,
46, 167, 59, 98, 114, 250, 29, 38, 165, 222, 33, 50, 218, 21, 30, 163,
91, 224, 17, 186, 237, 25, 159, 218, 94, 241, 153, 229, 21, 151, 211, 29,
49, 57, 253, 14, 147, 82, 239, 16, 25, 237, 10, 145, 209, 45, 240, 8,
221, 246, 140, 79, 109, 175, 248, 204, 242, 138, 203, 233, 142, 184, 156, 126,
135, 73, 169, 119, 136, 140, 118, 133, 200, 232, 22, 120, 132, 110, 123, 70,
168, 182, 87, 124, 102, 121, 197, 229, 116, 71, 92, 78, 191, 195, 164, 212,
59, 68, 74, 187, 66, 100, 116, 11, 60, 66, 183, 61, 35, 84, 219, 43,
62, 179, 188, 226, 50, 187, 35, 46, 167, 223, 97, 114, 234, 29, 34, 165,
93, 33, 50, 186, 5, 30, 163, 219, 158, 17, 170, 237, 21, 159, 90, 94,
113, 153, 221, 17, 151, 211, 239, 48, 57, 245, 14, 145, 210, 174, 16, 25,
237, 2, 143, 209, 109, 207, 8, 213, 246, 138, 79, 45, 175, 184, 204, 238,
136, 203, 233, 110, 152, 156, 122, 135, 72, 105, 87, 136, 140, 118, 129, 199,
232, 182, 103, 132, 110, 123, 197, 167, 150, 87, 132, 88, 68, 54, 133, 37,
84, 43, 56, 66, 214, 2, 131, 99, 35, 48, 56, 44, 220, 130, 99, 194,
45, 44, 35, 220, 194, 50, 186, 25, 44, 33, 91, 161, 18, 178, 21, 38,
160, 25, 129, 193, 105, 10, 22, 156, 21, 110, 113, 81, 225, 22, 151, 209,
109, 97, 17, 221, 22, 149, 144, 173, 48, 1, 217, 8, 19, 208, 76, 16,
225, 196, 4, 11, 206, 74, 176, 184, 184, 2, 139, 143, 235, 182, 216, 184,
108, 137, 76, 42, 134, 160, 160, 98, 136, 72, 9, 134, 136, 128, 96, 131,
8, 232, 53, 136, 124, 92, 129, 200, 199, 21, 88, 108, 92, 180, 132, 6,
21, 75, 80, 80, 49, 68, 132, 4, 59, 68, 64, 176, 65, 228, 243, 10,
68, 62, 174, 64, 228, 227, 10, 36, 52, 46, 89, 66, 131, 138, 37, 34,
168, 24, 34, 66, 130, 29, 34, 32, 88, 32, 242, 129, 5, 34, 31, 87,
224, 241, 113, 1, 30, 158, 22, 32, 161, 105, 201, 18, 146, 83, 44, 17,
57, 193, 6, 145, 15, 44, 16, 249, 192, 2, 145, 207, 43, 240, 240, 180,
0, 15, 79, 11, 144, 192, 180, 0, 9, 201, 41, 150, 136, 156, 96, 129,
200, 7, 22, 136, 124, 96, 129, 199, 231, 5, 120, 120, 90, 128, 71, 167,
5, 120, 116, 90, 0,
}
var hanQingJieQiPatternBits = []byte{
128, 182, 2, 64, 55, 1, 160, 91, 0, 176, 77, 0, 216, 22, 0, 236,
10, 0, 121, 5, 128, 218, 4, 64, 109, 1, 160, 182, 0, 80, 87, 0,
168, 43, 0, 212, 13, 0, 17, 66, 128, 109, 3, 128, 218, 2, 64, 93,
1, 160, 174, 0, 80, 87, 0, 168, 27, 0, 180, 21, 0, 218, 6, 0,
237, 2, 128, 118, 1, 32, 93, 1, 144, 173, 0, 200, 86, 0, 100, 27,
0, 178, 11, 0, 233, 10, 128, 108, 5, 64, 182, 1, 32, 187, 0, 144,
91, 0, 168, 45, 0, 212, 150, 0, 177, 11, 128, 212, 5, 64, 218, 18,
32, 118, 1, 144, 186, 0, 72, 91, 0, 164, 45, 2, 210, 86, 0, 105,
39, 0, 212, 5, 0, 218, 2, 0, 109, 9, 128, 182, 4, 64, 91, 1,
160, 173, 0, 208, 78, 0, 232, 42, 0, 116, 27, 0, 186, 9, 0, 221,
4, 128, 110, 1, 32, 132, 8, 136, 16, 1, 66, 132, 8, 17, 66, 132,
8, 17, 66, 132, 136, 32, 34, 68, 8, 33, 66, 132, 8, 1, 66, 132,
16, 33, 66, 132, 8, 33, 66, 132, 8, 17, 66, 132, 8, 17, 66, 132,
8, 17, 34, 132, 8, 17, 66, 132, 8, 17, 2, 8, 17, 18, 180, 19,
72, 136, 16, 133, 16, 33, 66, 132, 16, 33, 66, 132, 8, 33, 66, 132,
8, 161, 16, 34, 4, 66, 132, 4, 186, 9, 18, 33, 66, 33, 132, 136,
16, 34, 68, 136, 16, 34, 68, 136, 16, 34, 68, 138, 16, 34, 68, 136,
16, 34, 68, 136, 9, 17, 66,
}
var hanQingJieQiPatternExceptions = []uint16{
4096, 4117, 4119, 4140, 4142, 4163, 4165, 4186, 4188, 4209, 4211, 4232,
4234, 4255, 4257, 4278, 4280, 4301, 4303, 4325, 4326, 4347, 4349, 4371,
4372, 4394, 300, 4419, 4422, 4439, 4442, 4463, 4465, 4485, 4488, 4509,
4511, 4534, 4555, 4557, 4578, 4580, 4601, 4603, 4624, 4626, 4649, 4672,
4693, 4695, 4718, 4741, 4765, 4788, 4811, 4834, 4857, 4880, 4903, 4920,
4926, 4949, 4972, 4989, 4996, 5019, 5042, 5065, 5082, 5088, 5105, 5111,
5128, 5196, 5220, 5243, 5267, 5289, 5313, 5330, 5335, 5358, 5382, 5405,
9916, 9942, 5847, 5869, 5887, 5892, 10148, 6075, 6094, 6098, 6234,
}

View File

@ -358,10 +358,35 @@ func innerParseLunar(lunar string) ([]time.Time, error) {
if err != nil {
return []time.Time{}, err
}
if date.houMonth && date.comment != "" {
return nil, fmt.Errorf("未找到对应日期")
}
if date.year != 0 && date.comment == "" {
if date.year < -103 || date.year > 3000 {
if date.year < ancientBoundaryMinYear || date.year > 3000 {
return nil, fmt.Errorf("年份超出范围")
}
if date.houMonth && (date.year < qinHanMinYear || date.year > qinHanMaxYear) {
return nil, fmt.Errorf("未找到对应日期")
}
if date.year < qinHanMinYear {
d, ok := lunarToSolarAncientDefault(date.year, date.month, date.day, date.leap)
if !ok {
return nil, fmt.Errorf("未找到对应日期")
}
return []time.Time{d.Solar()}, nil
}
if date.year <= qinHanMaxYear {
if date.year == qinHanMaxYear {
if d, ok := lunarToSolarHanQingDefault(date.year, date.month, date.day, date.leap); ok {
return []time.Time{d.Solar()}, nil
}
}
d, ok := rapidSolarQinHan(date.year, date.month, date.day, date.leap)
if !ok {
return nil, fmt.Errorf("未找到对应日期")
}
return []time.Time{d}, nil
}
if date.year <= 1912 {
d := rapidSolarHan2Qing(date.year, date.month, date.day, date.leap, yearDiffLunar(date.year, date.month, date.day), nil)
return []time.Time{d}, nil

206
calendar/chineseQinHan.go Normal file
View File

@ -0,0 +1,206 @@
package calendar
import (
"math"
"time"
"b612.me/astro/basic"
)
const (
qinHanMinSolarYear = -221
qinHanMinYear = -220
qinHanMaxYear = -104
qinHanLunarMonth = 29.0 + 499.0/940.0
)
var qinHanLeapCycle = []int{0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1}
var qinHanAccMonthCycle = []int{0, 12, 24, 37, 49, 61, 74, 86, 98, 111, 123, 136, 148, 160, 173, 185, 197, 210, 222}
var qinHanMonthNums = []int{10, 11, 12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9}
var ancientMonthNames = []string{"", "正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"}
type qinHanMonth struct {
lunarYear int
month int
day int
leap bool
startJDN int
endJDN int
}
func innerSolarToLunarQinHan(date time.Time) (Time, bool) {
date = date.In(getCst())
month, ok := qinHanMonthBySolar(date.Year(), int(date.Month()), date.Day())
if !ok {
return Time{}, false
}
month.day = qinHanDateJDN(date.Year(), int(date.Month()), date.Day()) - month.startJDN + 1
return qinHanTime(date, month), true
}
func innerSolarToLunarQinHanByYMD(year, month, day int) (Time, bool) {
return innerSolarToLunarQinHan(time.Date(year, time.Month(month), day, 0, 0, 0, 0, getCst()))
}
func lunarToSolarQinHan(year, month, day int, leap bool) (Time, bool) {
lmonth, ok := qinHanMonthByLunar(year, month, leap)
if !ok {
return Time{}, false
}
if day < 1 || day > lmonth.endJDN-lmonth.startJDN {
return Time{}, false
}
lmonth.day = day
date := qinHanJDNToDate(lmonth.startJDN + day - 1)
return qinHanTime(date, lmonth), true
}
func rapidSolarQinHan(year, month, day int, leap bool) (time.Time, bool) {
result, ok := lunarToSolarQinHan(year, month, day, leap)
if !ok {
return time.Time{}, false
}
return result.Solar(), true
}
func qinHanTime(date time.Time, month qinHanMonth) Time {
return Time{
solarTime: date,
lunars: []LunarTime{
{
solarDate: date,
year: month.lunarYear,
month: month.month,
day: month.day,
leap: month.leap,
desc: formatQinHanLunarDateString(month.month, month.day, month.leap),
},
},
}
}
func qinHanMonthBySolar(year, month, day int) (qinHanMonth, bool) {
targetJDN := qinHanDateJDN(year, month, day)
for lunarYear := qinHanMaxInt(qinHanMinYear, year-1); lunarYear <= qinHanMinInt(qinHanMaxYear, year+1); lunarYear++ {
months := qinHanMonthsForYear(lunarYear)
for _, m := range months {
if targetJDN >= m.startJDN && targetJDN < m.endJDN {
return m, true
}
}
}
return qinHanMonth{}, false
}
func qinHanMonthByLunar(year, month int, leap bool) (qinHanMonth, bool) {
if year < qinHanMinYear || year > qinHanMaxYear {
return qinHanMonth{}, false
}
for _, m := range qinHanMonthsForYear(year) {
if m.month == month && m.leap == leap {
return m, true
}
}
return qinHanMonth{}, false
}
func qinHanMonthsForYear(year int) []qinHanMonth {
starts := qinHanMonthStartJDNs(year)
nextStarts := qinHanMonthStartJDNs(year + 1)
months := make([]qinHanMonth, 0, len(starts))
for i, start := range starts {
end := nextStarts[0]
if i+1 < len(starts) {
end = starts[i+1]
}
leap := i == 12
months = append(months, qinHanMonth{
lunarYear: year,
month: qinHanMonthNums[i],
leap: leap,
startJDN: start,
endJDN: end,
})
}
return months
}
func qinHanMonthStartJDNs(year int) []int {
jdEpoch, accMonthEpoch, yearEpochLeap := qinHanEpoch(year)
cycle := floorDiv(year-yearEpochLeap, 19)
yearInCycle := year - yearEpochLeap - 19*cycle
accMonths := accMonthEpoch + 235*cycle + qinHanAccMonthCycle[yearInCycle]
monthCount := 12 + qinHanLeapCycle[yearInCycle]
monthZero := jdEpoch + float64(accMonths)*qinHanLunarMonth
starts := make([]int, monthCount)
for i := 0; i < monthCount; i++ {
base := monthZero
// 高祖五年正月以后按汉初颛顼历新历元推算,前几个月仍沿用秦历续推。
if year == -201 && i >= 3 {
base = 1633701.5 + 470*qinHanLunarMonth
}
starts[i] = int(math.Floor(base + float64(i)*qinHanLunarMonth + 0.5 + 1e-9))
}
return starts
}
func qinHanEpoch(year int) (float64, int, int) {
// 三段历元分别对应秦历、汉初改元后和太初改历前的颛顼历推算参数。
if year >= -162 {
return 1646163.5, 321, -179
}
if year > -201 {
return 1633701.5, 174, -225
}
return 1589523.5, 1670, -225
}
func qinHanDateJDN(year, month, day int) int {
return int(math.Floor(basic.JDECalc(year, month, float64(day)) + 0.5))
}
func qinHanJDNToDate(jdn int) time.Time {
return basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true)
}
func floorDiv(a, b int) int {
q := a / b
r := a % b
if r != 0 && ((r < 0) != (b < 0)) {
q--
}
return q
}
func qinHanMinInt(a, b int) int {
if a < b {
return a
}
return b
}
func qinHanMaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func formatQinHanLunarDateString(lunarMonth, lunarDay int, isLeap bool) string {
if isLeap {
return "后九月" + formatLunarDayString(lunarDay)
}
return ancientMonthNames[lunarMonth] + "月" + formatLunarDayString(lunarDay)
}
func formatLunarDayString(lunarDay int) string {
dayNames := []string{"十", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
dayPrefixes := []string{"初", "十", "廿", "三"}
if lunarDay == 20 {
return "二十"
}
if lunarDay == 10 {
return "初十"
}
return dayPrefixes[lunarDay/10] + dayNames[lunarDay%10]
}

View File

@ -22,6 +22,12 @@ type lunarSolar struct {
GanZhiDay string
}
type solarYMD struct {
year int
month int
day int
}
func Test_ChineseCalendarModern(t *testing.T) {
var testData = []lunarSolar{
{Lyear: 1995, Lmonth: 12, Lday: 12, Leap: false, Year: 1996, Month: 1, Day: 31},
@ -141,6 +147,529 @@ func Test_ChineseCalendarModern2(t *testing.T) {
}
}
func Test_ChineseCalendarQinHan(t *testing.T) {
testData := []lunarSolar{
{Lyear: -130, Lmonth: 10, Lday: 1, Leap: false, Year: -131, Month: 11, Day: 25, Desc: "十月初一", GanZhiDay: "壬申"},
{Lyear: -130, Lmonth: 11, Lday: 1, Leap: false, Year: -131, Month: 12, Day: 24, Desc: "十一月初一", GanZhiDay: "辛丑"},
{Lyear: -130, Lmonth: 12, Lday: 1, Leap: false, Year: -130, Month: 1, Day: 23, Desc: "十二月初一", GanZhiDay: "辛未"},
{Lyear: -130, Lmonth: 1, Lday: 1, Leap: false, Year: -130, Month: 2, Day: 21, Desc: "正月初一", GanZhiDay: "庚子"},
{Lyear: -130, Lmonth: 9, Lday: 1, Leap: false, Year: -130, Month: 10, Day: 15, Desc: "九月初一", GanZhiDay: "丙申"},
{Lyear: -201, Lmonth: 10, Lday: 1, Leap: false, Year: -202, Month: 10, Day: 31, Desc: "十月初一", GanZhiDay: "甲午"},
{Lyear: -201, Lmonth: 1, Lday: 1, Leap: false, Year: -201, Month: 1, Day: 28, Desc: "正月初一", GanZhiDay: "癸亥"},
{Lyear: -201, Lmonth: 9, Lday: 1, Leap: true, Year: -201, Month: 10, Day: 20, Desc: "后九月初一", GanZhiDay: "戊子"},
// -104 的秦汉颛顼历日期与后续查表历存在重叠,秦汉语义用显式历法验证。
{Lyear: -104, Lmonth: 10, Lday: 1, Leap: false, Year: -105, Month: 11, Day: 8, Desc: "十月初一"},
}
for _, v := range testData {
res, err := SolarToLunarByYMD(v.Year, v.Month, v.Day)
if err != nil {
t.Fatal(v, err)
}
lunar := res.Lunar()
if lunar.LunarYear() != v.Lyear || lunar.LunarMonth() != v.Lmonth || lunar.LunarDay() != v.Lday || lunar.IsLeap() != v.Leap {
t.Fatal(v, lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap())
}
if lunar.MonthDay() != v.Desc {
t.Fatal(v, lunar.MonthDay())
}
if v.GanZhiDay != "" && lunar.GanZhiDay() != v.GanZhiDay {
t.Fatal(v, lunar.GanZhiDay())
}
if lunar.GanZhiMonth() != "" {
t.Fatal(v, lunar.GanZhiMonth())
}
if lunar.CalendarSystem() != AncientCalendarQinHan || lunar.CalendarName() != ancientCalendarName(AncientCalendarQinHan) {
t.Fatal(v, lunar.CalendarSystem(), lunar.CalendarName())
}
infos := res.LunarInfo()
if len(infos) != 1 || infos[0].CalendarSystem != AncientCalendarQinHan || infos[0].CalendarName != ancientCalendarName(AncientCalendarQinHan) {
t.Fatal(v, infos)
}
date, err := LunarToSolarByYMDWithCalendar(v.Lyear, v.Lmonth, v.Lday, v.Leap, AncientCalendarQinHan)
if err != nil {
t.Fatal(v, err)
}
solar := date.Time()
if solar.Year() != v.Year || int(solar.Month()) != v.Month || solar.Day() != v.Day {
t.Fatal(v, solar)
}
}
}
func Test_ChineseCalendarQinHanHandoffToHanQing(t *testing.T) {
lastQinHan, err := SolarToLunarByYMD(-104, 11, 25)
if err != nil {
t.Fatal(err)
}
lastLunar := lastQinHan.Lunar()
if lastLunar.LunarYear() != -104 || lastLunar.LunarMonth() != 9 || lastLunar.LunarDay() != 30 || !lastLunar.IsLeap() || lastLunar.CalendarSystem() != AncientCalendarQinHan {
t.Fatalf("unexpected last QinHan day: y=%d m=%d d=%d leap=%v system=%q",
lastLunar.LunarYear(), lastLunar.LunarMonth(), lastLunar.LunarDay(), lastLunar.IsLeap(), lastLunar.CalendarSystem())
}
after, err := SolarToLunarByYMD(-104, 12, 1)
if err != nil {
t.Fatal(err)
}
afterLunar := after.Lunar()
if afterLunar.LunarYear() != -104 || afterLunar.LunarMonth() != 10 || afterLunar.LunarDay() != 6 || afterLunar.IsLeap() || afterLunar.CalendarSystem() == AncientCalendarQinHan {
t.Fatalf("unexpected HanQing handoff day: y=%d m=%d d=%d leap=%v system=%q",
afterLunar.LunarYear(), afterLunar.LunarMonth(), afterLunar.LunarDay(), afterLunar.IsLeap(), afterLunar.CalendarSystem())
}
roundtrip, err := LunarToSolarByYMD(afterLunar.LunarYear(), afterLunar.LunarMonth(), afterLunar.LunarDay(), afterLunar.IsLeap())
if err != nil {
t.Fatal(err)
}
if roundtrip.Solar().Year() != -104 || int(roundtrip.Solar().Month()) != 12 || roundtrip.Solar().Day() != 1 {
t.Fatal(roundtrip.Solar())
}
parsed, err := LunarToSolar("-104年十月初六")
if err != nil {
t.Fatal(err)
}
if len(parsed) != 1 || parsed[0].Solar().Year() != -104 || int(parsed[0].Solar().Month()) != 12 || parsed[0].Solar().Day() != 1 {
t.Fatal(parsed)
}
}
func Test_ChineseCalendarQinHanWithCalendarPreservesTime(t *testing.T) {
input := time.Date(-200, time.January, 17, 13, 14, 15, 123, getCst())
result, err := SolarToLunarWithCalendar(input, AncientCalendarQinHan)
if err != nil {
t.Fatal(err)
}
if !result.Solar().Equal(input) {
t.Fatalf("solar time mismatch: got %s want %s", result.Solar(), input)
}
lunar := result.Lunar()
if lunar.CalendarSystem() != AncientCalendarQinHan {
t.Fatal(lunar.CalendarSystem())
}
infos := result.LunarInfo()
if len(infos) != 1 || !infos[0].SolarDate.Equal(input) {
t.Fatalf("lunar info solar date mismatch: %#v", infos)
}
byYMD, err := SolarToLunarByYMDWithCalendar(-200, 1, 17, AncientCalendarQinHan)
if err != nil {
t.Fatal(err)
}
if byYMD.Solar().Hour() != 0 || byYMD.Solar().Minute() != 0 || byYMD.Solar().Second() != 0 || byYMD.Solar().Nanosecond() != 0 {
t.Fatalf("expected YMD route to keep midnight, got %s", byYMD.Solar())
}
}
func Test_ChineseCalendarQinHanEveryFiveYears(t *testing.T) {
monthOrder := []struct {
month int
leap bool
}{
{10, false},
{11, false},
{12, false},
{1, false},
{2, false},
{3, false},
{4, false},
{5, false},
{6, false},
{7, false},
{8, false},
{9, false},
{9, true},
}
testData := []struct {
lunarYear int
starts []solarYMD
}{
{lunarYear: -220, starts: []solarYMD{{-221, 10, 31}, {-221, 11, 30}, {-221, 12, 29}, {-220, 1, 28}, {-220, 2, 27}, {-220, 3, 27}, {-220, 4, 26}, {-220, 5, 25}, {-220, 6, 24}, {-220, 7, 23}, {-220, 8, 22}, {-220, 9, 20}, {-220, 10, 20}}},
{lunarYear: -215, starts: []solarYMD{{-216, 11, 4}, {-216, 12, 4}, {-215, 1, 2}, {-215, 2, 1}, {-215, 3, 2}, {-215, 4, 1}, {-215, 5, 1}, {-215, 5, 30}, {-215, 6, 29}, {-215, 7, 28}, {-215, 8, 27}, {-215, 9, 25}, {-215, 10, 25}}},
{lunarYear: -210, starts: []solarYMD{{-211, 11, 9}, {-211, 12, 9}, {-210, 1, 7}, {-210, 2, 6}, {-210, 3, 7}, {-210, 4, 6}, {-210, 5, 5}, {-210, 6, 4}, {-210, 7, 3}, {-210, 8, 2}, {-210, 9, 1}, {-210, 9, 30}}},
{lunarYear: -205, starts: []solarYMD{{-206, 11, 14}, {-206, 12, 14}, {-205, 1, 12}, {-205, 2, 11}, {-205, 3, 12}, {-205, 4, 11}, {-205, 5, 10}, {-205, 6, 9}, {-205, 7, 8}, {-205, 8, 7}, {-205, 9, 5}, {-205, 10, 5}}},
{lunarYear: -200, starts: []solarYMD{{-201, 11, 19}, {-201, 12, 18}, {-200, 1, 17}, {-200, 2, 15}, {-200, 3, 16}, {-200, 4, 15}, {-200, 5, 14}, {-200, 6, 13}, {-200, 7, 12}, {-200, 8, 11}, {-200, 9, 9}, {-200, 10, 9}}},
{lunarYear: -195, starts: []solarYMD{{-196, 11, 23}, {-196, 12, 22}, {-195, 1, 21}, {-195, 2, 19}, {-195, 3, 21}, {-195, 4, 19}, {-195, 5, 19}, {-195, 6, 18}, {-195, 7, 17}, {-195, 8, 16}, {-195, 9, 14}, {-195, 10, 14}}},
{lunarYear: -190, starts: []solarYMD{{-191, 10, 29}, {-191, 11, 28}, {-191, 12, 27}, {-190, 1, 26}, {-190, 2, 24}, {-190, 3, 26}, {-190, 4, 24}, {-190, 5, 24}, {-190, 6, 22}, {-190, 7, 22}, {-190, 8, 21}, {-190, 9, 19}, {-190, 10, 19}}},
{lunarYear: -185, starts: []solarYMD{{-186, 11, 3}, {-186, 12, 3}, {-185, 1, 1}, {-185, 1, 31}, {-185, 3, 1}, {-185, 3, 31}, {-185, 4, 29}, {-185, 5, 29}, {-185, 6, 27}, {-185, 7, 27}, {-185, 8, 25}, {-185, 9, 24}, {-185, 10, 23}}},
{lunarYear: -180, starts: []solarYMD{{-181, 11, 8}, {-181, 12, 8}, {-180, 1, 6}, {-180, 2, 5}, {-180, 3, 5}, {-180, 4, 4}, {-180, 5, 3}, {-180, 6, 2}, {-180, 7, 1}, {-180, 7, 31}, {-180, 8, 29}, {-180, 9, 28}}},
{lunarYear: -175, starts: []solarYMD{{-176, 11, 12}, {-176, 12, 11}, {-175, 1, 10}, {-175, 2, 9}, {-175, 3, 10}, {-175, 4, 9}, {-175, 5, 8}, {-175, 6, 7}, {-175, 7, 6}, {-175, 8, 5}, {-175, 9, 3}, {-175, 10, 3}}},
{lunarYear: -170, starts: []solarYMD{{-171, 11, 17}, {-171, 12, 16}, {-170, 1, 15}, {-170, 2, 13}, {-170, 3, 15}, {-170, 4, 14}, {-170, 5, 13}, {-170, 6, 12}, {-170, 7, 11}, {-170, 8, 10}, {-170, 9, 8}, {-170, 10, 8}}},
{lunarYear: -165, starts: []solarYMD{{-166, 11, 22}, {-166, 12, 21}, {-165, 1, 20}, {-165, 2, 18}, {-165, 3, 20}, {-165, 4, 18}, {-165, 5, 18}, {-165, 6, 16}, {-165, 7, 16}, {-165, 8, 15}, {-165, 9, 13}, {-165, 10, 13}}},
{lunarYear: -160, starts: []solarYMD{{-161, 11, 27}, {-161, 12, 26}, {-160, 1, 25}, {-160, 2, 23}, {-160, 3, 24}, {-160, 4, 22}, {-160, 5, 22}, {-160, 6, 20}, {-160, 7, 20}, {-160, 8, 18}, {-160, 9, 17}, {-160, 10, 16}}},
{lunarYear: -155, starts: []solarYMD{{-156, 11, 1}, {-156, 12, 1}, {-156, 12, 30}, {-155, 1, 29}, {-155, 2, 27}, {-155, 3, 29}, {-155, 4, 27}, {-155, 5, 27}, {-155, 6, 25}, {-155, 7, 25}, {-155, 8, 23}, {-155, 9, 22}, {-155, 10, 21}}},
{lunarYear: -150, starts: []solarYMD{{-151, 11, 6}, {-151, 12, 5}, {-150, 1, 4}, {-150, 2, 3}, {-150, 3, 4}, {-150, 4, 3}, {-150, 5, 2}, {-150, 6, 1}, {-150, 6, 30}, {-150, 7, 30}, {-150, 8, 28}, {-150, 9, 27}, {-150, 10, 26}}},
{lunarYear: -145, starts: []solarYMD{{-146, 11, 11}, {-146, 12, 10}, {-145, 1, 9}, {-145, 2, 7}, {-145, 3, 9}, {-145, 4, 8}, {-145, 5, 7}, {-145, 6, 6}, {-145, 7, 5}, {-145, 8, 4}, {-145, 9, 2}, {-145, 10, 2}}},
{lunarYear: -140, starts: []solarYMD{{-141, 11, 16}, {-141, 12, 15}, {-140, 1, 14}, {-140, 2, 12}, {-140, 3, 13}, {-140, 4, 11}, {-140, 5, 11}, {-140, 6, 9}, {-140, 7, 9}, {-140, 8, 8}, {-140, 9, 6}, {-140, 10, 6}}},
{lunarYear: -135, starts: []solarYMD{{-136, 11, 20}, {-136, 12, 19}, {-135, 1, 18}, {-135, 2, 16}, {-135, 3, 18}, {-135, 4, 16}, {-135, 5, 16}, {-135, 6, 14}, {-135, 7, 14}, {-135, 8, 12}, {-135, 9, 11}, {-135, 10, 11}}},
{lunarYear: -130, starts: []solarYMD{{-131, 11, 25}, {-131, 12, 24}, {-130, 1, 23}, {-130, 2, 21}, {-130, 3, 23}, {-130, 4, 21}, {-130, 5, 21}, {-130, 6, 19}, {-130, 7, 19}, {-130, 8, 17}, {-130, 9, 16}, {-130, 10, 15}}},
{lunarYear: -125, starts: []solarYMD{{-126, 10, 31}, {-126, 11, 30}, {-126, 12, 29}, {-125, 1, 28}, {-125, 2, 26}, {-125, 3, 28}, {-125, 4, 26}, {-125, 5, 26}, {-125, 6, 24}, {-125, 7, 24}, {-125, 8, 22}, {-125, 9, 21}, {-125, 10, 20}}},
{lunarYear: -120, starts: []solarYMD{{-121, 11, 5}, {-121, 12, 4}, {-120, 1, 3}, {-120, 2, 1}, {-120, 3, 2}, {-120, 4, 1}, {-120, 4, 30}, {-120, 5, 30}, {-120, 6, 28}, {-120, 7, 28}, {-120, 8, 26}, {-120, 9, 25}, {-120, 10, 24}}},
{lunarYear: -115, starts: []solarYMD{{-116, 11, 9}, {-116, 12, 8}, {-115, 1, 7}, {-115, 2, 5}, {-115, 3, 7}, {-115, 4, 5}, {-115, 5, 5}, {-115, 6, 4}, {-115, 7, 3}, {-115, 8, 2}, {-115, 8, 31}, {-115, 9, 30}}},
{lunarYear: -110, starts: []solarYMD{{-111, 11, 14}, {-111, 12, 13}, {-110, 1, 12}, {-110, 2, 10}, {-110, 3, 12}, {-110, 4, 10}, {-110, 5, 10}, {-110, 6, 8}, {-110, 7, 8}, {-110, 8, 6}, {-110, 9, 5}, {-110, 10, 5}}},
{lunarYear: -105, starts: []solarYMD{{-106, 11, 19}, {-106, 12, 18}, {-105, 1, 17}, {-105, 2, 15}, {-105, 3, 17}, {-105, 4, 15}, {-105, 5, 15}, {-105, 6, 13}, {-105, 7, 13}, {-105, 8, 11}, {-105, 9, 10}, {-105, 10, 9}}},
{lunarYear: -104, starts: []solarYMD{{-105, 11, 8}, {-105, 12, 8}, {-104, 1, 6}, {-104, 2, 5}, {-104, 3, 5}, {-104, 4, 4}, {-104, 5, 3}, {-104, 6, 2}, {-104, 7, 1}, {-104, 7, 31}, {-104, 8, 29}, {-104, 9, 28}, {-104, 10, 27}}},
}
for _, tc := range testData {
if len(tc.starts) < 12 || len(tc.starts) > len(monthOrder) {
t.Fatal(tc.lunarYear, len(tc.starts))
}
for i, start := range tc.starts {
expectedMonth := monthOrder[i]
res, err := SolarToLunarByYMD(start.year, start.month, start.day)
if err != nil {
t.Fatal(tc.lunarYear, start, err)
}
lunar := res.Lunar()
if lunar.LunarYear() != tc.lunarYear || lunar.LunarMonth() != expectedMonth.month || lunar.LunarDay() != 1 || lunar.IsLeap() != expectedMonth.leap {
t.Fatal(tc.lunarYear, start, lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap())
}
if expectedMonth.leap && (lunar.LunarMonth() != 9 || !lunar.IsLeap() || lunar.MonthDay() != "后九月初一") {
t.Fatal(tc.lunarYear, start, lunar.LunarMonth(), lunar.IsLeap(), lunar.MonthDay())
}
solar, err := LunarToSolarByYMDWithCalendar(tc.lunarYear, expectedMonth.month, 1, expectedMonth.leap, AncientCalendarQinHan)
if err != nil {
t.Fatal(tc.lunarYear, expectedMonth, err)
}
if solar.Time().Year() != start.year || int(solar.Time().Month()) != start.month || solar.Time().Day() != start.day {
t.Fatal(tc.lunarYear, expectedMonth, solar.Time(), start)
}
}
}
}
func Test_ChineseCalendarQinHanHouJiuYueParse(t *testing.T) {
testData := []struct {
desc string
year int
month int
day int
}{
{desc: "-201年后九月初一", year: -201, month: 10, day: 20},
{desc: "-201年後九月初一", year: -201, month: 10, day: 20},
{desc: "-104年后九月初一", year: -104, month: 10, day: 27},
{desc: "-104年後九月初一", year: -104, month: 10, day: 27},
}
for _, tc := range testData {
results, err := LunarToSolar(tc.desc)
if err != nil {
t.Fatal(tc.desc, err)
}
if len(results) != 1 {
t.Fatal(tc.desc, len(results))
}
solar := results[0].Time()
lunar := results[0].Lunar()
if solar.Year() != tc.year || int(solar.Month()) != tc.month || solar.Day() != tc.day {
t.Fatal(tc.desc, solar)
}
if lunar.LunarYear() != tc.year || lunar.LunarMonth() != 9 || lunar.LunarDay() != 1 || !lunar.IsLeap() {
t.Fatal(tc.desc, lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap())
}
if lunar.MonthDay() != "后九月初一" {
t.Fatal(tc.desc, lunar.MonthDay())
}
if lunar.CalendarSystem() != AncientCalendarQinHan {
t.Fatal(tc.desc, lunar.CalendarSystem())
}
}
for _, desc := range []string{"2020年后四月初一", "2020年后九月初一", "元丰六年后九月初一"} {
if _, err := LunarToSolar(desc); err == nil {
t.Fatal("expected invalid hou month to be rejected:", desc)
}
}
if _, err := LunarToSolarWithCalendar("-250年后九月初一", AncientCalendarZhou); err == nil {
t.Fatal("expected explicit Zhou calendar to reject hou month")
}
}
func Test_ChineseCalendarNegativeGanZhiDayIndex(t *testing.T) {
lunar, err := SolarToLunarByYMD(-201, 1, 28)
if err != nil {
t.Fatal(err)
}
if got := lunar.Lunar().GanZhiDay(); got != "癸亥" {
t.Fatalf("unexpected gan zhi day: got %q want %q", got, "癸亥")
}
if got := GanZhiOfDay(time.Date(-201, time.January, 28, 0, 0, 0, 0, getCst())); got != "癸亥" {
t.Fatalf("unexpected direct gan zhi day: got %q want %q", got, "癸亥")
}
}
func Test_ChineseCalendarCalendricalJieQi(t *testing.T) {
testData := []struct {
name string
year int
term int
system AncientCalendarSystem
want solarYMD
}{
{name: "qin han xiaoxue", year: -202, term: JQ_小雪, system: AncientCalendarQinHan, want: solarYMD{-202, 11, 24}},
{name: "qin han dongzhi", year: -202, term: JQ_冬至, system: AncientCalendarQinHan, want: solarYMD{-202, 12, 25}},
{name: "qin han xiazhi", year: -201, term: JQ_夏至, system: AncientCalendarQinHan, want: solarYMD{-201, 6, 25}},
{name: "zhou dongzhi", year: -387, term: JQ_冬至, system: AncientCalendarZhou, want: solarYMD{-387, 12, 25}},
{name: "default han qing xiaohan", year: -103, term: JQ_小寒, system: AncientCalendarDefault, want: solarYMD{-103, 1, 9}},
{name: "default han qing lichun", year: -103, term: JQ_立春, system: AncientCalendarDefault, want: solarYMD{-103, 2, 8}},
{name: "default han qing dongzhi", year: -103, term: JQ_冬至, system: AncientCalendarDefault, want: solarYMD{-103, 12, 25}},
{name: "default han qing exception", year: 445, term: JQ_立春, system: AncientCalendarDefault, want: solarYMD{445, 2, 3}},
{name: "default han qing cross row", year: 1582, term: JQ_小寒, system: AncientCalendarDefault, want: solarYMD{1581, 12, 27}},
{name: "default han qing gregorian handoff", year: 1582, term: JQ_冬至, system: AncientCalendarDefault, want: solarYMD{1582, 12, 22}},
{name: "default han qing upper xiaohan", year: 1912, term: JQ_小寒, system: AncientCalendarDefault, want: solarYMD{1912, 1, 7}},
{name: "default han qing upper dongzhi", year: 1912, term: JQ_冬至, system: AncientCalendarDefault, want: solarYMD{1912, 12, 22}},
}
for _, tc := range testData {
t.Run(tc.name, func(t *testing.T) {
got, err := CalendricalJieQiWithCalendar(tc.year, tc.term, tc.system)
if err != nil {
t.Fatal(err)
}
assertCalendricalJieQiDate(t, got, tc.want)
})
}
got, err := CalendricalJieQi(-202, JQ_冬至)
if err != nil {
t.Fatal(err)
}
assertCalendricalJieQiDate(t, got, solarYMD{-202, 12, 25})
earlyDefault, err := CalendricalJieQi(-221, JQ_霜降)
if err != nil {
t.Fatal(err)
}
earlyZhou, err := CalendricalJieQiWithCalendar(-221, JQ_霜降, AncientCalendarZhou)
if err != nil {
t.Fatal(err)
}
if !earlyDefault.Equal(earlyZhou) || !earlyDefault.Before(qinHanStartDate()) {
t.Fatalf("unexpected default -221 pre-transition term: default=%s zhou=%s", earlyDefault, earlyZhou)
}
lateDefault, err := CalendricalJieQi(-221, JQ_立冬)
if err != nil {
t.Fatal(err)
}
lateQinHan, err := CalendricalJieQiWithCalendar(-221, JQ_立冬, AncientCalendarQinHan)
if err != nil {
t.Fatal(err)
}
if !lateDefault.Equal(lateQinHan) || lateDefault.Before(qinHanStartDate()) {
t.Fatalf("unexpected default -221 post-transition term: default=%s qinHan=%s", lateDefault, lateQinHan)
}
}
func Test_ChineseCalendarCalendricalJieQiBoundaries(t *testing.T) {
if _, err := CalendricalJieQiWithCalendar(-104, JQ_春分, AncientCalendarQinHan); err != nil {
t.Fatal(err)
}
if _, err := CalendricalJieQiWithCalendar(-500, JQ_冬至, AncientCalendarChunqiu); err == nil {
t.Fatal("expected Chunqiu calendrical solar terms to be unsupported")
}
if _, err := CalendricalJieQiWithCalendar(-221, JQ_霜降, AncientCalendarQinHan); err == nil {
t.Fatal("expected explicit QinHan solar terms before adoption to be rejected")
}
if _, err := CalendricalJieQiWithCalendar(-103, JQ_冬至, AncientCalendarQinHan); err == nil {
t.Fatal("expected explicit QinHan solar terms after range to be rejected")
}
if _, err := CalendricalJieQiWithCalendar(2026, JQ_冬至, AncientCalendarZhou); err == nil {
t.Fatal("expected explicit ancient calendar to reject modern solar-term year")
}
got, err := CalendricalJieQi(-103, JQ_冬至)
if err != nil {
t.Fatal(err)
}
assertCalendricalJieQiDate(t, got, solarYMD{-103, 12, 25})
if _, err := CalendricalJieQi(1913, JQ_冬至); err == nil {
t.Fatal("expected default calendrical solar terms to reject years after table")
}
if _, err := CalendricalJieQi(-202, 7); err == nil {
t.Fatal("expected invalid solar-term angle to be rejected")
}
}
func assertCalendricalJieQiDate(t *testing.T, got time.Time, want solarYMD) {
t.Helper()
if got.Year() != want.year || int(got.Month()) != want.month || got.Day() != want.day {
t.Fatalf("date mismatch: got %04d-%02d-%02d want %04d-%02d-%02d",
got.Year(), got.Month(), got.Day(), want.year, want.month, want.day)
}
if got.Hour() != 0 || got.Minute() != 0 || got.Second() != 0 || got.Nanosecond() != 0 {
t.Fatalf("expected midnight, got %s", got)
}
if _, offset := got.Zone(); offset != 8*3600 {
t.Fatalf("expected UTC+8, got %s", got)
}
}
func Test_ChineseCalendarAncientNegativeYearDescRoundtrip(t *testing.T) {
res, err := SolarToLunarByYMD(-251, 11, 30)
if err != nil {
t.Fatal(err)
}
descs := res.LunarDesc()
if len(descs) != 1 || descs[0] != "负二五零年正月初一" {
t.Fatalf("unexpected descs: %v", descs)
}
for _, desc := range []string{descs[0], "負二五零年正月初一"} {
results, err := LunarToSolar(desc)
if err != nil {
t.Fatal(desc, err)
}
if len(results) != 1 {
t.Fatal(desc, len(results))
}
solar := results[0].Solar()
if solar.Year() != -251 || int(solar.Month()) != 11 || solar.Day() != 30 {
t.Fatal(desc, solar)
}
lunar := results[0].Lunar()
if lunar.LunarYear() != -250 || lunar.LunarMonth() != 1 || lunar.LunarDay() != 1 || lunar.IsLeap() {
t.Fatal(desc, lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap())
}
}
}
func Test_ChineseCalendarAncientPreQin(t *testing.T) {
testData := []struct {
name string
system AncientCalendarSystem
lyear int
lmonth int
lday int
leap bool
year int
month int
day int
desc string
}{
{name: "default zhou", system: AncientCalendarDefault, lyear: -250, lmonth: 1, lday: 1, year: -251, month: 11, day: 30, desc: "正月初一"},
{name: "zhou", system: AncientCalendarZhou, lyear: -250, lmonth: 1, lday: 1, year: -251, month: 11, day: 30, desc: "正月初一"},
{name: "lu", system: AncientCalendarLu, lyear: -250, lmonth: 1, lday: 1, year: -251, month: 12, day: 1, desc: "正月初一"},
{name: "yin", system: AncientCalendarYin, lyear: -250, lmonth: 1, lday: 1, year: -250, month: 1, day: 29, desc: "正月初一"},
{name: "zhuanxu", system: AncientCalendarZhuanxu, lyear: -250, lmonth: 10, lday: 1, year: -251, month: 11, day: 1, desc: "十月初一"},
{name: "chunqiu", system: AncientCalendarChunqiu, lyear: -500, lmonth: 1, lday: 1, year: -501, month: 12, day: 5, desc: "正月初一"},
}
for _, tc := range testData {
t.Run(tc.name, func(t *testing.T) {
var res Time
var err error
if tc.system == AncientCalendarDefault {
res, err = LunarToSolarByYMD(tc.lyear, tc.lmonth, tc.lday, tc.leap)
} else {
res, err = LunarToSolarByYMDWithCalendar(tc.lyear, tc.lmonth, tc.lday, tc.leap, tc.system)
}
if err != nil {
t.Fatal(err)
}
if res.Solar().Year() != tc.year || int(res.Solar().Month()) != tc.month || res.Solar().Day() != tc.day {
t.Fatalf("solar mismatch: got %04d-%02d-%02d want %04d-%02d-%02d",
res.Solar().Year(), res.Solar().Month(), res.Solar().Day(), tc.year, tc.month, tc.day)
}
lunar := res.Lunar()
if lunar.LunarYear() != tc.lyear || lunar.LunarMonth() != tc.lmonth || lunar.LunarDay() != tc.lday || lunar.IsLeap() != tc.leap {
t.Fatalf("lunar mismatch: got y=%d m=%d d=%d leap=%v", lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap())
}
if lunar.MonthDay() != tc.desc {
t.Fatalf("desc mismatch: got %q want %q", lunar.MonthDay(), tc.desc)
}
if lunar.GanZhiMonth() != "" {
t.Fatalf("unexpected ancient ganzhi month: %q", lunar.GanZhiMonth())
}
if tc.system != AncientCalendarDefault && lunar.CalendarSystem() != tc.system {
t.Fatalf("system mismatch: got %q want %q", lunar.CalendarSystem(), tc.system)
}
infos := res.LunarInfo()
if len(infos) != 1 || infos[0].CalendarSystem != lunar.CalendarSystem() || infos[0].CalendarName != lunar.CalendarName() {
t.Fatalf("lunar info calendar mismatch: %#v", infos)
}
var back Time
if tc.system == AncientCalendarDefault {
back, err = SolarToLunarByYMD(tc.year, tc.month, tc.day)
} else {
back, err = SolarToLunarByYMDWithCalendar(tc.year, tc.month, tc.day, tc.system)
}
if err != nil {
t.Fatal(err)
}
backLunar := back.Lunar()
if backLunar.LunarYear() != tc.lyear || backLunar.LunarMonth() != tc.lmonth || backLunar.LunarDay() != tc.lday || backLunar.IsLeap() != tc.leap {
t.Fatalf("roundtrip lunar mismatch: got y=%d m=%d d=%d leap=%v", backLunar.LunarYear(), backLunar.LunarMonth(), backLunar.LunarDay(), backLunar.IsLeap())
}
})
}
}
func Test_ChineseCalendarAncientWithCalendarBoundaries(t *testing.T) {
if _, err := SolarToLunarByYMDWithCalendar(2026, 1, 1, AncientCalendarZhou); err == nil {
t.Fatal("expected explicit ancient calendar to reject modern year")
}
if _, err := SolarToLunarByYMD(-722, 1, 1); err == nil {
t.Fatal("expected default pre-Qin route to reject years before -721")
}
lower, err := SolarToLunarByYMD(-721, 1, 1)
if err != nil {
t.Fatal(err)
}
lowerLunar := lower.Lunar()
if lowerLunar.LunarYear() != -722 || lowerLunar.LunarMonth() != 12 || lowerLunar.LunarDay() != 16 || lowerLunar.CalendarSystem() != AncientCalendarChunqiu {
t.Fatalf("unexpected -721 lower boundary lunar: y=%d m=%d d=%d system=%q",
lowerLunar.LunarYear(), lowerLunar.LunarMonth(), lowerLunar.LunarDay(), lowerLunar.CalendarSystem())
}
lowerBack, err := LunarToSolarByYMD(-722, 12, 16, false)
if err != nil {
t.Fatal(err)
}
if lowerBack.Solar().Year() != -721 || int(lowerBack.Solar().Month()) != 1 || lowerBack.Solar().Day() != 1 {
t.Fatalf("unexpected -722 boundary roundtrip: %v", lowerBack.Solar())
}
if _, err := LunarToSolarByYMD(-722, 1, 1, false); err == nil {
t.Fatal("expected N_-722 dates before supported civil range to be rejected")
}
results, err := LunarToSolarWithCalendar("-250年正月初一", AncientCalendarLu)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Solar().Year() != -251 || int(results[0].Solar().Month()) != 12 || results[0].Solar().Day() != 1 {
t.Fatalf("unexpected LunarToSolarWithCalendar result: %#v", results)
}
defaultResults, err := LunarToSolar("-250年正月初一")
if err != nil {
t.Fatal(err)
}
if len(defaultResults) != 1 || defaultResults[0].Solar().Year() != -251 || int(defaultResults[0].Solar().Month()) != 11 || defaultResults[0].Solar().Day() != 30 {
t.Fatalf("unexpected default LunarToSolar result: %#v", defaultResults)
}
transition, err := SolarToLunarByYMD(-221, 1, 1)
if err != nil {
t.Fatal(err)
}
if transition.Lunar().CalendarSystem() != AncientCalendarZhou {
t.Fatalf("expected -221 early date to use Zhou fallback, got %q", transition.Lunar().CalendarSystem())
}
zhouTransition, err := SolarToLunarByYMDWithCalendar(-221, 11, 29, AncientCalendarZhou)
if err != nil {
t.Fatal(err)
}
zhouLunar := zhouTransition.Lunar()
if zhouLunar.LunarYear() != -220 || zhouLunar.LunarMonth() != 1 || zhouLunar.LunarDay() != 1 || zhouLunar.CalendarSystem() != AncientCalendarZhou {
t.Fatalf("unexpected explicit Zhou -221 transition: y=%d m=%d d=%d system=%q",
zhouLunar.LunarYear(), zhouLunar.LunarMonth(), zhouLunar.LunarDay(), zhouLunar.CalendarSystem())
}
zhouBack, err := LunarToSolarByYMDWithCalendar(-220, 1, 1, false, AncientCalendarZhou)
if err != nil {
t.Fatal(err)
}
if zhouBack.Solar().Year() != -221 || int(zhouBack.Solar().Month()) != 11 || zhouBack.Solar().Day() != 29 {
t.Fatalf("unexpected explicit Zhou N_-220 roundtrip: %v", zhouBack.Solar())
}
if _, err := LunarToSolarByYMDWithCalendar(-220, 3, 1, false, AncientCalendarZhou); err == nil {
t.Fatal("expected explicit Zhou N_-220 dates after supported civil range to be rejected")
}
}
func Test_ChineseCalendarAncient(t *testing.T) {
var testData = []lunarSolar{
{Lyear: -103, Lmonth: 1, Lday: 1, Leap: false, Year: -103, Month: 2, Day: 22, Desc: "太初元年正月初一", GanZhiYear: "丁丑", GanZhiMonth: "壬寅", GanZhiDay: "癸亥"},

View File

@ -28,6 +28,10 @@ type LunarInfo struct {
GanzhiMonth string `json:"ganzhiMonth"`
// GanzhiDay 农历日干支
GanzhiDay string `json:"ganzhiDay"`
// CalendarSystem 历法系统
CalendarSystem AncientCalendarSystem `json:"calendarSystem"`
// CalendarName 历法名称
CalendarName string `json:"calendarName"`
// Dynasty 朝代,如唐、宋、元、明、清等
Dynasty string `json:"dynasty"`
// Emperor 皇帝姓名(仅供参考,多个皇帝用同一个年号的场景,此处不准)
@ -52,6 +56,7 @@ type Time struct {
// Solar 公历时间 / solar time.
//
// 返回内部保存的公历 `time.Time`,不做时区或历法再计算。
// Returns the stored civil `time.Time` directly, without any further time-zone or calendar conversion.
func (t Time) Solar() time.Time {
return t.solarTime
}
@ -59,6 +64,7 @@ func (t Time) Solar() time.Time {
// Time 公历时间 / solar time.
//
// 是 `Solar` 的同义接口,便于把 `calendar.Time` 当作普通时间对象使用。
// This is an alias of `Solar`, convenient when `calendar.Time` is used as a regular time object.
func (t Time) Time() time.Time {
return t.solarTime
}
@ -66,6 +72,7 @@ func (t Time) Time() time.Time {
// Lunars 农历候选结果 / lunar candidates.
//
// 返回全部候选农历结果。
// Returns all candidate lunar-calendar results.
func (t Time) Lunars() []LunarTime {
return t.lunars
}
@ -73,6 +80,7 @@ func (t Time) Lunars() []LunarTime {
// LunarDesc 农历描述 / lunar descriptions.
//
// 返回全部候选结果的农历描述,如开元二年正月初一;若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the lunar-date descriptions for all candidates. If no era name is available, the year is described directly.
func (t Time) LunarDesc() []string {
var res []string
for _, v := range t.lunars {
@ -84,6 +92,7 @@ func (t Time) LunarDesc() []string {
// LunarDescWithEmperor 含君主信息的农历描述 / lunar descriptions with emperor.
//
// 返回全部候选结果中含有君主信息的农历描述,如唐玄宗 开元二年正月初一;若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the candidate descriptions with emperor names when available. If no era name is available, the year is described directly.
func (t Time) LunarDescWithEmperor() []string {
var res []string
for _, v := range t.lunars {
@ -95,6 +104,7 @@ func (t Time) LunarDescWithEmperor() []string {
// LunarDescWithDynasty 含朝代信息的农历描述 / lunar descriptions with dynasty.
//
// 返回全部候选结果中含有朝代信息的农历描述,如唐 开元二年正月初一;若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the candidate descriptions with dynasty names when available. If no era name is available, the year is described directly.
func (t Time) LunarDescWithDynasty() []string {
var res []string
for _, v := range t.lunars {
@ -106,6 +116,7 @@ func (t Time) LunarDescWithDynasty() []string {
// LunarDescWithDynastyAndEmperor 含朝代与君主信息的农历描述 / lunar descriptions with dynasty and emperor.
//
// 返回全部候选结果中含有朝代和君主信息的农历描述,如唐 唐玄宗 开元二年正月初一;若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the candidate descriptions with both dynasty and emperor names when available. If no era name is available, the year is described directly.
func (t Time) LunarDescWithDynastyAndEmperor() []string {
var res []string
for _, v := range t.lunars {
@ -117,6 +128,7 @@ func (t Time) LunarDescWithDynastyAndEmperor() []string {
// LunarInfo 农历结构化信息 / structured lunar information.
//
// 返回全部候选结果对应的结构化农历信息切片。
// Returns the structured lunar-calendar information for all candidates.
func (t Time) LunarInfo() []LunarInfo {
var res []LunarInfo
for _, v := range t.lunars {
@ -128,6 +140,7 @@ func (t Time) LunarInfo() []LunarInfo {
// Eras 朝代、皇帝、年号信息 / era information.
//
// 返回全部候选结果对应的朝代、皇帝、年号信息。
// Returns the dynasty, emperor, and era-name records associated with all candidates.
func (t Time) Eras() []EraDesc {
var res []EraDesc
for _, v := range t.lunars {
@ -139,6 +152,7 @@ func (t Time) Eras() []EraDesc {
// Lunar 首个农历结果 / first lunar result.
//
// 若存在多个候选结果,只返回第一个;无结果时返回零值 `LunarTime`。
// Returns only the first candidate when multiple results exist. A zero-value `LunarTime` is returned when no result is available.
func (t Time) Lunar() LunarTime {
if len(t.lunars) > 0 {
return t.lunars[0]
@ -149,6 +163,7 @@ func (t Time) Lunar() LunarTime {
// Add 时间偏移 / add a duration.
//
// 返回公历时间偏移后的农历结果。
// Returns the lunar-calendar result after applying the duration to the stored civil time.
func (t Time) Add(d time.Duration) Time {
if d < time.Second {
newT := t.solarTime.Add(d)
@ -179,6 +194,12 @@ type LunarTime struct {
comment string
//ganzhi of month 月干支
ganzhiMonth string
//后九月
houMonth bool
//历法系统
calendarSystem AncientCalendarSystem
//历法名称
calendarName string
eras []EraDesc
}
@ -233,9 +254,20 @@ func (l LunarTime) IsLeap() bool {
return l.leap
}
// CalendarSystem 历法系统 / calendar system.
func (l LunarTime) CalendarSystem() AncientCalendarSystem {
return l.calendarSystem
}
// CalendarName 历法名称 / calendar name.
func (l LunarTime) CalendarName() string {
return l.calendarName
}
// Eras 朝代、皇帝、年号信息 / era information.
//
// 返回该农历结果对应的朝代、皇帝、年号信息。
// Returns the dynasty, emperor, and era-name records associated with this lunar result.
func (l LunarTime) Eras() []EraDesc {
return l.eras
}
@ -243,6 +275,7 @@ func (l LunarTime) Eras() []EraDesc {
// MonthDay 农历月日描述 / lunar month-day description.
//
// 获取农历月日描述,如正月初一。此处,十一月表示为冬月,十二月表示为腊月。
// Returns the lunar month-day description, such as `正月初一`. In this package, month 11 is written as `冬月` and month 12 as `腊月`.
func (l LunarTime) MonthDay() string {
return l.desc
}
@ -250,6 +283,7 @@ func (l LunarTime) MonthDay() string {
// LunarDesc 农历描述 / lunar descriptions.
//
// 获取农历描述,如开元二年正月初一,若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the lunar-date descriptions for this result. If no era name is available, the year is described directly.
func (l LunarTime) LunarDesc() []string {
return l.innerDescWithNianHao(false, false)
}
@ -258,6 +292,7 @@ func (l LunarTime) LunarDesc() []string {
//
// 获取含有君主信息的农历描述,如唐玄宗 开元二年正月初一,若无年号,则返回年份描述,如二零二五年正月初一。
// 君主信息仅供参考,多个皇帝用同一个年号的场景,此处不准
// Returns the lunar-date descriptions with emperor names when available. Emperor names are for reference only and may be ambiguous when multiple emperors used the same era name.
func (l LunarTime) LunarDescWithEmperor() []string {
return l.innerDescWithNianHao(true, false)
}
@ -265,6 +300,7 @@ func (l LunarTime) LunarDescWithEmperor() []string {
// LunarDescWithDynasty 含朝代信息的农历描述 / lunar descriptions with dynasty.
//
// 获取含有朝代信息的农历描述,如唐 开元二年正月初一,若无年号,则返回年份描述,如二零二五年正月初一。
// Returns the lunar-date descriptions with dynasty names when available. If no era name is available, the year is described directly.
func (l LunarTime) LunarDescWithDynasty() []string {
return l.innerDescWithNianHao(false, true)
}
@ -273,6 +309,7 @@ func (l LunarTime) LunarDescWithDynasty() []string {
//
// 获取含有朝代和君主信息的农历描述,如唐 唐玄宗 开元二年正月初一,若无年号,则返回年份描述,如二零二五年正月初一。
// 君主信息仅供参考,多个皇帝用同一个年号的场景,此处不准
// Returns the lunar-date descriptions with both dynasty and emperor names when available. Emperor names are for reference only and may be ambiguous when multiple emperors used the same era name.
func (l LunarTime) LunarDescWithDynastyAndEmperor() []string {
return l.innerDescWithNianHao(true, true)
}
@ -299,6 +336,7 @@ func (l LunarTime) innerDescWithNianHao(withEmperor bool, withDynasty bool) []st
// LunarInfo 农历结构化信息 / structured lunar information.
//
// 返回该农历结果对应的结构化农历信息切片;若存在多个并行年号,则会有多条记录。
// Returns the structured lunar-calendar information for this result. Multiple records are returned when parallel era-name interpretations exist.
func (l LunarTime) LunarInfo() []LunarInfo {
var res []LunarInfo
for _, v := range l.eras {
@ -313,6 +351,8 @@ func (l LunarTime) LunarInfo() []LunarInfo {
GanzhiYear: GanZhiOfYear(l.year),
GanzhiMonth: l.ganzhiMonth,
GanzhiDay: GanZhiOfDay(l.solarDate),
CalendarSystem: l.calendarSystem,
CalendarName: l.calendarName,
Dynasty: v.Dynasty,
Emperor: v.Emperor,
Nianhao: v.Nianhao,
@ -335,6 +375,8 @@ func (l LunarTime) LunarInfo() []LunarInfo {
GanzhiYear: GanZhiOfYear(l.year),
GanzhiMonth: l.ganzhiMonth,
GanzhiDay: GanZhiOfDay(l.solarDate),
CalendarSystem: l.calendarSystem,
CalendarName: l.calendarName,
Dynasty: "",
Emperor: "",
Nianhao: "",

View File

@ -5,6 +5,7 @@ import "b612.me/astro/formula"
// AirmassPlaneParallelFromTrueAltitude 平行平板大气质量 / plane-parallel airmass from true altitude.
//
// 输入为真高度角,单位度。适合中高空几何近似,接近地平线时会发散。
// Input is true altitude in degrees. This geometric approximation is suitable at moderate and high altitudes but diverges near the horizon.
func AirmassPlaneParallelFromTrueAltitude(trueAltitude float64) float64 {
return formula.AirmassPlaneParallel(trueAltitude)
}
@ -12,6 +13,7 @@ func AirmassPlaneParallelFromTrueAltitude(trueAltitude float64) float64 {
// AirmassKastenYoungFromApparentAltitude Kasten-Young 大气质量 / Kasten-Young airmass from apparent altitude.
//
// 输入为视高度角,单位度。
// Input is apparent altitude in degrees.
func AirmassKastenYoungFromApparentAltitude(apparentAltitude float64) float64 {
return formula.AirmassKastenYoung(apparentAltitude)
}
@ -19,6 +21,7 @@ func AirmassKastenYoungFromApparentAltitude(apparentAltitude float64) float64 {
// AirmassPickeringFromApparentAltitude Pickering 大气质量 / Pickering airmass from apparent altitude.
//
// 输入为视高度角,单位度。
// Input is apparent altitude in degrees.
func AirmassPickeringFromApparentAltitude(apparentAltitude float64) float64 {
return formula.AirmassPickering(apparentAltitude)
}
@ -26,6 +29,7 @@ func AirmassPickeringFromApparentAltitude(apparentAltitude float64) float64 {
// AirmassKastenYoungFromTrueAltitude Kasten-Young 大气质量 / Kasten-Young airmass from true altitude.
//
// 先用 pressureHPa / temperatureC 估算大气折射,将真高度角换算为视高度角,再代入经验公式。
// First estimates atmospheric refraction from pressureHPa and temperatureC, converts true altitude to apparent altitude, and then applies the empirical formula.
func AirmassKastenYoungFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
return formula.AirmassKastenYoung(ApparentAltitude(trueAltitude, pressureHPa, temperatureC))
}
@ -33,6 +37,7 @@ func AirmassKastenYoungFromTrueAltitude(trueAltitude, pressureHPa, temperatureC
// AirmassPickeringFromTrueAltitude Pickering 大气质量 / Pickering airmass from true altitude.
//
// 先用 pressureHPa / temperatureC 估算大气折射,将真高度角换算为视高度角,再代入经验公式。
// First estimates atmospheric refraction from pressureHPa and temperatureC, converts true altitude to apparent altitude, and then applies the empirical formula.
func AirmassPickeringFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
return formula.AirmassPickering(ApparentAltitude(trueAltitude, pressureHPa, temperatureC))
}

View File

@ -9,6 +9,7 @@ import (
// AtmosphericRefractionFromTrueAltitude 真高度角折射修正 / atmospheric refraction from true altitude.
//
// 输入真高度角,返回应加到真高度角上的大气折射修正量,单位度。
// Takes true altitude and returns the atmospheric-refraction correction to be added to it, in degrees.
func AtmosphericRefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
return basic.RefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC)
}
@ -16,6 +17,7 @@ func AtmosphericRefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatur
// AtmosphericRefractionFromApparentAltitude 视高度角折射修正 / atmospheric refraction from apparent altitude.
//
// 输入视高度角,返回对应的大气折射修正量,单位度。
// Takes apparent altitude and returns the corresponding atmospheric-refraction correction, in degrees.
func AtmosphericRefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
return basic.RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC)
}
@ -23,6 +25,7 @@ func AtmosphericRefractionFromApparentAltitude(apparentAltitude, pressureHPa, te
// ApparentAltitude 真高度角转视高度角 / apparent altitude from true altitude.
//
// 输入真高度角,返回加入标准大气折射后的视高度角,单位度。
// Takes true altitude and returns the apparent altitude after applying standard atmospheric refraction, in degrees.
func ApparentAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
return basic.ApparentAltitude(trueAltitude, pressureHPa, temperatureC)
}
@ -30,6 +33,7 @@ func ApparentAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
// TrueAltitude 视高度角转真高度角 / true altitude from apparent altitude.
//
// 输入视高度角,返回去除大气折射后的真高度角,单位度。
// Takes apparent altitude and returns the true altitude after removing atmospheric refraction, in degrees.
func TrueAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
return basic.TrueAltitude(apparentAltitude, pressureHPa, temperatureC)
}

View File

@ -25,6 +25,8 @@ var icrsToGalacticMatrix = [3][3]float64{
// 返回:
//
// 赤经 RA单位度赤纬 Dec单位度
//
// Returns right ascension and declination in degrees for the supplied ecliptic longitude, latitude, and obliquity.
func EclipticToEquatorialByObliquity(lon, lat, obliquity float64) Equatorial {
sinLon, cosLon := sinCosDeg(lon)
sinLat, cosLat := sinCosDeg(lat)
@ -44,6 +46,8 @@ func EclipticToEquatorialByObliquity(lon, lat, obliquity float64) Equatorial {
// 返回:
//
// 黄经 Lon单位度黄纬 Lat单位度
//
// Returns ecliptic longitude and latitude in degrees for the supplied right ascension, declination, and obliquity.
func EquatorialToEclipticByObliquity(ra, dec, obliquity float64) Ecliptic {
sinRA, cosRA := sinCosDeg(ra)
sinDec, cosDec := sinCosDeg(dec)
@ -63,6 +67,8 @@ func EquatorialToEclipticByObliquity(ra, dec, obliquity float64) Ecliptic {
// 返回:
//
// 方位角 Azimuth正北为0顺时针增加、高度角 Altitude、天顶距 Zenith均为度
//
// Returns azimuth, altitude, and zenith distance in degrees from the supplied hour angle, declination, and site latitude.
func HourAngleDeclinationToHorizontal(hourAngle, declination, latitude float64) Horizontal {
sinLatitude, cosLatitude := sinCosDeg(latitude)
sinDeclination, cosDeclination := sinCosDeg(declination)
@ -87,6 +93,8 @@ func HourAngleDeclinationToHorizontal(hourAngle, declination, latitude float64)
// 返回:
//
// 时角 HourAngle单位度赤纬 Declination单位度
//
// Returns hour angle and declination in degrees from the supplied horizontal coordinates and site latitude.
func HorizontalToHourAngleDeclination(azimuth, altitude, latitude float64) (hourAngle, declination float64) {
sinLatitude, cosLatitude := sinCosDeg(latitude)
sinAltitude, cosAltitude := sinCosDeg(altitude)
@ -111,6 +119,8 @@ func HorizontalToHourAngleDeclination(azimuth, altitude, latitude float64) (hour
// 方位角 Azimuth正北为0顺时针增加、高度角 Altitude、天顶距 Zenith均为度
// 附带返回对应的时角 HourAngle单位度
//
// Returns horizontal coordinates in degrees from local sidereal time, right ascension, declination, and site latitude.
//
// 例:
//
// hz := coord.EquatorialToHorizontalByLocalSiderealTime(10.5, 83.6331, 22.0145, 31.2)
@ -130,6 +140,8 @@ func EquatorialToHorizontalByLocalSiderealTime(localSiderealTimeHours, ra, dec,
//
// 赤经 RA单位度赤纬 Dec单位度
//
// Returns right ascension and declination in degrees from local sidereal time and the supplied horizontal coordinates.
//
// 例:
//
// eq := coord.HorizontalToEquatorialByLocalSiderealTime(10.5, 128.2, 37.6, 31.2)
@ -147,6 +159,8 @@ func HorizontalToEquatorialByLocalSiderealTime(localSiderealTimeHours, azimuth,
// 返回:
//
// 银经 Lon单位度银纬 Lat单位度
//
// Returns galactic longitude and latitude in degrees from ICRS right ascension and declination.
func EquatorialToGalactic(ra, dec float64) Galactic {
vector := sphericalToVector(ra, dec)
rotated := matrixVectorMul(icrsToGalacticMatrix, vector)
@ -162,6 +176,8 @@ func EquatorialToGalactic(ra, dec float64) Galactic {
// 返回:
//
// ICRS 赤经 RA单位度ICRS 赤纬 Dec单位度
//
// Returns ICRS right ascension and declination in degrees from galactic longitude and latitude.
func GalacticToEquatorial(lon, lat float64) Equatorial {
vector := sphericalToVector(lon, lat)
rotated := matrixTransposeVectorMul(icrsToGalacticMatrix, vector)

View File

@ -9,6 +9,7 @@ import (
"b612.me/astro/jupiter"
"b612.me/astro/mars"
"b612.me/astro/mercury"
"b612.me/astro/moon"
"b612.me/astro/neptune"
"b612.me/astro/saturn"
"b612.me/astro/uranus"
@ -88,6 +89,56 @@ func TestPublicPlanetEventBoundaryIncludesCurrent(t *testing.T) {
}
}
func TestPublicMoonPlanetConjunctionBoundaryIncludesCurrent(t *testing.T) {
type eventFuncs struct {
last func(time.Time) time.Time
next func(time.Time) time.Time
}
cases := []struct {
name string
eventUT float64
funcs eventFuncs
}{
{name: "MoonMercuryConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionMercury), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionMercury) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionMercury) },
}},
{name: "MoonVenusConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionVenus), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionVenus) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionVenus) },
}},
{name: "MoonMarsConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionMars), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionMars) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionMars) },
}},
{name: "MoonJupiterConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionJupiter), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionJupiter) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionJupiter) },
}},
{name: "MoonSaturnConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionSaturn), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionSaturn) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionSaturn) },
}},
{name: "MoonUranusConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionUranus), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionUranus) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionUranus) },
}},
{name: "MoonNeptuneConjunction", eventUT: basic.NextMoonPlanetConjunction(eventBoundaryTT(2026), basic.MoonPlanetConjunctionNeptune), funcs: eventFuncs{
last: func(date time.Time) time.Time { return moon.LastConjunctionWithPlanet(date, moon.ConjunctionNeptune) },
next: func(date time.Time) time.Time { return moon.NextConjunctionWithPlanet(date, moon.ConjunctionNeptune) },
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eventTime := basic.JDE2DateByZone(tc.eventUT, time.UTC, false)
assertSameEventTime(t, "last", tc.funcs.last(eventTime), eventTime)
assertSameEventTime(t, "next", tc.funcs.next(eventTime), eventTime)
})
}
}
func eventBoundaryTT(year int) float64 {
return basic.TD2UT(basic.Date2JDE(time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)), true)
}

View File

@ -18,6 +18,8 @@ const (
// 返回:
//
// 峰值波长,单位米
//
// Returns the wavelength of maximum emission in meters for a blackbody at the supplied temperature.
func WienPeakWavelength(temperatureK float64) float64 {
if temperatureK <= 0 || math.IsNaN(temperatureK) || math.IsInf(temperatureK, 0) {
return math.NaN()
@ -32,6 +34,8 @@ func WienPeakWavelength(temperatureK float64) float64 {
// 返回:
//
// 单位面积总出射度,单位 W/m^2
//
// Returns the total radiant exitance in W/m^2 for a blackbody at the supplied temperature.
func StefanBoltzmannFlux(temperatureK float64) float64 {
if temperatureK < 0 || math.IsNaN(temperatureK) || math.IsInf(temperatureK, 0) {
return math.NaN()
@ -48,6 +52,8 @@ func StefanBoltzmannFlux(temperatureK float64) float64 {
//
// 谱辐亮度,单位 W·sr^-1·m^-3
//
// Returns spectral radiance in W·sr^-1·m^-3 at the supplied wavelength and temperature.
//
// 例:
//
// b := formula.PlanckRadianceByWavelength(500e-9, 5772)

View File

@ -11,6 +11,8 @@ import "math"
//
// 会合周期,单位与输入相同
//
// Returns the synodic period in the same unit as the two input periods.
//
// 例:
//
// // 地球与金星的会合周期,单位天

View File

@ -9,6 +9,8 @@ import "math"
// 返回:
//
// 距离模数 m-M
//
// Returns the distance modulus m-M for the supplied distance in parsecs.
func DistanceModulus(distanceParsec float64) float64 {
if distanceParsec <= 0 || math.IsNaN(distanceParsec) || math.IsInf(distanceParsec, 0) {
return math.NaN()
@ -24,6 +26,8 @@ func DistanceModulus(distanceParsec float64) float64 {
// 返回:
//
// 视星等 m
//
// Returns apparent magnitude from absolute magnitude and distance.
func ApparentMagnitudeFromAbsolute(absoluteMagnitude, distanceParsec float64) float64 {
modulus := DistanceModulus(distanceParsec)
if math.IsNaN(modulus) {
@ -40,6 +44,8 @@ func ApparentMagnitudeFromAbsolute(absoluteMagnitude, distanceParsec float64) fl
// 返回:
//
// 绝对星等 M
//
// Returns absolute magnitude from apparent magnitude and distance.
func AbsoluteMagnitudeFromApparent(apparentMagnitude, distanceParsec float64) float64 {
modulus := DistanceModulus(distanceParsec)
if math.IsNaN(modulus) {

View File

@ -16,6 +16,8 @@ const (
// 返回:
//
// 总光度,单位瓦特
//
// Returns stellar luminosity in watts from radius and effective temperature.
func LuminosityFromRadiusTemperature(radiusM, temperatureK float64) float64 {
if radiusM <= 0 || temperatureK <= 0 ||
math.IsNaN(radiusM) || math.IsInf(radiusM, 0) ||
@ -33,6 +35,8 @@ func LuminosityFromRadiusTemperature(radiusM, temperatureK float64) float64 {
// 返回:
//
// 恒星半径,单位米
//
// Returns stellar radius in meters from luminosity and effective temperature.
func RadiusFromLuminosityTemperature(luminosityW, temperatureK float64) float64 {
if luminosityW <= 0 || temperatureK <= 0 ||
math.IsNaN(luminosityW) || math.IsInf(luminosityW, 0) ||
@ -54,6 +58,8 @@ func RadiusFromLuminosityTemperature(luminosityW, temperatureK float64) float64
// 返回:
//
// 恒星有效温度,单位开尔文
//
// Returns stellar effective temperature in kelvin from luminosity and radius.
func EffectiveTemperatureFromLuminosityRadius(luminosityW, radiusM float64) float64 {
if luminosityW <= 0 || radiusM <= 0 ||
math.IsNaN(luminosityW) || math.IsInf(luminosityW, 0) ||
@ -75,6 +81,8 @@ func EffectiveTemperatureFromLuminosityRadius(luminosityW, radiusM float64) floa
// 返回:
//
// 总光度,单位为太阳光度 L☉
//
// Returns luminosity in solar units from radius in solar radii and effective temperature.
func LuminositySolarFromRadiusTemperature(radiusSolar, temperatureK float64) float64 {
if radiusSolar <= 0 || temperatureK <= 0 ||
math.IsNaN(radiusSolar) || math.IsInf(radiusSolar, 0) ||
@ -92,6 +100,8 @@ func LuminositySolarFromRadiusTemperature(radiusSolar, temperatureK float64) flo
// 返回:
//
// 恒星半径,单位为太阳半径 R☉
//
// Returns radius in solar radii from luminosity in solar units and effective temperature.
func RadiusSolarFromLuminosityTemperature(luminositySolar, temperatureK float64) float64 {
if luminositySolar <= 0 || temperatureK <= 0 ||
math.IsNaN(luminositySolar) || math.IsInf(luminositySolar, 0) ||
@ -110,6 +120,8 @@ func RadiusSolarFromLuminosityTemperature(luminositySolar, temperatureK float64)
//
// 恒星有效温度,单位开尔文
//
// Returns stellar effective temperature in kelvin from luminosity and radius expressed in solar units.
//
// 例:
//
// // 半径 2.5 R☉、光度 20 L☉ 的主序星
@ -128,6 +140,8 @@ func EffectiveTemperatureFromLuminositySolarRadius(luminositySolar, radiusSolar
// 返回:
//
// 太阳有效温度,单位开尔文
//
// Returns the adopted solar effective temperature constant in kelvin.
func SolarEffectiveTemperature() float64 {
return solarEffectiveTempK
}

View File

@ -12,6 +12,8 @@ const darkAdaptedPupilDiameterMM = 7.0
// 返回:
//
// 集光力比值,等于 (diameter1MM / diameter2MM)^2
//
// Returns the light-gathering power ratio, equal to (diameter1MM / diameter2MM)^2.
func LightGatheringPowerRatio(diameter1MM, diameter2MM float64) float64 {
if diameter1MM <= 0 || diameter2MM <= 0 ||
math.IsNaN(diameter1MM) || math.IsInf(diameter1MM, 0) ||
@ -28,6 +30,8 @@ func LightGatheringPowerRatio(diameter1MM, diameter2MM float64) float64 {
// 返回:
//
// Dawes 极限,单位角秒
//
// Returns the Dawes limit in arcseconds for the supplied aperture.
func DawesLimitArcsec(diameterMM float64) float64 {
if diameterMM <= 0 || math.IsNaN(diameterMM) || math.IsInf(diameterMM, 0) {
return math.NaN()
@ -42,6 +46,8 @@ func DawesLimitArcsec(diameterMM float64) float64 {
// 返回:
//
// Rayleigh 极限,单位角秒
//
// Returns the Rayleigh limit in arcseconds for the supplied aperture.
func RayleighLimitArcsec(diameterMM float64) float64 {
if diameterMM <= 0 || math.IsNaN(diameterMM) || math.IsInf(diameterMM, 0) {
return math.NaN()
@ -58,6 +64,8 @@ func RayleighLimitArcsec(diameterMM float64) float64 {
//
// 经验极限星等;这是经验值,不包含天空背景、倍率、透过率和观测经验修正
//
// Returns an empirical limiting magnitude estimate. It does not account for sky background, magnification, transmission, or observer skill.
//
// 例:
//
// // 70mm 小型折射镜,裸眼极限 6 等

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastOpposition 上一次冲日 / previous opposition.
//
// 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or before date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterOpposition(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastOpposition(date time.Time) time.Time {
// NextOpposition 下一次冲日 / next opposition.
//
// 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or after date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterOpposition(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextOpposition(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterProgradeToRetrograde(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterProgradeToRetrograde(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterRetrogradeToPrograde(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterRetrogradeToPrograde(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastEasternQuadrature 上一次东方照 / previous eastern quadrature.
//
// 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or before date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterEasternQuadrature(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastEasternQuadrature(date time.Time) time.Time {
// NextEasternQuadrature 下一次东方照 / next eastern quadrature.
//
// 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or after date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterEasternQuadrature(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextEasternQuadrature(date time.Time) time.Time {
// LastWesternQuadrature 上一次西方照 / previous western quadrature.
//
// 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or before date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterWesternQuadrature(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastWesternQuadrature(date time.Time) time.Time {
// NextWesternQuadrature 下一次西方照 / next western quadrature.
//
// 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or after date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterWesternQuadrature(jde), date.Location(), false)

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastOpposition 上一次冲日 / previous opposition.
//
// 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or before date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsOpposition(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastOpposition(date time.Time) time.Time {
// NextOpposition 下一次冲日 / next opposition.
//
// 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or after date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsOpposition(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextOpposition(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsProgradeToRetrograde(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsProgradeToRetrograde(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsRetrogradeToPrograde(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsRetrogradeToPrograde(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastEasternQuadrature 上一次东方照 / previous eastern quadrature.
//
// 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or before date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsEasternQuadrature(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastEasternQuadrature(date time.Time) time.Time {
// NextEasternQuadrature 下一次东方照 / next eastern quadrature.
//
// 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or after date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsEasternQuadrature(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextEasternQuadrature(date time.Time) time.Time {
// LastWesternQuadrature 上一次西方照 / previous western quadrature.
//
// 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or before date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMarsWesternQuadrature(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastWesternQuadrature(date time.Time) time.Time {
// NextWesternQuadrature 下一次西方照 / next western quadrature.
//
// 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or after date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMarsWesternQuadrature(jde), date.Location(), false)

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastInferiorConjunction 上一次下合 / previous inferior conjunction.
//
// 返回 date 当前或之前最近一次下合时刻,结果保持 date 的时区。
// Returns the nearest inferior conjunction at or before date, keeping date's time zone.
func LastInferiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryInferiorConjunctionInclusive(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastInferiorConjunction(date time.Time) time.Time {
// NextInferiorConjunction 下一次下合 / next inferior conjunction.
//
// 返回 date 当前或之后最近一次下合时刻,结果保持 date 的时区。
// Returns the nearest inferior conjunction at or after date, keeping date's time zone.
func NextInferiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryInferiorConjunctionInclusive(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextInferiorConjunction(date time.Time) time.Time {
// LastSuperiorConjunction 上一次上合 / previous superior conjunction.
//
// 返回 date 当前或之前最近一次上合时刻,结果保持 date 的时区。
// Returns the nearest superior conjunction at or before date, keeping date's time zone.
func LastSuperiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercurySuperiorConjunctionInclusive(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastSuperiorConjunction(date time.Time) time.Time {
// NextSuperiorConjunction 下一次上合 / next superior conjunction.
//
// 返回 date 当前或之后最近一次上合时刻,结果保持 date 的时区。
// Returns the nearest superior conjunction at or after date, keeping date's time zone.
func NextSuperiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercurySuperiorConjunctionInclusive(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextSuperiorConjunction(date time.Time) time.Time {
// LastRetrograde 上一次留 / previous stationary point.
//
// 返回 date 当前或之前最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。
// Returns the nearest stationary point at or before date, regardless of the direction change, keeping date's time zone.
func LastRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryRetrogradeInclusive(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrograde(date time.Time) time.Time {
// NextRetrograde 下一次留 / next stationary point.
//
// 返回 date 当前或之后最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。
// Returns the nearest stationary point at or after date, regardless of the direction change, keeping date's time zone.
func NextRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryRetrogradeInclusive(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrograde(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryProgradeToRetrogradeInclusive(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryRetrogradeToProgradeInclusive(jde), date.Location(), false)
@ -304,6 +316,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastGreatestElongation 上一次大距 / previous greatest elongation.
//
// 返回 date 当前或之前最近一次大距时刻,不区分东西大距,结果保持 date 的时区。
// Returns the nearest greatest elongation at or before date, regardless of east or west, keeping date's time zone.
func LastGreatestElongation(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationInclusive(jde), date.Location(), false)
@ -312,6 +325,7 @@ func LastGreatestElongation(date time.Time) time.Time {
// NextGreatestElongation 下一次大距 / next greatest elongation.
//
// 返回 date 当前或之后最近一次大距时刻,不区分东西大距,结果保持 date 的时区。
// Returns the nearest greatest elongation at or after date, regardless of east or west, keeping date's time zone.
func NextGreatestElongation(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationInclusive(jde), date.Location(), false)
@ -320,6 +334,7 @@ func NextGreatestElongation(date time.Time) time.Time {
// LastGreatestElongationEast 上一次东大距 / previous greatest eastern elongation.
//
// 返回 date 当前或之前最近一次东大距时刻,结果保持 date 的时区。
// Returns the nearest eastern greatest elongation at or before date, keeping date's time zone.
func LastGreatestElongationEast(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationEastInclusive(jde), date.Location(), false)
@ -328,6 +343,7 @@ func LastGreatestElongationEast(date time.Time) time.Time {
// NextGreatestElongationEast 下一次东大距 / next greatest eastern elongation.
//
// 返回 date 当前或之后最近一次东大距时刻,结果保持 date 的时区。
// Returns the nearest eastern greatest elongation at or after date, keeping date's time zone.
func NextGreatestElongationEast(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationEastInclusive(jde), date.Location(), false)
@ -336,6 +352,7 @@ func NextGreatestElongationEast(date time.Time) time.Time {
// LastGreatestElongationWest 上一次西大距 / previous greatest western elongation.
//
// 返回 date 当前或之前最近一次西大距时刻,结果保持 date 的时区。
// Returns the nearest western greatest elongation at or before date, keeping date's time zone.
func LastGreatestElongationWest(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMercuryGreatestElongationWestInclusive(jde), date.Location(), false)
@ -344,6 +361,7 @@ func LastGreatestElongationWest(date time.Time) time.Time {
// NextGreatestElongationWest 下一次西大距 / next greatest western elongation.
//
// 返回 date 当前或之后最近一次西大距时刻,结果保持 date 的时区。
// Returns the nearest western greatest elongation at or after date, keeping date's time zone.
func NextGreatestElongationWest(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMercuryGreatestElongationWestInclusive(jde), date.Location(), false)

72
moon/conjunction.go Normal file
View File

@ -0,0 +1,72 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// ConjunctionPlanet 月球合月目标行星 / target planet for Moon-planet conjunction.
type ConjunctionPlanet string
const (
ConjunctionMercury ConjunctionPlanet = "mercury"
ConjunctionVenus ConjunctionPlanet = "venus"
ConjunctionMars ConjunctionPlanet = "mars"
ConjunctionJupiter ConjunctionPlanet = "jupiter"
ConjunctionSaturn ConjunctionPlanet = "saturn"
ConjunctionUranus ConjunctionPlanet = "uranus"
ConjunctionNeptune ConjunctionPlanet = "neptune"
)
func conjunctionPlanetToBasic(planet ConjunctionPlanet) basic.MoonPlanetConjunctionPlanet {
switch planet {
case ConjunctionMercury:
return basic.MoonPlanetConjunctionMercury
case ConjunctionVenus:
return basic.MoonPlanetConjunctionVenus
case ConjunctionMars:
return basic.MoonPlanetConjunctionMars
case ConjunctionJupiter:
return basic.MoonPlanetConjunctionJupiter
case ConjunctionSaturn:
return basic.MoonPlanetConjunctionSaturn
case ConjunctionUranus:
return basic.MoonPlanetConjunctionUranus
case ConjunctionNeptune:
return basic.MoonPlanetConjunctionNeptune
default:
return 0
}
}
func validConjunctionPlanet(planet ConjunctionPlanet) bool {
return conjunctionPlanetToBasic(planet) != 0
}
// LastConjunctionWithPlanet 上一次行星合月(赤经合) / previous Moon-planet conjunction.
func LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
}
// NextConjunctionWithPlanet 下一次行星合月(赤经合) / next Moon-planet conjunction.
func NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
}
// ClosestConjunctionWithPlanet 最近一次行星合月(赤经合) / closest Moon-planet conjunction.
func ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.ClosestMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
}

103
moon/conjunction_test.go Normal file
View File

@ -0,0 +1,103 @@
package moon
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestConjunctionPlanetWrappersMatchBasic(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
query := time.Date(2026, 1, 15, 20, 0, 0, 0, loc)
queryTT := basic.TD2UT(basic.Date2JDE(query.UTC()), true)
cases := []struct {
name string
planet ConjunctionPlanet
basic basic.MoonPlanetConjunctionPlanet
}{
{name: "Mercury", planet: ConjunctionMercury, basic: basic.MoonPlanetConjunctionMercury},
{name: "Venus", planet: ConjunctionVenus, basic: basic.MoonPlanetConjunctionVenus},
{name: "Mars", planet: ConjunctionMars, basic: basic.MoonPlanetConjunctionMars},
{name: "Jupiter", planet: ConjunctionJupiter, basic: basic.MoonPlanetConjunctionJupiter},
{name: "Saturn", planet: ConjunctionSaturn, basic: basic.MoonPlanetConjunctionSaturn},
{name: "Uranus", planet: ConjunctionUranus, basic: basic.MoonPlanetConjunctionUranus},
{name: "Neptune", planet: ConjunctionNeptune, basic: basic.MoonPlanetConjunctionNeptune},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertSameConjunctionTime(t, "last", LastConjunctionWithPlanet(query, tc.planet), basic.LastMoonPlanetConjunction(queryTT, tc.basic), loc)
assertSameConjunctionTime(t, "next", NextConjunctionWithPlanet(query, tc.planet), basic.NextMoonPlanetConjunction(queryTT, tc.basic), loc)
assertSameConjunctionTime(t, "closest", ClosestConjunctionWithPlanet(query, tc.planet), basic.ClosestMoonPlanetConjunction(queryTT, tc.basic), loc)
})
}
}
func assertSameConjunctionTime(t *testing.T, name string, got time.Time, wantJDE float64, loc *time.Location) {
t.Helper()
want := basic.JDE2DateByZone(wantJDE, loc, false)
if got.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", name, got.Location().String(), loc.String())
}
if !got.Equal(want) {
t.Fatalf("%s time mismatch: got %s want %s", name, got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano))
}
}
func TestClosestConjunctionReturnsNearestCandidate(t *testing.T) {
query := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
last := LastConjunctionWithPlanet(query, ConjunctionMercury)
next := NextConjunctionWithPlanet(query, ConjunctionMercury)
got := ClosestConjunctionWithPlanet(query, ConjunctionMercury)
lastDiff := math.Abs(query.Sub(last).Seconds())
nextDiff := math.Abs(next.Sub(query).Seconds())
if lastDiff <= nextDiff {
if !got.Equal(last) {
t.Fatalf("closest should match last: got %s want %s", got.Format(time.RFC3339Nano), last.Format(time.RFC3339Nano))
}
return
}
if !got.Equal(next) {
t.Fatalf("closest should match next: got %s want %s", got.Format(time.RFC3339Nano), next.Format(time.RFC3339Nano))
}
}
func TestInvalidConjunctionPlanetReturnsZeroTime(t *testing.T) {
query := time.Date(2026, 1, 15, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
invalid := ConjunctionPlanet("pluto")
for name, fn := range map[string]func(time.Time, ConjunctionPlanet) time.Time{
"last": LastConjunctionWithPlanet,
"next": NextConjunctionWithPlanet,
"closest": ClosestConjunctionWithPlanet,
} {
if got := fn(query, invalid); !got.IsZero() {
t.Fatalf("%s should return zero time for invalid planet, got %s", name, got.Format(time.RFC3339Nano))
}
}
}
func TestNextConjunctionAdvancesPastReturnedEvent(t *testing.T) {
cursor := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
first := NextConjunctionWithPlanet(cursor, ConjunctionMercury)
query := first.Add(time.Second)
next := NextConjunctionWithPlanet(query, ConjunctionMercury)
if !next.After(query) {
t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%v",
query.Format(time.RFC3339Nano),
next.Format(time.RFC3339Nano),
next.Sub(query),
)
}
if next.Equal(first) {
t.Fatalf("expected next conjunction to advance: first=%s next=%s",
first.Format(time.RFC3339Nano),
next.Format(time.RFC3339Nano),
)
}
}

View File

@ -0,0 +1,43 @@
package moon
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestGeocentricApparentRaDecComponentsMatch(t *testing.T) {
date := time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC)
ra, dec := GeocentricApparentRaDec(date)
if diff := math.Abs(ra - GeocentricApparentRa(date)); diff > 1e-12 {
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, GeocentricApparentRa(date))
}
if diff := math.Abs(dec - GeocentricApparentDec(date)); diff > 1e-12 {
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, GeocentricApparentDec(date))
}
}
func TestGeocentricApparentRaDecDiffersFromTopocentricAtSite(t *testing.T) {
date := time.Date(2026, 1, 1, 6, 0, 0, 0, time.FixedZone("CST", 8*3600))
geoRA, geoDec := GeocentricApparentRaDec(date)
topoRA, topoDec := ApparentRaDec(date, 121.4737, 31.2304)
if math.Abs(geoRA-topoRA) < 1e-6 && math.Abs(geoDec-topoDec) < 1e-6 {
t.Fatalf("geocentric apparent RA/Dec unexpectedly matches topocentric values: geo=(%.12f, %.12f) topo=(%.12f, %.12f)",
geoRA, geoDec, topoRA, topoDec)
}
}
func TestTrueRaDecUsesBasicGeocentricTrue(t *testing.T) {
date := time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC)
wantRA, wantDec := basic.HMoonGeocentricTrueRaDec(basic.TD2UT(basic.Date2JDE(date.UTC()), true))
gotRA, gotDec := TrueRaDec(date)
if math.Abs(gotRA-wantRA) > 1e-12 || math.Abs(gotDec-wantDec) > 1e-12 {
t.Fatalf("TrueRaDec mismatch: got (%.15f, %.15f) want (%.15f, %.15f)", gotRA, gotDec, wantRA, wantDec)
}
}

View File

@ -84,7 +84,7 @@ func ApparentLo(date time.Time) float64 {
// Returns the Moon's geocentric true right ascension at the instant represented by date, in degrees.
func TrueRa(date time.Time) float64 {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonTrueRa(basic.TD2UT(jde, true))
return basic.HMoonGeocentricTrueRa(basic.TD2UT(jde, true))
}
// TrueDec 月亮地心真赤纬 / true geocentric declination.
@ -93,7 +93,7 @@ func TrueRa(date time.Time) float64 {
// Returns the Moon's geocentric true declination at the instant represented by date, in degrees.
func TrueDec(date time.Time) float64 {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonTrueDec(basic.TD2UT(jde, true))
return basic.HMoonGeocentricTrueDec(basic.TD2UT(jde, true))
}
// TrueRaDec 月亮地心真赤经、真赤纬 / true geocentric right ascension and declination.
@ -102,7 +102,34 @@ func TrueDec(date time.Time) float64 {
// Returns the Moon's geocentric true right ascension and declination at the instant represented by date, in degrees.
func TrueRaDec(date time.Time) (float64, float64) {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonTrueRaDec(basic.TD2UT(jde, true))
return basic.HMoonGeocentricTrueRaDec(basic.TD2UT(jde, true))
}
// GeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension.
//
// 返回月亮在 date 对应绝对时刻的地心视赤经,单位度。
// Returns the Moon's apparent geocentric right ascension at the instant represented by date, in degrees.
func GeocentricApparentRa(date time.Time) float64 {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonGeocentricApparentRa(basic.TD2UT(jde, true))
}
// GeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination.
//
// 返回月亮在 date 对应绝对时刻的地心视赤纬,单位度。
// Returns the Moon's apparent geocentric declination at the instant represented by date, in degrees.
func GeocentricApparentDec(date time.Time) float64 {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonGeocentricApparentDec(basic.TD2UT(jde, true))
}
// GeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination.
//
// 返回月亮在 date 对应绝对时刻的地心视赤经与视赤纬,单位度。
// Returns the Moon's apparent geocentric right ascension and declination at the instant represented by date, in degrees.
func GeocentricApparentRaDec(date time.Time) (float64, float64) {
jde := basic.Date2JDE(date.UTC())
return basic.HMoonGeocentricApparentRaDec(basic.TD2UT(jde, true))
}
// ApparentRa 月亮站心视赤经 / apparent topocentric right ascension.

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastOpposition 上一次冲日 / previous opposition.
//
// 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or before date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneOpposition(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastOpposition(date time.Time) time.Time {
// NextOpposition 下一次冲日 / next opposition.
//
// 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or after date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneOpposition(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextOpposition(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneProgradeToRetrograde(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneProgradeToRetrograde(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneRetrogradeToPrograde(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneRetrogradeToPrograde(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastEasternQuadrature 上一次东方照 / previous eastern quadrature.
//
// 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or before date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneEasternQuadrature(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastEasternQuadrature(date time.Time) time.Time {
// NextEasternQuadrature 下一次东方照 / next eastern quadrature.
//
// 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or after date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneEasternQuadrature(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextEasternQuadrature(date time.Time) time.Time {
// LastWesternQuadrature 上一次西方照 / previous western quadrature.
//
// 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or before date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastNeptuneWesternQuadrature(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastWesternQuadrature(date time.Time) time.Time {
// NextWesternQuadrature 下一次西方照 / next western quadrature.
//
// 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or after date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextNeptuneWesternQuadrature(jde), date.Location(), false)

View File

@ -12,13 +12,17 @@ var (
ERR_ORBIT_NEVER_SET = errors.New("ERROR:轨道目标今日永远在地平线上!")
)
// Elements 日心二体圆锥曲线根数,参考系为 J2000 平黄道/平春分点。
// Elements 日心二体圆锥曲线根数 / heliocentric two-body conic elements.
// 参考系为 J2000 平黄道/平春分点。
// The reference frame is the J2000 mean ecliptic and mean equinox.
// EpochJD 与 TpJD 使用 TT/TDB 对应的儒略日。
// EpochJD and TpJD are Julian days on the TT/TDB scale.
//
// 经典椭圆根数A/E/I/Omega/W/M0
// 近日点形式Q/E/I/Omega/W/TpJD
//
// 线性 rates 仅作用于经典椭圆根数,单位均为每天变化量。
// The linear rates apply only to the classical elliptical element form and are expressed per day.
type Elements struct {
EpochJD float64 // 历元儒略日TT/TDB / epoch Julian day in TT/TDB.
A float64 // 半长径,单位 AU / semi-major axis in AU.
@ -38,14 +42,20 @@ type Elements struct {
MDot float64 // 平近点角日变化,单位 deg/day / daily rate of M.
}
// EclipticPosition 黄道球坐标结果Lon/Lat 单位度Distance 单位 AU。
// EclipticPosition 黄道球坐标结果 / ecliptic spherical coordinates.
//
// Lon/Lat 单位度Distance 单位 AU。
// Lon/Lat are in degrees and Distance is in AU.
type EclipticPosition struct {
Lon float64
Lat float64
Distance float64
}
// EquatorialPosition 赤道球坐标结果RA/Dec 单位度Distance 单位 AU。
// EquatorialPosition 赤道球坐标结果 / equatorial spherical coordinates.
//
// RA/Dec 单位度Distance 单位 AU。
// RA/Dec are in degrees and Distance is in AU.
type EquatorialPosition struct {
RA float64
Dec float64
@ -55,6 +65,7 @@ type EquatorialPosition struct {
// MeanMotion 平均角速度 / mean motion.
//
// 返回平均角速度,单位度/日;对抛物线和双曲线轨道返回 `NaN`。
// Returns mean motion in degrees per day. Parabolic and hyperbolic cases return `NaN`.
func MeanMotion(elements Elements) float64 {
return basic.OrbitMeanMotion(toBasicElements(elements))
}
@ -62,6 +73,7 @@ func MeanMotion(elements Elements) float64 {
// MeanAnomaly 平近点角 / mean anomaly.
//
// 返回给定时刻的平近点角,单位度;对抛物线和双曲线轨道返回 `NaN`。
// Returns mean anomaly in degrees for the supplied instant. Parabolic and hyperbolic cases return `NaN`.
func MeanAnomaly(date time.Time, elements Elements) float64 {
return basic.OrbitMeanAnomaly(ttJulianDay(date), toBasicElements(elements))
}
@ -69,6 +81,7 @@ func MeanAnomaly(date time.Time, elements Elements) float64 {
// TrueAnomaly 真近点角 / true anomaly.
//
// 返回给定时刻的真近点角,单位度。
// Returns true anomaly in degrees for the supplied instant.
func TrueAnomaly(date time.Time, elements Elements) float64 {
return basic.OrbitTrueAnomaly(ttJulianDay(date), toBasicElements(elements))
}
@ -76,6 +89,7 @@ func TrueAnomaly(date time.Time, elements Elements) float64 {
// HeliocentricEclipticJ2000 日心 J2000 平黄道坐标 / heliocentric J2000 ecliptic coordinates.
//
// 返回黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns heliocentric J2000 ecliptic longitude, latitude, and distance. Angles are in degrees and distance is in AU.
func HeliocentricEclipticJ2000(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitHeliocentricEclipticJ2000(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@ -84,6 +98,7 @@ func HeliocentricEclipticJ2000(date time.Time, elements Elements) EclipticPositi
// HeliocentricEcliptic 日心历元黄道坐标 / heliocentric ecliptic coordinates of date.
//
// 返回历元黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns heliocentric ecliptic longitude, latitude, and distance of date. Angles are in degrees and distance is in AU.
func HeliocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitHeliocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@ -92,6 +107,7 @@ func HeliocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
// GeocentricEclipticJ2000 地心 J2000 平黄道坐标 / geocentric J2000 ecliptic coordinates.
//
// 返回黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns geocentric J2000 ecliptic longitude, latitude, and distance. Angles are in degrees and distance is in AU.
func GeocentricEclipticJ2000(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitGeocentricEclipticJ2000(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@ -100,6 +116,7 @@ func GeocentricEclipticJ2000(date time.Time, elements Elements) EclipticPosition
// GeocentricEcliptic 地心历元黄道坐标 / geocentric ecliptic coordinates of date.
//
// 返回历元黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns geocentric ecliptic longitude, latitude, and distance of date. Angles are in degrees and distance is in AU.
func GeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitGeocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@ -108,6 +125,7 @@ func GeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
// GeocentricEquatorialJ2000 地心 J2000 平赤道坐标 / geocentric J2000 equatorial coordinates.
//
// 返回赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns geocentric J2000 right ascension, declination, and distance. Angles are in degrees and distance is in AU.
func GeocentricEquatorialJ2000(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitGeocentricEquatorialJ2000(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@ -116,6 +134,7 @@ func GeocentricEquatorialJ2000(date time.Time, elements Elements) EquatorialPosi
// GeocentricEquatorial 地心历元平赤道坐标 / geocentric equatorial coordinates of date.
//
// 返回历元赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns geocentric right ascension, declination, and distance of date. Angles are in degrees and distance is in AU.
func GeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitGeocentricEquatorial(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@ -124,6 +143,7 @@ func GeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition
// AstrometricGeocentricEquatorialJ2000 地心测算 J2000 赤道坐标 / astrometric geocentric J2000 equatorial coordinates.
//
// 返回加入光行时修正后的地心 J2000 赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns astrometric geocentric J2000 right ascension, declination, and distance after light-time correction. Angles are in degrees and distance is in AU.
func AstrometricGeocentricEquatorialJ2000(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitAstrometricGeocentricEquatorialJ2000(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@ -132,6 +152,7 @@ func AstrometricGeocentricEquatorialJ2000(date time.Time, elements Elements) Equ
// ApparentGeocentricEcliptic 地心视黄道坐标 / apparent geocentric ecliptic coordinates.
//
// 返回加入光行时与章动修正后的地心视黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric ecliptic longitude, latitude, and distance after light-time and nutation corrections. Angles are in degrees and distance is in AU.
func ApparentGeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitApparentGeocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@ -140,6 +161,7 @@ func ApparentGeocentricEcliptic(date time.Time, elements Elements) EclipticPosit
// ApparentGeocentricEquatorial 地心视赤道坐标 / apparent geocentric equatorial coordinates.
//
// 返回加入光行时与章动修正后的地心视赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric right ascension, declination, and distance after light-time and nutation corrections. Angles are in degrees and distance is in AU.
func ApparentGeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentGeocentricEquatorial(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@ -149,6 +171,7 @@ func ApparentGeocentricEquatorial(date time.Time, elements Elements) EquatorialP
//
// 返回加入光行时、章动和站心修正后的视赤经、赤纬和距离;
// `observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
// Returns apparent topocentric right ascension, declination, and distance after light-time, nutation, and topocentric corrections.
func ApparentTopocentricEquatorial(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentTopocentricEquatorial(ttJulianDay(date), observerLon, observerLat, observerHeight, toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@ -157,6 +180,7 @@ func ApparentTopocentricEquatorial(date time.Time, elements Elements, observerLo
// Altitude 视高度角 / apparent altitude.
//
// 返回目标在观测者所在地的视高度角,单位度;经度东正西负,纬度北正南负,海拔单位米。
// Returns the apparent altitude of the target for the observing site, in degrees. Longitude is east-positive, latitude is north-positive, and height is in meters.
func Altitude(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitHeight(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
@ -165,6 +189,7 @@ func Altitude(date time.Time, elements Elements, observerLon, observerLat, obser
// Zenith 天顶距 / zenith distance.
//
// 返回目标在观测者所在地的天顶距,单位度。
// Returns the zenith distance of the target for the observing site, in degrees.
func Zenith(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
return 90 - Altitude(date, elements, observerLon, observerLat, observerHeight)
}
@ -172,6 +197,7 @@ func Zenith(date time.Time, elements Elements, observerLon, observerLat, observe
// Azimuth 视方位角 / apparent azimuth.
//
// 返回目标在观测者所在地的视方位角,按正北为 0°、向东增加。
// Returns the apparent azimuth of the target for the observing site, measured from north toward east.
func Azimuth(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitAzimuth(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
@ -180,6 +206,7 @@ func Azimuth(date time.Time, elements Elements, observerLon, observerLat, observ
// HourAngle 站心视时角 / topocentric hour angle.
//
// 返回目标在观测者所在地的站心视时角,单位度。
// Returns the apparent topocentric hour angle of the target for the observing site, in degrees.
func HourAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitHourAngle(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
@ -188,6 +215,7 @@ func HourAngle(date time.Time, elements Elements, observerLon, observerLat, obse
// CulminationTime 中天时刻 / culmination time.
//
// 返回目标在给定当地日期内的中天时刻,结果保持输入 `date` 的时区。
// Returns the culmination time of the target on the supplied local civil day. The result keeps the timezone of `date`.
func CulminationTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) time.Time {
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
@ -201,6 +229,7 @@ func CulminationTime(date time.Time, elements Elements, observerLon, observerLat
// RiseTime 升起时刻 / rise time.
//
// 返回目标在给定当地日期内的升起时刻;`aero=true` 时加入标准大气折射修正。
// Returns the rise time of the target on the supplied local civil day. When `aero` is true, standard atmospheric refraction is included.
func RiseTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64, aero bool) (time.Time, error) {
var aeroFloat float64
if aero {
@ -218,6 +247,7 @@ func RiseTime(date time.Time, elements Elements, observerLon, observerLat, obser
// SetTime 落下时刻 / set time.
//
// 返回目标在给定当地日期内的落下时刻;`aero=true` 时加入标准大气折射修正。
// Returns the set time of the target on the supplied local civil day. When `aero` is true, standard atmospheric refraction is included.
func SetTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64, aero bool) (time.Time, error) {
var aeroFloat float64
if aero {

View File

@ -9,6 +9,7 @@ import (
// ParallacticAngle 轨道目标视差角(天顶方向角) / orbit-target parallactic angle.
//
// 返回轨道目标在观测者所在地的视差角,单位度;`observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
// Returns the parallactic angle of the orbital target for the observing site, in degrees. `observerLon` is east-positive, `observerLat` is north-positive, and `observerHeight` is in meters.
func ParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight)
return basic.ParallacticAngleByHourAngle(

View File

@ -8,7 +8,10 @@ import (
const visualBinaryDeg = 180 / math.Pi
const visualBinaryRad = math.Pi / 180
// VisualBinaryElements 视双星轨道要素,采用《天文算法》第 55 章的经典口径。
// VisualBinaryElements 视双星轨道要素 / visual-binary orbital elements.
//
// 采用《天文算法》第 55 章的经典口径。
// Uses the classical convention described in Chapter 55 of Astronomical Algorithms.
type VisualBinaryElements struct {
PeriodYears float64 // 周期 P单位平太阳年 / orbital period in mean solar years.
PeriastronYear float64 // 过近星点时刻 T采用带小数的年 / epoch of periastron as a decimal year.
@ -19,7 +22,7 @@ type VisualBinaryElements struct {
PeriastronArgument float64 // 近星点角距 ω,单位度 / argument of periastron in degrees.
}
// VisualBinaryPosition 视双星在天球上的计算结果
// VisualBinaryPosition 视双星在天球上的计算结果 / computed sky-plane position of a visual binary.
type VisualBinaryPosition struct {
Year float64 // 计算使用的小数年 / decimal year used for the evaluation.
MeanAnomaly float64 // 平近点角 M单位度 / mean anomaly in degrees.
@ -41,6 +44,7 @@ func VisualBinary(date time.Time, elements VisualBinaryElements) VisualBinaryPos
// VisualBinaryByYear 视双星位置(按小数年) / visual binary position by decimal year.
//
// 返回给定小数年对应的视双星位置角和角距离。
// Returns the position angle and apparent separation for the supplied decimal year.
func VisualBinaryByYear(year float64, elements VisualBinaryElements) VisualBinaryPosition {
if !validVisualBinaryElements(year, elements) {
return invalidVisualBinaryPosition(year)

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastOpposition 上一次冲日 / previous opposition.
//
// 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or before date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnOpposition(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastOpposition(date time.Time) time.Time {
// NextOpposition 下一次冲日 / next opposition.
//
// 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or after date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnOpposition(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextOpposition(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnProgradeToRetrograde(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnProgradeToRetrograde(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnRetrogradeToPrograde(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnRetrogradeToPrograde(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastEasternQuadrature 上一次东方照 / previous eastern quadrature.
//
// 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or before date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnEasternQuadrature(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastEasternQuadrature(date time.Time) time.Time {
// NextEasternQuadrature 下一次东方照 / next eastern quadrature.
//
// 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or after date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnEasternQuadrature(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextEasternQuadrature(date time.Time) time.Time {
// LastWesternQuadrature 上一次西方照 / previous western quadrature.
//
// 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or before date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastSaturnWesternQuadrature(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastWesternQuadrature(date time.Time) time.Time {
// NextWesternQuadrature 下一次西方照 / next western quadrature.
//
// 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or after date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextSaturnWesternQuadrature(jde), date.Location(), false)

View File

@ -10,6 +10,8 @@ import (
//
// ra/dec 为瞬时赤经赤纬单位度lon/lat 为观测者经纬度,东正西负、北正南负。
// 返回值为有符号视差角,单位度。
// ra/dec are apparent equatorial coordinates in degrees; lon/lat are east-positive and north-positive.
// Returns the signed parallactic angle in degrees.
func ParallacticAngle(date time.Time, ra, dec, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()

View File

@ -100,6 +100,8 @@ func DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, e
// DownTimeN 截断项日落时刻别名 / deprecated truncated sunset alias.
//
// Deprecated: use SetTimeN instead.
//
// 参数与 SetTimeN 相同,仅为兼容旧接口保留。
// Same as SetTimeN and kept only for backward compatibility.
func DownTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) {

View File

@ -15,6 +15,12 @@ import (
//
// 坐标系沿本章通用约定x 轴位于盘面内且保持水平y 轴沿盘面最大坡度向上,
// x 正向为右侧y 正向为上坡方向。
// Latitude is geographic latitude, north positive. PlaneNormalAzimuth is the azimuth of the dial-plane normal,
// measured from north toward east. PlaneNormalZenithDistance is the zenith distance of the plane normal:
// 0 degrees for a horizontal dial and 90 degrees for a vertical dial. StylusLength is the length of the direct stylus normal to the plane.
//
// The plane coordinates follow the chapter convention: the x axis lies in the plane and remains horizontal,
// the y axis follows the steepest upward direction in the plane, x positive points to the right, and y positive points upslope.
type PlanarDial struct {
Latitude float64
PlaneNormalAzimuth float64
@ -27,6 +33,9 @@ type PlanarDial struct {
// X/Y 为影尖在盘面坐标系中的坐标DenominatorQ 对应书中公式里的 Q
// SunAboveHorizon 表示太阳在地平线上方PlaneIlluminated 表示盘面被太阳照亮;
// Illuminated 为前两者同时满足。
// X/Y are the shadow-tip coordinates in the dial-plane coordinate system. DenominatorQ is the Q term from the book formula.
// SunAboveHorizon reports whether the Sun is above the horizon. PlaneIlluminated reports whether sunlight reaches the dial plane.
// Illuminated is true only when both conditions are satisfied.
type PlanarShadowPoint struct {
X float64
Y float64
@ -41,6 +50,9 @@ type PlanarShadowPoint struct {
// CenterX/CenterY 为日晷中心极轴晷针固定点坐标PolarStylusLength 为极轴晷针长度;
// PolarStylusPlaneAngle 为极轴晷针与盘面的夹角。HasFiniteCenter 为 false 时,
// 表示极轴晷针与盘面平行,中心退化到无穷远处。
// CenterX/CenterY are the coordinates of the dial center, where the polar stylus is fixed. PolarStylusLength is the polar-stylus length.
// PolarStylusPlaneAngle is the angle between the polar stylus and the dial plane. When HasFiniteCenter is false,
// the polar stylus is parallel to the plane and the center degenerates to infinity.
type PlanarGeometry struct {
CenterX float64
CenterY float64
@ -53,6 +65,8 @@ type PlanarGeometry struct {
//
// Start/End 均为有符号太阳时角,单位度,满足 Start <= End。
// 约定取值范围为 [-180, 180],用于表达一天中的一段连续时角。
// Start and End are signed solar hour angles in degrees, with Start <= End.
// The intended range is [-180, 180], representing one continuous hour-angle span within a day.
type HourAngleInterval struct {
Start float64
End float64
@ -61,6 +75,7 @@ type HourAngleInterval struct {
// DeclinationCurveSample 赤纬曲线采样点 / sampled point on a declination curve.
//
// HourAngle 为采样点的太阳时角Point 为该时角下的影尖位置与照明状态。
// HourAngle is the solar hour angle of the sample. Point gives the shadow-tip position and illumination state at that hour angle.
type DeclinationCurveSample struct {
HourAngle float64
Point PlanarShadowPoint
@ -69,6 +84,7 @@ type DeclinationCurveSample struct {
// DeclinationCurveSegment 赤纬曲线分段 / one illuminated segment of a declination curve.
//
// Interval 给出该段对应的连续受光时角范围Samples 为该段内部的采样点。
// Interval gives the continuous illuminated hour-angle span for the segment. Samples contains the sampled points within that segment.
type DeclinationCurveSegment struct {
Declination float64
Interval HourAngleInterval
@ -80,6 +96,9 @@ type DeclinationCurveSegment struct {
// Date 为该采样点对应的绝对时刻;其日期来自输入 date钟面时间由调用参数指定。
// Declination 为该采样瞬时的太阳赤纬HourAngle 为换算后的视太阳时角;
// Point 为该时角下的影尖位置与照明状态。
// Date is the absolute instant of the sample. Its date comes from the input date, while the clock reading comes from the call parameters.
// Declination is the solar declination at that instant. HourAngle is the derived apparent-solar hour angle.
// Point gives the shadow-tip position and illumination state at that hour angle.
type TimeLineSample struct {
Date time.Time
Declination float64
@ -90,6 +109,7 @@ type TimeLineSample struct {
// EquatorialNorthDial 北面赤道日晷 / north-face equatorial dial.
//
// 北半球时用于春夏半年(太阳赤纬为正);南半球也可直接按公式使用。
// In the northern hemisphere this face is used during the spring and summer half-year, when solar declination is positive. The same formula can also be used directly in the southern hemisphere.
func EquatorialNorthDial(latitude, stylusLength float64) PlanarDial {
return PlanarDial{
Latitude: latitude,
@ -102,6 +122,7 @@ func EquatorialNorthDial(latitude, stylusLength float64) PlanarDial {
// EquatorialSouthDial 南面赤道日晷 / south-face equatorial dial.
//
// 北半球时用于秋冬半年(太阳赤纬为负);南半球也可直接按公式使用。
// In the northern hemisphere this face is used during the autumn and winter half-year, when solar declination is negative. The same formula can also be used directly in the southern hemisphere.
func EquatorialSouthDial(latitude, stylusLength float64) PlanarDial {
return PlanarDial{
Latitude: latitude,
@ -114,6 +135,7 @@ func EquatorialSouthDial(latitude, stylusLength float64) PlanarDial {
// HorizontalDial 水平日晷 / horizontal dial.
//
// 该构造器采用经典水平日晷的坐标约定x 轴向东y 轴向北。
// This constructor follows the classical horizontal-dial coordinate convention: the x axis points east and the y axis points north.
func HorizontalDial(latitude, stylusLength float64) PlanarDial {
return PlanarDial{
Latitude: latitude,
@ -127,6 +149,8 @@ func HorizontalDial(latitude, stylusLength float64) PlanarDial {
//
// planeNormalAzimuth 为盘面法线方位角,按正北为 0°、向东增加。
// 例如:朝南墙面取 180°朝东墙面取 90°。
// planeNormalAzimuth is the azimuth of the plane normal, measured from north toward east.
// For example, use 180 degrees for a south-facing wall and 90 degrees for an east-facing wall.
func VerticalDial(latitude, planeNormalAzimuth, stylusLength float64) PlanarDial {
return PlanarDial{
Latitude: latitude,
@ -136,7 +160,10 @@ func VerticalDial(latitude, planeNormalAzimuth, stylusLength float64) PlanarDial
}
}
// Geometry 返回平面日晷的中心与极轴晷针几何量 / returns the derived planar geometry.
// Geometry 平面日晷中心与极轴晷针几何量 / derived planar-dial center and polar-stylus geometry.
//
// 返回平面日晷的中心与极轴晷针几何量。
// Returns the derived center and polar-stylus geometry of the planar dial.
func (dial PlanarDial) Geometry() PlanarGeometry {
geometry := PlanarGeometry{
CenterX: math.NaN(),
@ -167,6 +194,7 @@ func (dial PlanarDial) Geometry() PlanarGeometry {
// ShadowPointByHourAngleDeclination 影尖坐标(按时角与赤纬) / shadow point from hour angle and declination.
//
// hourAngle 为有符号太阳时角上午为负下午为正declination 为太阳赤纬,单位度。
// hourAngle is the signed solar hour angle, negative in the morning and positive in the afternoon. declination is solar declination in degrees.
func (dial PlanarDial) ShadowPointByHourAngleDeclination(hourAngle, declination float64) PlanarShadowPoint {
point := PlanarShadowPoint{
X: math.NaN(),
@ -194,6 +222,7 @@ func (dial PlanarDial) ShadowPointByHourAngleDeclination(hourAngle, declination
// ShadowPointAt 影尖坐标(按绝对时刻) / shadow point at an instant.
//
// 直接读取该时刻对应的视太阳时角和瞬时太阳赤纬,并返回平面日晷上的影尖位置。
// Uses the apparent-solar hour angle and instantaneous solar declination at the supplied instant, and returns the shadow-tip position on the planar dial.
func (dial PlanarDial) ShadowPointAt(date time.Time, lon float64) PlanarShadowPoint {
return dial.ShadowPointByHourAngleDeclination(HourAngle(date, lon), sun.ApparentDec(date))
}
@ -202,6 +231,8 @@ func (dial PlanarDial) ShadowPointAt(date time.Time, lon float64) PlanarShadowPo
//
// date 应处于目标地点的地方平太阳时区,例如 `MeanSolarTime` 的返回值;其原有钟面时间会被忽略。
// meanSolarHours 为地方平太阳时钟面读数,单位小时,例如 9.5 表示 09:30。
// date should already be expressed in the local mean-solar timezone of the site, for example a value returned by `MeanSolarTime`; its original clock fields are ignored.
// meanSolarHours is the local mean-solar clock reading in hours, for example 9.5 for 09:30.
func (dial PlanarDial) MeanSolarTimePoint(date time.Time, meanSolarHours float64) PlanarShadowPoint {
sampleTime := dateWithClockHours(date, meanSolarHours)
declination := sun.ApparentDec(sampleTime)
@ -211,6 +242,7 @@ func (dial PlanarDial) MeanSolarTimePoint(date time.Time, meanSolarHours float64
// ZoneTimePoint 区时影尖位置 / shadow point for zone time.
//
// date 提供民用日期和时区原有钟面时间会被忽略zoneTimeHours 为该时区下的区时钟面读数。
// date provides the civil date and timezone; its original clock fields are ignored. zoneTimeHours is the civil clock reading in that timezone.
func (dial PlanarDial) ZoneTimePoint(date time.Time, lon, zoneTimeHours float64) PlanarShadowPoint {
sampleTime := dateWithClockHours(date, zoneTimeHours)
declination := sun.ApparentDec(sampleTime)
@ -221,6 +253,8 @@ func (dial PlanarDial) ZoneTimePoint(date time.Time, lon, zoneTimeHours float64)
//
// dates 由调用者自行决定取样日期密度,例如每月或每日;每个 date 都应处于目标地点的地方平太阳时区,
// 例如先通过 `MeanSolarTime` 得到对应地点的地方平太阳时再取其年月日。meanSolarHours 为地方平太阳时钟面读数。
// dates define the sampling cadence, for example monthly or daily. Each date should already be in the site's local mean-solar timezone,
// for example by first calling `MeanSolarTime` and then keeping its year, month, and day. meanSolarHours is the local mean-solar clock reading.
func (dial PlanarDial) MeanSolarTimeLine(dates []time.Time, meanSolarHours float64) []TimeLineSample {
if !isFinite(meanSolarHours) {
return nil
@ -244,6 +278,8 @@ func (dial PlanarDial) MeanSolarTimeLine(dates []time.Time, meanSolarHours float
//
// dates 由调用者自行决定取样日期密度zoneTimeHours 为 date 所在时区的区时钟面读数。
// 每个 date 的原有钟面时间都会被 zoneTimeHours 替换。
// dates define the sampling cadence. zoneTimeHours is the civil clock reading in the timezone carried by each date.
// The original clock fields of every date are replaced by zoneTimeHours.
func (dial PlanarDial) ZoneTimeLine(dates []time.Time, lon, zoneTimeHours float64) []TimeLineSample {
if !isFinite(zoneTimeHours) || !isFinite(lon) {
return nil
@ -266,6 +302,7 @@ func (dial PlanarDial) ZoneTimeLine(dates []time.Time, lon, zoneTimeHours float6
// PlaneIlluminatedHourAngleIntervals 盘面受光时角区间 / plane-illuminated hour-angle intervals.
//
// declination 为太阳赤纬,单位度。返回的区间只考虑盘面受光,不判断太阳是否在地平线上方。
// declination is solar declination in degrees. The returned intervals consider only whether the dial plane is illuminated and do not test whether the Sun is above the horizon.
func (dial PlanarDial) PlaneIlluminatedHourAngleIntervals(declination float64) []HourAngleInterval {
if !dial.isFinite() || !isFinite(declination) {
return nil
@ -277,6 +314,7 @@ func (dial PlanarDial) PlaneIlluminatedHourAngleIntervals(declination float64) [
// IlluminatedHourAngleIntervals 可见且受光时角区间 / illuminated hour-angle intervals.
//
// declination 为太阳赤纬,单位度。结果可直接用于日晷绘图时筛掉无效时线。
// declination is solar declination in degrees. The result can be used directly to discard invalid hour-line segments in sundial plotting.
func (dial PlanarDial) IlluminatedHourAngleIntervals(declination float64) []HourAngleInterval {
aboveHorizon := SunAboveHorizonHourAngleIntervals(dial.Latitude, declination)
planeLit := dial.PlaneIlluminatedHourAngleIntervals(declination)
@ -287,6 +325,8 @@ func (dial PlanarDial) IlluminatedHourAngleIntervals(declination float64) []Hour
//
// declination 为太阳赤纬单位度hourAngleStep 为采样步长,单位度,常用值是 15°每小时一格
// 返回值按受光区间分段每段都带有精确的时角范围Samples 只包含区间内部的有效采样点。
// declination is solar declination in degrees. hourAngleStep is the sampling step in degrees; 15 degrees is a common one-hour spacing.
// The return value is split by illuminated intervals. Each segment carries the exact hour-angle bounds, and Samples contains only valid interior sample points.
func (dial PlanarDial) DeclinationCurve(declination, hourAngleStep float64) []DeclinationCurveSegment {
if !dial.isFinite() || !isFinite(declination) || !isFinite(hourAngleStep) || hourAngleStep <= 0 {
return nil
@ -315,6 +355,7 @@ func (dial PlanarDial) DeclinationCurve(declination, hourAngleStep float64) []De
// DeclinationCurveAt 瞬时赤纬曲线采样 / declination-curve samples at an instant.
//
// 用 date 对应瞬时太阳赤纬生成日晷分段曲线采样。
// Builds the segmented declination-curve samples from the instantaneous solar declination at date.
func (dial PlanarDial) DeclinationCurveAt(date time.Time, hourAngleStep float64) []DeclinationCurveSegment {
return dial.DeclinationCurve(sun.ApparentDec(date), hourAngleStep)
}
@ -323,6 +364,7 @@ func (dial PlanarDial) DeclinationCurveAt(date time.Time, hourAngleStep float64)
//
// latitude 为地理纬度declination 为太阳赤纬,单位度。结果只反映太阳是否升到地平线上方,
// 不包含盘面朝向的影响。
// latitude is geographic latitude and declination is solar declination, both in degrees. The result reflects only whether the Sun is above the horizon and does not include dial-plane orientation.
func SunAboveHorizonHourAngleIntervals(latitude, declination float64) []HourAngleInterval {
if !isFinite(latitude) || !isFinite(declination) {
return nil

View File

@ -39,6 +39,9 @@ func HourAngle(date time.Time, lon float64) float64 {
// date 负责提供地方平太阳时日期与时区,原有钟面时间会被 meanSolarHours 替换;
// meanSolarHours 为地方平太阳时钟面读数,单位小时,例如 9.5 表示地方平太阳时 09:30。
// 返回对应的视太阳时角,单位度,上午为负,下午为正。
// date provides the local mean-solar date and timezone, while its clock fields are replaced by meanSolarHours.
// meanSolarHours is the local mean-solar clock reading in hours, for example 9.5 for 09:30.
// Returns the corresponding apparent-solar hour angle in degrees, negative in the morning and positive in the afternoon.
func MeanSolarHourAngle(date time.Time, meanSolarHours float64) float64 {
if !isFinite(meanSolarHours) {
return math.NaN()
@ -52,6 +55,9 @@ func MeanSolarHourAngle(date time.Time, meanSolarHours float64) float64 {
// zoneTimeHours 为 date 所在时区下的钟面读数单位小时lon 为当地经度,东正西负。
// 返回该区时在给定经度上对应的视太阳时角,单位度,上午为负,下午为正。
// date 提供民用日期和时区;其原有钟面时间会被 zoneTimeHours 替换。
// zoneTimeHours is the civil clock reading in the timezone carried by date, in hours; lon is east-positive longitude.
// Returns the apparent-solar hour angle for that civil time and longitude, in degrees, negative in the morning and positive in the afternoon.
// date provides the civil date and timezone, and its original clock fields are replaced by zoneTimeHours.
func ZoneTimeHourAngle(date time.Time, lon, zoneTimeHours float64) float64 {
if !isFinite(zoneTimeHours) || !isFinite(lon) {
return math.NaN()
@ -79,6 +85,7 @@ func HorizontalHourLineAngle(lat, hourAngle float64) float64 {
// HorizontalHourLineAngleAt 水平日晷时线角 / horizontal sundial hour-line angle.
//
// 先按给定时刻和经度求瞬时视太阳时角,再结合纬度返回水平日晷的时线角。
// First derives the apparent-solar hour angle for the supplied instant and longitude, then returns the horizontal-dial hour-line angle for the given latitude.
func HorizontalHourLineAngleAt(date time.Time, lon, lat float64) float64 {
return HorizontalHourLineAngle(lat, HourAngle(date, lon))
}

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastOpposition 上一次冲日 / previous opposition.
//
// 返回 date 当前或之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or before date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusOpposition(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastOpposition(date time.Time) time.Time {
// NextOpposition 下一次冲日 / next opposition.
//
// 返回 date 当前或之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the nearest opposition at or after date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusOpposition(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextOpposition(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusProgradeToRetrograde(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusProgradeToRetrograde(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusRetrogradeToPrograde(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusRetrogradeToPrograde(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastEasternQuadrature 上一次东方照 / previous eastern quadrature.
//
// 返回 date 当前或之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or before date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusEasternQuadrature(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastEasternQuadrature(date time.Time) time.Time {
// NextEasternQuadrature 下一次东方照 / next eastern quadrature.
//
// 返回 date 当前或之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the nearest eastern quadrature at or after date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusEasternQuadrature(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextEasternQuadrature(date time.Time) time.Time {
// LastWesternQuadrature 上一次西方照 / previous western quadrature.
//
// 返回 date 当前或之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or before date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastUranusWesternQuadrature(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastWesternQuadrature(date time.Time) time.Time {
// NextWesternQuadrature 下一次西方照 / next western quadrature.
//
// 返回 date 当前或之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the nearest western quadrature at or after date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextUranusWesternQuadrature(jde), date.Location(), false)

View File

@ -208,6 +208,7 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
// LastConjunction 上一次合日 / previous conjunction with the Sun.
//
// 返回 date 当前或之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or before date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusConjunction(jde), date.Location(), false)
@ -216,6 +217,7 @@ func LastConjunction(date time.Time) time.Time {
// NextConjunction 下一次合日 / next conjunction with the Sun.
//
// 返回 date 当前或之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the nearest conjunction with the Sun at or after date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusConjunction(jde), date.Location(), false)
@ -224,6 +226,7 @@ func NextConjunction(date time.Time) time.Time {
// LastInferiorConjunction 上一次下合 / previous inferior conjunction.
//
// 返回 date 当前或之前最近一次下合时刻,结果保持 date 的时区。
// Returns the nearest inferior conjunction at or before date, keeping date's time zone.
func LastInferiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusInferiorConjunctionInclusive(jde), date.Location(), false)
@ -232,6 +235,7 @@ func LastInferiorConjunction(date time.Time) time.Time {
// NextInferiorConjunction 下一次下合 / next inferior conjunction.
//
// 返回 date 当前或之后最近一次下合时刻,结果保持 date 的时区。
// Returns the nearest inferior conjunction at or after date, keeping date's time zone.
func NextInferiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusInferiorConjunctionInclusive(jde), date.Location(), false)
@ -240,6 +244,7 @@ func NextInferiorConjunction(date time.Time) time.Time {
// LastSuperiorConjunction 上一次上合 / previous superior conjunction.
//
// 返回 date 当前或之前最近一次上合时刻,结果保持 date 的时区。
// Returns the nearest superior conjunction at or before date, keeping date's time zone.
func LastSuperiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusSuperiorConjunctionInclusive(jde), date.Location(), false)
@ -248,6 +253,7 @@ func LastSuperiorConjunction(date time.Time) time.Time {
// NextSuperiorConjunction 下一次上合 / next superior conjunction.
//
// 返回 date 当前或之后最近一次上合时刻,结果保持 date 的时区。
// Returns the nearest superior conjunction at or after date, keeping date's time zone.
func NextSuperiorConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusSuperiorConjunctionInclusive(jde), date.Location(), false)
@ -256,6 +262,7 @@ func NextSuperiorConjunction(date time.Time) time.Time {
// LastRetrograde 上一次留 / previous stationary point.
//
// 返回 date 当前或之前最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。
// Returns the nearest stationary point at or before date, regardless of the direction change, keeping date's time zone.
func LastRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusRetrogradeInclusive(jde), date.Location(), false)
@ -264,6 +271,7 @@ func LastRetrograde(date time.Time) time.Time {
// NextRetrograde 下一次留 / next stationary point.
//
// 返回 date 当前或之后最近一次留时刻,不区分顺转逆还是逆转顺,结果保持 date 的时区。
// Returns the nearest stationary point at or after date, regardless of the direction change, keeping date's time zone.
func NextRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusRetrogradeInclusive(jde), date.Location(), false)
@ -272,6 +280,7 @@ func NextRetrograde(date time.Time) time.Time {
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 当前或之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from prograde to retrograde, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusProgradeToRetrogradeInclusive(jde), date.Location(), false)
@ -280,6 +289,7 @@ func LastProgradeToRetrograde(date time.Time) time.Time {
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 当前或之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from prograde to retrograde, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusProgradeToRetrogradeInclusive(jde), date.Location(), false)
@ -288,6 +298,7 @@ func NextProgradeToRetrograde(date time.Time) time.Time {
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 当前或之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or before date where motion changes from retrograde to prograde, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusRetrogradeToProgradeInclusive(jde), date.Location(), false)
@ -296,6 +307,7 @@ func LastRetrogradeToPrograde(date time.Time) time.Time {
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 当前或之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the nearest station at or after date where motion changes from retrograde to prograde, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusRetrogradeToProgradeInclusive(jde), date.Location(), false)
@ -304,6 +316,7 @@ func NextRetrogradeToPrograde(date time.Time) time.Time {
// LastGreatestElongation 上一次大距 / previous greatest elongation.
//
// 返回 date 当前或之前最近一次大距时刻,不区分东西大距,结果保持 date 的时区。
// Returns the nearest greatest elongation at or before date, regardless of east or west, keeping date's time zone.
func LastGreatestElongation(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusGreatestElongationInclusive(jde), date.Location(), false)
@ -312,6 +325,7 @@ func LastGreatestElongation(date time.Time) time.Time {
// NextGreatestElongation 下一次大距 / next greatest elongation.
//
// 返回 date 当前或之后最近一次大距时刻,不区分东西大距,结果保持 date 的时区。
// Returns the nearest greatest elongation at or after date, regardless of east or west, keeping date's time zone.
func NextGreatestElongation(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusGreatestElongationInclusive(jde), date.Location(), false)
@ -320,6 +334,7 @@ func NextGreatestElongation(date time.Time) time.Time {
// LastGreatestElongationEast 上一次东大距 / previous greatest eastern elongation.
//
// 返回 date 当前或之前最近一次东大距时刻,结果保持 date 的时区。
// Returns the nearest eastern greatest elongation at or before date, keeping date's time zone.
func LastGreatestElongationEast(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusGreatestElongationEastInclusive(jde), date.Location(), false)
@ -328,6 +343,7 @@ func LastGreatestElongationEast(date time.Time) time.Time {
// NextGreatestElongationEast 下一次东大距 / next greatest eastern elongation.
//
// 返回 date 当前或之后最近一次东大距时刻,结果保持 date 的时区。
// Returns the nearest eastern greatest elongation at or after date, keeping date's time zone.
func NextGreatestElongationEast(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusGreatestElongationEastInclusive(jde), date.Location(), false)
@ -336,6 +352,7 @@ func NextGreatestElongationEast(date time.Time) time.Time {
// LastGreatestElongationWest 上一次西大距 / previous greatest western elongation.
//
// 返回 date 当前或之前最近一次西大距时刻,结果保持 date 的时区。
// Returns the nearest western greatest elongation at or before date, keeping date's time zone.
func LastGreatestElongationWest(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastVenusGreatestElongationWestInclusive(jde), date.Location(), false)
@ -344,6 +361,7 @@ func LastGreatestElongationWest(date time.Time) time.Time {
// NextGreatestElongationWest 下一次西大距 / next greatest western elongation.
//
// 返回 date 当前或之后最近一次西大距时刻,结果保持 date 的时区。
// Returns the nearest western greatest elongation at or after date, keeping date's time zone.
func NextGreatestElongationWest(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextVenusGreatestElongationWestInclusive(jde), date.Location(), false)