作成: 2026-06-15 / カテゴリ: 技術(アプリ設計) / 対象プロジェクト: github.com/lunelukkio/health-moniter(private)
null=unknown)を時刻つきで記録し、その記録時刻の気象(気圧・気温・湿度・24時間の気圧変化)を Open-Meteo から自動取得して、症状と気象の相関を見るためのローカル Web アプリです。バックエンドは Python + FastAPI + SQLite、フロントエンドは React + Vite + TypeScript。本ノートは「記録 → 気象付与 → 解析 → 可視化/Excel 出力」というデータの流れと、その中で起きている設計上の判断(unknown の扱い・タイムゾーン突合・気象取得失敗時の保護・Excel 全件再生成など)を図中心に解説します。これは
フロントエンドツールチェーン(React / Vite / TypeScript)と
バックエンドツールチェーン(FastAPI / uvicorn / SQLite / plotly)の 2 本のノートで個別に説明した技術を、1 つのアプリに統合した実例です。
2026-06-15T14:00)で保存される。null の状態。「痛み 0(=痛くない)」とは明確に区別し、相関解析からは除外する。「記録していない/わからない」を表す。まず鳥瞰図です。ユーザの操作(症状の記録)を起点に、データがどこを通って「相関グラフ」「Excel」になるかを 1 枚にまとめます。記録 1 件を作るたびに、その場で気象付与と Excel 再生成まで走るのが本アプリの特徴です(解析だけは閲覧時に都度計算)。
/api→ FastAPI が SQLite 保存と Open-Meteo 気象付与を行い、pandas/scipy で相関、openpyxl で Excel を生成。記録 1 件ごとに気象付与+Excel 再生成まで連動し、相関だけは閲覧時に都度計算する。🟢「記録ボタン」を押すと、サーバ側では次の順で処理が進みます(main.py の create_symptom)。
データは schema.sql で定義された 3 テーブルに収まります。中心は symptom_log(症状)で、そこに weather_log(気象)が 1 対 1 でぶら下がり、location(地点)を参照します。
symptom_log 1 件に weather_log が最大 1 件(symptom_log_id が UNIQUE、症状削除で CASCADE 連動)。location は固定設定のため原則 1 行で、症状から N:1 で参照される。🟢
症状レベルは INTEGER ですが、「0」と「NULL」は意味が違います。
| 値 | 意味 | 相関解析での扱い |
|---|---|---|
0 | 痛みが「無い」ことを記録した(観測済み) | データとして使う(「痛くない日」も相関に必要) |
1〜10 | 痛みの強さ(観測済み) | 使う |
NULL (unknown) | 記録していない/わからない | そのペアから除外(dropna) |
もし unknown を「0」として保存してしまうと、「記録し忘れた日」が全部「痛くない日」に化け、相関が嘘になります。だから DB は NULL を許容し、CHECK (level IS NULL OR level BETWEEN 0 AND 10) で「NULL か 0〜10 のいずれか」だけを通します。1 日に複数回記録できる(recorded_at が日時単位)ため、頭痛だけ記録して背中は unknown、という片方だけ記録も自然に表現できます。
気象を取るには「どこの」気象かが要ります。本アプリは固定地点方式で、ユーザが一度地点を設定すると location テーブル 1 行に保存し、以後の記録はその地点の気象を使います(記録ごとに別地点も指定可能)。地点は地名で検索して選びます。
地名検索は Open-Meteo の Geocoding API(weather.geocode)。地名を投げると候補が返り、各候補に latitude / longitude のほか timezone(IANA 名)が付いてきます。地点を保存する PUT /api/location では、改めて fetch_timezone(lat, lon) で timezone=auto 指定の問い合わせを行い、確実に IANA タイムゾーン(例 Asia/Tokyo)を location.timezone に格納します。
ブラウザのタイムゾーンと観測地点のタイムゾーンは一致するとは限りません(旅行中・海外サーバなど)。そこで本アプリは recorded_at をタイムゾーンを含まない「壁時計」として保存し、気象取得時に地点の timezone を Open-Meteo に明示指定します。すると Open-Meteo が返す hourly の時刻も同じ地点ローカル時刻になり、14:00 の記録は地点の 14:00 の気象に正しく対応づきます。フロント側の time.ts(nowInTimeZone)も、入力欄の初期値を地点タイムゾーンの現在時刻で埋めることで、この前提を崩さないようにしています(Intl.DateTimeFormat で hourCycle: 'h23' を使い「24時」表記による日付ズレを防止)。🟢
気象付与の本体は weather.fetch_weather です。やることは 2 つ。(A) 記録時刻の気象値を hourly 配列から拾う、(B) 24 時間前の気圧との差を計算する。
実装のキモを抜粋します。時刻は分以下を切り捨て、"%Y-%m-%dT%H:00" という文字列キーで hourly の time 配列を index() 検索し、見つかった添字で各指標を取り出します。
# weather.py(要点・抜粋) target = _parse(recorded_at).replace(minute=0, second=0, microsecond=0) prev = target - timedelta(hours=24) ti = _index_for(times, target) # 記録時刻の添字 if ti is None: ti = len(times) - 1 # 無ければ最終時刻にフォールバック observed = _parse(times[ti]) pi = _index_for(times, observed - timedelta(hours=24)) # 24h前は観測時刻基準で取り直す pressure_change = None if pi is not None and pressure is not None: pressure_change = round(pressure - p_prev, 2)
取得に失敗したらどうなるか。 main.py の _apply_weather は、Open-Meteo 取得が例外になった場合に既存の気象を消さずに保持します(更新時に通信失敗で過去の気象が消えるのを防ぐ)。位置が無い記録なら気象は付けず None。この「失敗しても記録は守る」方針は図 2 の③④と同じ思想です。🟢
解析は GET /api/analysis で都度計算します(analysis.compute_analysis)。症状 2 種 ×(気圧・24h 気圧変化・気温・湿度)の 4 指標、計 8 組み合わせについて、Pearson の r と p 値、Spearman の ρ を返します。
| 状況 | 処理 | 理由 |
|---|---|---|
| 症状が unknown(NULL) | そのペアで dropna | 「わからない日」を 0 と混同しない(図 4) |
| 気象が欠損(取得失敗等) | そのペアで dropna | 欠損を 0 等で埋めると相関が歪む |
| 有効ペアが 3 未満 | r/p を出さない(null) | 2 点では相関 r が常に ±1 になり無意味 |
| 片方が定数(全部同じ値) | r/p を出さない(null) | 分散 0 では相関係数が定義できない(0 割り) |
Pearson は「直線的な関係」を、Spearman は「順位の単調な関係(曲がっていてもよい)」を測ります。両方返すのは、気圧と痛みのような関係が必ずしも直線とは限らないため、頑健性を二重に確認するためです。p 値は「相関が偶然でない度合い」の目安で、記録が少ない初期は大きく出やすい点に注意します。🟡
症状を作成・更新・削除するたびに、export.write_excel が data/health.xlsx を作り直します。GET /api/export でダウンロードもできます。重要なのは追記ではなく毎回全件再生成している点です。
出力は openpyxl で、見出し行(ID / 記録日時 / 頭痛 / 背中 / メモ / 気象観測時刻 / 気圧 / 気温 / 湿度 / 24h 気圧変化 / 地点ID)の下に、crud.list_symptoms を逆順(=古い順)に並べて書きます。気象が無い記録は気象列が空欄になります。Excel 生成が失敗してもリクエストは止めません(図 2 の④)。🟢
最後に、同じコードが「開発」と「本番」で違う形で動く点です。鍵は /api をどう繋ぐかと誰が静的ファイルを配るかです。
/api を FastAPI(8000) へプロキシする 2 プロセス構成。本番は frontend/dist が存在すれば FastAPI が静的配信も兼ね、1 プロセスで完結する。🟢
切り替えは main.py 末尾の if FRONTEND_DIST.exists(): 一行で行われます。ビルド済みの frontend/dist があれば StaticFiles(..., html=True) を / にマウントし、無ければ(開発時)何もしないので Vite が配信を担います。同一オリジンで /api を叩くため、開発・本番のどちらでもフロントの fetch('/api/...') はそのまま動き、CORS 設定が不要です。🟢
| 疑問 | 答え |
|---|---|
| 記録すると何が起きる? | 位置解決 → DB 挿入 → 気象付与 → Excel 再生成 → 応答(図 2)。気象・Excel が失敗しても記録は残す。 |
| 0 と「未記録」はどう違う? | 0 は観測済み(解析に使う)、NULL=unknown は除外。混同すると相関が嘘になる(図 4)。 |
| テーブルは? | symptom_log(症状)に weather_log が 1:1(UNIQUE/CASCADE)、location を N:1 参照(図 3)。 |
| 地点と時刻はどう合わせる? | 地名→lat/lon/IANA TZ を取得。recorded_at を「地点TZの壁時計」として hourly と突合(図 5)。 |
| 気象病の指標は? | 24h 気圧変化(観測時刻の気圧 − 24h 前の気圧)。該当時刻が無ければ最終時刻にフォールバック(図 6)。 |
| 相関はどう出す? | ペアごとに dropna し、n≥3 かつ両変数が非定数なら Pearson/Spearman を計算(図 7)。 |
| Excel はなぜ全件再生成? | 追記だと編集・削除で DB とズレるため。毎回 DB と一致する 1 枚を書く(図 8)。 |
| 開発と本番の違いは? | 開発=Vite が /api をプロキシ(2 プロセス)。本番=FastAPI が dist も配信(1 プロセス)(図 9)。 |