Skip to content

Commit 6ca6f00

Browse files
feat: add true solar time resolution
1 parent 902127d commit 6ca6f00

16 files changed

Lines changed: 918 additions & 47 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
### 精确排盘
1010
- **四柱计算** — 年/月/日/时柱,ephem 天文库节气精度 ±1 秒
11+
- **真太阳时口径** — 选择内置城市时按经度与均时差校正;城市未知时严格保留用户输入时间,不伪造地点精度
1112
- **藏干十神** — 地支藏干(本气/中气/余气),天干十神定位
1213
- **格局判定** — 月令透干优先 + 建禄/羊刃特殊处理
1314
- **神煞检测** — 天乙贵人/红鸾/天喜/桃花/驿马/文昌/羊刃/灾煞/丧门/吊客
@@ -82,6 +83,8 @@ python -m uvicorn bazi_engine.api:app --host 0.0.0.0 --port 8000
8283

8384
浏览器打开 `http://localhost:8000` 使用前端界面。
8485

86+
出生城市由本地清单搜索选择;未选城市默认“未知”。高级设置可填写手动经度和时区。时间口径、校正分钟数、UTC 出生瞬间和最终排盘时间会写入报告元数据,详见 [时间口径](docs/time-basis.md)
87+
8588
### 测试
8689

8790
```bash

docs/time-basis.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# 出生时间与真太阳时口径
2+
3+
## 默认行为
4+
5+
- 出生城市默认“未知”。未知城市按用户录入的钟表时间排盘,默认时区为中国标准时间 `UTC+8`
6+
- 从内置城市清单选择地点后,系统默认使用真太阳时。
7+
- 高级设置的手动经度优先于城市经度;时区偏移可同时手动指定。
8+
- 运行时不调用第三方地理编码、定位或地图服务。城市名称的自由输入不作为经度来源,只有从内置清单选中后才会启用城市经度。
9+
10+
## 两条时间线
11+
12+
每次排盘都会保留三种可追溯时间:
13+
14+
| 名称 | 用途 |
15+
|---|---|
16+
| 输入民用时间 | 用户出生记录、年龄计算与报告原始输入 |
17+
| 真实出生瞬间 | 将输入时间按时区换算为 UTC;用于节气、年柱、月柱、起运与交运距离 |
18+
| 最终排盘时间 | 输入民用时间加真太阳时校正;用于日柱、时柱、命宫、身宫、小运与时柱下游规则 |
19+
20+
真太阳时校正使用:
21+
22+
`均时差 + 4 × (经度 - 时区中央经线)` 分钟。
23+
24+
均时差采用 NOAA 的标准近似式。报告会显示总校正分钟数及最终排盘时间;它不是对出生记录本身精确性的证明。
25+
26+
## 23 点边界
27+
28+
无论城市是否已知,最终排盘时间在 `23:00``23:59` 时,日柱统一按次日计算。年柱、月柱和起运仍使用真实出生瞬间,不会因真太阳时跨日而重算。
29+
30+
## 准确度与历史报告
31+
32+
`time_accuracy` 表示出生记录精度(分钟、时辰或未知),与 `hour_confirmed` 的分析确认状态独立保存。报告元数据从 `schema_version 1.1` 起记录 `time_resolution_version: 2.0`、城市清单版本、时间模式、UTC 瞬间、经度来源与最终排盘时间。
33+
34+
旧报告不会在读取时自动套用新算法。只有新的排盘请求会生成新的时间口径元数据。
35+
36+
## 城市清单范围
37+
38+
当前内置清单版本为 `cn-major-cities-v1`,覆盖省会、直辖市、特别行政区及常用主要城市,坐标为城市中心经度。它不把未覆盖的县区、乡镇或同名地自动映射到附近城市;遇到未列地点应保持“未知”或由用户在高级设置填写经度。后续扩充必须随清单版本、坐标来源与回归用例一起提交。

frontend/app.js

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ document.getElementById('themeToggle').addEventListener('click', toggleTheme);
5555
Form: Enter to submit
5656
========================================== */
5757
document.getElementById('formCard').addEventListener('keydown', function(e){
58+
if (e.target && e.target.id === 'citySearch') return;
5859
if (e.key === 'Enter') {
5960
e.preventDefault();
6061
go();
@@ -78,6 +79,177 @@ function updateHourHint(){
7879
document.getElementById('hourConfirmed').addEventListener('change', updateHourHint);
7980
updateHourHint();
8081

82+
/* ==========================================
83+
Birth place and time basis
84+
========================================== */
85+
let LocationState = {
86+
city: null,
87+
citySearchTimer: null,
88+
previewTimer: null,
89+
requestId: 0,
90+
previewRequestId: 0,
91+
};
92+
93+
function getApiBase(){
94+
return document.getElementById('apiUrl').value.replace(/\/$/, '');
95+
}
96+
97+
function getLocationPayload(){
98+
let longitude = document.getElementById('longitude').value.trim();
99+
let timezoneOffset = document.getElementById('timezoneOffset').value.trim();
100+
return {
101+
city_id: LocationState.city ? LocationState.city.id : null,
102+
longitude: longitude === '' ? null : Number(longitude),
103+
timezone_offset_minutes: timezoneOffset === '' ? null : Number(timezoneOffset),
104+
requested_time_mode: 'auto',
105+
time_accuracy: document.getElementById('timeAccuracy').value,
106+
};
107+
}
108+
109+
function setTimeBasisNote(text){
110+
let note = document.getElementById('timeBasisNote');
111+
if (note) note.textContent = text;
112+
}
113+
114+
function hideCityOptions(){
115+
let options = document.getElementById('cityOptions');
116+
let search = document.getElementById('citySearch');
117+
options.hidden = true;
118+
search.setAttribute('aria-expanded', 'false');
119+
}
120+
121+
function selectCity(city){
122+
LocationState.city = city;
123+
document.getElementById('citySearch').value = city.name;
124+
hideCityOptions();
125+
queueTimePreview();
126+
}
127+
128+
function clearCity(){
129+
LocationState.city = null;
130+
document.getElementById('citySearch').value = '';
131+
hideCityOptions();
132+
queueTimePreview();
133+
}
134+
135+
function renderCityOptions(items){
136+
let options = document.getElementById('cityOptions');
137+
let search = document.getElementById('citySearch');
138+
if (!items.length){
139+
options.hidden = true;
140+
search.setAttribute('aria-expanded', 'false');
141+
return;
142+
}
143+
let html = '';
144+
for (let i = 0; i < items.length; i++){
145+
let city = items[i];
146+
html += '<button type="button" class="city-option" role="option" aria-selected="false" data-city-id="' + esc(city.id) + '">';
147+
html += '<span>' + esc(city.name) + '</span><small>' + esc(city.province || '') + '</small></button>';
148+
}
149+
options.innerHTML = html;
150+
options.hidden = false;
151+
search.setAttribute('aria-expanded', 'true');
152+
let buttons = options.querySelectorAll('.city-option');
153+
for (let i = 0; i < buttons.length; i++){
154+
buttons[i].addEventListener('click', function(){
155+
for (let j = 0; j < items.length; j++){
156+
if (items[j].id === this.dataset.cityId){
157+
selectCity(items[j]);
158+
return;
159+
}
160+
}
161+
});
162+
}
163+
}
164+
165+
function searchCities(){
166+
let search = document.getElementById('citySearch');
167+
let query = search.value.trim();
168+
if (!query){
169+
hideCityOptions();
170+
return;
171+
}
172+
let requestId = ++LocationState.requestId;
173+
fetch(getApiBase() + '/api/locations?q=' + encodeURIComponent(query))
174+
.then(function(response){ return response.ok ? response.json() : {items: []}; })
175+
.then(function(data){
176+
if (requestId !== LocationState.requestId) return;
177+
renderCityOptions(Array.isArray(data.items) ? data.items : []);
178+
})
179+
.catch(function(){ hideCityOptions(); });
180+
}
181+
182+
function scheduleCitySearch(){
183+
let search = document.getElementById('citySearch');
184+
if (LocationState.city && search.value !== LocationState.city.name) LocationState.city = null;
185+
window.clearTimeout(LocationState.citySearchTimer);
186+
LocationState.citySearchTimer = window.setTimeout(searchCities, 160);
187+
queueTimePreview();
188+
}
189+
190+
function timeFieldsAreComplete(){
191+
let fields = ['year', 'month', 'day', 'hour', 'minute'];
192+
for (let i = 0; i < fields.length; i++){
193+
let value = document.getElementById(fields[i]).value;
194+
if (value === '') return false;
195+
}
196+
return true;
197+
}
198+
199+
function formatTimeBasisPreview(preview){
200+
if (preview.effective_time_mode !== 'true_solar'){
201+
setTimeBasisNote('出生地未知:按输入时间排盘,默认采用中国标准时间(UTC+8)。');
202+
return;
203+
}
204+
let correction = Number(preview.solar_correction_minutes || 0);
205+
let signed = (correction >= 0 ? '+' : '') + correction.toFixed(1);
206+
let city = preview.city ? preview.city.label : '手动经度';
207+
let rollover = preview.day_pillar_uses_next_date ? ';23 点后按次日子时取日柱' : '';
208+
setTimeBasisNote(city + ':真太阳时 ' + preview.pillar_time + '(校正 ' + signed + ' 分钟)' + rollover + '。');
209+
}
210+
211+
function requestTimePreview(){
212+
if (!timeFieldsAreComplete()) return;
213+
let previewRequestId = ++LocationState.previewRequestId;
214+
let payload = getLocationPayload();
215+
payload.year = Number(document.getElementById('year').value);
216+
payload.month = Number(document.getElementById('month').value);
217+
payload.day = Number(document.getElementById('day').value);
218+
payload.hour = Number(document.getElementById('hour').value);
219+
payload.minute = Number(document.getElementById('minute').value);
220+
fetch(getApiBase() + '/api/time/preview', {
221+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
222+
}).then(function(response){
223+
return response.ok ? response.json() : null;
224+
}).then(function(preview){
225+
if (preview && previewRequestId === LocationState.previewRequestId) formatTimeBasisPreview(preview);
226+
}).catch(function(){
227+
if (previewRequestId === LocationState.previewRequestId) {
228+
setTimeBasisNote('出生地未知:按输入时间排盘,默认采用中国标准时间(UTC+8)。');
229+
}
230+
});
231+
}
232+
233+
function queueTimePreview(){
234+
window.clearTimeout(LocationState.previewTimer);
235+
LocationState.previewTimer = window.setTimeout(requestTimePreview, 180);
236+
}
237+
238+
document.getElementById('citySearch').addEventListener('input', scheduleCitySearch);
239+
document.getElementById('citySearch').addEventListener('keydown', function(event){
240+
if (event.key !== 'Enter') return;
241+
let options = document.querySelectorAll('#cityOptions .city-option');
242+
if (!options.length) return;
243+
event.preventDefault();
244+
options[0].click();
245+
});
246+
document.getElementById('citySearch').addEventListener('blur', function(){ window.setTimeout(hideCityOptions, 120); });
247+
document.getElementById('cityClear').addEventListener('click', clearCity);
248+
['year', 'month', 'day', 'hour', 'minute', 'longitude', 'timezoneOffset', 'timeAccuracy'].forEach(function(id){
249+
document.getElementById(id).addEventListener('input', queueTimePreview);
250+
document.getElementById(id).addEventListener('change', queueTimePreview);
251+
});
252+
81253
/* ==========================================
82254
Shared app state
83255
========================================== */
@@ -309,6 +481,14 @@ function _buildChartParams(){
309481
hour_confirmed: document.getElementById('hourConfirmed').checked,
310482
practical: true, // 公网只显示白话解读,不暴露技术推导
311483
});
484+
let location = getLocationPayload();
485+
if (location.city_id) params.set('city_id', location.city_id);
486+
if (location.longitude !== null && Number.isFinite(location.longitude)) params.set('longitude', String(location.longitude));
487+
if (location.timezone_offset_minutes !== null && Number.isFinite(location.timezone_offset_minutes)) {
488+
params.set('timezone_offset_minutes', String(location.timezone_offset_minutes));
489+
}
490+
params.set('requested_time_mode', location.requested_time_mode);
491+
params.set('time_accuracy', location.time_accuracy);
312492
if (lnFrom) params.set('liunian_from', lnFrom);
313493
if (lnTo) params.set('liunian_to', lnTo);
314494
let lsOverride = sessionStorage.getItem('bazi-life-stage');
@@ -352,7 +532,7 @@ async function go(){
352532
document.getElementById('copyBtn').style.display = 'none';
353533
document.getElementById('reportActions').classList.remove('active');
354534

355-
let api = document.getElementById('apiUrl').value.replace(/\/$/, '');
535+
let api = getApiBase();
356536
let payload = _buildChartRequest();
357537
let controller = beginStreamRequest('chart');
358538

@@ -1437,7 +1617,18 @@ function render(d){
14371617
let daySource = traceability.day_pillar_source === 'override' ? '用户提供的日柱覆盖' : '公式计算';
14381618
h += '<details class="evidence-details" style="margin-bottom:16px"><summary>报告依据与边界</summary>';
14391619
h += '<div class="evidence-scale-content">';
1440-
h += '<div>出生时间:' + esc(input.birth_time || d.birth || '') + '(分钟精度)</div>';
1620+
let accuracyLabels = {minute: '精确到分钟', hour: '仅确认时辰', unknown: '时间不确定'};
1621+
let accuracy = accuracyLabels[input.time_accuracy || input.time_precision] || '未说明';
1622+
let isTrueSolar = input.effective_time_mode === 'true_solar';
1623+
let cityLabel = input.city && input.city.label ? input.city.label : '未知';
1624+
h += '<div>出生时间:' + esc(input.birth_time || d.birth || '') + '(' + esc(accuracy) + ')</div>';
1625+
if (isTrueSolar){
1626+
let correction = Number(input.solar_correction_minutes || 0);
1627+
let signed = (correction >= 0 ? '+' : '') + correction.toFixed(1);
1628+
h += '<div>排盘时间:' + esc(input.pillar_time || '') + '(' + esc(cityLabel) + '真太阳时,校正 ' + esc(signed) + ' 分钟)</div>';
1629+
} else {
1630+
h += '<div>排盘时间:按输入时间;出生地:' + esc(cityLabel) + '(默认 UTC+8)</div>';
1631+
}
14411632
h += '<div>日柱来源:' + esc(daySource) + ';流年信号:' + esc(signalSources) + '</div>';
14421633
h += '<div>' + esc(uncertainty.scope || '传统文化参考,不构成对具体事件的确定性判断') + '</div>';
14431634
h += '</div></details>';

frontend/index.html

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<title>八字解读</title>
77
<script>(function(){var t=localStorage.getItem('bazi-theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme:dark)').matches))document.documentElement.classList.add('dark')})()</script>
88
<link rel="icon" href="favicon.svg?v=20260716" type="image/svg+xml">
9-
<link rel="stylesheet" href="style.css?v=20260718-birth-layout1">
9+
<link rel="stylesheet" href="style.css?v=20260718-time-resolution1">
1010
<link rel=stylesheet href=chat.css>
1111
</head>
1212
<body>
@@ -45,7 +45,15 @@ <h1>八字解读</h1>
4545
<label><span></span><input id="hour" type="number" placeholder="12" min="0" max="23"></label>
4646
<label><span></span><input id="minute" type="number" placeholder="00" min="0" max="59" value="0"></label>
4747
</div>
48-
<p class="time-basis-note">出生时间按中国标准时间(UTC+8)计算,暂不自动校正真太阳时。</p>
48+
<div class="birth-location-row">
49+
<label for="citySearch"><span>出生城市(默认未知)</span></label>
50+
<div class="city-picker">
51+
<input id="citySearch" type="search" placeholder="搜索内置城市,如成都" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="cityOptions">
52+
<button type="button" class="city-clear" id="cityClear" title="清除城市" aria-label="清除城市">&times;</button>
53+
<div class="city-options" id="cityOptions" role="listbox" hidden></div>
54+
</div>
55+
</div>
56+
<p class="time-basis-note" id="timeBasisNote">出生地未知:按输入时间排盘,默认采用中国标准时间(UTC+8)。</p>
4957
<div class="form-row" style="margin-bottom:6px">
5058
<label style="flex-direction:row;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary)">
5159
<input type="checkbox" id="hourConfirmed" style="width:16px;height:16px;accent-color:var(--accent);cursor:pointer">
@@ -55,6 +63,11 @@ <h1>八字解读</h1>
5563
</div>
5664
<details class="advanced-options">
5765
<summary>高级设置</summary>
66+
<div class="form-row advanced-time-grid" style="margin-top:10px">
67+
<label><span>出生记录精度</span><select id="timeAccuracy"><option value="minute">精确到分钟</option><option value="hour">仅确认时辰</option><option value="unknown">时间不确定</option></select></label>
68+
<label><span>手动经度(东经为正)</span><input id="longitude" type="number" step="0.0001" min="-180" max="180" placeholder="可选"></label>
69+
<label><span>时区偏移(分钟)</span><input id="timezoneOffset" type="number" min="-720" max="840" value="480"></label>
70+
</div>
5871
<div class="form-row" style="margin-top:10px">
5972
<label><span>流年范围</span><input id="lnFrom" type="number" placeholder="2026" min="1900" max="2100"></label>
6073
<label><span></span><input id="lnTo" type="number" placeholder="2031" min="1900" max="2100"></label>
@@ -88,7 +101,7 @@ <h3>日主详情 <button class="modal-close" id="modalClose">&times;</button></h
88101
<button type="button" onclick="window.scrollTo({top:0,behavior:'smooth'})">顶部</button>
89102
</div>
90103

91-
<script src="app.js?v=20260718-ai-review1"></script>
104+
<script src="app.js?v=20260718-time-resolution1"></script>
92105

93106
<!-- ═══ Chat Toggle ═══ -->
94107
<button class=chat-toggle id=chatToggle onclick=toggleChat() title="AI 追问">💬<span class=badge id=chatBadge></span></button>

frontend/style.css

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,32 @@ body{
142142
.birth-date-context{grid-column:1 / 4}
143143
.birth-time-context{grid-column:4 / 6}
144144
.birth-datetime-grid label{grid-row:2;min-width:0}
145+
.birth-datetime-grid label span{white-space:nowrap}
146+
.birth-location-row{margin:-2px 0 12px}
147+
.birth-location-row > label{display:block;margin-bottom:4px}
148+
.birth-location-row > label span{font-size:12px;font-weight:500;color:var(--text-secondary)}
149+
.city-picker{position:relative}
150+
.city-picker input{padding-right:40px}
151+
.city-clear{
152+
position:absolute;right:6px;top:50%;transform:translateY(-50%);
153+
width:28px;height:28px;border:0;background:transparent;color:var(--text-tertiary);
154+
font-size:22px;line-height:1;cursor:pointer;
155+
}
156+
.city-clear:hover{color:var(--text)}
157+
.city-options{
158+
position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);
159+
max-height:228px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);
160+
border-radius:var(--radius-sm);box-shadow:var(--shadow-modal);padding:4px;
161+
}
162+
.city-option{
163+
display:flex;align-items:baseline;justify-content:space-between;width:100%;border:0;
164+
background:transparent;color:var(--text);font:inherit;text-align:left;padding:8px 9px;
165+
border-radius:4px;cursor:pointer;
166+
}
167+
.city-option:hover,.city-option[aria-selected="true"]{background:var(--tag-bg)}
168+
.city-option small{font-size:11px;color:var(--text-tertiary);margin-left:8px}
169+
.advanced-time-grid{align-items:flex-end}
170+
.advanced-time-grid label{min-width:0}
145171
.advanced-options{
146172
margin-bottom:var(--space-md);
147173
color:var(--text-secondary);
@@ -830,7 +856,7 @@ html.dark .tag-xi{background:#1a3b2a;color:#6ee7b7}
830856
.event-main .direction-good{font-size:12px;font-weight:600;color:var(--good)}
831857
.event-main .prediction-text{font-size:13px;color:var(--text);font-weight:500}
832858
.event-main .llm-badge{font-size:11px;background:linear-gradient(135deg,#a78bfa,#7c3aed);color:#fff;border-radius:3px;padding:0 4px;line-height:1.5}
833-
.time-basis-note{margin:-6px 0 12px;font-size:12px;line-height:1.5;color:var(--text-tertiary)}
859+
.time-basis-note{margin:-2px 0 12px;font-size:12px;line-height:1.5;color:var(--text-tertiary)}
834860
.ai-review{margin:8px 18px 14px;padding:10px 12px;border:1px solid var(--border);border-radius:4px;background:var(--tag-bg)}
835861
.ai-review summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--text-secondary)}
836862
.ai-review-item{padding:8px 0;border-top:1px solid var(--border);font-size:12px;line-height:1.6}
@@ -1358,6 +1384,8 @@ html.dark .calendar-legend .dot.bad{background:#c53030}
13581384
.form-row{flex-wrap:wrap;gap:8px}
13591385
.birth-datetime-grid{column-gap:6px;row-gap:5px}
13601386
.birth-datetime-grid input{padding:10px 7px}
1387+
.advanced-time-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
1388+
.advanced-time-grid label:last-child{grid-column:1 / -1}
13611389
.info-grid{grid-template-columns:1fr;gap:8px}
13621390
.traits-grid{grid-template-columns:1fr;gap:6px}
13631391
.evidence-signals{grid-template-columns:1fr 1fr;gap:6px 12px}

0 commit comments

Comments
 (0)