Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
coverage/
dist/
.nyc_output/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
See you later
# DaYun Computation Module

This project implements DaYun (大运) sequencing with gender-based 顺逆 (forward/reverse) determination and solar-term aware 起运 age calculation.

## Running tests

```
npm test
```

The tests cover:

- 顺运/逆运 direction logic driven by the heavenly stem and gender.
- 起运 age calculation from adjacent solar terms, including boundary cases.
- Generation of the full ten-step DaYun timeline with contiguous start and end dates.
16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "dayun-calculator",
"version": "1.0.0",
"description": "DaYun computation module with gender and solar term aware sequencing.",
"main": "src/dayun.js",
"scripts": {
"test": "node test/dayun.test.js"
},
"keywords": [
"dayun",
"bazi",
"astrology"
],
"author": "",
"license": "MIT"
}
247 changes: 247 additions & 0 deletions src/dayun.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
const MS_PER_MINUTE = 60 * 1000;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MONTHS_PER_YEAR = 12;
const DAYS_PER_MONTH_IN_QIYUN = 30;
const MONTHS_PER_DAY_DIFFERENCE = 4; // 1 day difference corresponds to 4 months of age
const DA_YUN_CYCLE_LENGTH_YEARS = 10;
const EPSILON = 1e-9;

const HEAVENLY_STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
const YANG_HEAVENLY_STEMS = new Set(['甲', '丙', '戊', '庚', '壬']);

const GENDER_MAP = new Map([
['male', 'male'],
['m', 'male'],
['男', 'male'],
['female', 'female'],
['f', 'female'],
['女', 'female']
]);

function isValidDate(value) {
return value instanceof Date && !Number.isNaN(value.getTime());
}

function normalizeGender(gender) {
if (typeof gender !== 'string') {
throw new TypeError('Gender must be provided as a string.');
}
const normalized = GENDER_MAP.get(gender.toLowerCase());
if (!normalized) {
throw new RangeError('Unsupported gender value. Accepted: male/female.');
}
return normalized;
}

function validateYearStem(yearStem) {
if (typeof yearStem !== 'string') {
throw new TypeError('Year stem must be provided as a string.');
}
if (!HEAVENLY_STEMS.includes(yearStem)) {
throw new RangeError(`Invalid heavenly stem "${yearStem}".`);
}
return yearStem;
}

function determineDaYunDirection(yearStem, gender) {
const normalizedStem = validateYearStem(yearStem);
const normalizedGender = normalizeGender(gender);
const isYangStem = YANG_HEAVENLY_STEMS.has(normalizedStem);

if (normalizedGender === 'male') {
return isYangStem ? 'forward' : 'reverse';
}
return isYangStem ? 'reverse' : 'forward';
}

function ensureValidSolarTermReference(direction, birthDate, nextSolarTerm, prevSolarTerm) {
if (!isValidDate(birthDate)) {
throw new TypeError('birthDate must be a valid Date instance.');
}

if (direction === 'forward') {
if (!isValidDate(nextSolarTerm)) {
throw new TypeError('nextSolarTerm must be a valid Date instance for forward (順) DaYun.');
}
if (nextSolarTerm.getTime() < birthDate.getTime() - EPSILON) {
throw new RangeError('nextSolarTerm must not occur before the birthDate for forward DaYun.');
}
return nextSolarTerm;
}

if (!isValidDate(prevSolarTerm)) {
throw new TypeError('prevSolarTerm must be a valid Date instance for reverse (逆) DaYun.');
}
if (prevSolarTerm.getTime() > birthDate.getTime() + EPSILON) {
throw new RangeError('prevSolarTerm must not occur after the birthDate for reverse DaYun.');
}
return prevSolarTerm;
}

function computeAgeBreakdownFromYears(totalYears) {
const totalMonths = totalYears * MONTHS_PER_YEAR;
let years = Math.floor(totalYears + EPSILON);
let remainingMonths = totalMonths - years * MONTHS_PER_YEAR;

let months = Math.floor(remainingMonths + EPSILON);
remainingMonths -= months;

let daysFloat = remainingMonths * DAYS_PER_MONTH_IN_QIYUN;
let days = Math.floor(daysFloat + EPSILON);
let remainingDays = daysFloat - days;

let hoursFloat = remainingDays * 24;
let hours = Math.floor(hoursFloat + EPSILON);
let remainingHours = hoursFloat - hours;

let minutes = Math.round(remainingHours * 60);

// Normalise cascading overflow
if (minutes === 60) {
minutes = 0;
hours += 1;
}
if (hours === 24) {
hours = 0;
days += 1;
}
if (days === DAYS_PER_MONTH_IN_QIYUN) {
days = 0;
months += 1;
}
if (months === MONTHS_PER_YEAR) {
months = 0;
years += 1;
}

const totalMonthsExact = totalYears * MONTHS_PER_YEAR;

return {
years,
months,
days,
hours,
minutes,
totalYears,
totalMonths: totalMonthsExact
};
}

function computeAgeBreakdownFromDifference(diffMilliseconds) {
const totalDaysDifference = diffMilliseconds / MS_PER_DAY;
const totalMonths = totalDaysDifference * MONTHS_PER_DAY_DIFFERENCE;
const totalYears = totalMonths / MONTHS_PER_YEAR;
return computeAgeBreakdownFromYears(totalYears);
}

function addMonthsUTC(date, monthsToAdd) {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();
const hour = date.getUTCHours();
const minute = date.getUTCMinutes();
const second = date.getUTCSeconds();
const millisecond = date.getUTCMilliseconds();

const totalMonths = year * 12 + month + monthsToAdd;
const targetYear = Math.floor(totalMonths / 12);
const targetMonth = ((totalMonths % 12) + 12) % 12;

const daysInTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
const targetDay = Math.min(day, daysInTargetMonth);

return new Date(Date.UTC(targetYear, targetMonth, targetDay, hour, minute, second, millisecond));
}

function addAgeToDateUTC(date, ageBreakdown) {
let result = new Date(date.getTime());
const totalMonths = ageBreakdown.years * 12 + ageBreakdown.months;
if (totalMonths) {
result = addMonthsUTC(result, totalMonths);
}
if (ageBreakdown.days) {
result = new Date(result.getTime() + ageBreakdown.days * MS_PER_DAY);
}
if (ageBreakdown.hours) {
result = new Date(result.getTime() + ageBreakdown.hours * MS_PER_HOUR);
}
if (ageBreakdown.minutes) {
result = new Date(result.getTime() + ageBreakdown.minutes * MS_PER_MINUTE);
}
return result;
}

function computeQiYun({
birthDate,
gender,
yearStem,
nextSolarTerm,
prevSolarTerm
}) {
const direction = determineDaYunDirection(yearStem, gender);
const referenceSolarTerm = ensureValidSolarTermReference(direction, birthDate, nextSolarTerm, prevSolarTerm);

const diffMilliseconds = Math.abs(referenceSolarTerm.getTime() - birthDate.getTime());
const startAgeBreakdown = computeAgeBreakdownFromDifference(diffMilliseconds);
const startDate = addAgeToDateUTC(birthDate, startAgeBreakdown);

return {
direction,
referenceSolarTerm,
differenceMilliseconds: diffMilliseconds,
startAge: startAgeBreakdown,
startDate
};
}

function generateDaYunSequence({
birthDate,
gender,
yearStem,
nextSolarTerm,
prevSolarTerm
}) {
const qiYun = computeQiYun({ birthDate, gender, yearStem, nextSolarTerm, prevSolarTerm });
const sequence = [];

let cycleStartDate = new Date(qiYun.startDate.getTime());

for (let index = 0; index < 10; index += 1) {
const startAgeYears = qiYun.startAge.totalYears + index * DA_YUN_CYCLE_LENGTH_YEARS;
const endAgeYears = qiYun.startAge.totalYears + (index + 1) * DA_YUN_CYCLE_LENGTH_YEARS;

const startAge = computeAgeBreakdownFromYears(startAgeYears);
const endAge = computeAgeBreakdownFromYears(endAgeYears);
const endDate = addMonthsUTC(cycleStartDate, DA_YUN_CYCLE_LENGTH_YEARS * MONTHS_PER_YEAR);

sequence.push({
index: index + 1,
direction: qiYun.direction,
startAge,
endAge,
startAgeYears,
endAgeYears,
startDate: cycleStartDate,
startYear: cycleStartDate.getUTCFullYear(),
endDate,
endYear: endDate.getUTCFullYear()
});

cycleStartDate = new Date(endDate.getTime());
}

return {
...qiYun,
sequence
};
}

module.exports = {
determineDaYunDirection,
computeQiYun,
generateDaYunSequence,
DA_YUN_CYCLE_LENGTH_YEARS,
HEAVENLY_STEMS,
YANG_HEAVENLY_STEMS
};
Loading