← health-moniter 目次へサイトトップ

HM-001

health-moniter アプリの仕組み

作成: 2026-06-15 / カテゴリ: 技術(アプリ設計) / 対象プロジェクト: github.com/lunelukkio/health-moniter(private)

このノートの要約。 health-moniter は、頭痛・背中の痛み(各 0〜10 または null=unknown)を時刻つきで記録し、その記録時刻の気象(気圧・気温・湿度・24時間の気圧変化)を Open-Meteo から自動取得して、症状と気象の相関を見るためのローカル Web アプリです。バックエンドは Python + FastAPI + SQLite、フロントエンドは React + Vite + TypeScript。本ノートは「記録 → 気象付与 → 解析 → 可視化/Excel 出力」というデータの流れと、その中で起きている設計上の判断(unknown の扱い・タイムゾーン突合・気象取得失敗時の保護・Excel 全件再生成など)を図中心に解説します。これは フロントエンドツールチェーン(React / Vite / TypeScript)と バックエンドツールチェーン(FastAPI / uvicorn / SQLite / plotly)の 2 本のノートで個別に説明した技術を、1 つのアプリに統合した実例です。

本ノートでの用語規約。 解説対象が複数視点で揺れやすい語を、ここで先に固定します。
目次
  1. 全体データフロー
  2. 記録モデル(3 テーブルの関係と unknown の扱い)
  3. 位置と地名検索(Geocoding とタイムゾーン)
  4. 気象取得(hourly 突合と 24h 気圧差)
  5. 相関解析(Pearson / Spearman と除外ルール)
  6. Excel 出力(DB 全件からの再生成)
  7. 開発/本番の起動構成
  8. まとめ(疑問と答え)

1. 全体データフロー

まず鳥瞰図です。ユーザの操作(症状の記録)を起点に、データがどこを通って「相関グラフ」「Excel」になるかを 1 枚にまとめます。記録 1 件を作るたびに、その場で気象付与と Excel 再生成まで走るのが本アプリの特徴です(解析だけは閲覧時に都度計算)。

ブラウザ(React + Vite + TS) 症状フォーム / 一覧 位置設定 / ダッシュボード fetch('/api/...') plotly で相関グラフ描画 FastAPI(uvicorn が起動) POST/PUT/DELETE /api/symptoms GET /api/analysis GET/PUT /api/location · /api/geocode GET /api/export pydantic で入力検証・型変換 crud / weather / analysis / export SQLite(health.db) symptom_log / weather_log / location 標準 sqlite3・1 ファイル Open-Meteo(外部 API) Geocoding / forecast hourly httpx・無料・APIキー不要 解析・出力 pandas / scipy → Pearson・Spearman openpyxl → data/health.xlsx 記録のたびに Excel 再生成 /api(JSON) JSON 応答 保存/取得 気象/地名 DB 全件を読む
図 1. 全体データフロー。ブラウザ(React) →/api→ FastAPI が SQLite 保存と Open-Meteo 気象付与を行い、pandas/scipy で相関、openpyxl で Excel を生成。記録 1 件ごとに気象付与+Excel 再生成まで連動し、相関だけは閲覧時に都度計算する。🟢

記録 1 件が作られるときの順序(POST /api/symptoms)

「記録ボタン」を押すと、サーバ側では次の順で処理が進みます(main.pycreate_symptom)。

① 位置を解決 指定なら確認/補完、無ければ固定地点 ② DB に記録挿入 symptom_log に 1 行・id を得る ③ 気象付与 Open-Meteo で recorded_at の 気象を取得 → weather_log ④ Excel 再生成 DB 全件から health.xlsx ⑤ 応答を返す SymptomOut(気象込み) ③④は失敗してもリクエスト全体は止めない(気象取得失敗・Excel 失敗を握りつぶしてログのみ) =「とにかく症状記録は残す」を最優先する設計
図 2. 記録作成シーケンス。位置解決 → DB 挿入 → 気象付与 → Excel 再生成 → 応答。気象取得や Excel 生成が失敗しても症状記録自体は保存される(後述の保護設計)。🟢

2. 記録モデル(3 テーブルの関係と unknown の扱い)

データは schema.sql で定義された 3 テーブルに収まります。中心は symptom_log(症状)で、そこに weather_log(気象)が 1 対 1 でぶら下がり、location(地点)を参照します。

symptom_log(症状記録) id (PK) recorded_at TEXT ISO8601・1日複数可 headache_level INTEGER 0〜10 / NULL back_pain_level INTEGER 0〜10 / NULL memo TEXT(音声入力テキスト等) location_id → location.id(FK) CHECK: level は NULL または 0〜10 weather_log(気象) id (PK) · symptom_log_id (FK, UNIQUE) observed_at / pressure_hpa / temperature_c humidity_pct / pressure_change_24h location(地点・固定設定) id (PK) label / lat / lon timezone IANA(例 Asia/Tokyo) 原則 1 行で運用(upsert) 1 : 1(UNIQUE) ON DELETE CASCADE N : 1
図 3. ER 図。symptom_log 1 件に weather_log が最大 1 件(symptom_log_id が UNIQUE、症状削除で CASCADE 連動)。location は固定設定のため原則 1 行で、症状から N:1 で参照される。🟢

0〜10 と null(unknown)の区別がなぜ重要か

症状レベルは 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、という片方だけ記録も自然に表現できます。

記録された症状値 headache / back_pain 0〜10(観測済み・0 も含む) → DataFrame に残り、相関計算に参加 NULL(unknown) → そのペアで dropna され除外 「0 を unknown 代用」は禁止 記録漏れが「痛くない日」に化け相関が歪む
図 4. 症状値の分岐。0〜10 は(0 も含めて)解析に使い、NULL=unknown は各ペアで除外する。「unknown を 0 で代用しない」ことが相関の正しさを支える。🟢

3. 位置と地名検索(Geocoding とタイムゾーン)

気象を取るには「どこの」気象かが要ります。本アプリは固定地点方式で、ユーザが一度地点を設定すると location テーブル 1 行に保存し、以後の記録はその地点の気象を使います(記録ごとに別地点も指定可能)。地点は地名で検索して選びます。

地名 → 緯度経度 + IANA タイムゾーン

地名検索は Open-Meteo の Geocoding API(weather.geocode)。地名を投げると候補が返り、各候補に latitude / longitude のほか timezone(IANA 名)が付いてきます。地点を保存する PUT /api/location では、改めて fetch_timezone(lat, lon)timezone=auto 指定の問い合わせを行い、確実に IANA タイムゾーン(例 Asia/Tokyo)を location.timezone に格納します。

地名を入力 「東京」「Tokyo」… Geocoding API 候補: name / lat / lon / country / admin1 / timezone location に保存 lat / lon / label timezone(IANA) 記録時の突合の考え方 recorded_at(例 14:00)を「地点TZの壁時計」として解釈し、 同じ timezone で取得した hourly の 14:00 と突き合わせる
図 5. 地名検索とタイムゾーンの役割。Geocoding で lat/lon/timezone を得て location に保存し、記録時はその IANA タイムゾーンを基準に recorded_at と hourly を突き合わせる。🟢

なぜ「地点ローカルの壁時計」として扱うのか

ブラウザのタイムゾーンと観測地点のタイムゾーンは一致するとは限りません(旅行中・海外サーバなど)。そこで本アプリは recorded_atタイムゾーンを含まない「壁時計」として保存し、気象取得時に地点の timezone を Open-Meteo に明示指定します。すると Open-Meteo が返す hourly の時刻も同じ地点ローカル時刻になり、14:00 の記録は地点の 14:00 の気象に正しく対応づきます。フロント側の time.tsnowInTimeZone)も、入力欄の初期値を地点タイムゾーンの現在時刻で埋めることで、この前提を崩さないようにしています(Intl.DateTimeFormathourCycle: 'h23' を使い「24時」表記による日付ズレを防止)。🟢

4. 気象取得(hourly 突合と 24h 気圧差)

気象付与の本体は weather.fetch_weather です。やることは 2 つ。(A) 記録時刻の気象値を hourly 配列から拾う(B) 24 時間前の気圧との差を計算する

前日 14:00 記録 14:37 → 14:00 に丸め observed_at = 今日 14:00 24時間前 14:00 pressure_change_24h = 今の気圧 − 24h前の気圧 (A) 値の取り出し hourly.time の中で "YYYY-MM-DDTHH:00" を探し 同 index の pressure/temp/humidity を採用 (B) フォールバック 該当時刻が無ければ hourly の最終時刻に寄せ、 24h 前は「実際の観測時刻」基準で取り直す
図 6. 気象取得。recorded_at を時単位に丸めて hourly から値を取り(A)、24 時間前の気圧との差を取る。該当時刻が無いときは最終時刻にフォールバックし、24h 前もその観測時刻基準で取り直す(B)。🟢

実装のキモを抜粋します。時刻は分以下を切り捨て、"%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 の③④と同じ思想です。🟢

5. 相関解析(Pearson / Spearman と除外ルール)

解析は GET /api/analysis で都度計算します(analysis.compute_analysis)。症状 2 種 ×(気圧・24h 気圧変化・気温・湿度)の 4 指標、計 8 組み合わせについて、Pearson の rp 値、Spearman の ρ を返します。

JOIN で取得 気象付き記録のみ dropna 各ペアごとに NULL 除外 計算可否の判定 n ≥ 3(MIN_PAIRS) かつ 症状が定数でない かつ 気象が定数でない 満たさなければ r/p は null scipy.stats pearsonr → r, p / spearmanr → ρ
図 7. 相関解析の前処理。気象付き記録を JOIN → ペアごとに dropna(unknown/欠損除外) → 件数 3 以上かつ両変数が定数でないことを確認 → scipy で Pearson/Spearman を計算。条件を満たさないペアは r/p を null で返す。🟢
状況処理理由
症状が unknown(NULL)そのペアで dropna「わからない日」を 0 と混同しない(図 4)
気象が欠損(取得失敗等)そのペアで dropna欠損を 0 等で埋めると相関が歪む
有効ペアが 3 未満r/p を出さない(null)2 点では相関 r が常に ±1 になり無意味
片方が定数(全部同じ値)r/p を出さない(null)分散 0 では相関係数が定義できない(0 割り)

Pearson は「直線的な関係」を、Spearman は「順位の単調な関係(曲がっていてもよい)」を測ります。両方返すのは、気圧と痛みのような関係が必ずしも直線とは限らないため、頑健性を二重に確認するためです。p 値は「相関が偶然でない度合い」の目安で、記録が少ない初期は大きく出やすい点に注意します。🟡

6. Excel 出力(DB 全件からの再生成)

症状を作成・更新・削除するたびに、export.write_exceldata/health.xlsx作り直します。GET /api/export でダウンロードもできます。重要なのは追記ではなく毎回全件再生成している点です。

追記方式(採用しない) 記録のたびに 1 行を末尾へ足す → 編集・削除すると DB とズレる → 行番号と id の対応が崩れる 整合性の維持が難しい 全件再生成(採用) 毎回 DB 全件を古い順で 1 枚に書く → 常に DB と完全一致 症状+気象を 1 行にまとめる 単純で壊れにくい
図 8. Excel 出力の方式比較。追記方式は編集・削除で DB とズレるため採用せず、毎回 DB 全件から 1 シートを再生成して常に DB と一致させる。1 行に症状と気象(観測時刻・気圧・気温・湿度・24h 気圧変化)を並べる。🟢

出力は openpyxl で、見出し行(ID / 記録日時 / 頭痛 / 背中 / メモ / 気象観測時刻 / 気圧 / 気温 / 湿度 / 24h 気圧変化 / 地点ID)の下に、crud.list_symptoms逆順(=古い順)に並べて書きます。気象が無い記録は気象列が空欄になります。Excel 生成が失敗してもリクエストは止めません(図 2 の④)。🟢

7. 開発/本番の起動構成

最後に、同じコードが「開発」と「本番」で違う形で動く点です。鍵は /api をどう繋ぐか誰が静的ファイルを配るかです。

開発(2 プロセス) ブラウザ localhost:5173 Vite dev (5173) JS/TS を即時変換・HMR /api を 8000 へプロキシ FastAPI (8000) /api/* を処理 uvicorn --reload proxy フロントとバックを別々に起動。CORS 不要(同一オリジン扱い)。 本番(1 プロセス) ブラウザ localhost:8000 FastAPI (8000) のみ /api/* を処理 + frontend/dist を静的配信 vite build 済みなら mount("/") ビルド成果物を 1 プロセスで配信。Vite は不要。
図 9. 開発と本番の起動構成。開発は Vite(5173) が /api を FastAPI(8000) へプロキシする 2 プロセス構成。本番は frontend/dist が存在すれば FastAPI が静的配信も兼ね、1 プロセスで完結する。🟢

切り替えは main.py 末尾の if FRONTEND_DIST.exists(): 一行で行われます。ビルド済みの frontend/dist があれば StaticFiles(..., html=True)/ にマウントし、無ければ(開発時)何もしないので Vite が配信を担います。同一オリジンで /api を叩くため、開発・本番のどちらでもフロントの fetch('/api/...') はそのまま動き、CORS 設定が不要です。🟢

8. まとめ(疑問と答え)

疑問答え
記録すると何が起きる?位置解決 → 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)。

← health-moniter 目次へサイトトップ更新履歴