true-solar-time-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +99 -0
- package/dist/solar-time.d.ts +137 -0
- package/dist/solar-time.js +230 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shan Liu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# true-solar-time-mcp
|
|
2
|
+
|
|
3
|
+
**True solar time correction for BaZi / Four Pillars charts — the step most calculators skip.**
|
|
4
|
+
|
|
5
|
+
An MCP server, CLI, and TypeScript library that converts a recorded birth time (wall clock + IANA time zone + longitude) into the sun's actual time at the birthplace. Deterministic: same input, same output, every time.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ npx true-solar-time 1988-07-01 08:00 Asia/Shanghai 121.47
|
|
9
|
+
|
|
10
|
+
clock time 1988-07-01 08:00 (Asia/Shanghai, DST GMT+9)
|
|
11
|
+
true solar 1988-07-01 07:02
|
|
12
|
+
correction -58 min
|
|
13
|
+
DST -60
|
|
14
|
+
longitude +6
|
|
15
|
+
eq. of time -4
|
|
16
|
+
hour branch 辰 (2 min from 卯)
|
|
17
|
+
⚠ within 8 min of a branch boundary — cast both candidates
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why this exists
|
|
21
|
+
|
|
22
|
+
A BaZi chart is a function of the sun's position, but birth records are written in civil clock time. Between the two sit three corrections that nearly every chart tool — including the popular open-source LLM "skills" — silently skips:
|
|
23
|
+
|
|
24
|
+
1. **Historical DST.** China observed daylight saving time from 1986 to 1991. Everyone born in those six summers has a birth certificate recording a clock moved forward one hour — enough to cross an entire hour branch, and at the right time of night, the day pillar too. The IANA/ICU time-zone database knows this; most calculators never ask it.
|
|
25
|
+
2. **Longitude.** China spans four geographic time zones and runs on one clock. At 07:10 Beijing time in Ürümqi, local solar time is barely past 05:00 — two full branches away.
|
|
26
|
+
3. **Equation of time.** True and mean solar time drift apart by up to ±16 minutes over the year (NOAA approximation here, error well under a minute). In early November it alone can move a chart across a branch boundary.
|
|
27
|
+
|
|
28
|
+
This library also reports two things most tools won't tell you:
|
|
29
|
+
|
|
30
|
+
- **Whether your birth time ever existed.** On a spring-forward night the local clock jumps from 01:59 to 03:00; a birth record saying 02:30 refers to no real instant. We detect the gap, report its width, and never silently guess.
|
|
31
|
+
- **How close the corrected time sits to a branch boundary.** Within a few minutes, the honest answer is "cast both candidates", not false precision.
|
|
32
|
+
|
|
33
|
+
## What it deliberately does *not* do
|
|
34
|
+
|
|
35
|
+
It does not cast charts, pick favorable elements, or interpret anything. Casting is a solved problem with good open-source implementations; interpretation has no unique right answer and doesn't belong in a lookup library. One step, done carefully.
|
|
36
|
+
|
|
37
|
+
The full methodology — every constant, threshold, and convention, including the ones this library uses — is published at **[auspiceoracle.com/en/method](https://auspiceoracle.com/en/method)**. Three worked examples with full derivations: **[the birth-time test](https://auspiceoracle.com/en/content/birth-time-test)**. Background essay: **[true solar time](https://auspiceoracle.com/en/content/true-solar-time)**.
|
|
38
|
+
|
|
39
|
+
## MCP server
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"true-solar-time": {
|
|
45
|
+
"command": "npx",
|
|
46
|
+
"args": ["-y", "true-solar-time-mcp"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Tools:
|
|
53
|
+
|
|
54
|
+
- **`true_solar_time`** — full correction: corrected instant, DST/longitude/EoT breakdown (the three always sum exactly to the total), hour branch, boundary distance, nonexistent-time detection.
|
|
55
|
+
- **`hour_branch`** — branch membership and boundary distance for an already-corrected time.
|
|
56
|
+
|
|
57
|
+
If you're building a BaZi skill or agent: call `true_solar_time` **before** casting, and pass the corrected time to your caster. An LLM cannot do this conversion in-context — the equation of time is a trigonometric series and the DST history lives in a database, not in model weights.
|
|
58
|
+
|
|
59
|
+
## Library
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { toTrueSolarTime } from 'true-solar-time-mcp'
|
|
63
|
+
|
|
64
|
+
const r = toTrueSolarTime(1992, 6, 15, 7, 10, 'Asia/Shanghai', 87.6)
|
|
65
|
+
// r.hour === 4, r.minute === 59, r.correctionMinutes === -130
|
|
66
|
+
// r.hourBranch === '寅' — two branches away from what the clock says
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Conventions, stated plainly
|
|
70
|
+
|
|
71
|
+
- Hour branches switch on odd hours (子 starts at 23:00), 120 minutes each.
|
|
72
|
+
- The boundary-risk flag uses an 8-minute threshold. That is a convention, not physics — tune it to your own tolerance; near-boundary charts should be cast both ways regardless.
|
|
73
|
+
- Historical time zones come from your runtime's ICU data (Node ≥ 18). We do not maintain our own tables.
|
|
74
|
+
- When the standard offset itself changed mid-year (Russia 2011), the DST/longitude split of the *breakdown* can attribute imperfectly; the total correction is always computed from the actual offset and is unaffected.
|
|
75
|
+
|
|
76
|
+
## Relationship to auspiceoracle.com
|
|
77
|
+
|
|
78
|
+
This is an extracted mirror of the solar-time layer of the engine behind [Auspice Oracle](https://auspiceoracle.com). The main engine is the source of truth; the golden tests here pin this mirror to the same published examples. Bug reports are very welcome; PRs that change the conventions above will be declined (a convention fork would make the published methodology untrue).
|
|
79
|
+
|
|
80
|
+
MIT © [Shan Liu](https://auspiceoracle.com/en/about#author)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI: npx true-solar-time 1988-07-01 08:00 Asia/Shanghai 121.47
|
|
4
|
+
*/
|
|
5
|
+
import { toTrueSolarTime, BOUNDARY_RISK_MINUTES } from './solar-time.js';
|
|
6
|
+
const [date, time, timeZone, lonArg] = process.argv.slice(2);
|
|
7
|
+
if (!date || !time || !timeZone || lonArg === undefined) {
|
|
8
|
+
console.log('Usage: true-solar-time <YYYY-MM-DD> <HH:mm> <IANA-timezone> <longitude>');
|
|
9
|
+
console.log(' eg: true-solar-time 1988-07-01 08:00 Asia/Shanghai 121.47');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
13
|
+
const [hh, mm] = time.split(':').map(Number);
|
|
14
|
+
const lon = Number(lonArg);
|
|
15
|
+
if ([y, m, d, hh, mm, lon].some(Number.isNaN)) {
|
|
16
|
+
console.error('Could not parse arguments.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
let info;
|
|
20
|
+
try {
|
|
21
|
+
info = toTrueSolarTime(y, m, d, hh, mm, timeZone, lon);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)} — is "${timeZone}" a valid IANA zone?`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
28
|
+
const sign = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
29
|
+
console.log(`clock time ${date} ${time} (${info.timeZone}${info.dst.active ? `, DST ${info.dst.abbr}` : ''})`);
|
|
30
|
+
console.log(`true solar ${info.year}-${pad(info.month)}-${pad(info.day)} ${pad(info.hour)}:${pad(info.minute)}`);
|
|
31
|
+
console.log(`correction ${sign(info.correctionMinutes)} min`);
|
|
32
|
+
console.log(` DST ${sign(info.parts.dstMinutes)}`);
|
|
33
|
+
console.log(` longitude ${sign(info.parts.longitudeMinutes)}`);
|
|
34
|
+
console.log(` eq. of time ${sign(info.parts.equationOfTimeMinutes)}`);
|
|
35
|
+
console.log(`hour branch ${info.hourBranch} (${info.minutesToBoundary} min from ${info.neighborBranch})`);
|
|
36
|
+
if (info.boundaryRisk) {
|
|
37
|
+
console.log(`⚠ within ${BOUNDARY_RISK_MINUTES} min of a branch boundary — cast both candidates`);
|
|
38
|
+
}
|
|
39
|
+
if (info.nonexistentWallTime.hit) {
|
|
40
|
+
console.log(`⚠ this clock time never existed in ${info.timeZone} (DST gap of ${info.nonexistentWallTime.gapMinutes} min) — verify the birth record`);
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP server: true solar time correction for BaZi / Four Pillars charts.
|
|
4
|
+
*
|
|
5
|
+
* One tool, on purpose. Casting a chart is a solved, commoditized problem
|
|
6
|
+
* (lunar-typescript, many MIT casters). The step nearly every tool skips is
|
|
7
|
+
* converting the recorded clock time into the sun's time at the birthplace —
|
|
8
|
+
* historical DST included. This server does exactly that step, and nothing else.
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { toTrueSolarTime, hourBranchContext, BOUNDARY_RISK_MINUTES } from './solar-time.js';
|
|
14
|
+
const server = new McpServer({
|
|
15
|
+
name: 'true-solar-time',
|
|
16
|
+
version: '1.0.0',
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('true_solar_time', {
|
|
19
|
+
title: 'True solar time correction',
|
|
20
|
+
description: 'Convert a recorded birth time (wall clock + IANA time zone + longitude) into true solar time — ' +
|
|
21
|
+
'the time a BaZi / Four Pillars chart should actually be cast from. Returns the corrected instant, ' +
|
|
22
|
+
'the total correction decomposed into DST + longitude + equation-of-time (the three always sum exactly), ' +
|
|
23
|
+
'the resulting two-hour branch, distance to the nearest branch boundary, and whether the reported ' +
|
|
24
|
+
'clock time even existed (spring-forward DST gaps, e.g. China 1986–91). ' +
|
|
25
|
+
'Deterministic: same input, same output, every time.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
year: z.number().int().min(1800).max(2200).describe('Birth year (Gregorian)'),
|
|
28
|
+
month: z.number().int().min(1).max(12),
|
|
29
|
+
day: z.number().int().min(1).max(31),
|
|
30
|
+
hour: z.number().int().min(0).max(23).describe('Recorded wall-clock hour, 24h'),
|
|
31
|
+
minute: z.number().int().min(0).max(59),
|
|
32
|
+
timeZone: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe("IANA time zone of the birthplace, e.g. 'Asia/Shanghai', 'America/Vancouver'"),
|
|
35
|
+
longitude: z
|
|
36
|
+
.number()
|
|
37
|
+
.min(-180)
|
|
38
|
+
.max(180)
|
|
39
|
+
.describe('Birthplace longitude in degrees, east positive (Ürümqi ≈ 87.6, Vancouver ≈ -123.1)'),
|
|
40
|
+
},
|
|
41
|
+
}, async ({ year, month, day, hour, minute, timeZone, longitude }) => {
|
|
42
|
+
let info;
|
|
43
|
+
try {
|
|
44
|
+
info = toTrueSolarTime(year, month, day, hour, minute, timeZone, longitude);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: `Error: ${e instanceof Error ? e.message : String(e)} — check that timeZone is a valid IANA name.`,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
58
|
+
const summary = [
|
|
59
|
+
`Corrected (true solar) time: ${info.year}-${pad(info.month)}-${pad(info.day)} ${pad(info.hour)}:${pad(info.minute)}`,
|
|
60
|
+
`Total correction: ${info.correctionMinutes >= 0 ? '+' : ''}${info.correctionMinutes} min = DST ${info.parts.dstMinutes} + longitude ${info.parts.longitudeMinutes >= 0 ? '+' : ''}${info.parts.longitudeMinutes} + equation of time ${info.parts.equationOfTimeMinutes >= 0 ? '+' : ''}${info.parts.equationOfTimeMinutes}`,
|
|
61
|
+
`Hour branch: ${info.hourBranch} (${info.minutesToBoundary} min from the ${info.neighborBranch} boundary)`,
|
|
62
|
+
info.boundaryRisk
|
|
63
|
+
? `⚠ Boundary risk: within ${BOUNDARY_RISK_MINUTES} min of a branch boundary — a small error in the recorded time flips the hour pillar. Cast both candidates.`
|
|
64
|
+
: '',
|
|
65
|
+
info.nonexistentWallTime.hit
|
|
66
|
+
? `⚠ The reported clock time never existed in ${info.timeZone} (spring-forward gap of ${info.nonexistentWallTime.gapMinutes} min). The birth record was likely written in pre-jump time — verify which clock it used.`
|
|
67
|
+
: '',
|
|
68
|
+
info.dst.active ? `DST was in effect (${info.dst.abbr}).` : '',
|
|
69
|
+
]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join('\n');
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{ type: 'text', text: summary },
|
|
75
|
+
{ type: 'text', text: JSON.stringify(info, null, 2) },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
server.registerTool('hour_branch', {
|
|
80
|
+
title: 'Hour branch of a clock time',
|
|
81
|
+
description: 'Which two-hour branch (子丑寅卯…) a given time falls in, and how many minutes it sits from the ' +
|
|
82
|
+
'nearest branch boundary. Use on an already-corrected time; for raw birth records use true_solar_time.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
hour: z.number().int().min(0).max(23),
|
|
85
|
+
minute: z.number().int().min(0).max(59),
|
|
86
|
+
},
|
|
87
|
+
}, async ({ hour, minute }) => {
|
|
88
|
+
const hb = hourBranchContext(hour, minute);
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: `${hb.branch} — ${hb.minutes} min from the ${hb.neighbor} boundary${hb.minutes <= BOUNDARY_RISK_MINUTES ? ' (⚠ boundary risk: cast both candidates)' : ''}`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
const transport = new StdioServerTransport();
|
|
99
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True solar time correction — the step most BaZi / Four Pillars calculators skip.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline:
|
|
5
|
+
* 1. wall-clock time + IANA time zone (historical DST rules included, via ICU) → UTC instant
|
|
6
|
+
* 2. UTC + longitude × 4 min/degree → local mean solar time
|
|
7
|
+
* 3. + equation of time → true (apparent) solar time
|
|
8
|
+
*
|
|
9
|
+
* The total correction is also decomposed into three parts (DST + longitude +
|
|
10
|
+
* equation of time) whose rounded sum is always exactly equal to the total —
|
|
11
|
+
* so the result can be shown as an addition table a reader can check by hand.
|
|
12
|
+
*
|
|
13
|
+
* Equation of time uses the NOAA approximation (error well under a minute; it
|
|
14
|
+
* can only matter within ±1 minute of an hour-branch boundary). The bigger,
|
|
15
|
+
* more common risk is the corrected time landing near a boundary at all —
|
|
16
|
+
* see `minutesToBoundary` / `boundaryRisk`.
|
|
17
|
+
*
|
|
18
|
+
* No home-grown historical time-zone tables: Node's ICU data already knows
|
|
19
|
+
* China's 1986–91 DST, early Taiwan/HK/Singapore DST, pre-1949 Asia/Harbin,
|
|
20
|
+
* and so on. Give it the right IANA zone name and the offset is right.
|
|
21
|
+
*
|
|
22
|
+
* This file mirrors the engine behind https://auspiceoracle.com — the full
|
|
23
|
+
* methodology, with every constant, is published at
|
|
24
|
+
* https://auspiceoracle.com/en/method
|
|
25
|
+
*/
|
|
26
|
+
export interface SolarTimeParts {
|
|
27
|
+
/** DST component: usually −60 when DST was in effect, else 0. Always ≤ 0. */
|
|
28
|
+
dstMinutes: number;
|
|
29
|
+
/** Longitude component: (birth longitude − zone's standard meridian) × 4 min/deg */
|
|
30
|
+
longitudeMinutes: number;
|
|
31
|
+
/** Equation-of-time component */
|
|
32
|
+
equationOfTimeMinutes: number;
|
|
33
|
+
}
|
|
34
|
+
export interface TrueSolarTimeInfo {
|
|
35
|
+
/** Corrected instant, as local true-solar calendar fields */
|
|
36
|
+
year: number;
|
|
37
|
+
month: number;
|
|
38
|
+
day: number;
|
|
39
|
+
hour: number;
|
|
40
|
+
minute: number;
|
|
41
|
+
/** Total correction relative to the reported wall-clock time (minutes) */
|
|
42
|
+
correctionMinutes: number;
|
|
43
|
+
/** Equation of time, one decimal (the rounded integer lives in `parts`) */
|
|
44
|
+
equationOfTimeMinutes: number;
|
|
45
|
+
/** DST + longitude + equation of time; the three always sum to correctionMinutes */
|
|
46
|
+
parts: SolarTimeParts;
|
|
47
|
+
timeZone: string;
|
|
48
|
+
/** Longitude normalized to within ±180° of the zone's standard meridian */
|
|
49
|
+
longitude: number;
|
|
50
|
+
/** Zone's actual UTC offset at that instant (minutes, DST included) */
|
|
51
|
+
tzOffsetMinutes: number;
|
|
52
|
+
/** Zone's standard offset (DST excluded) */
|
|
53
|
+
standardOffsetMinutes: number;
|
|
54
|
+
/** Standard meridian of the zone (degrees, east positive) = standardOffsetMinutes / 4 */
|
|
55
|
+
standardMeridian: number;
|
|
56
|
+
dst: {
|
|
57
|
+
active: boolean;
|
|
58
|
+
abbr: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The reported wall-clock time never existed in that zone (spring-forward
|
|
62
|
+
* gap — e.g. China, each year 1986–91, one night jumps 02:00 → 03:00).
|
|
63
|
+
* When hit, the correction is computed from the jump instant, and callers
|
|
64
|
+
* should tell the user to double-check which clock the record was written in.
|
|
65
|
+
*/
|
|
66
|
+
nonexistentWallTime: {
|
|
67
|
+
hit: boolean;
|
|
68
|
+
gapMinutes: number;
|
|
69
|
+
};
|
|
70
|
+
/** Two-hour branch (子丑寅…) the corrected time falls in */
|
|
71
|
+
hourBranch: string;
|
|
72
|
+
/** Branch on the other side of the nearest boundary */
|
|
73
|
+
neighborBranch: string;
|
|
74
|
+
/** Minutes to the nearest hour-branch boundary; 0 = exactly on it */
|
|
75
|
+
minutesToBoundary: number;
|
|
76
|
+
/**
|
|
77
|
+
* Corrected time sits close enough to a boundary that a small error in the
|
|
78
|
+
* recorded birth time flips the entire hour pillar. Threshold below —
|
|
79
|
+
* it is a convention, not physics; treat near-boundary charts as "cast both".
|
|
80
|
+
*/
|
|
81
|
+
boundaryRisk: boolean;
|
|
82
|
+
}
|
|
83
|
+
/** Within this many minutes of a branch boundary we flag `boundaryRisk` */
|
|
84
|
+
export declare const BOUNDARY_RISK_MINUTES = 8;
|
|
85
|
+
/** UTC offset (minutes, east positive) of an IANA zone at a given UTC instant */
|
|
86
|
+
export declare function tzOffsetMinutes(timeZone: string, utcMillis: number): number;
|
|
87
|
+
/** Zone abbreviation, e.g. 'PDT' / 'GMT+9' */
|
|
88
|
+
export declare function tzAbbr(timeZone: string, utcMillis: number): string;
|
|
89
|
+
/**
|
|
90
|
+
* Standard (non-DST) offset of the zone around a given instant.
|
|
91
|
+
*
|
|
92
|
+
* JS has no isdst API, so sample Jan 1, Jul 1 and the instant itself and take
|
|
93
|
+
* the minimum: DST always moves the clock forward (larger offset), so the
|
|
94
|
+
* minimum is the standard offset in both hemispheres. Including the instant
|
|
95
|
+
* itself keeps dstMinutes ≤ 0 — otherwise Morocco-style "negative DST"
|
|
96
|
+
* (Ramadan) would be reported as +60 minutes of DST.
|
|
97
|
+
*
|
|
98
|
+
* Limitation: in a year where the standard offset itself changed (Russia 2011,
|
|
99
|
+
* parts of South America), this heuristic attributes to "standard" whichever
|
|
100
|
+
* is smaller. That only affects how the parts are labeled — the total
|
|
101
|
+
* correction always comes from the actual offset and is unaffected.
|
|
102
|
+
*/
|
|
103
|
+
export declare function standardOffsetMinutes(timeZone: string, utcMillis: number): number;
|
|
104
|
+
/**
|
|
105
|
+
* Wall-clock time (in the birth zone) → UTC millis, reporting whether that
|
|
106
|
+
* clock reading ever actually existed.
|
|
107
|
+
*
|
|
108
|
+
* At a spring-forward transition the local clock jumps straight from 01:59 to
|
|
109
|
+
* 03:00 — the hour in between **never happened in that zone** (China each
|
|
110
|
+
* year 1986–91; early DST in Taiwan/HK/Singapore too). For such inputs the
|
|
111
|
+
* fixed-point iteration below has no solution: the computed UTC instant reads
|
|
112
|
+
* back to a different local time than the input. A naive implementation
|
|
113
|
+
* returns it anyway, silently landing after the jump — so 02:59 and 03:00
|
|
114
|
+
* map to the same real instant yet produce different hour pillars.
|
|
115
|
+
*
|
|
116
|
+
* So we verify by reading back. On mismatch, classify as a gap input, pin
|
|
117
|
+
* `utc` to the jump instant (the first moment the clock is continuous again)
|
|
118
|
+
* and report the gap width, letting the caller say "this clock time did not
|
|
119
|
+
* exist that night" instead of guessing on the user's behalf.
|
|
120
|
+
*/
|
|
121
|
+
export declare function resolveWallTime(year: number, month: number, day: number, hour: number, minute: number, timeZone: string): {
|
|
122
|
+
utc: number;
|
|
123
|
+
nonexistent: boolean;
|
|
124
|
+
gapMinutes: number;
|
|
125
|
+
};
|
|
126
|
+
/** Equation of time (minutes), NOAA approximation */
|
|
127
|
+
export declare function equationOfTimeMinutes(dayOfYear: number): number;
|
|
128
|
+
/**
|
|
129
|
+
* Branch membership and boundary distance. Branches switch on odd hours
|
|
130
|
+
* (子 starts 23:00, 丑 starts 01:00, …), 120 minutes each.
|
|
131
|
+
*/
|
|
132
|
+
export declare function hourBranchContext(hour: number, minute: number): {
|
|
133
|
+
branch: string;
|
|
134
|
+
neighbor: string;
|
|
135
|
+
minutes: number;
|
|
136
|
+
};
|
|
137
|
+
export declare function toTrueSolarTime(year: number, month: number, day: number, hour: number, minute: number, timeZone: string, longitude: number): TrueSolarTimeInfo;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True solar time correction — the step most BaZi / Four Pillars calculators skip.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline:
|
|
5
|
+
* 1. wall-clock time + IANA time zone (historical DST rules included, via ICU) → UTC instant
|
|
6
|
+
* 2. UTC + longitude × 4 min/degree → local mean solar time
|
|
7
|
+
* 3. + equation of time → true (apparent) solar time
|
|
8
|
+
*
|
|
9
|
+
* The total correction is also decomposed into three parts (DST + longitude +
|
|
10
|
+
* equation of time) whose rounded sum is always exactly equal to the total —
|
|
11
|
+
* so the result can be shown as an addition table a reader can check by hand.
|
|
12
|
+
*
|
|
13
|
+
* Equation of time uses the NOAA approximation (error well under a minute; it
|
|
14
|
+
* can only matter within ±1 minute of an hour-branch boundary). The bigger,
|
|
15
|
+
* more common risk is the corrected time landing near a boundary at all —
|
|
16
|
+
* see `minutesToBoundary` / `boundaryRisk`.
|
|
17
|
+
*
|
|
18
|
+
* No home-grown historical time-zone tables: Node's ICU data already knows
|
|
19
|
+
* China's 1986–91 DST, early Taiwan/HK/Singapore DST, pre-1949 Asia/Harbin,
|
|
20
|
+
* and so on. Give it the right IANA zone name and the offset is right.
|
|
21
|
+
*
|
|
22
|
+
* This file mirrors the engine behind https://auspiceoracle.com — the full
|
|
23
|
+
* methodology, with every constant, is published at
|
|
24
|
+
* https://auspiceoracle.com/en/method
|
|
25
|
+
*/
|
|
26
|
+
/** Within this many minutes of a branch boundary we flag `boundaryRisk` */
|
|
27
|
+
export const BOUNDARY_RISK_MINUTES = 8;
|
|
28
|
+
/** UTC offset (minutes, east positive) of an IANA zone at a given UTC instant */
|
|
29
|
+
export function tzOffsetMinutes(timeZone, utcMillis) {
|
|
30
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
31
|
+
timeZone,
|
|
32
|
+
hourCycle: 'h23',
|
|
33
|
+
year: 'numeric',
|
|
34
|
+
month: '2-digit',
|
|
35
|
+
day: '2-digit',
|
|
36
|
+
hour: '2-digit',
|
|
37
|
+
minute: '2-digit',
|
|
38
|
+
second: '2-digit',
|
|
39
|
+
});
|
|
40
|
+
const parts = {};
|
|
41
|
+
for (const p of dtf.formatToParts(utcMillis)) {
|
|
42
|
+
if (p.type !== 'literal')
|
|
43
|
+
parts[p.type] = parseInt(p.value, 10);
|
|
44
|
+
}
|
|
45
|
+
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
46
|
+
return Math.round((asUtc - utcMillis) / 60000);
|
|
47
|
+
}
|
|
48
|
+
/** Zone abbreviation, e.g. 'PDT' / 'GMT+9' */
|
|
49
|
+
export function tzAbbr(timeZone, utcMillis) {
|
|
50
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' }).formatToParts(utcMillis);
|
|
51
|
+
return parts.find((p) => p.type === 'timeZoneName')?.value ?? '';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Standard (non-DST) offset of the zone around a given instant.
|
|
55
|
+
*
|
|
56
|
+
* JS has no isdst API, so sample Jan 1, Jul 1 and the instant itself and take
|
|
57
|
+
* the minimum: DST always moves the clock forward (larger offset), so the
|
|
58
|
+
* minimum is the standard offset in both hemispheres. Including the instant
|
|
59
|
+
* itself keeps dstMinutes ≤ 0 — otherwise Morocco-style "negative DST"
|
|
60
|
+
* (Ramadan) would be reported as +60 minutes of DST.
|
|
61
|
+
*
|
|
62
|
+
* Limitation: in a year where the standard offset itself changed (Russia 2011,
|
|
63
|
+
* parts of South America), this heuristic attributes to "standard" whichever
|
|
64
|
+
* is smaller. That only affects how the parts are labeled — the total
|
|
65
|
+
* correction always comes from the actual offset and is unaffected.
|
|
66
|
+
*/
|
|
67
|
+
export function standardOffsetMinutes(timeZone, utcMillis) {
|
|
68
|
+
const year = new Date(utcMillis).getUTCFullYear();
|
|
69
|
+
return Math.min(tzOffsetMinutes(timeZone, Date.UTC(year, 0, 1)), tzOffsetMinutes(timeZone, Date.UTC(year, 6, 1)), tzOffsetMinutes(timeZone, utcMillis));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Wall-clock time (in the birth zone) → UTC millis, reporting whether that
|
|
73
|
+
* clock reading ever actually existed.
|
|
74
|
+
*
|
|
75
|
+
* At a spring-forward transition the local clock jumps straight from 01:59 to
|
|
76
|
+
* 03:00 — the hour in between **never happened in that zone** (China each
|
|
77
|
+
* year 1986–91; early DST in Taiwan/HK/Singapore too). For such inputs the
|
|
78
|
+
* fixed-point iteration below has no solution: the computed UTC instant reads
|
|
79
|
+
* back to a different local time than the input. A naive implementation
|
|
80
|
+
* returns it anyway, silently landing after the jump — so 02:59 and 03:00
|
|
81
|
+
* map to the same real instant yet produce different hour pillars.
|
|
82
|
+
*
|
|
83
|
+
* So we verify by reading back. On mismatch, classify as a gap input, pin
|
|
84
|
+
* `utc` to the jump instant (the first moment the clock is continuous again)
|
|
85
|
+
* and report the gap width, letting the caller say "this clock time did not
|
|
86
|
+
* exist that night" instead of guessing on the user's behalf.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveWallTime(year, month, day, hour, minute, timeZone) {
|
|
89
|
+
const target = Date.UTC(year, month - 1, day, hour, minute);
|
|
90
|
+
let guess = target;
|
|
91
|
+
for (let i = 0; i < 2; i++) {
|
|
92
|
+
const offset = tzOffsetMinutes(timeZone, guess);
|
|
93
|
+
guess = target - offset * 60000;
|
|
94
|
+
}
|
|
95
|
+
// Read-back check: an existing clock time must be self-consistent
|
|
96
|
+
if (tzOffsetMinutes(timeZone, guess) * 60000 + guess === target) {
|
|
97
|
+
return { utc: guess, nonexistent: false, gapMinutes: 0 };
|
|
98
|
+
}
|
|
99
|
+
// Inside the gap. Bisect on the **UTC axis** (the local axis is
|
|
100
|
+
// discontinuous here, bisecting it is meaningless) for the minute where
|
|
101
|
+
// the offset jumps.
|
|
102
|
+
let lo = target - 86400000;
|
|
103
|
+
let hi = target + 86400000;
|
|
104
|
+
const offBefore = tzOffsetMinutes(timeZone, lo);
|
|
105
|
+
const offAfter = tzOffsetMinutes(timeZone, hi);
|
|
106
|
+
while (hi - lo > 60000) {
|
|
107
|
+
const mid = lo + Math.floor((hi - lo) / 2 / 60000) * 60000;
|
|
108
|
+
if (mid <= lo)
|
|
109
|
+
break;
|
|
110
|
+
if (tzOffsetMinutes(timeZone, mid) === offBefore)
|
|
111
|
+
lo = mid;
|
|
112
|
+
else
|
|
113
|
+
hi = mid;
|
|
114
|
+
}
|
|
115
|
+
// hi = first instant after the jump; gap width = minutes the clock skipped
|
|
116
|
+
return { utc: hi, nonexistent: true, gapMinutes: offAfter - offBefore };
|
|
117
|
+
}
|
|
118
|
+
/** Equation of time (minutes), NOAA approximation */
|
|
119
|
+
export function equationOfTimeMinutes(dayOfYear) {
|
|
120
|
+
const b = (2 * Math.PI * (dayOfYear - 81)) / 364;
|
|
121
|
+
return 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b);
|
|
122
|
+
}
|
|
123
|
+
function utcDayOfYear(millis) {
|
|
124
|
+
const d = new Date(millis);
|
|
125
|
+
const start = Date.UTC(d.getUTCFullYear(), 0, 1);
|
|
126
|
+
return Math.floor((millis - start) / 86400000) + 1;
|
|
127
|
+
}
|
|
128
|
+
/** The twelve hour-branches; index = branch ordinal (子 = 0) */
|
|
129
|
+
const HOUR_BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
|
|
130
|
+
/**
|
|
131
|
+
* Branch membership and boundary distance. Branches switch on odd hours
|
|
132
|
+
* (子 starts 23:00, 丑 starts 01:00, …), 120 minutes each.
|
|
133
|
+
*/
|
|
134
|
+
export function hourBranchContext(hour, minute) {
|
|
135
|
+
const idx = Math.floor(((hour + 1) % 24) / 2);
|
|
136
|
+
const pos = (((hour * 60 + minute - 60) % 120) + 120) % 120;
|
|
137
|
+
return pos <= 60
|
|
138
|
+
? { branch: HOUR_BRANCHES[idx], neighbor: HOUR_BRANCHES[(idx + 11) % 12], minutes: pos }
|
|
139
|
+
: { branch: HOUR_BRANCHES[idx], neighbor: HOUR_BRANCHES[(idx + 1) % 12], minutes: 120 - pos };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Normalize longitude to within ±180° of the zone's standard meridian.
|
|
143
|
+
* Regions across the date line from their meridian (Chatham at 176.5°W on
|
|
144
|
+
* UTC+12:45, Samoa, Kiritimati) would otherwise get a mean solar time a full
|
|
145
|
+
* day off — which mis-casts the day pillar.
|
|
146
|
+
*/
|
|
147
|
+
function normalizeLongitude(longitude, standardMeridian) {
|
|
148
|
+
let lon = longitude;
|
|
149
|
+
while (lon - standardMeridian > 180)
|
|
150
|
+
lon -= 360;
|
|
151
|
+
while (lon - standardMeridian < -180)
|
|
152
|
+
lon += 360;
|
|
153
|
+
return lon;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The three rounded parts must sum exactly to the rounded total, or the
|
|
157
|
+
* addition table stops adding up. Distribute the residual (at most ±1) to the
|
|
158
|
+
* component with the largest rounding error — the zone offset is always whole
|
|
159
|
+
* minutes, so it lands on longitude or the equation of time.
|
|
160
|
+
*/
|
|
161
|
+
function allocateParts(total, exact) {
|
|
162
|
+
const rounded = exact.map((v) => Math.round(v));
|
|
163
|
+
let residual = total - rounded[0] - rounded[1] - rounded[2];
|
|
164
|
+
const order = exact
|
|
165
|
+
.map((v, i) => ({ i, err: Math.abs(v - rounded[i]) }))
|
|
166
|
+
.sort((a, b) => b.err - a.err);
|
|
167
|
+
for (const { i } of order) {
|
|
168
|
+
if (residual === 0)
|
|
169
|
+
break;
|
|
170
|
+
const step = Math.sign(residual);
|
|
171
|
+
rounded[i] += step;
|
|
172
|
+
residual -= step;
|
|
173
|
+
}
|
|
174
|
+
return rounded;
|
|
175
|
+
}
|
|
176
|
+
export function toTrueSolarTime(year, month, day, hour, minute, timeZone, longitude) {
|
|
177
|
+
const resolved = resolveWallTime(year, month, day, hour, minute, timeZone);
|
|
178
|
+
const utc = resolved.utc;
|
|
179
|
+
const actualOffset = tzOffsetMinutes(timeZone, utc);
|
|
180
|
+
const stdOffset = standardOffsetMinutes(timeZone, utc);
|
|
181
|
+
const standardMeridian = stdOffset / 4;
|
|
182
|
+
const lon = normalizeLongitude(longitude, standardMeridian);
|
|
183
|
+
// local mean solar time = UTC + longitude × 4 min (east positive)
|
|
184
|
+
const meanSolar = utc + lon * 4 * 60000;
|
|
185
|
+
const eot = equationOfTimeMinutes(utcDayOfYear(meanSolar));
|
|
186
|
+
const trueSolar = meanSolar + eot * 60000;
|
|
187
|
+
const d = new Date(trueSolar);
|
|
188
|
+
// The correction's reference is the clock time the user reported. A gap
|
|
189
|
+
// input has no such clock time, so use the local reading at the jump
|
|
190
|
+
// instant instead — otherwise the three parts stop summing to the total.
|
|
191
|
+
const clockAsUtc = resolved.nonexistent
|
|
192
|
+
? utc + actualOffset * 60000
|
|
193
|
+
: Date.UTC(year, month - 1, day, hour, minute);
|
|
194
|
+
const correctionMinutes = Math.round((trueSolar - clockAsUtc) / 60000);
|
|
195
|
+
// total = −actual offset + longitude×4 + EoT
|
|
196
|
+
// = (standard − actual) + (longitude×4 − standard) + EoT
|
|
197
|
+
// = DST + longitude + EoT
|
|
198
|
+
const [dstMinutes, longitudeMinutes, eotMinutes] = allocateParts(correctionMinutes, [
|
|
199
|
+
stdOffset - actualOffset,
|
|
200
|
+
lon * 4 - stdOffset,
|
|
201
|
+
eot,
|
|
202
|
+
]);
|
|
203
|
+
const outHour = d.getUTCHours();
|
|
204
|
+
const outMinute = d.getUTCMinutes();
|
|
205
|
+
const hb = hourBranchContext(outHour, outMinute);
|
|
206
|
+
return {
|
|
207
|
+
year: d.getUTCFullYear(),
|
|
208
|
+
month: d.getUTCMonth() + 1,
|
|
209
|
+
day: d.getUTCDate(),
|
|
210
|
+
hour: outHour,
|
|
211
|
+
minute: outMinute,
|
|
212
|
+
correctionMinutes,
|
|
213
|
+
equationOfTimeMinutes: Math.round(eot * 10) / 10,
|
|
214
|
+
parts: { dstMinutes, longitudeMinutes, equationOfTimeMinutes: eotMinutes },
|
|
215
|
+
timeZone,
|
|
216
|
+
longitude: lon,
|
|
217
|
+
tzOffsetMinutes: actualOffset,
|
|
218
|
+
standardOffsetMinutes: stdOffset,
|
|
219
|
+
standardMeridian,
|
|
220
|
+
dst: { active: dstMinutes !== 0, abbr: tzAbbr(timeZone, utc) },
|
|
221
|
+
nonexistentWallTime: {
|
|
222
|
+
hit: resolved.nonexistent,
|
|
223
|
+
gapMinutes: resolved.nonexistent ? resolved.gapMinutes : 0,
|
|
224
|
+
},
|
|
225
|
+
hourBranch: hb.branch,
|
|
226
|
+
neighborBranch: hb.neighbor,
|
|
227
|
+
minutesToBoundary: hb.minutes,
|
|
228
|
+
boundaryRisk: hb.minutes <= BOUNDARY_RISK_MINUTES,
|
|
229
|
+
};
|
|
230
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "true-solar-time-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"mcpName": "io.github.shann5/true-solar-time",
|
|
5
|
+
"description": "True solar time correction for BaZi / Four Pillars charts — historical DST + longitude + equation of time. The step most chart calculators skip. MCP server + CLI + library.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"bazi",
|
|
8
|
+
"four-pillars",
|
|
9
|
+
"chinese-astrology",
|
|
10
|
+
"true-solar-time",
|
|
11
|
+
"equation-of-time",
|
|
12
|
+
"mcp",
|
|
13
|
+
"mcp-server",
|
|
14
|
+
"timezone",
|
|
15
|
+
"dst"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Shan Liu (https://auspiceoracle.com)",
|
|
19
|
+
"homepage": "https://auspiceoracle.com/en/content/true-solar-time",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Shann5/true-solar-time-mcp.git"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "dist/solar-time.js",
|
|
26
|
+
"types": "dist/solar-time.d.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"true-solar-time-mcp": "dist/index.js",
|
|
29
|
+
"true-solar-time": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"prepublishOnly": "npm run build && npm test"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
44
|
+
"zod": "^3.25.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"typescript": "^5.6.0",
|
|
48
|
+
"vitest": "^3.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|