dsh-metaphysics 0.18.4 → 0.20.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/engine/almanac.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // 彭祖百忌、胎神等全量黄历数据未收录——输出里如实标注「未收录」,不臆造。
8
8
  // 宜忌只按「建除十二神」通行简表给出(传统择日的第一层判据),不是逐日全量黄历。
9
9
  import { solarToLunar } from './lunar.js';
10
+ import { nayinOf } from './nayin.js';
10
11
  import {
11
12
  toJDN, fromJDN, dayGanzhiIndex, ganzhiName, termWindow, termInstantJd, partsToJd, STEMS, BRANCHES, TERM_NAMES,
12
13
  } from './calendar.js';
@@ -89,6 +90,89 @@ export function lunarMonthText(month, isLeap = false) {
89
90
  return `${isLeap ? '闰' : ''}${MONTH_TEXT[month - 1] || month}月`;
90
91
  }
91
92
 
93
+ /** 黄道/黑道十二神(青龙、明堂…勾陈),值日与时家共用。 */
94
+ export const TIAN_SHEN = ['青龙', '明堂', '天刑', '朱雀', '金匮', '天德', '白虎', '玉堂', '天牢', '玄武', '司命', '勾陈'];
95
+ /** 六黄道(吉)/ 六黑道(凶)。 */
96
+ export const TIAN_SHEN_TYPE = {
97
+ 青龙: '黄道', 明堂: '黄道', 金匮: '黄道', 天德: '黄道', 玉堂: '黄道', 司命: '黄道',
98
+ 天刑: '黑道', 朱雀: '黑道', 白虎: '黑道', 天牢: '黑道', 玄武: '黑道', 勾陈: '黑道',
99
+ };
100
+ /** 十二神起例:以月支(日家)或日支(时家)为锚的偏移。 */
101
+ const TIAN_SHEN_OFFSET = { 子: 4, 丑: 2, 寅: 0, 卯: 10, 辰: 8, 巳: 6, 午: 4, 未: 2, 申: 0, 酉: 10, 戌: 8, 亥: 6 };
102
+
103
+ /** 日家十二神:日支 + 月支偏移;时家十二神:时支 + 日支偏移。 */
104
+ export function tianShenOf(branch, anchorBranch) {
105
+ const b = BRANCHES.indexOf(branch);
106
+ const off = TIAN_SHEN_OFFSET[anchorBranch];
107
+ if (b < 0 || off == null) return null;
108
+ const name = TIAN_SHEN[(b + off) % 12];
109
+ const type = TIAN_SHEN_TYPE[name];
110
+ return { name, type, luck: type === '黄道' ? '吉' : '凶' };
111
+ }
112
+
113
+ /** 二十八宿:值日宿按「日支 + 星期」轮转(四组各七宿)。 */
114
+ export const MANSIONS = {
115
+ 角: { luck: '吉', zheng: '木', animal: '蛟', gong: '东' }, 亢: { luck: '凶', zheng: '金', animal: '龙', gong: '东' },
116
+ 氐: { luck: '凶', zheng: '土', animal: '貉', gong: '东' }, 房: { luck: '吉', zheng: '日', animal: '兔', gong: '东' },
117
+ 心: { luck: '凶', zheng: '月', animal: '狐', gong: '东' }, 尾: { luck: '吉', zheng: '火', animal: '虎', gong: '东' },
118
+ 箕: { luck: '吉', zheng: '水', animal: '豹', gong: '东' }, 斗: { luck: '吉', zheng: '木', animal: '獬', gong: '北' },
119
+ 牛: { luck: '凶', zheng: '金', animal: '牛', gong: '北' }, 女: { luck: '凶', zheng: '土', animal: '蝠', gong: '北' },
120
+ 虚: { luck: '凶', zheng: '日', animal: '鼠', gong: '北' }, 危: { luck: '凶', zheng: '月', animal: '燕', gong: '北' },
121
+ 室: { luck: '吉', zheng: '火', animal: '猪', gong: '北' }, 壁: { luck: '吉', zheng: '水', animal: '獝', gong: '北' },
122
+ 奎: { luck: '凶', zheng: '木', animal: '狼', gong: '西' }, 娄: { luck: '吉', zheng: '金', animal: '狗', gong: '西' },
123
+ 胃: { luck: '吉', zheng: '土', animal: '彘', gong: '西' }, 昴: { luck: '凶', zheng: '日', animal: '鸡', gong: '西' },
124
+ 毕: { luck: '吉', zheng: '月', animal: '乌', gong: '西' }, 觜: { luck: '凶', zheng: '火', animal: '猴', gong: '西' },
125
+ 参: { luck: '吉', zheng: '水', animal: '猿', gong: '西' }, 井: { luck: '吉', zheng: '木', animal: '犴', gong: '南' },
126
+ 鬼: { luck: '凶', zheng: '金', animal: '羊', gong: '南' }, 柳: { luck: '凶', zheng: '土', animal: '獐', gong: '南' },
127
+ 星: { luck: '凶', zheng: '日', animal: '马', gong: '南' }, 张: { luck: '吉', zheng: '月', animal: '鹿', gong: '南' },
128
+ 翼: { luck: '凶', zheng: '火', animal: '蛇', gong: '南' }, 轸: { luck: '吉', zheng: '水', animal: '蚓', gong: '南' },
129
+ };
130
+ const XIU_GROUPS = {
131
+ A: ['虚', '毕', '翼', '箕', '奎', '鬼', '氐'],
132
+ B: ['房', '危', '觜', '轸', '斗', '娄', '柳'],
133
+ C: ['星', '心', '室', '参', '角', '牛', '胃'],
134
+ D: ['昴', '张', '尾', '壁', '井', '亢', '女'],
135
+ };
136
+ const XIU_GROUP_OF = { 子: 'A', 辰: 'A', 申: 'A', 丑: 'B', 巳: 'B', 酉: 'B', 寅: 'C', 午: 'C', 戌: 'C', 卯: 'D', 未: 'D', 亥: 'D' };
137
+
138
+ /** 值日宿:weekday 0=周日。 */
139
+ export function xiuOf(dayBranch, weekdayIndex) {
140
+ const g = XIU_GROUP_OF[dayBranch];
141
+ if (!g) return null;
142
+ const name = XIU_GROUPS[g][weekdayIndex];
143
+ const info = MANSIONS[name];
144
+ return info ? { name, ...info } : null;
145
+ }
146
+
147
+ /** 吉神方位(按日干;福神取通行一变,另一变注明差异)。 */
148
+ const DIR_OF = { 坎: '正北', 艮: '东北', 震: '正东', 巽: '东南', 离: '正南', 坤: '西南', 兑: '正西', 乾: '西北' };
149
+ const POS_XI = { 甲: '艮', 乙: '乾', 丙: '坤', 丁: '离', 戊: '巽', 己: '艮', 庚: '乾', 辛: '坤', 壬: '离', 癸: '巽' };
150
+ const POS_CAI = { 甲: '艮', 乙: '艮', 丙: '坤', 丁: '坤', 戊: '坎', 己: '坎', 庚: '震', 辛: '震', 壬: '离', 癸: '离' };
151
+ const POS_FU = { 甲: '坎', 乙: '坤', 丙: '乾', 丁: '巽', 戊: '艮', 己: '坎', 庚: '坤', 辛: '乾', 壬: '巽', 癸: '艮' };
152
+ const POS_YANG_GUI = { 甲: '坤', 乙: '坤', 丙: '兑', 丁: '乾', 戊: '艮', 己: '坎', 庚: '离', 辛: '艮', 壬: '震', 癸: '巽' };
153
+ const POS_YIN_GUI = { 甲: '艮', 乙: '坎', 丙: '乾', 丁: '兑', 戊: '坤', 己: '坤', 庚: '艮', 辛: '离', 壬: '巽', 癸: '震' };
154
+ const posOf = (map, stem) => (map[stem] ? { gua: map[stem], dir: DIR_OF[map[stem]] } : null);
155
+
156
+ /** 胎神(逐日干支,通行表)。 */
157
+ const TAI_SHEN = {
158
+ 甲子: '占门碓 外东南', 乙丑: '碓磨厕 外东南', 丙寅: '厨灶炉 外正南', 丁卯: '仓库门 外正南', 戊辰: '房床栖 外正南',
159
+ 己巳: '占门床 外正南', 庚午: '占碓磨 外正南', 辛未: '厨灶厕 外西南', 壬申: '仓库炉 外西南', 癸酉: '房床门 外西南',
160
+ 甲戌: '占门栖 外西南', 乙亥: '碓磨床 外西南', 丙子: '厨灶碓 外西南', 丁丑: '仓库厕 外正西', 戊寅: '房床炉 外正西',
161
+ 己卯: '占大门 外正西', 庚辰: '碓磨栖 外正西', 辛巳: '厨灶床 外正西', 壬午: '仓库碓 外西北', 癸未: '房床厕 外西北',
162
+ 甲申: '占门炉 外西北', 乙酉: '碓磨门 外西北', 丙戌: '厨灶栖 外西北', 丁亥: '仓库床 外西北', 戊子: '房床碓 外正北',
163
+ 己丑: '占门厕 外正北', 庚寅: '碓磨炉 外正北', 辛卯: '厨灶门 外正北', 壬辰: '仓库栖 外正北', 癸巳: '占房床 房内北',
164
+ 甲午: '占门碓 房内北', 乙未: '碓磨厕 房内北', 丙申: '厨灶炉 房内北', 丁酉: '仓库门 房内北', 戊戌: '房床栖 房内中',
165
+ 己亥: '占门床 房内中', 庚子: '占碓磨 房内南', 辛丑: '厨灶厕 房内南', 壬寅: '仓库炉 房内南', 癸卯: '房床门 房内西',
166
+ 甲辰: '占门栖 房内东', 乙巳: '碓磨床 房内东', 丙午: '厨灶碓 房内东', 丁未: '仓库厕 房内东', 戊申: '房床炉 房内中',
167
+ 己酉: '占大门 外东北', 庚戌: '碓磨栖 外东北', 辛亥: '厨灶床 外东北', 壬子: '仓库碓 外东北', 癸丑: '房床厕 外东北',
168
+ 甲寅: '占门炉 外东北', 乙卯: '碓磨门 外正东', 丙辰: '厨灶栖 外正东', 丁巳: '仓库床 外正东', 戊午: '房床碓 外正东',
169
+ 己未: '占门厕 外正东', 庚申: '碓磨炉 外东南', 辛酉: '厨灶门 外东南', 壬戌: '仓库栖 外东南', 癸亥: '占房床 外东南',
170
+ };
171
+
172
+ /** 六合 / 三合(对日支取吉配生肖)。 */
173
+ const LIUHE = { 子: '丑', 丑: '子', 寅: '亥', 亥: '寅', 卯: '戌', 戌: '卯', 辰: '酉', 酉: '辰', 巳: '申', 申: '巳', 午: '未', 未: '午' };
174
+ const SANHE = { 申: '申子辰', 子: '申子辰', 辰: '申子辰', 亥: '亥卯未', 卯: '亥卯未', 未: '亥卯未', 寅: '寅午戌', 午: '寅午戌', 戌: '寅午戌', 巳: '巳酉丑', 酉: '巳酉丑', 丑: '巳酉丑' };
175
+
92
176
  /** 每日一卦:日期哈希确定性映射到通行本 64 卦(同日同卦)。 */
93
177
  export function dailyHexagram(year, month, day) {
94
178
  const names = Object.keys(HEX64);
@@ -153,7 +237,42 @@ export function buildAlmanac(input) {
153
237
  return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
154
238
  };
155
239
 
156
- const weekday = ['日', '一', '二', '三', '四', '五', '六'][(jdn + 1) % 7];
240
+ const weekdayIdx = (jdn + 1) % 7;
241
+ const weekday = ['日', '一', '二', '三', '四', '五', '六'][weekdayIdx];
242
+ // 值日十二神(黄道/黑道)、二十八宿、胎神、纳音、吉神方位、时辰吉凶
243
+ const dayGod = tianShenOf(dayBranch, BRANCHES[monthBranchIdx]);
244
+ const xiu = xiuOf(dayBranch, weekdayIdx);
245
+ const taiShen = TAI_SHEN[dayGz.name] || null;
246
+ const hourBase = (STEMS.indexOf(dayGz.stem) % 5) * 2;
247
+ const hours = BRANCHES.map((zhi, i) => {
248
+ const gan = STEMS[(hourBase + i) % 10];
249
+ const gz = ganzhiName(hourComposite((hourBase + i) % 10, i));
250
+ const shen = tianShenOf(zhi, dayBranch);
251
+ return {
252
+ zhi,
253
+ name: `${zhi}时`,
254
+ ganZhi: gz.name,
255
+ gan,
256
+ shen: shen ? shen.name : null,
257
+ type: shen ? shen.type : null,
258
+ luck: shen ? shen.luck : null,
259
+ };
260
+ });
261
+ const sanheGroup = SANHE[dayBranch] || '';
262
+ const liuheBranch = LIUHE[dayBranch] || null;
263
+ const lucky = {
264
+ liuhe: liuheBranch ? SHENGXIAO[BRANCHES.indexOf(liuheBranch)] : null,
265
+ sanhe: [...sanheGroup].filter((b) => b !== dayBranch).map((b) => SHENGXIAO[BRANCHES.indexOf(b)]),
266
+ };
267
+ const positions = {
268
+ caiShen: CAI_SHEN[dayGz.stem] || null,
269
+ xiShen: XI_SHEN[dayGz.stem] || null,
270
+ fuShen: posOf(POS_FU, dayGz.stem),
271
+ yangGui: posOf(POS_YANG_GUI, dayGz.stem),
272
+ yinGui: posOf(POS_YIN_GUI, dayGz.stem),
273
+ xiShenFull: posOf(POS_XI, dayGz.stem),
274
+ caiShenFull: posOf(POS_CAI, dayGz.stem),
275
+ };
157
276
  const lunarText = `农历${springGz.name}年${lunarMonthText(lunar.month, lunar.isLeap)}${dayText(lunar.day)}`;
158
277
  const boundaryNote = yearGz.name !== springGz.name
159
278
  ? `年界:本条按立春界作${yearGz.name}年·属${SHENGXIAO[BRANCHES.indexOf(yearGz.branch)]}(命理排盘用);春节界为${springGz.name}年·属${SHENGXIAO[BRANCHES.indexOf(springGz.branch)]},两种都对,取用场合不同`
@@ -165,7 +284,7 @@ export function buildAlmanac(input) {
165
284
  date: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
166
285
  weekday: `星期${weekday}`,
167
286
  ...(boundaryNote ? { boundaryNote } : {}),
168
- scope: '通行简表口径:宜忌只按建除十二神;逐日神煞/二十八宿/彭祖百忌/胎神等全量黄历数据未收录',
287
+ scope: '通行口径:宜忌按建除十二神简表;已收录值日十二神(黄道/黑道)、时辰吉凶、二十八宿、胎神、吉神方位、纳音、六合三合吉配;逐日全量吉神凶煞与彭祖百忌未收录',
169
288
  },
170
289
  lunar: {
171
290
  year: springYear,
@@ -186,7 +305,13 @@ export function buildAlmanac(input) {
186
305
  prev: prevTerm ? { name: termName(prevTerm), date: fmtJdDate(prevTerm.jd) } : null,
187
306
  next: nextTerm ? { name: termName(nextTerm), date: fmtJdDate(nextTerm.jd) } : null,
188
307
  },
189
- positions: { caiShen: CAI_SHEN[dayGz.stem] || null, xiShen: XI_SHEN[dayGz.stem] || null },
308
+ positions,
309
+ dayGod,
310
+ xiu,
311
+ taiShen,
312
+ nayin: { day: nayinOf(dayGanzhiIndex(jdn)) },
313
+ lucky,
314
+ hours,
190
315
  zhiXing,
191
316
  yi: ZHIXING_YI[zhiXing].slice(),
192
317
  ji: ZHIXING_JI[zhiXing].slice(),
@@ -199,6 +324,7 @@ export function buildAlmanac(input) {
199
324
  sources: [
200
325
  '农历与节气:本地天文历法(定朔 + 节气黄经)',
201
326
  '建除十二神与冲煞:传统通行口径',
327
+ '值日/时家十二神(青龙…勾陈)、二十八宿、胎神、吉神方位:通行表',
202
328
  '宜忌:建除十二神通行简表(非逐日全量黄历)',
203
329
  '每日一卦:按公历日期确定性取卦(同日同卦)',
204
330
  ],
@@ -213,3 +339,11 @@ function monthComposite(stemIdx, branchIdx) {
213
339
  }
214
340
  return 0;
215
341
  }
342
+
343
+ /** 由时干序与时支序求六十甲子序。 */
344
+ function hourComposite(stemIdx, branchIdx) {
345
+ for (let i = 0; i < 60; i++) {
346
+ if (i % 10 === stemIdx && i % 12 === branchIdx) return i;
347
+ }
348
+ return 0;
349
+ }
package/engine/bazi.js CHANGED
@@ -506,7 +506,8 @@ export function buildBazi(input) {
506
506
  list: dayun,
507
507
  },
508
508
  strength,
509
- frame,
509
+ frame,
510
+ climate: frame.yongshen.climate || frame.yongshen.tiaohou || null,
510
511
  liunian,
511
512
  liunianFrom: thisYear,
512
513
  };
@@ -0,0 +1,137 @@
1
+ // 公农历互转(本地确定性,不依赖外部历法库)
2
+ //
3
+ // 两种方向:
4
+ // · 公历 → 农历:直接查本地定朔历(engine/lunar.js),给出农历年月日、闰月标记、干支、生肖。
5
+ // · 农历 → 公历:按目标农历年的「月首」序列反查(每月 29/30 天,闰月单独一轮),
6
+ // 逐月定位后再加日偏移;不做「节气月」换算,农历就是朔望月口径。
7
+ //
8
+ // 诚实边界:只处理 1900–2100 的本地历法覆盖范围(与 lunar.js 一致);闰月必须显式给出,
9
+ // 不猜「第一个同名月」;农历日期越界(如小月三十)如实报错,不静默回退。
10
+ import { solarToLunar } from './lunar.js';
11
+ import { fromJDN, toJDN } from './calendar.js';
12
+ import { buildAlmanac } from './almanac.js';
13
+ import { assertCivil, daysInMonth } from './validate.js';
14
+
15
+ const WEEK = ['日', '一', '二', '三', '四', '五', '六'];
16
+ const MONTH_TEXT = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
17
+ const DAY_TENS = ['初', '十', '廿'];
18
+ const DAY_UNITS = ['十', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
19
+
20
+ function dayText(day) {
21
+ if (day === 10) return '初十';
22
+ if (day === 20) return '二十';
23
+ if (day === 30) return '三十';
24
+ return `${DAY_TENS[Math.floor(day / 10)]}${DAY_UNITS[day % 10]}`;
25
+ }
26
+
27
+ function lunarText(year, month, day, isLeap) {
28
+ return `农历${year}年${isLeap ? '闰' : ''}${MONTH_TEXT[month - 1] || month}月${dayText(day)}`;
29
+ }
30
+
31
+ /** 找某个农历年的正月初一(公历 1/20–2/25 之间)。 */
32
+ function lunarNewYearJdn(lunarYear) {
33
+ const from = toJDN(lunarYear, 1, 20);
34
+ const to = toJDN(lunarYear, 2, 25);
35
+ for (let jdn = from; jdn <= to; jdn++) {
36
+ const p = fromJDN(jdn);
37
+ const l = solarToLunar(p.year, p.month, p.day);
38
+ if (l.lunarYear === lunarYear && l.month === 1 && l.day === 1) return jdn;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** 某农历年的月首序列(含闰月)。 */
44
+ function lunarMonths(lunarYear) {
45
+ const first = lunarNewYearJdn(lunarYear);
46
+ if (first == null) return [];
47
+ const out = [];
48
+ let cur = first;
49
+ for (let i = 0; i < 14; i++) {
50
+ const p = fromJDN(cur);
51
+ const l = solarToLunar(p.year, p.month, p.day);
52
+ if (i > 0 && l.month === 1 && !l.isLeap) break;
53
+ let next = null;
54
+ for (let k = 29; k <= 31; k++) {
55
+ const d = fromJDN(cur + k);
56
+ const l2 = solarToLunar(d.year, d.month, d.day);
57
+ if (l2.day === 1) { next = cur + k; break; }
58
+ }
59
+ if (next == null) break;
60
+ out.push({ month: l.month, isLeap: l.isLeap, startJdn: cur, length: next - cur });
61
+ cur = next;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * 公农历互转。
68
+ * @param {{mode?:'solar2lunar'|'lunar2solar', year?:number, month?:number, day?:number,
69
+ * lunarYear?:number, lunarMonth?:number, lunarDay?:number, isLeapMonth?:boolean}} input
70
+ */
71
+ export function buildConvert(input) {
72
+ const mode = input.mode === 'lunar2solar' ? 'lunar2solar' : 'solar2lunar';
73
+ let solar;
74
+ let lunar;
75
+ let note = null;
76
+ if (mode === 'solar2lunar') {
77
+ solar = { year: input.year, month: input.month, day: input.day };
78
+ assertCivil(solar, '公农历转换');
79
+ if (solar.day > daysInMonth(solar.year, solar.month)) {
80
+ throw new Error(`非法日期:${solar.year} 年 ${solar.month} 月没有 ${solar.day} 日`);
81
+ }
82
+ lunar = solarToLunar(solar.year, solar.month, solar.day);
83
+ } else {
84
+ const y = Number(input.lunarYear);
85
+ const m = Number(input.lunarMonth);
86
+ const d = Number(input.lunarDay);
87
+ if (![y, m, d].every(Number.isInteger)) throw new Error('农历转换需要 lunarYear / lunarMonth / lunarDay');
88
+ if (m < 1 || m > 12) throw new Error('农历月份须在 1–12');
89
+ const months = lunarMonths(y);
90
+ if (!months.length) throw new Error(`农历 ${y} 年超出本地历法覆盖范围(1900–2100)`);
91
+ const isLeap = input.isLeapMonth === true;
92
+ const hit = months.find((x) => x.month === m && x.isLeap === isLeap);
93
+ if (!hit) {
94
+ const leapMonths = months.filter((x) => x.isLeap).map((x) => `闰${MONTH_TEXT[x.month - 1]}月`);
95
+ throw new Error(`农历 ${y} 年没有${leapMonths.length ? `该月(该年闰月为 ${leapMonths.join('、')})` : '闰月'}:${isLeap ? '请确认闰月标记' : '请确认月份'}`);
96
+ }
97
+ if (d < 1 || d > hit.length) throw new Error(`农历 ${y} 年${isLeap ? '闰' : ''}${MONTH_TEXT[m - 1]}月只有 ${hit.length} 天,没有 ${d} 日`);
98
+ const p = fromJDN(hit.startJdn + d - 1);
99
+ solar = { year: p.year, month: p.month, day: p.day };
100
+ const check = solarToLunar(p.year, p.month, p.day);
101
+ if (check.month !== m || check.day !== d || check.isLeap !== isLeap) {
102
+ note = '反查结果与目标农历日不完全一致,已按本地历法实际月首回推';
103
+ }
104
+ lunar = check;
105
+ }
106
+
107
+ const a = buildAlmanac(solar);
108
+ const jdn = toJDN(solar.year, solar.month, solar.day);
109
+ const solarText = `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`;
110
+ return {
111
+ system: 'convert',
112
+ meta: {
113
+ mode,
114
+ solarDate: solarText,
115
+ weekday: `星期${WEEK[(jdn + 1) % 7]}`,
116
+ ...(note ? { note } : {}),
117
+ scope: '公农历互转按本地定朔历(1900–2100);农历闰月须显式指定,不自动猜同名月',
118
+ },
119
+ solar: { year: solar.year, month: solar.month, day: solar.day, text: solarText },
120
+ lunar: {
121
+ year: lunar.lunarYear,
122
+ month: lunar.month,
123
+ day: lunar.day,
124
+ isLeap: lunar.isLeap,
125
+ text: lunarText(lunar.lunarYear, lunar.month, lunar.day, lunar.isLeap),
126
+ },
127
+ ganzhi: {
128
+ year: a.ganzhi.year,
129
+ month: a.ganzhi.month,
130
+ day: a.ganzhi.day,
131
+ shengXiao: a.ganzhi.shengXiao,
132
+ springYear: a.ganzhi.springYear,
133
+ },
134
+ sources: ['农历:本地定朔 + 节气黄经历法(engine/lunar.js)', '干支与生肖:与黄历同口径(年按立春界,另列春节界)'],
135
+ disclaimer: '历法换算为确定性计算;民俗历法各版本在极少数日期(交节当日、闰月边界)存在日界差异,已注明口径。',
136
+ };
137
+ }
package/engine/geju.js CHANGED
@@ -2,10 +2,12 @@
2
2
  //
3
3
  // 定位:在 bazi.js 算出四柱、五行、旺衰之后,给出
4
4
  // 1) 格局(月令格 + 特殊格 + 常见组合);
5
- // 2) 用神 / 喜忌(扶抑为主,调候作简化提示);
6
- // 3) 大运、流年的吉凶倾向评分。
5
+ // 2) 用神 / 喜忌(调候优先 + 扶抑校验;气候平和时仍以扶抑为主);
6
+ // 3) 大运、流年的吉凶倾向评分(含调候修正项)。
7
7
  // 取舍:格局派别差异极大,这里只给「规则可复核」的判定,evidence 写明依据;
8
- // 调候未做穷通宝鉴 120 条全表,只按寒暖燥湿给方向,并在 detail 里注明。
8
+ // 调候由 tiaohou.js 独立判定,与扶抑冲突时显式裁决并写明理由,不静默取用。
9
+
10
+ import { judgeClimate, arbitrate } from './tiaohou.js';
9
11
 
10
12
  const STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
11
13
  const BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
@@ -197,38 +199,37 @@ function specialPatterns(pillars, dayStem, monthBranch, strength, wuxing, gods)
197
199
 
198
200
  // ── 用神 / 喜忌 ──────────────────────────────────────────────
199
201
 
200
- const TIAOHOU = {
201
- 亥: ['火', '冬月寒水当令,最喜火暖局'],
202
- 子: ['火', '仲冬水旺,最喜火暖局'],
203
- 丑: ['火', '季冬湿寒,喜火解冻'],
204
- 巳: ['水', '初夏火旺,喜水制衡'],
205
- 午: ['水', '仲夏火炎,最喜水调候'],
206
- 未: ['水', '季夏燥土,喜水润局'],
207
- 寅: ['火', '初春尚寒,得火而发'],
208
- 卯: ['火', '仲春木旺,喜火吐秀'],
209
- 辰: ['金', '季春湿土,喜金水疏泄'],
210
- 申: ['火', '初秋金旺,喜火炼金'],
211
- 酉: ['火', '仲秋金锐,喜火制'],
212
- 戌: ['水', '季秋燥土,喜水润泽'],
213
- };
214
202
 
215
- function yongshenOf(dayStem, monthBranch, strength, wuxing) {
203
+ function yongshenOf(dayStem, monthBranch, strength, wuxing, branches = []) {
216
204
  const roles = elementRoles(dayStem);
217
205
  const me = roles.me;
218
206
  const weak = strength.level === '身弱' || strength.level === '偏弱';
219
207
  const strong = strength.level === '身强' || strength.level === '偏强';
220
208
 
221
- const favor = weak ? [roles.inMe, me] : strong ? [roles.shi, roles.cai, roles.guan] : [roles.inMe, me, roles.shi];
222
- const avoid = weak ? [roles.shi, roles.cai, roles.guan] : strong ? [me, roles.inMe] : [roles.guan, roles.cai];
223
- const favorableTenGods = weak ? ['正印', '偏印', '比肩', '劫财'] : strong
224
- ? ['食神', '伤官', '正财', '偏财', '正官', '七杀']
225
- : ['正印', '偏印', '食神', '伤官'];
226
-
227
- const th = TIAOHOU[monthBranch];
228
- const tiaohou = th ? { element: th[0], note: th[1] } : null;
209
+ const baseFavor = weak ? [roles.inMe, me] : strong ? [roles.shi, roles.cai, roles.guan] : [roles.inMe, me, roles.shi];
210
+ const baseAvoid = weak ? [roles.shi, roles.cai, roles.guan] : strong ? [me, roles.inMe] : [roles.guan, roles.cai];
211
+ let favor = baseFavor.slice();
212
+ let avoid = baseAvoid.slice();
213
+
214
+ // 先判气候,再决定要不要让调候压过扶抑
215
+ const climate = judgeClimate({ monthBranch, dayStem, wuxing, branches });
216
+ const arb = arbitrate(climate, baseFavor, baseAvoid);
217
+ if (climate.need && climate.priority === '调候优先') {
218
+ const needEl = climate.need.element;
219
+ if (!favor.includes(needEl)) favor.unshift(needEl);
220
+ avoid = avoid.filter((e) => e !== needEl);
221
+ if (climate.avoidElement && !avoid.includes(climate.avoidElement)) avoid.unshift(climate.avoidElement);
222
+ }
229
223
 
230
224
  const roleName = (el) => (el === me ? '比劫' : el === roles.inMe ? '印'
231
225
  : el === roles.shi ? '食伤' : el === roles.cai ? '财' : '官杀');
226
+ const expand = {
227
+ 比劫: ['比肩', '劫财'], 印: ['正印', '偏印'], 食伤: ['食神', '伤官'],
228
+ 财: ['正财', '偏财'], 官杀: ['正官', '七杀'],
229
+ };
230
+ const favorTenGods = Array.from(new Set(favor.flatMap((el) => expand[roleName(el)] || [])));
231
+ const avoidTenGods = Array.from(new Set(avoid.flatMap((el) => expand[roleName(el)] || [])));
232
+
232
233
  const detail = [
233
234
  `日主${dayStem}${me},${strength.level}(${strength.score} 分):`,
234
235
  weak
@@ -236,24 +237,22 @@ function yongshenOf(dayStem, monthBranch, strength, wuxing) {
236
237
  : strong
237
238
  ? `喜${roles.shi}(食伤)泄秀、${roles.cai}(财)耗、${roles.guan}(官杀)制;忌${me}(比劫)与${roles.inMe}(印)再助。`
238
239
  : '身中和,先取调候与通关,喜忌随大运流年动态变化。',
239
- tiaohou ? `调候(简化):${tiaohou.note},宜${tiaohou.element}。` : '',
240
- '扶抑法为主;调候未用穷通宝鉴全表,各派取用不同,仅作方向提示。',
240
+ `调候:${climate.label}(${(climate.evidence || []).join('、')})${climate.need ? `,取${climate.need.stem}${climate.need.element},忌${climate.avoidElement}` : ',气候平和'}。`,
241
+ arb.text,
242
+ arb.clash ? '取舍:本盘按「调候优先、扶抑次之」取用;若你更认同扶抑派,可把调候当环境偏好,强弱判断不受影响。' : '',
241
243
  ].filter(Boolean).join('');
242
244
 
243
245
  return {
244
- method: '扶抑为主 + 调候简化提示',
246
+ method: climate.priority === '调候优先' ? '调候优先 + 扶抑校验' : '扶抑为主 + 调候校验',
245
247
  level: strength.level,
246
248
  favor,
247
249
  avoid,
248
250
  favorRoles: favor.map(roleName),
249
251
  avoidRoles: avoid.map(roleName),
250
- favorTenGods: favorableTenGods,
251
- avoidTenGods: weak
252
- ? ['食神', '伤官', '正财', '偏财', '正官', '七杀']
253
- : strong
254
- ? ['比肩', '劫财', '正印', '偏印']
255
- : ['正官', '七杀', '正财', '偏财'],
256
- tiaohou,
252
+ favorTenGods,
253
+ avoidTenGods,
254
+ tiaohou: { ...climate, ...arb, element: climate.need ? climate.need.element : null },
255
+ climate,
257
256
  detail,
258
257
  };
259
258
  }
@@ -274,33 +273,55 @@ export function scoreLuck(ganzhi, frame) {
274
273
  const branch = ganzhi[1];
275
274
  const stemEl = STEM_ELEMENT[stem];
276
275
  const branchEl = BRANCH_ELEMENT[branch];
276
+ const th = ys.tiaohou || ys.climate || null;
277
277
  const scoreEl = (el) => {
278
278
  let s = 0;
279
279
  if (ys.favor.includes(el)) s += 1;
280
280
  if (ys.avoid.includes(el)) s -= 1;
281
- if (ys.tiaohou && el === ys.tiaohou.element) s += 0.4;
282
281
  return s;
283
282
  };
284
283
  let score = scoreEl(stemEl) * 1.0 + scoreEl(branchEl) * 0.8;
285
284
  for (const h of (HIDDEN[branch] || []).slice(1)) {
286
285
  score += scoreEl(STEM_ELEMENT[h]) * 0.2;
287
286
  }
288
- score = Math.round(score * 10) / 10;
289
-
290
- const godStem = tenGod(frame.dayStem, stem);
291
- const bonus = ys.favorTenGods.includes(godStem) ? 0.3 : 0;
292
- const total = Math.round((score + bonus) * 10) / 10;
293
287
 
294
- const tier = TIERS.find(([min]) => total >= min)[1];
295
288
  const reasons = [];
296
289
  if (ys.favor.includes(stemEl)) reasons.push(`天干${stem}${stemEl}为用神`);
297
290
  else if (ys.avoid.includes(stemEl)) reasons.push(`天干${stem}${stemEl}为忌神`);
298
291
  else reasons.push(`天干${stem}${stemEl}不喜不忌`);
299
292
  if (ys.favor.includes(branchEl)) reasons.push(`地支${branch}${branchEl}助用`);
300
293
  else if (ys.avoid.includes(branchEl)) reasons.push(`地支${branch}${branchEl}助忌`);
301
- if (ys.tiaohou && (stemEl === ys.tiaohou.element || branchEl === ys.tiaohou.element)) {
302
- reasons.push(`合调候(${ys.tiaohou.element})`);
294
+
295
+ // 调候修正:不是"有没有这个五行",而是"这一年是给命局添了一把火,还是泼了一盆水"
296
+ if (th && th.need && th.priority === '调候优先') {
297
+ const needEl = th.need.element;
298
+ const oppEl = th.avoidElement;
299
+ const cold = String(th.kind).includes('寒');
300
+ if (stemEl === needEl) {
301
+ score += 1.1;
302
+ reasons.push(`${th.label}之局得${stem}${stemEl},${cold ? '解冻' : '得润'}`);
303
+ } else if (stemEl === oppEl) {
304
+ score -= 0.9;
305
+ reasons.push(`${th.label}之局再逢${stem}${stemEl},${cold ? '冻木难生' : '燥上加燥'}`);
306
+ }
307
+ if (branchEl === needEl) {
308
+ score += 0.9;
309
+ reasons.push(`地支${branch}${branchEl}合调候`);
310
+ } else if (branchEl === oppEl) {
311
+ score -= 1.0;
312
+ reasons.push(`地支${branch}${branchEl}加重${th.label}`);
313
+ }
314
+ if (cold && (stemEl === '水' || branchEl === '水')) score -= 0.3;
315
+ } else if (th && th.need && (stemEl === th.need.element || branchEl === th.need.element)) {
316
+ reasons.push(`合调候(${th.need.element})`);
303
317
  }
318
+
319
+ const godStem = tenGod(frame.dayStem, stem);
320
+ const bonus = ys.favorTenGods.includes(godStem) ? 0.3 : 0;
321
+ let total = Math.round((score + bonus) * 10) / 10;
322
+ total = Math.max(-4, Math.min(4, total));
323
+
324
+ const tier = TIERS.find(([min]) => total >= min)[1];
304
325
  return { ganzhi, score: total, tier, stemTenGod: godStem, reasons, note: `${godStem}运,${tier}(${total})` };
305
326
  }
306
327
 
@@ -319,7 +340,7 @@ export function analyzeBaziFrame({ pillars, dayStem, monthBranch, strength, wuxi
319
340
  const month = monthPattern(pillars, dayStem, monthBranch);
320
341
  const special = specialPatterns(pillars, dayStem, monthBranch, strength, wuxing, gods);
321
342
  const geju = [month, ...special];
322
- const yongshen = yongshenOf(dayStem, monthBranch, strength, wuxing);
343
+ const yongshen = yongshenOf(dayStem, monthBranch, strength, wuxing, Object.values(pillars).map((p) => p.branch));
323
344
  return {
324
345
  geju,
325
346
  primary: month,
package/engine/geo.js CHANGED
@@ -143,7 +143,7 @@ const DETAIL = /(区|县|镇|乡|村|小区|街道|路|号|巷|弄|组|队)/;
143
143
 
144
144
  /** OpenStreetMap Nominatim:免密钥、支持中文。失败返回 null。
145
145
  * UA 按 OSM 使用政策要求写明应用与联系方式(改版本时同步这里)。 */
146
- const USER_AGENT = 'dsh-metaphysics/0.18.4 (+https://github.com/xuanfu-web/dsh-metaphysics)';
146
+ const USER_AGENT = 'dsh-metaphysics/0.20.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
147
147
 
148
148
  async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
149
149
  if (typeof fetch !== 'function') return null;
package/engine/index.js CHANGED
@@ -21,7 +21,8 @@ import { buildZeri } from './zeri.js';
21
21
  import { buildChenggu } from './chenggu.js';
22
22
  import { buildXiaoliuren } from './xiaoliuren.js';
23
23
  import { buildStar } from './star.js';
24
- import { buildNumerology } from './numerology.js';
24
+ import { buildNumerology } from './numerology.js';
25
+ import { buildConvert } from './convert.js';
25
26
  import { buildTrend } from './trend.js';
26
27
  import { buildFengshui } from './fengshui.js';
27
28
  import { buildName } from './name.js';
@@ -109,6 +110,7 @@ function baziText(r) {
109
110
  `   ${r.frame.geju.map((g) => `${g.name}[${g.kind}]`).join(' ')}`,
110
111
  `用神:喜${r.frame.yongshen.favor.join('')}(${r.frame.yongshen.favorTenGods.join('、')}) 忌${r.frame.yongshen.avoid.join('')}(${r.frame.yongshen.avoidTenGods.join('、')}) ${r.frame.yongshen.method}`,
111
112
  `   ${r.frame.yongshen.detail}`,
113
+ `寒暖:${r.climate ? `${r.climate.label}(${(r.climate.evidence || []).join('、')})${r.climate.need ? ` 调候取${r.climate.need.stem}${r.climate.need.element} 忌${r.climate.avoidElement}` : ''}` : '—'}`,
112
114
  `十神权重:${Object.entries(r.frame.godStats.groups).map(([k, v]) => `${k}${v}`).join(' ')}`,
113
115
  ] : [];
114
116
  return [
@@ -617,6 +619,26 @@ function computeRaw(args = {}) {
617
619
  const result = buildChenggu({ year: dt.year, month: dt.month, day: dt.day, hour, minute: hasTime ? dt.minute : 0 });
618
620
  return { ...result, summary: chengguText(result) };
619
621
  }
622
+ if (system === 'convert') {
623
+ const mode = args.mode === 'lunar2solar' ? 'lunar2solar' : 'solar2lunar';
624
+ let result;
625
+ if (mode === 'lunar2solar') {
626
+ if (args.lunarYear == null || args.lunarMonth == null || args.lunarDay == null) {
627
+ throw fail('E_INPUT', '农历转公历需要 lunarYear / lunarMonth / lunarDay(闰月另加 isLeapMonth: true)');
628
+ }
629
+ result = buildConvert({
630
+ mode,
631
+ lunarYear: Number(args.lunarYear),
632
+ lunarMonth: Number(args.lunarMonth),
633
+ lunarDay: Number(args.lunarDay),
634
+ isLeapMonth: args.isLeapMonth === true,
635
+ });
636
+ } else {
637
+ const dt = parseDateTime(args.datetime);
638
+ result = buildConvert({ mode, year: dt.year, month: dt.month, day: dt.day });
639
+ }
640
+ return { ...result, summary: convertText(result) };
641
+ }
620
642
  if (system === 'xiaoliuren') {
621
643
  const dt = args.datetime ? parseDateTime(args.datetime) : null;
622
644
  const result = buildXiaoliuren({
@@ -700,22 +722,35 @@ function computeRaw(args = {}) {
700
722
  return { ...result, summary: nameText(result) };
701
723
  }
702
724
  if (!system) {
703
- throw fail('E_INPUT', '缺少 system,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、trend(人生 K 线)、fengshui(风水)、name(起名)。');
725
+ throw fail('E_INPUT', '缺少 system,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、trend(人生 K 线)、fengshui(风水)、name(起名)、convert(公农历互转)。');
704
726
  }
705
- throw fail('E_SYSTEM', `未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、trend(人生 K 线)、fengshui(风水)、name(起名)。`);
727
+ throw fail('E_SYSTEM', `未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、trend(人生 K 线)、fengshui(风水)、name(起名)、convert(公农历互转)。`);
706
728
  }
707
729
 
708
730
  /** 黄历文本 */
709
731
  function almanacText(r) {
710
732
  const g = r.ganzhi;
711
733
  const jq = r.jieQi && r.jieQi.next ? ` 下一节:${r.jieQi.next.name} ${r.jieQi.next.date}` : '';
734
+ const god = r.dayGod ? `值日:${r.dayGod.name}(${r.dayGod.type}·${r.dayGod.luck})` : '';
735
+ const xiu = r.xiu ? `二十八宿:${r.xiu.name}宿(${r.xiu.luck}·${r.xiu.zheng}·${r.xiu.animal})` : '';
736
+ const tai = r.taiShen ? `胎神:${r.taiShen}` : '';
737
+ const pos = r.positions
738
+ ? `吉神方位:喜神${r.positions.xiShen || '—'} 财神${r.positions.caiShen || '—'} 福神${r.positions.fuShen ? r.positions.fuShen.dir : '—'} 阳贵${r.positions.yangGui ? r.positions.yangGui.dir : '—'} 阴贵${r.positions.yinGui ? r.positions.yinGui.dir : '—'}`
739
+ : '';
740
+ const lucky = r.lucky ? `吉配生肖:六合${r.lucky.liuhe || '—'} 三合${(r.lucky.sanhe || []).join('、') || '—'}` : '';
741
+ const nayin = r.nayin && r.nayin.day ? `日柱纳音:${r.nayin.day.name}` : '';
742
+ const hours = (r.hours || []).map((h) => `${h.zhi}${h.ganZhi.slice(0, 1)}·${h.shen}${h.luck === '吉' ? '吉' : '凶'}`).join(' ');
712
743
  return [
713
744
  `【黄历】${r.meta.date} ${r.meta.weekday} ${r.lunar.text}`,
714
745
  `干支:${g.year}年 ${g.month}月 ${g.day}日 属${g.shengXiao}`,
715
746
  ...(r.meta.boundaryNote ? [` ${r.meta.boundaryNote}`] : []),
716
- `建除:${r.zhiXing}日 冲:${r.chong.text}${r.chong.sha ? `(${r.chong.sha})` : ''} 财神:${r.positions.caiShen || '—'} 喜神:${r.positions.xiShen || '—'}`,
747
+ `建除:${r.zhiXing}日 冲:${r.chong.text}${r.chong.sha ? `(${r.chong.sha})` : ''} ${god}`,
748
+ [xiu, nayin, tai].filter(Boolean).join(' '),
717
749
  `宜:${r.yi.join('、')}`,
718
750
  `忌:${r.ji.join('、')}`,
751
+ pos,
752
+ lucky,
753
+ hours ? `时辰吉凶:${hours}` : '',
719
754
  `节气:上一节 ${r.jieQi.prev ? `${r.jieQi.prev.name} ${r.jieQi.prev.date}` : '—'}${jq}`,
720
755
  r.dailyHexagram ? `每日一卦:${r.dailyHexagram.name} ${r.dailyHexagram.meaning || ''}` : '',
721
756
  `口径:${r.meta.scope}`,
@@ -775,6 +810,18 @@ function starText(r) {
775
810
  ].join('\n');
776
811
  }
777
812
 
813
+ /** 公农历互转文本 */
814
+ function convertText(r) {
815
+ const g = r.ganzhi;
816
+ const dir = r.meta.mode === 'lunar2solar' ? '农历 → 公历' : '公历 → 农历';
817
+ return [
818
+ `【公农历转换·${dir}】${r.lunar.text} ⇄ ${r.solar.text} ${r.meta.weekday}`,
819
+ `干支:${g.year}年 ${g.month}月 ${g.day}日 属${g.shengXiao}${g.springYear && g.springYear !== g.year ? `(春节界为${g.springYear}年)` : ''}`,
820
+ ...(r.meta.note ? [`注:${r.meta.note}`] : []),
821
+ `口径:${r.meta.scope}`,
822
+ ].join('\n');
823
+ }
824
+
778
825
  /** 生命数字文本 */
779
826
  function numerologyText(r) {
780
827
  return [
@@ -883,7 +930,7 @@ export { buildBazi, buildLiuyao, tossLines };
883
930
  export { followup };
884
931
  export { pastSignals, verifyFit, rectifyBirth };
885
932
  export { crossCheck, crossText };
886
- export { buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buildNumerology };
933
+ export { buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buildNumerology, buildConvert };
887
934
  export { buildTrend, buildFengshui, buildName };
888
935
 
889
936