From 5e4264953ef63b0651b9fc35aa950060e711117f Mon Sep 17 00:00:00 2001 From: "engine-labs-app[bot]" <140088366+engine-labs-app[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 06:29:34 +0000 Subject: [PATCH] feat(dayun): implement DaYun computation module with gender and solar term logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DaYun module supporting age-at-start (起运) calculation using solar term timing and gender-based 顺运/逆运 (forward/reverse) logic. Provide 10-step DaYun sequence generation with contiguous year boundaries and metadata. - Computes direction based on heavenly stem and gender - Calculates age at start from adjacent solar terms per bazi rules - Generates full sequence including start/end dates and ages - Includes thorough tests for main functionality and edge cases This enables accurate generation of DaYun timelines for Bazi astrology applications. --- .gitignore | 8 ++ README.md | 16 ++- package.json | 16 +++ src/dayun.js | 247 +++++++++++++++++++++++++++++++++++++++++++++ test/dayun.test.js | 143 ++++++++++++++++++++++++++ 5 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/dayun.js create mode 100644 test/dayun.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..e4fabb6e98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +coverage/ +dist/ +.nyc_output/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store diff --git a/README.md b/README.md index a1f92f5862..8b90c8f7d8 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ -See you later \ No newline at end of file +# 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. diff --git a/package.json b/package.json new file mode 100644 index 0000000000..3266a8c63b --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/dayun.js b/src/dayun.js new file mode 100644 index 0000000000..7d1462cd0b --- /dev/null +++ b/src/dayun.js @@ -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 +}; diff --git a/test/dayun.test.js b/test/dayun.test.js new file mode 100644 index 0000000000..6fce197a04 --- /dev/null +++ b/test/dayun.test.js @@ -0,0 +1,143 @@ +const assert = require('assert'); + +const { + determineDaYunDirection, + computeQiYun, + generateDaYunSequence +} = require('../src/dayun'); + +function approxEqual(actual, expected, epsilon = 1e-6) { + assert.ok(Math.abs(actual - expected) <= epsilon, `Expected ${actual} to be within ${epsilon} of ${expected}`); +} + +function test(name, fn) { + try { + fn(); + console.log(`✓ ${name}`); + } catch (error) { + console.error(`✗ ${name}`); + console.error(error); + process.exit(1); + } +} + +test('determineDaYunDirection handles gender and heavenly stem yin/yang', () => { + assert.strictEqual(determineDaYunDirection('甲', 'male'), 'forward'); + assert.strictEqual(determineDaYunDirection('乙', 'male'), 'reverse'); + assert.strictEqual(determineDaYunDirection('甲', 'female'), 'reverse'); + assert.strictEqual(determineDaYunDirection('辛', 'female'), 'forward'); +}); + +test('computeQiYun utilises next solar term for forward DaYun', () => { + const birthDate = new Date(Date.UTC(1990, 0, 10, 12, 0, 0)); + const nextSolarTerm = new Date(Date.UTC(1990, 0, 15, 12, 0, 0)); + const prevSolarTerm = new Date(Date.UTC(1989, 11, 25, 12, 0, 0)); + + const result = computeQiYun({ + birthDate, + gender: 'male', + yearStem: '甲', + nextSolarTerm, + prevSolarTerm + }); + + assert.strictEqual(result.direction, 'forward'); + assert.strictEqual(result.referenceSolarTerm.toISOString(), nextSolarTerm.toISOString()); + approxEqual(result.startAge.totalYears, 5 / 3); + assert.strictEqual(result.startAge.years, 1); + assert.strictEqual(result.startAge.months, 8); + assert.strictEqual(result.startAge.days, 0); + assert.strictEqual(result.startDate.toISOString(), '1991-09-10T12:00:00.000Z'); +}); + +test('computeQiYun utilises previous solar term for reverse DaYun', () => { + const birthDate = new Date(Date.UTC(2000, 4, 20, 8, 0, 0)); + const prevSolarTerm = new Date(Date.UTC(2000, 4, 5, 8, 0, 0)); + const nextSolarTerm = new Date(Date.UTC(2000, 4, 25, 8, 0, 0)); + + const result = computeQiYun({ + birthDate, + gender: 'male', + yearStem: '乙', + nextSolarTerm, + prevSolarTerm + }); + + assert.strictEqual(result.direction, 'reverse'); + assert.strictEqual(result.referenceSolarTerm.toISOString(), prevSolarTerm.toISOString()); + approxEqual(result.startAge.totalYears, 5); + assert.strictEqual(result.startAge.years, 5); + assert.strictEqual(result.startAge.months, 0); + assert.strictEqual(result.startDate.toISOString(), '2005-05-20T08:00:00.000Z'); +}); + +test('generateDaYunSequence creates ten contiguous cycles with correct ages and years', () => { + const birthDate = new Date(Date.UTC(1990, 0, 10, 12, 0, 0)); + const nextSolarTerm = new Date(Date.UTC(1990, 0, 15, 12, 0, 0)); + const prevSolarTerm = new Date(Date.UTC(1989, 11, 25, 12, 0, 0)); + + const result = generateDaYunSequence({ + birthDate, + gender: 'male', + yearStem: '甲', + nextSolarTerm, + prevSolarTerm + }); + + assert.strictEqual(result.sequence.length, 10); + + const firstCycle = result.sequence[0]; + assert.strictEqual(firstCycle.startYear, 1991); + assert.strictEqual(firstCycle.endYear, 2001); + approxEqual(firstCycle.startAgeYears, 5 / 3); + approxEqual(firstCycle.endAgeYears, 35 / 3); + assert.strictEqual(firstCycle.endDate.toISOString(), '2001-09-10T12:00:00.000Z'); + + const secondCycle = result.sequence[1]; + assert.strictEqual(secondCycle.startDate.toISOString(), firstCycle.endDate.toISOString()); + approxEqual(secondCycle.startAgeYears, firstCycle.endAgeYears); + assert.strictEqual(secondCycle.index, 2); +}); + +test('birth on solar term boundary produces zero start age', () => { + const birthDate = new Date(Date.UTC(2020, 5, 1, 0, 0, 0)); + const solarTerm = new Date(Date.UTC(2020, 5, 1, 0, 0, 0)); + const prevSolarTerm = new Date(Date.UTC(2020, 4, 20, 0, 0, 0)); + + const result = computeQiYun({ + birthDate, + gender: 'female', + yearStem: '乙', + nextSolarTerm: solarTerm, + prevSolarTerm + }); + + assert.strictEqual(result.startAge.totalYears, 0); + assert.strictEqual(result.startAge.years, 0); + assert.strictEqual(result.startDate.toISOString(), birthDate.toISOString()); +}); + +test('missing required solar term input throws descriptive error', () => { + const birthDate = new Date(Date.UTC(1995, 3, 12, 0, 0, 0)); + const prevSolarTerm = new Date(Date.UTC(1995, 2, 28, 0, 0, 0)); + + assert.throws(() => { + computeQiYun({ + birthDate, + gender: 'female', + yearStem: '辛', + prevSolarTerm + }); + }, /nextSolarTerm/); + + assert.throws(() => { + computeQiYun({ + birthDate, + gender: 'male', + yearStem: '乙', + nextSolarTerm: new Date(Date.UTC(1995, 3, 20, 0, 0, 0)) + }); + }, /prevSolarTerm/); +}); + +console.log('All DaYun tests passed.');