refactor: 사주 Python 엔진 제거 + lunar-javascript 기반 절기 계산 도입

- lib/solar-terms.ts: solarlunar → lunar-javascript로 전면 교체
  - getSolarTermDate(): LunarYear.fromYear().getJieQiJulianDays() 사용 (시분 단위 정밀도)
  - 소한(22)/대한(23)은 year-1로 조회해 해당 연도 1월 날짜 정확히 반환
  - getCurrentSolarTerm(): 입춘 기준 두 구간 분리, Date.UTC() 비교
- lib/daeun-calculator.ts: getSolarTermDate 정확도 향상으로 termYear 수동 보정 제거
- lib/saju-calculator.ts: 일주 기준일 甲戌, Date.UTC(), 오호둔월법 공식 적용
- lib/ai-interpretation.ts: 신약 용신 후보 내림차순 정렬 수정
- app/saju/result/page.tsx: Python 엔진(fetchFromPythonEngine) 완전 제거, TS 전용
- app/api/saju/calculate/route.ts: Python 프록시 라우트 삭제
- app/saju/page.tsx: fromHistory 파라미터 제거
- types/lunar-javascript.d.ts: 타입 선언 파일 추가

검증 케이스(1992-12-23 16:30 남성): 壬申/壬子/癸酉/庚申

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:38:25 +09:00
parent 7f4fb8027a
commit 1193a075c2
12 changed files with 351 additions and 343 deletions

View File

@@ -252,7 +252,9 @@ export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): Y
{ elem: producingMe, score: balance[producingMe as keyof ElementBalance], name: '인성' },
{ elem: dayElement, score: balance[dayElement as keyof ElementBalance], name: '비겁' },
];
candidates.sort((a, b) => a.score - b.score);
// 신약: 인성/비겁 중 사주에 더 강하게 존재하는 것이 실질적 용신
// (점수가 높을수록 사주에서 작용하는 힘이 강해 일간을 도울 수 있음)
candidates.sort((a, b) => b.score - a.score);
const yong = candidates[0];
const hee = candidates[1];

View File

@@ -42,17 +42,8 @@ function calculateDaeunStartAge(
const currentTerm = getCurrentSolarTerm(birthYear, birthMonth, birthDay);
const termDate = getSolarTermDate(birthYear, currentTerm);
let termYear = termDate.year;
let termMonth = termDate.month;
// 대한, 소한 처리
if (currentTerm >= 22 && birthMonth >= 2) {
termYear = birthYear;
} else if (currentTerm >= 22) {
termYear = birthYear - 1;
}
const termDateObj = new Date(termYear, termMonth - 1, termDate.day);
// getSolarTermDate가 소한(22)/대한(23)에 대해 birthYear 1월 날짜를 올바르게 반환
const termDateObj = new Date(termDate.year, termDate.month - 1, termDate.day);
const birthDateObj = new Date(birthYear, birthMonth - 1, birthDay);
const diffTime = birthDateObj.getTime() - termDateObj.getTime();

View File

@@ -45,11 +45,32 @@ const BASE_YEAR_BRANCH = 0; // 子
/**
* 년도의 간지를 계산
* month, day를 전달하면 입춘(立春) 기준으로 전년도 년주를 적용합니다.
*/
export function getYearGanzi(year: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
const yearDiff = year - BASE_YEAR;
const stemIndex = (BASE_YEAR_STEM + yearDiff) % 10;
const branchIndex = (BASE_YEAR_BRANCH + yearDiff) % 12;
export function getYearGanzi(year: number, month?: number, day?: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
let adjustedYear = year;
// 입춘(立春) 이전 출생이면 전년도 년주 사용
if (month !== undefined && day !== undefined) {
try {
const { getSolarTermDate } = require('./solar-terms');
const ipchun = getSolarTermDate(year, 0); // termIndex 0 = 입춘
const birthUTC = Date.UTC(year, month - 1, day);
const ipchunUTC = Date.UTC(year, ipchun.month - 1, ipchun.day);
if (birthUTC < ipchunUTC) {
adjustedYear = year - 1;
}
} catch {
// 절기 계산 실패 시 양력 2월 4일을 입춘 근사값으로 사용
if (month === 1 || (month === 2 && day < 4)) {
adjustedYear = year - 1;
}
}
}
const yearDiff = adjustedYear - BASE_YEAR;
const stemIndex = ((BASE_YEAR_STEM + yearDiff) % 10 + 10) % 10;
const branchIndex = ((BASE_YEAR_BRANCH + yearDiff) % 12 + 12) % 12;
return {
stem: HEAVENLY_STEMS[stemIndex],
@@ -67,12 +88,14 @@ export function getMonthGanzi(year: number, month: number, day: number): { stem:
const { getSolarTermMonthBranch } = require('./solar-terms');
const branchIndex = getSolarTermMonthBranch(year, month, day);
// 월 천간 계산 (년간에 따라 달라짐)
const yearStem = getYearGanzi(year).stem;
// 월 천간 계산 — 입춘 보정된 년간 사용
const yearStem = getYearGanzi(year, month, day).stem;
const yearStemIndex = HEAVENLY_STEMS.indexOf(yearStem as any);
// 월 천간 공식: (년간 * 2 + 월지지) % 10
const stemIndex = (yearStemIndex * 2 + branchIndex) % 10;
// 오호둔월법 (五虎遁月法): 寅月(branchIndex=2)을 기준으로 년간별 시작 천간 결정
// 甲/己년: 寅月=丙(2), 乙/庚년: 寅月=戊(4), 丙/辛년: 寅月=庚(6), 丁/壬년: 寅月=壬(8), 戊/癸년: 寅月=甲(0)
const startStem = ((yearStemIndex % 5) * 2 + 2) % 10;
const stemIndex = (startStem + (branchIndex - 2 + 12) % 12) % 10;
return {
stem: HEAVENLY_STEMS[stemIndex],
@@ -86,14 +109,14 @@ export function getMonthGanzi(year: number, month: number, day: number): { stem:
* 일의 간지를 계산 (만세력 기준)
*/
export function getDayGanzi(year: number, month: number, day: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
// 기준일 (1900-01-01) 부터의 일수 계산
const baseDate = new Date(1900, 0, 1);
const targetDate = new Date(year, month - 1, day);
const daysDiff = Math.floor((targetDate.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24));
// UTC 기준으로 일수 계산 (로컬 타임존/DST 영향 제거)
const baseUTC = Date.UTC(1900, 0, 1);
const targetUTC = Date.UTC(year, month - 1, day);
const daysDiff = Math.floor((targetUTC - baseUTC) / (1000 * 60 * 60 * 24));
// 1900-01-01 = 丙寅일
const baseDayStem = 2; //
const baseDayBranch = 2; //
// 1900-01-01 = 甲戌일 (60갑자 기준, JDN+49 공식 검증)
const baseDayStem = 0; //
const baseDayBranch = 10; //
const stemIndex = (baseDayStem + daysDiff) % 10;
const branchIndex = (baseDayBranch + daysDiff) % 12;
@@ -223,7 +246,7 @@ export function calculateSaju(
hour: number | null,
gender: 'male' | 'female'
): SajuData {
const yearGanzi = getYearGanzi(year);
const yearGanzi = getYearGanzi(year, month, day);
const monthGanzi = getMonthGanzi(year, month, day);
const dayGanzi = getDayGanzi(year, month, day);
const hourGanzi = hour !== null ? getHourGanzi(dayGanzi, hour) : null;

View File

@@ -1,7 +1,8 @@
/**
* 24절기 계산
* 24절기 계산 — lunar-javascript 라이브러리 기반 (정밀 천문학 계산)
* 사주 계산에서 월주는 절기를 기준으로 합니다.
*/
import { LunarYear, Solar } from 'lunar-javascript';
// 24절기 (입춘부터 시작)
export const SOLAR_TERMS = [
@@ -36,184 +37,110 @@ interface SolarTermDate {
}
/**
* 정밀한 절기 계산 (천문학적 계산 기반)
* solarlunar 라이브러리 사용
* 정밀한 절기 날짜 계산 (lunar-javascript 기반)
*
* termIndex 매핑 (0~23):
* 0=입춘, 1=우수, ..., 21=동지, 22=소한, 23=대한
*
* LunarYear.fromYear(y).getJieQiJulianDays() 인덱스 구조:
* [0]=大雪(y-1), [1]=冬至(y-1), [2]=小寒(y), [3]=大寒(y),
* [4]=立春(y) ← termIndex 0
* [5]=雨水(y) ← termIndex 1
* ...
* [25]=冬至(y) ← termIndex 21
* [26]=小寒(y+1) ← termIndex 22
* [27]=大寒(y+1) ← termIndex 23
*
* 반환 규칙:
* getSolarTermDate(year, 0~21) → year 내 절기 날짜
* getSolarTermDate(year, 22~23) → year 1월의 소한/대한 날짜
* (내부적으로 LunarYear.fromYear(year - 1) 사용)
*/
export function getSolarTermDate(year: number, termIndex: number): SolarTermDate {
try {
const solarLunar = require('solarlunar');
// 소한(22)/대한(23)은 해당 연도 1월에 위치.
// LunarYear.fromYear(y)[26/27]은 y+1년 1월을 반환하므로
// year의 1월 소한/대한을 얻으려면 year-1로 조회.
const lunarYear = termIndex >= 22 ? year - 1 : year;
const jds = LunarYear.fromYear(lunarYear).getJieQiJulianDays();
const jd = jds[termIndex + 4];
const solar = Solar.fromJulianDay(jd);
// solarlunar의 절기 데이터 가져오기
// 각 년도의 절기 정보를 계산
const termNames = [
'立春', '雨水', '驚蟄', '春分', '清明', '穀雨',
'立夏', '小滿', '芒種', '夏至', '小暑', '大暑',
'立秋', '處暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
];
// 해당 년도의 절기 찾기
// solarlunar는 양력 날짜로 절기 확인 가능
// 각 절기의 대략적인 날짜 범위에서 검색
const searchRanges = [
{ month: 2, startDay: 3, endDay: 5 }, // 입춘
{ month: 2, startDay: 18, endDay: 20 }, // 우수
{ month: 3, startDay: 5, endDay: 7 }, // 경칩
{ month: 3, startDay: 20, endDay: 22 }, // 춘분
{ month: 4, startDay: 4, endDay: 6 }, // 청명
{ month: 4, startDay: 19, endDay: 21 }, // 곡우
{ month: 5, startDay: 5, endDay: 7 }, // 입하
{ month: 5, startDay: 20, endDay: 22 }, // 소만
{ month: 6, startDay: 5, endDay: 7 }, // 망종
{ month: 6, startDay: 20, endDay: 22 }, // 하지
{ month: 7, startDay: 6, endDay: 8 }, // 소서
{ month: 7, startDay: 22, endDay: 24 }, // 대서
{ month: 8, startDay: 7, endDay: 9 }, // 입추
{ month: 8, startDay: 22, endDay: 24 }, // 처서
{ month: 9, startDay: 7, endDay: 9 }, // 백로
{ month: 9, startDay: 22, endDay: 24 }, // 추분
{ month: 10, startDay: 7, endDay: 9 }, // 한로
{ month: 10, startDay: 23, endDay: 24 },// 상강
{ month: 11, startDay: 7, endDay: 8 }, // 입동
{ month: 11, startDay: 21, endDay: 23 },// 소설
{ month: 12, startDay: 6, endDay: 8 }, // 대설
{ month: 12, startDay: 21, endDay: 23 },// 동지
{ month: 1, startDay: 5, endDay: 7 }, // 소한
{ month: 1, startDay: 19, endDay: 21 }, // 대한
];
const range = searchRanges[termIndex];
const termName = termNames[termIndex];
// 해당 범위 내에서 절기 찾기
for (let day = range.startDay; day <= range.endDay; day++) {
const lunar = solarLunar.solar2lunar(year, range.month, day);
if (lunar && lunar.term === termName) {
return {
year,
month: range.month,
day,
hour: 0,
minute: 0
};
}
}
// 찾지 못한 경우 중간값 사용
const midDay = Math.floor((range.startDay + range.endDay) / 2);
return {
year,
month: range.month,
day: midDay,
hour: 0,
minute: 0
};
} catch (error) {
console.error('절기 계산 오류:', error);
// 폴백: 기존 근사값 사용
const baseMonth = [
2, 2, 3, 3, 4, 4,
5, 5, 6, 6, 7, 7,
8, 8, 9, 9, 10, 10,
11, 11, 12, 12, 1, 1
];
const baseDay = [
4, 19, 5, 20, 4, 20,
5, 21, 6, 21, 7, 23,
7, 23, 8, 23, 8, 23,
7, 22, 7, 22, 5, 20
];
return {
year,
month: baseMonth[termIndex],
day: baseDay[termIndex],
hour: 0,
minute: 0
};
}
return {
year: solar.getYear(),
month: solar.getMonth(),
day: solar.getDay(),
hour: solar.getHour(),
minute: solar.getMinute(),
};
}
/**
* 주어진 날짜가 어느 절기 이후인지 확인
* @param year 년
* @param month 월
* @param day 일
* @returns 절기 인덱스 (0~23)
*/
export function getCurrentSolarTerm(year: number, month: number, day: number): number {
const date = new Date(year, month - 1, day);
const dateValue = date.getTime();
const dateValue = Date.UTC(year, month - 1, day);
// 각 절기 날짜 확인
for (let i = 23; i >= 0; i--) {
const termDate = getSolarTermDate(year, i);
let termYear = termDate.year;
let termMonth = termDate.month;
const ipchunData = getSolarTermDate(year, 0);
const ipchunValue = Date.UTC(ipchunData.year, ipchunData.month - 1, ipchunData.day);
// 대한, 소한은 이전 해 처리
if (i >= 22 && month >= 2) {
termYear = year;
} else if (i >= 22) {
termYear = year - 1;
if (dateValue >= ipchunValue) {
// 입춘 이후: 동지(21)→입춘(0) 역순 검색
for (let i = 21; i >= 0; i--) {
const td = getSolarTermDate(year, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
const term = new Date(termYear, termMonth - 1, termDate.day);
if (dateValue >= term.getTime()) {
return i;
return 0;
} else {
// 입춘 이전 (1월 또는 2월 초): 이 해의 소한(22)/대한(23) 먼저 확인
for (let i = 23; i >= 22; i--) {
const td = getSolarTermDate(year, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
// 전년도 동지(21)→입춘(0) 역순 검색
for (let i = 21; i >= 0; i--) {
const td = getSolarTermDate(year - 1, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
return 23;
}
// 입춘 이전이면 전년도 대한 이후
return 23;
}
/**
* 절기 기준 월주 지지 인덱스 계산
* @param year 년
* @param month 월
* @param day 일
* @returns 지지 인덱스 (0: 자, 1: 축, 2: 인, ...)
*/
export function getSolarTermMonthBranch(year: number, month: number, day: number): number {
const termIndex = getCurrentSolarTerm(year, month, day);
// 절기 인덱스를 월로 변환
// 입춘(0) -> 인월(2)
// 경칩(2) -> 묘월(3)
// 청명(4) -> 진월(4)
// ...
const monthBranches = [
2, // 입춘 -> 인월
2, // 우수 -> 인월
3, // 경칩 -> 묘월
3, // 춘분 -> 묘월
4, // 청명 -> 진월
4, // 곡우 -> 진월
5, // 입하 -> 사월
5, // 소만 -> 사월
6, // 망종 -> 오월
6, // 하지 -> 오월
7, // 소서 -> 미월
7, // 대서 -> 미월
8, // 입추 -> 신월
8, // 처서 -> 신월
9, // 백로 -> 유월
9, // 추분 -> 유월
10, // 한로 -> 술월
10, // 상강 -> 술월
11, // 입동 -> 해월
11, // 소설 -> 해월
0, // 대설 -> 자월
0, // 동지 -> 자월
1, // 소한 -> 축월
1, // 대한 -> 축월
2, // 입춘 인월
2, // 우수 인월
3, // 경칩 묘월
3, // 춘분 묘월
4, // 청명 진월
4, // 곡우 진월
5, // 입하 사월
5, // 소만 사월
6, // 망종 오월
6, // 하지 오월
7, // 소서 미월
7, // 대서 미월
8, // 입추 신월
8, // 처서 신월
9, // 백로 유월
9, // 추분 유월
10, // 한로 술월
10, // 상강 술월
11, // 입동 해월
11, // 소설 해월
0, // 대설 자월
0, // 동지 자월
1, // 소한 축월
1, // 대한 축월
];
return monthBranches[termIndex];
@@ -234,10 +161,8 @@ export function getDaysToNextSolarTerm(year: number, month: number, day: number)
const currentTerm = getCurrentSolarTerm(year, month, day);
const nextTermIndex = (currentTerm + 1) % 24;
let nextYear = year;
if (currentTerm === 23) {
nextYear = year + 1;
}
// 대한(23) 다음은 입춘(0) — 다음 연도
const nextYear = currentTerm === 23 ? year + 1 : year;
const nextTerm = getSolarTermDate(nextYear, nextTermIndex);
const nextDate = new Date(nextTerm.year, nextTerm.month - 1, nextTerm.day);