はじめに
暮らしのお金を試算するサイト「いくらかかるの研究所」を個人で作っています。エアコンの電気代、給湯器、太陽光発電の回収年数などを、国の住宅省エネ基準の計算方法(建築研究所)で試算するサイトです。
計算方法には、建築研究所ほかが公開している Python 実装 pyhees(MIT ライセンス)を使っています。
ただしサイトは Astro の静的出力+Cloudflare Workers の静的アセット だけで動いていて、サーバーで Python は動かしていません。
1年8760時間の計算を、どうやってブラウザで回しているのかをまとめます。
前に書いた「正解値テスト」の記事の続きで、今回は 設計と、データの持ち方 の話です。
方針:重い計算は事前計算、軽い計算はTypeScriptに移植
| 計算 | どこで | 理由 |
|---|---|---|
| 部屋ごとの暖冷房負荷(第三章) | Python で事前計算 → JSON | 住宅全体の熱収支で重い。入力(地域×断熱等級)の組み合わせが少ない |
| 給湯負荷・給湯器(第七章) | Python で事前計算 → JSON | 入力の組み合わせ(地域×人数×機種×節湯)が有限 |
| ルームエアコン(第四章第三節) | TypeScript に移植 | 機種の能力・効率が連続値で、組み合わせを事前に作れない |
| 太陽光発電(第九章第一節) | TypeScript に移植 | 容量・方位・傾き・パワコン効率が連続値。式も少ない |
| 料金・回収年数・利回り | TypeScript | 単価や費用は利用者が入力する |
判断の基準は単純で、入力が「選択肢」なら事前計算、「数値」なら移植 です。
事前計算したものは、ブラウザで単価を掛けたり、時間帯で分けたりするだけにしています。
データの持ち方
事前計算の JSON は public/data/ に置き、選んだ地域の分だけ fetch します。主なファイルの大きさです(6地域の例)。
| ファイル | 中身 | 大きさ | gzip後 |
|---|---|---|---|
aircon/climate-r6.json |
外気温・湿度 8760時間 | 102KB | 37KB |
aircon/loads-r6-g4.json |
部屋ごとの暖冷房負荷(床面積1m²あたり) | 76KB | 30KB |
pv/sol/r6-a3.json |
日射量・太陽位置 8760時間 | 130KB | 38KB |
pv/house.json |
標準世帯の家電・照明・換気 8760時間×4人分 | 314KB | 3.4KB |
ecocute/r6.json |
エコキュートの消費電力(月×平日休日×時刻) | 167KB | 28KB |
工夫は3つです。
1. 0 が続く時系列は「開始時刻と値の列」で持つ
暖房負荷は夏にずっと0、冷房負荷は冬にずっと0です。そこで、0でない区間だけを [開始時刻, [値, 値, ...]] で持っています。
"LH": [[7, [0.944613, 0.192716]], [16, [0.096697, 0.153677, 0.118471]], ...]
export function decodeSparse(runs: [number, number[]][], scale = 1): Float64Array {
const out = new Float64Array(8760);
for (const [start, values] of runs) {
for (let k = 0; k < values.length; k++) out[start + k] = values[k] * scale;
}
return out;
}
負荷は 床面積1m²あたり で持ち、ブラウザで部屋の広さを掛けます(国の計算方法でも負荷は床面積に比例します)。これで「6畳」「14畳」ごとのファイルが要りません。
2. 元のCSVの桁のまま整数にする
太陽光の日射量データは、pyhees に入っている CSV(MJ/m²・h、小数2桁)を100倍した整数で持ち、ブラウザで単位を変換しています。
iDN[t] = ((f.DN[t] / 100) / 3.6) * 1000; // MJ/(m2・h) → W/m2
2 / 100 は IEEE 754 で 0.02 と同じ値になる(正しく丸められる)ので、CSV の文字列から読んだ場合と同じ double になります。変換の順番も pyhees と同じにしているので、後述のテストで pyhees と 1e-9 の精度で一致します。
3. 繰り返しの多いデータは gzip に任せる
家電・照明・換気の消費電力は、国の標準の生活スケジュール(平日・休日のパターン)の繰り返しなので、314KB が gzip で 3.4KB になりました。独自の圧縮はしていません。
初期表示はビルド時に計算して、HTMLに入れる
Astro のページ(.astro)はビルド時に Node で動くので、初期条件の結果をビルド時に計算して、React の島に props で渡しています。
---
const defaults = defaultState(d, zone0);
const initialResult = runModel(defaults, constants, loadedFor(defaults)); // ビルド時に Node で計算
---
<Simulator client:load defaults={defaults} initialResult={initialResult} />
- 検索エンジンや、JavaScript を読み込む前の画面にも結果が出る
- 本文の「この試算でわかること」も同じ関数で計算して文章にする(前提を変えると本文も自動で変わる)
計算の関数は、ブラウザとビルドで同じものを使っています。データの読み込みだけを分けています(ビルドは fs、ブラウザは fetch)。
8760時間のループは十分速い
太陽光の「家で使う割合」では、1時間ごとの発電量(第九章第一節の移植)と、家の電気(家電・照明・換気+エアコン2台+エコキュート)を重ねて、8760時間それぞれで小さいほうを足しています。
let self = 0;
for (let t = 0; t < 8760; t++) self += Math.min(pv[t], load[t]);
エアコン2台分の第四章第三節の計算を含めても、Node で1回あたり数ms〜数十msでした。入力を変えるたびに計算し直しても、体感の遅れはありません。
Float64Array を使い、途中で配列を作り直さないようにしているくらいで、特別な最適化はしていません。
状態は URL に入れる(保存しない)
入力条件は history.replaceState で URL のクエリに入れています。サーバーにもブラウザのストレージにも保存しません。
- 共有・ブックマークは URL でできる
- 「入力した内容は保存しません」とサイトの約束に書ける
- 既定値と同じパラメータは書かない(URL を短く保つ)
TypeScript移植の検証
移植した部分は、pyhees で直接計算した値を JSON(fixture)に書き出し、Vitest で 相対誤差 1e-9 以内 の一致を確認しています。
for (const c of golden.cases) {
it(`${c.region}地域 A${c.a} 方位${c.azimuth}° 傾斜${c.tilt}°`, () => {
const e = pvHourly(sol(c.region, c.a), { kw: 1, azimuthDeg: c.azimuth, tiltDeg: c.tilt, etaInR: c.eta, setup: c.setup });
expect(Math.abs(sum(e) / c.annual - 1)).toBeLessThan(1e-9);
});
}
さらに、事前計算の月別データ(Python)と、移植した関数で計算した合計(TypeScript)が一致することもテストしています。2つの経路で同じ答えになることを確かめておくと、どちらかを直したときの取り違えに気づけます。
まとめ
- 入力が「選択肢」なら Python で事前計算、「数値」なら TypeScript に移植
- 時系列は「0の区間を省く」「床面積1m²あたりで持つ」「元の桁の整数で持つ」で小さくし、あとは gzip に任せる
- 初期表示はビルド時に同じ関数で計算して HTML に入れる。本文の数字もそこから作る
- 移植は pyhees と 1e-9、事前計算とは合計で、2つの経路を突き合わせてテストする
実際のツールはこちらです。