本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

前記事 で、OpenDrift を教えてもらって試した記録を書きました。本記事はその続きです。

別途、旧暦・月齢・潮汐・日の出日の入りを計算する JavaScript アプリを開発しています(現在は非公開)。本記事は、そのアプリと漂流シミュレーションを接続できるか試してみた記録です。具体的には、JavaScript で計算した日付を Python の漂流シミュレーションに渡し、結果を JavaScript の地図ライブラリで表示するところまでを実装しました。

接続するもの

ツール答えるもの言語
暦・潮汐計算アプリ選んだ日付の旧暦・月齢・潮位・日の出日の入りJavaScript
OpenDrift Leeway 漂流シミュレーションその日付に対応する月の気候値での漂流経路Python

別エコシステムのツールを繋ぐ共通言語は「日付」と「座標」だけです。JavaScript で計算した出航日時を Python に渡して漂流計算を走らせ、結果の地図データを JavaScript の世界に戻して可視化する、という構成にしました。

設計

┌─────────────────────────────────┐         ┌─────────────────────────────────┐
│  暦・潮汐計算アプリ (JS)        │         │  drift_sim (Python)             │
│  ─────────────────────────────  │         │  ─────────────────────────────  │
│  ・旧暦 → 新暦変換 (HuTime API) │  日付   │  ・気候値ベースの漂流計算       │
│  ・月齢計算 (astronomy-engine)  │ ──────► │  ・GeoJSON で結果を返す         │
│  ・潮汐 (海上保安庁)            │ 座標    │                                 │
│  ・日の出日の入り (SunCalc)     │         │                                 │
└─────────────────────────────────┘         └─────────────────────────────────┘

Python 側の入り口として「日付と座標を受け取って漂流軌跡の GeoJSON を返す」CLI を作りました。

GeoJSON を選んだのは、Leaflet・MapLibre GL JS・Mapbox GL のような地図ライブラリが GeoJSON をそのままレンダリングできるためです。Python 側の出力形式に追加変換層を挟む必要がなくなります。

実装

ブリッジ本体は 220 行強の Python スクリプトです。CLI 引数で日付と座標を受け取り、その月の気候値で漂流計算を回し、軌跡を GeoJSON に整形して書き出します。

軌跡を GeoJSON に変換する部分の抜粋:

def to_geojson(nc_path, *, departure_date, seed_lon, seed_lat, month, hours):
    ds = xr.open_dataset(nc_path)
    lon = ds["lon"].values
    lat = ds["lat"].values
    st = ds["status"].values
    cats = ds["status"].attrs.get("flag_meanings", "").split()
    active_idx = cats.index("active") if "active" in cats else -1

    features = [{
        "type": "Feature",
        "geometry": {"type": "Point", "coordinates": [seed_lon, seed_lat]},
        "properties": {"role": "seed", "departure_date": departure_date},
    }]
    for i in range(lon.shape[0]):
        valid = ~np.isnan(lon[i])
        if valid.sum() < 2:
            continue
        coords = [[float(x), float(y)]
                  for x, y in zip(lon[i][valid], lat[i][valid])]
        final = int(st[i][valid][-1])
        features.append({
            "type": "Feature",
            "geometry": {"type": "LineString", "coordinates": coords},
            "properties": {
                "particle_id": int(i),
                "status": cats[final] if 0 <= final < len(cats) else f"code{final}",
                "is_active": final == active_idx,
            },
        })
    n_total    = sum(1 for f in features if f["geometry"]["type"] == "LineString")
    n_active   = sum(1 for f in features if f["properties"].get("is_active") is True)
    return {
        "type": "FeatureCollection",
        "metadata": {
            "model": "OpenDrift Leeway (LIFE-RAFT-NB-1)",
            "currents": "OSCAR v2.0 monthly climatology 2015-2020",
            "wind": "NCEP/NCAR R1 monthly climatology 1991-2020",
            "departure_date": departure_date,
            "month_used": month,
            "duration_hours": hours,
            "seed": {"lon": seed_lon, "lat": seed_lat},
            "summary": {
                "n_total":     n_total,
                "n_active":    n_active,
                "n_stranded":  n_total - n_active,
                "escape_rate": n_active / n_total if n_total else 0.0,
            },
        },
        "features": features,
    }

CLI の使い方

# 2025年5月15日に名瀬港沖から240時間漂流
python bridge.py --date 2025-05-15

# 別の出発点と期間も指定可能
python bridge.py --date 2025-08-01 --lon 130.0 --lat 27.5 --hours 168

実行すると次のように動きます:

running drift sim: date=2025-05-15  month=5  seed=(129.65,28.3)  hours=240
saved -> out/drift_2025-05-15.geojson  (n=200, active=29, escape=14%)

GeoJSON ファイルが生成され、Leaflet や MapLibre にそのまま投入できます。

JavaScript 側からの呼び出し

const response = await fetch(
  `/api/drift?date=${departureDate}&lon=${lon}&lat=${lat}`
);
const geojson = await response.json();

L.geoJSON(geojson, {
  style: feature => ({
    color: feature.properties.status === "active" ? "#1f77b4" : "#888",
    weight: feature.properties.status === "active" ? 1.2 : 0.5,
    opacity: 0.6,
  }),
}).addTo(map);

これで暦・潮汐計算アプリが決めた日に出航した場合の漂流地図が地図ライブラリ上に表示されます。

計算量

手元の MacBook Pro(M1)で実測した値です。

設定所要時間GeoJSONサイズ
50 粒子・5 日間約 10 秒約 130 KB
200 粒子・10 日間約 26 秒約 620 KB

500 粒子クラスは未実測ですが、粒子数と時間にほぼ比例する想定で約 1 分 / 約 1.5 MB と見込んでいます。

リアルタイム性は高くないので、Web で公開する場合は計算中の状態表示が必要になります。

サーバ配置の選択肢

OpenDrift は scipy・netCDF4・cartopy・geopandas など多くのパッケージに依存し、全体のサイズも大きいので、Vercel の Python サーバレス関数のような軽量実行環境に載せるのは現実的でないようです。

現実的な配置:

  • 常駐 Python サーバとして配置し、FastAPI で薄くラップして REST API 化
  • Render / Fly.io / Railway などの常駐型 PaaS を利用
  • 学術用途なら Web 化せず、Python の研究者が手元で CLI として回す

どこで運用するかは別途検討です。本記事の段階ではローカル CLI で動作する状態までを実装しています。

旧暦から新暦への変換

旧暦↔新暦の変換は JavaScript 側で完結しています。暦・潮汐計算アプリは HuTime のオープン Web API を使って変換しているので、その結果(西暦 YYYY-MM-DD)を Python の bridge に渡すだけで済みます。Python 側は旧暦のことを知らなくてよく、責任分界が明確になります。

const lunarDate = { year: 1700, month: 4, day: 15 };
const gregorian = await convertLunarToGregorian(lunarDate);  // → "1700-05-23"
const moonPhase = getMoonPhase(gregorian);
const tide = await getTide(gregorian, location);

const drift = await fetch(
  `/api/drift?date=${gregorian}&lon=...&lat=...`
);

データの時代制約

OpenDrift に渡している海流(OSCAR)も風(NCEP/NCAR Reanalysis)も、いずれも 1990 年代以降の観測から作られた現代の気候値です。暦・潮汐計算アプリ側で過去の旧暦日付を入力しても、漂流計算は「現代の気候値で対応する月の典型像」を返すことになります。両者の時間軸が一致していない点はそのまま明示するのが妥当です。

潮汐は OpenDrift のリーダーとしては未組込です。海上保安庁の推算データを取り込むのは次の課題として残しています。

可視化の動作確認

GeoJSON が正しい形式で出力できているか確かめるため、MapLibre GL JS で読み込む簡易ビューワを作りました。粒子が時間とともに動く様子を再生でき、月別ボタンで気候値の異なる漂流結果を切り替えられます。

MapLibre 漂流アニメーション 11月の6日後

画面下部の再生ボタンで時間を進められます。タイムスライダーで任意の時刻にスクラブ、速度セレクタ(1× 〜 20×)で再生速度を切り替えできます。粒子の青い丸が現在位置、線が通過軌跡を示し、漂着した粒子は赤に変わって停止します。

実装は次のように 2 つの GeoJSON ソースを使い分けています。

// 軌跡(時刻 t までの座標を持つ LineString)
map.addSource("tails", { type: "geojson", data: emptyFC });
map.addLayer({ id: "tails-active",   type: "line", source: "tails",
               filter: ["==", ["get", "is_active"], true],
               paint: { "line-color": "#1f77b4", "line-width": 1.4 } });
map.addLayer({ id: "tails-stranded", type: "line", source: "tails",
               filter: ["==", ["get", "is_active"], false],
               paint: { "line-color": "#d62728", "line-width": 1.0 } });

// 現在位置(時刻 t 時点の単一 Point)
map.addSource("heads", { type: "geojson", data: emptyFC });
map.addLayer({ id: "heads-active",   type: "circle", source: "heads",
               filter: ["==", ["get", "state"], "active"],
               paint: { "circle-radius": 3.5, "circle-color": "#1f77b4" } });

毎フレーム、tailsheads のソースを setData() で更新する単純なループです。GeoJSON の LineString は 1 時間刻みで 241 点(240 時間 + 開始時点)を持つので、フレーム t では coordinates.slice(0, t+1) を切り出して尾、coordinates[t] を現在位置として描画します。

このビューワ自体は単一の HTML ファイルで完結しており、bridge.py が出力した GeoJSON をそのまま data/ に置けば動きます。本記事段階では月別の 4 ファイルを事前生成して切り替えていますが、API 化すれば任意の日付を動的に計算できます。

次の作業

Python 側(GeoJSON を返すブリッジ)と簡易ビューワの動作までを実装しました。次は暦・潮汐計算アプリ側に「日付を選んだら漂流地図を呼ぶ」UI を組み込む作業になります。

参考