はじめに
JavaScript の Date オブジェクトは、
1582 年のユリウス暦 → グレゴリオ暦の1582年の改暦(10日削除)を表現する用途には適していない。
例えば:
- 1582/10/4 の翌日が 1582/10/15 にならない
- 1582/10/5~14 が存在しないことを表現できない
- 差分計算が歴史的事実と一致しない
そこで、
歴史的な暦の穴を正しく扱える日付ライブラリ「N6LDate」を自作しました。
このライブラリの特徴
- ユリウス暦・グレゴリオ暦を自動判別
- 1582/10/4 → 1582/10/15 が +1 日になる(歴史的に正しい)
- 逆方向も -1 日になる(往復で整合性が取れる)
- 通算日数の raw と正規化後を分離(数学的に正しい設計)
- 約1200 行で実装(軽量)
- ゲームエンジン的な設計(状態・変換・正規化・差分の分離)
実際の動作例(改暦点を跨ぐ計算)
1582/10/4 → 1582/10/15
const a = new N6LDate(1582,10,4).normalize();
const b = new N6LDate(1582,10,15).normalize();
console.log(b.diffDays(a)); // +1
1582/10/15 → 1582/10/4
console.log(a.diffDays(b)); // -1
歴史的に正しい結果が返ります。
なぜ既存ライブラリでは正しく扱えないのか
JavaScript の Date や moment.js / dayjs / luxon は、
- 1582/10/5~14 を「存在する日」として扱ってしまう
- 改暦点の10日削除を内部で表現できない
- 通算日数が連続している前提で設計されている
そのため、歴史的な1582年の改暦を表現する用途には適していない
設計思想(N6LDate が安定している理由)
1. raw 通算日数と正規化後の pastdays を分離
calendarToDay() // raw(誤差含む)
normalize() // 正規化(正しい pastdays / ms を生成)
diffDays() // 正規化後のミリ秒差を使う
これにより、改暦点の「穴」を自然に吸収できます。
2. adjustCalendar() が暦の空間を正しく切り替える
if (targetDay >= gregorianStartDay)
gregorianDayToDate()
else
julianDayToDate()
通算日数の空間を暦ごとに分けることで、
ユリウス暦 → グレゴリオ暦の境界を正しく扱える ようになります。
3. normalize() が唯一の正規化入口
normalize() {
const val = this.adjustCalendar();
if (val.ma > 12) { val.ma -= 12; val.ya++; }
return this.SetVal(val);
}
- 再帰なし
- 副作用なし
- 一貫した正規化
これが安定性の源です。
コード(GitHub)
全文はこちらに公開しています:
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/date.js
wiki:
https://github.com/NAS6mixfoolv/NAS6LIB/wiki/N6LDateJapanese
デモページ:
https://nas6.net/koyomiEx000.htm
サンプル:日付操作
// ######### TestCode Start #########
//イリーガルな初期化
const dtz = new N6LDate(2000,15.5,55.5,30).normalize();
console.log(dtz.toString());
// → 2001/05/10(Thu) 06:00:00.000
//日付加算
const dta = new N6LDate(1582,10,4).normalize();
console.log(dta.addDays(1).toString());
// → 1582/10/15(Fri) 00:00:00.000
const dtb = new N6LDate(2026,10,1,6).normalize();
console.log(dtb.addMonths(-1.5).toString());
// → 2026/08/16(Sun) 08:23:47.040
const dtc = new N6LDate(2026,12,15,6).normalize();
console.log(dtc.addYears(1.25).toString());
// → 2028/03/15(Wed) 19:30:00.000
//時刻加算
const dtd = new N6LDate(2026,7,15,12).normalize();
console.log(dtd.toString());
// → 2026/07/15(Wed) 12:00:00.000
console.log(dtd.addDays(0.75).toString());
// → 2026/07/16(Thu) 06:00:00.000
const dte = new N6LDate(2026,7,15,12).normalize();
console.log(dte.addHours(18).toString());
// → 2026/07/16(Thu) 06:00:00.000
//改暦デフォルト
const a = new N6LDate(1582,10,4).normalize();
const b = new N6LDate(1582,10,15).normalize();
console.log(b.diffDays(a)); // +1
console.log(a.diffDays(b)); // -1
//改暦日本
const jedjp = new N6LDate(1582,10,4,0,0,0,0,"JP").normalize();
const gsdjp = new N6LDate(1582,10,15,0,0,0,0,"JP").normalize();
console.log(gsdjp.diffDays(jedjp)); // +1
console.log(jedjp.diffDays(gsdjp)); // -1
//改暦イギリス
const jedgb = new N6LDate(1752,9,2,0,0,0,0,"GB").normalize();
const gsdgb = new N6LDate(1752,9,14,0,0,0,0,"GB").normalize();
console.log(gsdgb.diffDays(jedgb)); // +1
console.log(jedgb.diffDays(gsdgb)); // -1
//改暦ロシア
const jedru = new N6LDate(1918,1,31,0,0,0,0,"RU").normalize();
const gsdru = new N6LDate(1918,2,14,0,0,0,0,"RU").normalize();
console.log(gsdru.diffDays(jedru)); // +1
console.log(jedru.diffDays(gsdru)); // -1
//改暦ギリシャ
const jedgr = new N6LDate(1923,2,15,0,0,0,0,"GR").normalize();
const gsdgr = new N6LDate(1923,3,1,0,0,0,0,"GR").normalize();
console.log(gsdgr.diffDays(jedgr)); // +1
console.log(jedgr.diffDays(gsdgr)); // -1
//各種変換
const jp2000 = new N6LDate(2000,1,1,0,0,0,0,"JP").normalize();
console.log(jp2000.toString()); // 2000/01/01(Sat) 00:00:00.000
const jdjp2000 = jp2000.toJD();
console.log(jdjp2000); // 2451544.5
const fmjdjp2000 = jp2000.fromJD(jdjp2000);
console.log(fmjdjp2000.toString()); // 2000/01/01(Sat) 00:00:00.000
const uxjp2000 = jp2000.toUNIX();
console.log(uxjp2000); // 946684800
const fmuxjp2000 = jp2000.fromUNIX(uxjp2000);
console.log(fmuxjp2000.toString()); // 2000/01/01(Sat) 00:00:00.000
const ru2000 = new N6LDate(2000,1,1,0,0,0,0,"RU").normalize();
console.log(ru2000.toString()); // 2000/01/01(Sat) 00:00:00.000
const jdru2000 = ru2000.toJD();
console.log(jdru2000); // 2451544.5
const fmjdru2000 = ru2000.fromJD(jdru2000);
console.log(fmjdru2000.toString()); // 2000/01/01(Sat) 00:00:00.000
//平年100年400年検証
const jp19000228 = new N6LDate(1900,2,28,0,0,0,0,"JP").normalize();
console.log(jp19000228.toString()); // 1900/02/28(Wed) 00:00:00.000
const jdjp19000228 = jp19000228.toJD();
console.log(jdjp19000228); // 2415078.5
const fmjdjp19000228 = jp19000228.fromJD(jdjp19000228);
console.log(fmjdjp19000228.toString()); // 1900/02/28(Wed) 00:00:00.000
const jp19000229 = new N6LDate(1900,2,29,0,0,0,0,"JP").normalize();
console.log(jp19000229.toString()); // 1900/03/01(Thu) 00:00:00.000
const jdjp19000229 = jp19000229.toJD();
console.log(jdjp19000229); // 2415079.5
const fmjdjp19000229 = jp19000229.fromJD(jdjp19000229);
console.log(fmjdjp19000229.toString()); // 1900/03/01(Thu) 00:00:00.000
const jp19000301 = new N6LDate(1900,3,1,0,0,0,0,"JP").normalize();
console.log(jp19000301.toString()); // 1900/03/01(Thu) 00:00:00.000
const jdjp19000301 = jp19000301.toJD();
console.log(jdjp19000301); // 2415079.5
const fmjdjp19000301 = jp19000301.fromJD(jdjp19000301);
console.log(fmjdjp19000301.toString()); // 1900/03/01(Thu) 00:00:00.000
const jp20000228 = new N6LDate(2000,2,28,0,0,0,0,"JP").normalize();
console.log(jp20000228.toString()); // 2000/02/28(Mon) 00:00:00.000
const jdjp20000228 = jp20000228.toJD();
console.log(jdjp20000228); // 2451602.5
const fmjdjp20000228 = jp20000228.fromJD(jdjp20000228);
console.log(fmjdjp20000228.toString()); // 2000/02/28(Mon) 00:00:00.000
const jp20000229 = new N6LDate(2000,2,29,0,0,0,0,"JP").normalize();
console.log(jp20000229.toString()); // 2000/02/29(Tue) 00:00:00.000
const jdjp20000229 = jp20000229.toJD();
console.log(jdjp20000229); // 2451603.5
const fmjdjp20000229 = jp20000229.fromJD(jdjp20000229);
console.log(fmjdjp20000229.toString()); // 2000/02/29(Tue) 00:00:00.000
const jp20000301 = new N6LDate(2000,3,1,0,0,0,0,"JP").normalize();
console.log(jp20000301.toString()); // 2000/03/01(Wed) 00:00:00.000
const jdjp20000301 = jp20000301.toJD();
console.log(jdjp20000301); // 2451604.5
const fmjdjp20000301 = jp20000301.fromJD(jdjp20000301);
console.log(fmjdjp20000301.toString()); // 2000/03/01(Wed) 00:00:00.000
const jp20010228 = new N6LDate(2001,2,28,0,0,0,0,"JP").normalize();
console.log(jp20010228.toString()); // 2001/02/28(Wed) 00:00:00.000
const jdjp20010228 = jp20010228.toJD();
console.log(jdjp20010228); // 2451968.5
const fmjdjp20010228 = jp20010228.fromJD(jdjp20010228);
console.log(fmjdjp20010228.toString()); // 2001/02/28(Wed) 00:00:00.000
const jp20010229 = new N6LDate(2001,2,29,0,0,0,0,"JP").normalize();
console.log(jp20010229.toString()); // 2001/03/01(Thu) 00:00:00.000
const jdjp20010229 = jp20010229.toJD();
console.log(jdjp20010229); // 2451969.5
const fmjdjp20010229 = jp20010229.fromJD(jdjp20010229);
console.log(fmjdjp20010229.toString()); // 2001/03/01(Thu) 00:00:00.000
const jp20010301 = new N6LDate(2001,3,1,0,0,0,0,"JP").normalize();
console.log(jp20010301.toString()); // 2001/03/01(Thu) 00:00:00.000
const jdjp20010301 = jp20010301.toJD();
console.log(jdjp20010301); // 2451969.5
const fmjdjp20010301 = jp20010301.fromJD(jdjp20010301);
console.log(fmjdjp20010301.toString()); // 2001/03/01(Thu) 00:00:00.000
const jp2000715 = new N6LDate(2000,7,15).normalize();
console.log(jp2000715.getISOWeekDate()); // {isoYear: 2000, isoWeek: 29, isoWeekDay: 3}
const jp2010715 = new N6LDate(2010,7,15).normalize();
console.log(jp2000715.getISOTimeDuration(jp2010715)); // -P9Y11M28DT3H34M24.960S
console.log(jp2000715.getReformInfo()); //In UK, due to the calendar reform, the day following 1752-09-02 was 1752-09-14, and 11 days were skipped.
console.log(jp2000715.format("YYYY-MM-DD hh:mm:ss.SSS (W)")); // 2000-07-15 00:00:00.000 (Sat)
jp2000715.regionW = "JP";
console.log(jp2000715.format("YYYY年 MMMM月 DD日 (W)")); // 2000年 7月 15日 (土)
const jp2030915 = new N6LDate(2030,9,15,12).normalize();
console.log(jp2030915.toString()); //2030/09/15(Sun) 12:00:00.000
const uxjp2030915 = jp2030915.toUNIX(jp2030915);
console.log(uxjp2030915); //1915704000
const fmuxjp2030915 = jp2030915.fromUNIX(uxjp2030915);
console.log(fmuxjp2030915.toString()); //2030/09/15(Sun) 12:00:00.000
const ctuxjp2030915 = new N6LDate(uxjp2030915*1000).normalize();
console.log(ctuxjp2030915.toString()); //2030/09/15(Sun) 12:00:00.000
//シリアライズ
const dtSz = new N6LDate(2030, 9, 15, 12, 34, 56, 789).normalize();
dtSz.region = "JP";
const json = JSON.stringify(dtSz); // toJSONが自動呼び出し
// → {"year":2030,"month":9,"day":15,...}
const restored = N6LDate.fromJSON(JSON.parse(json));
console.log(restored.toString()); // 2030/09/15(Sun) 12:34:56.789
const jp20150606 = new N6LDate(2015, 6, 6, 12, 34, 56, 789).normalize();
console.log(jp20150606.startOf("day").toString()); //2015/06/06(Sat) 00:00:00.000
console.log(jp20150606.startOf("month").toString()); //2015/06/01(Mon) 00:00:00.000
console.log(jp20150606.startOf("year").toString()); //2015/01/01(Thu) 00:00:00.000
console.log(jp20150606.startOf("week").toString()); //2015/06/01(Mon) 00:00:00.000
console.log(jp20150606.endOf("day").toString()); //2015/06/06(Sat) 23:59:59.999
console.log(jp20150606.endOf("month").toString()); //2015/06/30(Tue) 23:59:59.999
console.log(jp20150606.endOf("year").toString()); //2015/12/31(Thu) 23:59:59.999
console.log(jp20150606.endOf("week").toString()); //2015/06/07(Sun) 23:59:59.999
// ######### TestCode End #########
このように地域別改暦にも対応しています
🟦 1. 基本はユリウス閏日数 JL
ユリウス暦では 4 年に 1 回必ず閏年。
したがって、年 Y のユリウス閏日数 JL は:
$$
JL = \left\lfloor \frac{Y}{4} \right\rfloor
$$
これが「基準値」になります。
🟧 2. グレゴリオ暦の修正は 1600 年から適用
改暦が 1582 年なので、
100 年ルール・400 年ルールは 1600 年から有効。
- 100 年は平年(閏年ではない) → -1
- 400 年は閏年(例外的に閏年) → +0
つまり、1600 年以降の各世紀で加減するだけです。
🟨 3. 具体例(最小構成)
1200 年 1 月 1 日(ユリウス暦のみ)
$$
JL = 1200/4 = 300
$$
$$
L = 300
$$
1800 年 1 月 1 日
$$
JL = 1800/4 = 450
$$
グレゴリオ修正(1600 年以降):
- 1600 → +0
- 1700 → -1
合計:
$$
+0 -1 = -1
$$
$$
L = 449
$$
2100 年 1 月 1 日
$$
JL = 2100/4 = 525
$$
グレゴリオ修正:
- 1600 → +0
- 2000 → +0
- 1700 → -1
- 1800 → -1
- 1900 → -1
合計:
$$
+0 -3 = -3
$$
$$
L = 525 - 3 = 522
$$
🟥 4. なぜ閏日数 L が必要なのか?
改暦 1582 年の場合を考えます。
まずユリウス閏日数:
$$
JL = 1582/4 = 395
$$
次にグレゴリオ閏日数:
$$
GL = Y/4 - (Y/100 - Y/400)
$$
$$
GL = 395 - (15 - 3) = 383
$$
ここで 12 日の差異 が生じます。
しかし実際の改暦では 10 日が削除されました。
なぜ 12 日ではなく 10 日なのか?
🟥 5. 325年ニカイア公会議への回帰
歴史的事実は次のとおりです:
325年の第1ニカイア公会議で、春分を「3月21日」と定めた。
ユリウス暦の誤差(約128年で1日)により、1582年時点で実際の春分は暦上の3月21日から約10日ずれて3月11日頃になっていた。
教皇グレゴリウス13世は、ニカイア公会議当時の春分位置(3月21日)に戻すために10日を削除した。
計算上の根拠はおおむね
$$
\frac{1582 - 325}{128} \approx 9.8 \text{日} \quad \rightarrow \quad 10\text{日}
$$
です。西暦1年からの純粋な数学的差(12日)のうち、ニカイア以前にすでに生じていた約2日分は「補正対象外」とされたため、結果として10日になった、というのが標準的な説明です。
従って西暦1/1/1を基準にすればこの春分点3月21日との改暦時点における実際のずれと数学的なずれを加味して改暦(ニカイア)補正としてさらに2日と計算されて結局12→10日となる。
🟩 まとめ
- 閏日数 L は JL を基準に 100 年・400 年ルールを加減するだけ
- 改暦 1582 年では ユリウスとグレゴリオの差が 12 日
- しかし ニカイア公会議基準への回帰により 2 日補正されて 10 日になる
- この 10 日が 改暦の穴(1582/10/5〜14 の欠落)
この方法は、歴史的日付計算を扱う上で
最小構成で実用的かつ正確な閏日数算出方法です。
📝 UnixTime ⇔ グレゴリオ暦をAPIなしで計算してみたら丸め誤差地獄だった話
フェアフィールドの公式は正しいのにズレる理由と、積算閏日数による暦計算
1. はじめに
UnixTimeを自力でグレゴリオ暦へ変換してみると、意外な問題に遭遇します。
UnixTimeは基本的に、
1970年1月1日 00:00:00 UTCからの経過時間
として扱えます。
そのため、
UnixTime
↓
経過日数
↓
年月日
という変換を自分で実装すれば、外部APIを使わずに日付を求めることができます。
ところが、暦計算には、
- 365日と366日の切り替え
- 4年ごとの閏年
- 100年ルール
- 400年ルール
- 月ごとの28~31日の違い
- 改暦による日付の欠落
などがあります。
さらに、日数から年月日を逆算する方式では、浮動小数点による境界判定が問題になることがあります。
今回は、実際に暦計算を実装してみて分かったことをまとめます。
2. フェアフィールドの公式
グレゴリオ暦の日付を通算日へ変換する方法として、フェアフィールドの公式があります。
西暦1年1月1日からの日数を求める場合、例えば次のような形になります。
$ \text{days} = [(365 * \text{y} + (\text{y}/4) - ((\text{y}/100) - (\text{y}/400)) ]$
$+ [306*(\text{m}+1)/10]+[\text{d}-429] + 2$
1月・2月については前年の13月・14月として扱います。
1月 → 前年の13月
2月 → 前年の14月
3月 → 3月
...
12月 → 12月
この方法そのものが間違っているわけではありません。
むしろ、整数演算として正しく実装すれば非常に有効な公式です。
3. 問題は「逆変換」
問題になったのは、
通算日数
↓
年月日
という逆変換です。
年や月を直接求めるために、
$$
ya =
\left\lfloor
\frac{(day+305)\cdot400}{y400}
\right\rfloor
$$
のような計算を行ったり、
$$
m=\frac{x}{30.6001}+3
$$
のような近似値から月を求めたりします。
さらに日について、
$$
d=(m-3)\bmod30.6001
$$
のような計算をすると、浮動小数点演算が入ってきます。
ここで問題になるのが、境界値です。
4. 暦計算では「境界」が非常に重要
例えば、
2025/01/31
2025/02/01
の境界を考えてみます。
日付計算では、
30.999999999...
31.000000000...
のような僅かな差が、
1月31日
と
2月1日
を分けることになります。
浮動小数点演算によって、
31.000000000000004
や、
30.999999999999996
のような値が発生すると、floor() や整数化の位置によって結果が変わる可能性があります。
つまり、
計算式そのものが正しくても、近似値を使った境界判定では実装上の誤差が問題になる
ということです。
5. 暦は「積算」すると非常に扱いやすい
そこで、発想を逆にします。
年月日を直接逆算するのではなく、
まず正確な通算日数を整数として求める
という方法です。
例えば、
UnixTime
↓
経過ミリ秒
↓
経過秒
↓
経過日
とします。
ここで重要なのは、
$$
1日=86400秒
$$
として、日数部分を整数として扱うことです。
6. 積算閏日数を利用する
年 (Y) までの閏日数を求める場合、まずユリウス暦を基準にします。
$$
JL=\left\lfloor\frac{Y}{4}\right\rfloor
$$
グレゴリオ暦では、
- 100年ごとの閏年を取り消す →
-1 - 400年ごとの閏年を復活させる →
+0
という補正を行います。
例えば2100年なら、
$$
JL=\left\lfloor\frac{2100}{4}\right\rfloor=525
$$
です。
そして、
1600 → +0
1700 → -1
1800 → -1
1900 → -1
2000 → +0
なので、
$$
525+0-1-1-1+0=522
$$
となります。
そして同様にUnixTimeの基準日1970年の閏日数
$$
492+0-1-1-1=489
$$
なので基準日からの2100年の閏日数は
$$
522-489=33
$$
このように、
「何年が閏年だったか」を一つずつ判定するのではなく、「ここまでに何回閏日が存在したか」を直接求める
ことができます。
7. 年月日を順番に決めていく
正確な通算日数が得られたら、あとは、
通算日
↓
年
↓
残りの日数
↓
月
↓
残りの日数
↓
日
という順番で決定できます。
ここでは、必要に応じて、
1月 = 31日
2月 = 28日 or 29日
3月 = 31日
4月 = 30日
...
という整数値を使います。
閏年についても、すでに求めた閏日数または閏年判定によって2月の日数を決めればよいわけです。
重要なのは、
月や日の境界を浮動小数点の近似値から推測しない
ことです。
8. UnixTimeとの相性が良い
UnixTimeとの変換では、特にこの方法が扱いやすくなります。
例えば、
UnixTime
↓
86400秒で割る
↓
経過日数
↓
1970年からの日数
↓
積算閏日数で補正
↓
年月日
という流れです。
さらに時刻については、
残り秒
↓
時
↓
分
↓
秒
と整数演算すればよい。
ミリ秒まで扱う場合も、
残りミリ秒
↓
時
↓
分
↓
秒
↓
ミリ秒
と分離できます。
9. 「逆変換」より「整数の積算」を重視する
ここで重要なのは、
フェアフィールドの公式が間違っている
という話ではありません。
フェアフィールドの公式は、日付と通算日を相互変換するための数学的な公式です。
概算として求めるならば大いに結構なことです。
問題になるのは、その逆変換を、
浮動小数点
+
近似値
+
floor()
という形で実装した場合です。
暦の境界では、この僅かな誤差が結果として、
1日ずれる
という大きな問題になります。
したがって、今回の実装では、
暦を連続量として近似するのではなく、整数の日数(1日の中の時間の小数を含む)として積算する
という方式を採用しました。
10. N6LDateでの考え方
N6LDateでは、日付を扱う際に、
年
↓
累積日数
↓
閏日数による補正
↓
通算日
という考え方を基本にしています。
つまり、
「この日付は何日目なのか?」
をまず整数として確定させます。
その後、
通算日
↓
年
↓
月
↓
日
と戻していきます。
この方法なら、月境界を、
30.6001
のような浮動小数点近似値に依存して判定する必要がありません。
11. ただし「浮動小数点を一切使わない」とは限らない
ここは重要です。
UnixTimeそのものが秒やミリ秒という整数で表現されている場合でも、
1年 ≒ 365.2425日
のような値を使って概算年を求めることはできます。
例えば、
$$
Y\approx
\frac{\text{経過秒}}
{365.2425\times86400}
+1970
$$
という計算です。
しかし、この値はあくまで、
どの年付近にいるかを推定するための概算
として使います。
最終的な年月日の確定は、整数の日数計算によって行います。
つまり、
浮動小数点
↓
概算・探索範囲の決定
整数演算
↓
最終的な年月日の確定
と役割を分けることができます。
これなら浮動小数点の誤差が、日付境界そのものを決定してしまうことを防げます。
🟥 12. エッジケースまとめ
- 2100/1/1
フェアフィールド
$[(365 * 2099 + 524 - (20 - 5)] + [306*14/10] + [1 -429] + 2$
$=766644$
$\text{Y}=(((766644+305)*400)/146097)=2099.8$
$\text{x}=(766644+305)-(2099*146097)/400=766644+305-766644=305$
$\text{x}=305-31-30-31-30-31-31-30-31-30=30$
$\text{M}=12$
$\text{D}=30$
→2099/12/30:誤判定
修正版
$2099*365=766135$
$\text{lp}=524+0-1-1-1+0=521$
$\text{yd}=306*14/10+1-429=0$
$\text{days}=766135+521-10+0+2=766648$
$\text{Y}=(((766648+305)*400)/146097)=2099.8$
$\text{tdays}=(2099*365)+521-10=766646$
$\text{x}=(766648+305)-766646=307$
$\text{M}=13$
$\text{D}=1$
→2100/1/1:正解
- 2050/1/1
フェアフィールド
$[(365 * 2049 + 512 - (20 - 5)] + [306*14/10] + [1 -429] + 2$
$=748384$
$\text{Y}=(((748384+305)*400)/146097)=2049.8$
$\text{x}=(748384+305)-(2049*146097)/400=748384+305-748381=308$
$\text{x}=308-31-30-31-30-31-31-30-31-30-31=2$
$\text{M}=13$
$\text{D}=2$
→2050/1/2:誤判定
修正版
$2049*365=747885$
$\text{lp}=512+0-1-1-1+0=509$
$\text{yd}=306*14/10+1-429=0$
$\text{days}=747885+509-10+0+2=748386$
$\text{Y}=(((748386+305)*400)/146097)=2049.8$
$\text{tdays}=(2049*365)+509-10=748384$
$\text{x}=(748386+305)-748384=307$
$\text{M}=13$
$\text{D}=1$
→2050/1/1:正解
- 2000/2/29
フェアフィールド
$[(365 * 1999 + 499 - (19 - 4)] + [306*14/10] + [1 -429] + 2$
$=730180$
$\text{Y}=(((730180+305)*400)/146097)=2000$
$\text{x}=(730180+305)-(2000*146097)/400=730485-730485=0$
$\text{x}<=0$
$\text{x}=(730180+305)-(1999*146097)/400=730485-730119=366$
$\text{x}=366-31-30-31-30-31-31-30-31-30-31-31=29$
$\text{M}=14$
$\text{D}=29$
→2000/2/29:正解
修正版
$1999*365=729635$
$\text{lp}=499+0-1-1-1=496$
$\text{yd}=306*15/10+29-429=59$
$\text{days}=729635+496-10+59+2=730182$
$\text{days}=729635+496-10+2=730123$
$\text{Y}=(((730180+305)*400)/146097)=2000$
$\text{lp}=500+0-1-1-1+0=497$
$\text{tdays}=(2000*365)+497-10=730487$
$\text{x}=(730182+305)-730487=0$
$\text{x}<=0$
$\text{tdays}=\text{tdays}-366=730121$
$\text{x}=(730182+305)-730121=366$
$\text{M}=2$
$\text{D}=29$
→2000/2/29:正解
修正版アルゴリズムまとめ
Y年M月D日において
基礎経過日$bd = Y \times 365$
改暦年RY改暦補正RD
積算閏日数$lp = \frac{Y}{4}$
//周期カウントアップ
collection(y,ry,term){
var nextterm = ry + (term - ry % term);
var ret = 0;
while(nextterm < y) {
ret++;
nextterm += term;
}
return ret;
}
lp += -collection(Y,RY,100) + collection(Y,RY,400);
年内経過日$\text{yd}=306*(\text{M}+1)/10+\text{D}-429$
通算経過日$\text{days}=\text{bd}+\text{lp}-\text{RD}+\text{yd}$
$\text{Y}=(((\text{days}+305)*400)/146097)$
通算経過日$ \text{tdays} = ( \text{Y} * 365) + \text{lp} - \text{RD}$
年内経過日$\text{x}=(\text{days}+305)-\text{tdays}$
xからM,D決定
フェアフィールドの公式
フェアフィールドの公式では、
$ \text{days} = [(365 * \text{y} + (\text{y}/4) - ((\text{y}/100) - (\text{y}/400)) ]$
$+ [306*(\text{m}+1)/10]+[\text{d}-429] + 2$
とします。
2100年1月1日は、1月を前年の13月として扱うため、
y = 2099
m = 13
d = 1
となります。
したがって、
$$365\times2099=766135$$
閏日数は、
$$\frac{2099}{4} -(\frac{2099}{100}-\frac{2099}{400})$$
なので、
$$
524-(20-5)=509
$$
です。
月部分は、
$$
\left\lfloor\frac{306\times14}{10}\right\rfloor=428
$$
となり、
$$\text{days} = 766135+509+428+1-429+2$$
より、
$$
\boxed{\text{days}=766646}
$$
となります。
「+2」は何なのか?
ここで、式の最後にある +2 が気になります。
これは単純な計算誤差ではありません。
西暦1年を基準としてユリウス暦とグレゴリオ暦の閏日を比較すると、1582年の改暦時点では、
$$
\boxed{12\text{日}}
$$
の数学的な差が生じます。
一方、1582年のグレゴリオ改暦では、
1582/10/4
↓
1582/10/15
と、暦上では10日が削除されました。
したがって、
$$
12-10=2
$$
となります。
この2日を、ここでは
改暦(ニカイア)補正
として扱います。
ただし、この +2 は「1582年に2日追加した」という意味ではありません。
西暦1年を基準にした数学的な閏日差12日と、実際の改暦で削除された10日との基準差2日を、計算体系の中で吸収するための補正値です。
つまり、この +2 を外してしまうと、
西暦1年を基準とした通算日数と、1582年の改暦後の暦日との接続点がずれてしまいます。
これが +2 を残す理由です。
なぜ ズレるのか(本質)
フェアフィールド逆変換は「浮動小数点で境界を推定する」ためズレる可能性がある。また基準日も数式に埋もれ少し曖昧。
積算閏日数方式は「整数で境界を確定する」ため絶対にズレない。
この 2050/1/1 → 2050/1/2 のズレは、
暦計算の本質的な落とし穴を示す「最良の実例」です。
改暦(ニカイア)補正(2 日)が混乱の原因
- 西暦 1 年 → 1582 年の数学的差:12 日
- 325 年 → 1582 年の歴史的差:10 日
- 325 年以前の誤差:2 日(補正対象外)
$
12 - 10 = 2
$
この 改暦(ニカイア)補正(2 日) がフェアフィールド公式の +2 に相当し、
多くの人が混乱する最大の理由です。
暦計算は逆算ではなく積算で行うべきである。
浮動小数点で境界を推定するとズレることがある。
1年から1582年までユリウス暦の閏年規則を積算した場合と、
グレゴリオ暦の閏年規則を適用した場合には12日の差が生じる。
一方、1582年の改暦で実際に削除されたのは10日だった。
その差である2日が、1/1/1からの通算日数と改暦後の実際の日付を
接続するときの基準差として残る。
12日と10日はそれぞれ正しい。しかし、その差である2日が
「通算日数の基準」と「実際の日付」の間に残るため、
順変換と逆変換ではこの2日の扱いが異なって見え、
暦計算が非常に分かりにくくなる。
🟧 重要:+2と「12日補正」は同じ体系を別の形で表している
ここは非常に紛らわしい部分なので整理しておきます。
フェアフィールドの公式では、
$$
\boxed{+2}
$$
という形で補正が入っています。
一方、積算閏日数方式では、
$$
\boxed{-12}
$$
という形で改暦境界の数学的差を補正しています。
一見すると違う処理に見えます。
しかし、
ユリウス暦を基準とした閏日積算
↓
グレゴリオ暦の閏年規則
↓
改暦境界の12日差を補正
という考え方で整理すると、
$$
12-10=2
$$
という関係によって両者を同じ暦体系に接続できます。
つまり、
+2は単純な丸め誤差ではなく、改暦境界を接続するための基準補正
として理解するのがポイントです。
🟩 この方法のメリット
このようにすると、暦計算を、
浮動小数点による逆算
ではなく、
整数の日数
+
積算閏日数
+
改暦境界補正
として考えることができます。
浮動小数点は、
「どの年付近なのか?」
を探索するための概算に利用できます。
しかし、
「この日は何年何月何日なのか?」
という最終判定には整数の日数を利用します。
この役割分担によって、日付境界そのものを浮動小数点の近似値に任せないことができます。
14. まとめ
今回、UnixTimeからグレゴリオ暦を自力で変換してみて分かったのは、
暦計算では「近似値を求めること」と「日付を確定すること」を分離することが重要
ということでした。
フェアフィールドの公式は数学的に正しい。
しかし、逆変換を浮動小数点の近似値だけで実装すると、月・日などの境界で誤差が問題になることがあります。
そこで、
UnixTime
↓
整数の経過日数
↓
積算閏日数による補正
↓
正確な通算日
↓
年
↓
月
↓
日
という方式にします。
これによって、
- 月境界の丸め誤差を避けられる
- 閏年を整数として扱える
- 長期間の日付計算に向いている
- UnixTimeとの相互変換が分かりやすくなる
というメリットがあります。
そして、今回の実装で一番大きかった発見は、
暦計算は「逆算して当てる」より、「整数の日数を積算して確定する」と考えたほうが実装しやすい
ということでした。
おまけ:この記事で一番伝えたいこと
公式を覚える
↓
年月日を逆算する
↓
丸め誤差に悩む
ではなく、
時間
↓
整数の日数
↓
積算閏日数
↓
通算日
↓
年月日
と考える。
暦は「連続した時間」としてではなく、「整数の日数の積み重ね」として扱う。
これが、今回N6LDateを実装してみて得られた、最も大きな教訓でした。
応用(ユリウス日・MJD・UNIX 時間対応)
N6LDate は以下にも対応しています:
- Julian Day
- Modified Julian Day
- UNIX time
- ISO 8601
- 週番号
- 差分構造体(年/月/日/時/分/秒)
まとめ
- JavaScript の Date は歴史的日付計算が苦手
- 一般的なJavaScriptの日付ライブラリでは、1582年の改暦をそのまま扱う設計にはなっていません
- N6LDate は ユリウス暦 → グレゴリオ暦の10日削除を自然に処理できる
- 設計が数学的に正しいため、安定して動作する
- 約1200 行で実装できる軽量ライブラリ
歴史的な日付計算や天文学シミュレーションに興味がある方は、ぜひ使ってみてください。
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net
気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!
