1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python/PowerShellとOSMで熊リスクマップを作ろうとして5回モデルを作り直した話

1
Posted at

― 秋田市の実データで検証したら全部壊れた ―

本記事のコードはPython版とPowerShell版を併記しています。実証試験はPowerShellで実施し、再現用にPython版を追加しました。


はじめに

秋田県は全国最多の熊出没件数を記録しています(2026年1〜5月: 1,321件、前年同期比 +104.5%)。

本記事では、Python(またはPowerShell)を用いて

  • OpenStreetMap(OSM)から空間データを取得
  • 周辺環境の特徴量抽出
  • リスクスコアリング
  • GeoJSON生成 → QGISで可視化
  • Leaflet.jsによるブラウザヒートマップ生成

までを一気通貫で構築する過程と、実データで検証したらモデルが5回壊れて作り直した記録を書きます。

bear_risk_map_screenshot
完成したヒートマップ。青が2025年、赤が2026年の出没分布。オレンジ/赤の丸がv4リスクスコア地点。

この記事で得られるもの

  • OSM Overpass APIのPython/PowerShellからの叩き方と安定運用戦略
  • 空間データを使ったリスクスコアリングの設計と3つの落とし穴
  • 実データ検証の重要性(モック値 vs 実データの乖離)
  • Leaflet.jsヒートマップのPowerShellからの自動生成

動作確認環境

項目 バージョン
Python 3.10+ (requests)
PowerShell 7.6.0 (Windows)
QGIS 3.x
Overpass API overpass-api.de (主系)
OS Windows 11

対象読者

GIS初心者だがPythonかPowerShellがわかる人。「動くコードだけ見たい」人は最終版スクリプト(v4)まで飛んでください。


全体構成

入力(テスト座標5地点)
   ↓
OSMデータ取得(Overpass API × 3クエリ)
   ├── Q1: landuse + natural(森林・農地・草地)
   ├── Q2: waterway(河川種別: river/stream/drain)
   └── Q3: building count(建物数)
   ↓
特徴量抽出(v4: waterway分離 + tree除外)
   ↓
リスクスコア計算(v4: 対数比 + 疎密度ペナルティ)
   ↓
GeoJSON生成
   ├── QGIS可視化(ベクタレイヤ)
   └── Leaflet.jsヒートマップ(HTML自動生成)
   ↓
クマダス出没データと重ね合わせ
   ↓
LLM考察生成(Groq qwen3-32b)

1. なぜ5回作り直したのか

最初のモデルは単純でした:

# v1: 全要因を加算(これが間違い)
$score = ($forest * 0.5) + ($water * 0.2) + ($resi * 0.3)
# v1: 全要因を加算(これが間違い)
score = (forest * 0.5) + (water * 0.2) + (resi * 0.3)

この式では住宅地が多いほどリスクが上がります。直感に反しています。

以下がモデル変遷の全記録です:

Version 方式 秋田市5地点の順序 致命的問題
v1 全要因加算 NG residential加算が逆
v2 正規化+減算 NG 0クランプで差が消滅
v3(モック) 対数比 OK 実データ未検証
v3(実データ) 対数比 NG building 5,260件が全てを支配
v4 対数比+waterway分離+疎密度ペナルティ OK 採用

v3がモックデータではOKだったのに実データで壊れた。これが最大の教訓です。


2. 実データで判明した3つの落とし穴

落とし穴1: building件数の支配

上北手百崎(実際に熊出没が多い地域)でbuilding=5,260。モック値では60と推定していたので87倍の乖離

森林24件の影響がゼロに近くなり、スコアが0.016(LOW)に。熊が出る場所なのにLOW判定。

対策: buildingを対数スケール化

$bldgLog = [Math]::Log10([Math]::Max($building, 1) + 1)
# log10(5260+1) = 3.72  vs  log10(9+1) = 1.0
import math
bldg_log = math.log10(max(building, 1) + 1)
# log10(5260+1) = 3.72  vs  log10(9+1) = 1.0

落とし穴2: waterway種別の無視

御所野ニュータウン(大型商業施設が並ぶ郊外)でwaterway=61。うちstream=47、drain=11。

国土数値情報(KSJ2)由来の用水路データが大量にインポートされており、熊の移動経路となる本流(river)とは性質が全く異なります。

対策: waterway種別で重みを分離

$rWater = ($wRiver * 3.0) + ($wStream * 0.3) + ($wDrain * 0.0)
r_water = (w_river * 3.0) + (w_stream * 0.3) + (w_drain * 0.0)

落とし穴3: OSMデータの疎密度パラドックス

仁別(太平山奥地の完全な山林地帯)で環境要素がたった13件。forest=1。

OSMの山林地帯はデータが極端に少ないのです。森林は広域ポリゴン(relation)で1件カウントされますが、都市部のように個別登録されません。

「データが少ない ≒ 人間活動が少ない ≒ 自然が多い」 — この逆転の論理をモデルに組み込む必要がありました。

対策: 疎密度ペナルティ

$densityPenalty = [Math]::Max(0, 3.0 - [Math]::Log10($envTotal + 1)) * 25.0
# 仁別(13件): (3.0-1.15)*25 = 46.3 → リスク加算
# 秋田駅(297件): (3.0-2.47)*25 = 13.1 → 小加算
density_penalty = max(0, 3.0 - math.log10(env_total + 1)) * 25.0
# 仁別(13件): (3.0-1.15)*25 = 46.3 → リスク加算
# 秋田駅(297件): (3.0-2.47)*25 = 13.1 → 小加算

3. 最終版スクリプト(v4)

3.1 Overpass API: クエリ設計と安定運用戦略

クエリ設計

3本のクエリに分離します。

# Q1: 環境要素(tree/tree_row/cemeteryを除外)
$q1 = @"
[out:json][timeout:60];
(
  nwr(around:$Radius,$Lat,$Lon)["landuse"];
  nwr(around:$Radius,$Lat,$Lon)["natural"~"^(wood|water|grassland|scrub|wetland)$"];
);
out tags;
"@

# Q2: waterway(種別を取得)
$q2 = @"
[out:json][timeout:30];
(
  nwr(around:$Radius,$Lat,$Lon)["waterway"];
);
out tags;
"@

# Q3: 建物数のみ(out countで軽量化)
$q3 = @"
[out:json][timeout:30];
(
  nwr(around:$Radius,$Lat,$Lon)["building"];
);
out count;
"@

Overpassクエリ自体は言語非依存のため、PythonでもPowerShellでも同じ文字列を送信します。

設計上のポイント:

  • nwr でnode/way/relationを全取得(nodeだけではlanduse=forestが取れない)
  • out tags; でジオメトリを省略(タグ情報のみ必要なので通信量を削減)
  • Q1のnaturalを正規表現でフィルタし、tree/tree_rowを除外(街路樹の誤カウント防止)
  • Q3はout count;で件数のみ取得(建物数千件のデータ転送を回避)

Overpass APIの安定運用戦略

開発中に以下の問題に遭遇しました。

問題 原因 対策
406 Not Acceptable Invoke-RestMethodのContent-Type不正 HttpWebRequestで明示的にUTF-8エンコード
504 Gateway Timeout クエリが重すぎる building/envクエリ分離、out count;活用
接続不安定 単一エンドポイント依存 overpass-api.de主系 + kumi.systemsフォールバック

フォールバック実装:

$endpoints = @(
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter"
)

function Invoke-Overpass {
    param([string]$Query)
    foreach ($ep in $endpoints) {
        try {
            $body = "data=" + [System.Uri]::EscapeDataString($Query)
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($body)

            $req = [System.Net.HttpWebRequest]::Create($ep)
            $req.Method = "POST"
            $req.ContentType = "application/x-www-form-urlencoded; charset=utf-8"
            $req.Timeout = 90000
            $req.ContentLength = $bytes.Length

            $stream = $req.GetRequestStream()
            $stream.Write($bytes, 0, $bytes.Length)
            $stream.Close()

            $response = $req.GetResponse()
            $reader = New-Object System.IO.StreamReader(
                $response.GetResponseStream(),
                [System.Text.Encoding]::UTF8
            )
            $jsonStr = $reader.ReadToEnd()
            $reader.Close()
            $response.Close()

            return ($jsonStr | ConvertFrom-Json)
        }
        catch {
            Write-Host "  FAIL: $ep" -ForegroundColor Red
        }
    }
    return $null
}

Invoke-RestMethodではなくHttpWebRequestを使う理由は、Content-Typeとエンコーディングを完全に制御するためです。Invoke-RestMethodの暗黙変換がOverpass APIの406を引き起こすケースがありました。

Python版(requests):

import requests

ENDPOINTS = [
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
]

def query_overpass(query: str) -> dict | None:
    for ep in ENDPOINTS:
        try:
            resp = requests.post(ep, data={"data": query}, timeout=90)
            resp.raise_for_status()
            return resp.json()
        except Exception as e:
            print(f"  FAIL: {ep} ({e})")
    return None

PythonのrequestsではContent-Typeの問題は発生しないため、シンプルに書けます。

3.2 特徴量抽出

function Get-FeaturesV4 {
    param($LanduseElements, $WaterwayElements, $BuildingCount)

    $forest = 0; $residential = 0; $farmland = 0; $grassland = 0

    foreach ($el in $LanduseElements) {
        $tags = $el.tags
        if (-not $tags) { continue }
        if ($tags.landuse -eq "forest" -or
            $tags.natural -eq "wood") { $forest++ }
        elseif ($tags.landuse -eq "residential") { $residential++ }
        elseif ($tags.landuse -eq "farmland") { $farmland++ }
        elseif ($tags.landuse -eq "meadow" -or
                $tags.natural -eq "grassland" -or
                $tags.natural -eq "scrub") { $grassland++ }
    }

    # waterway種別分離
    $wRiver = 0; $wStream = 0; $wDrain = 0
    foreach ($el in $WaterwayElements) {
        switch ($el.tags.waterway) {
            "river" { $wRiver++ }
            "canal" { $wRiver++ }
            "stream" { $wStream++ }
            "drain"  { $wDrain++ }
            "ditch"  { $wDrain++ }
        }
    }

    return @{
        forest = $forest; residential = $residential
        farmland = $farmland; grassland = $grassland
        wRiver = $wRiver; wStream = $wStream; wDrain = $wDrain
        building = $BuildingCount
        envTotal = $LanduseElements.Count
    }
}

Python版:

def extract_features(landuse_elements, waterway_elements, building_count):
    forest = residential = farmland = grassland = 0
    for el in landuse_elements:
        tags = el.get("tags", {})
        lu, na = tags.get("landuse", ""), tags.get("natural", "")
        if lu == "forest" or na == "wood":
            forest += 1
        elif lu == "residential":
            residential += 1
        elif lu == "farmland":
            farmland += 1
        elif lu == "meadow" or na in ("grassland", "scrub"):
            grassland += 1

    w_river = w_stream = w_drain = 0
    for el in waterway_elements:
        wt = el.get("tags", {}).get("waterway", "")
        if wt in ("river", "canal"):
            w_river += 1
        elif wt == "stream":
            w_stream += 1
        elif wt in ("drain", "ditch"):
            w_drain += 1

    return {
        "forest": forest, "residential": residential,
        "farmland": farmland, "grassland": grassland,
        "w_river": w_river, "w_stream": w_stream, "w_drain": w_drain,
        "building": building_count,
        "env_total": len(landuse_elements),
    }

3.3 リスクスコア計算(v4 対数比モデル)

function Get-RiskScoreV4 {
    param($F)

    # --- Risk (環境要因) ---
    $rForest = $F.forest * 3.0     # 採餌・隠蔽空間
    $rWater  = ($F.wRiver * 3.0) + # 移動経路(河川回廊)
               ($F.wStream * 0.3) + # 小規模水系(弱いリスク)
               ($F.wDrain * 0.0)    # 人工水路(除外)
    $rFarm   = $F.farmland * 0.8   # 誘引要因(農作物)
    $rGrass  = $F.grassland * 0.3  # 耕作放棄地等
    $riskEnv = $rForest + $rWater + $rFarm + $rGrass

    # --- Risk (OSM観測バイアス補正) ---
    # データが少ない地域 = 人間活動が少ない = 自然が多い
    $envT = [Math]::Max($F.envTotal, 1)
    $densityPenalty = [Math]::Max(0, 3.0 - [Math]::Log10($envT + 1)) * 25.0

    $riskTotal = $riskEnv + $densityPenalty

    # --- Safe (人間活動の抑制要因) ---
    # 建物は対数スケール(5000件と500件の差を圧縮)
    $bldgLog = [Math]::Log10([Math]::Max($F.building, 1) + 1)
    $safeSum = ($bldgLog * 15.0) + ($F.residential * 5.0) + 1

    # Sigmoid正規化: score = ratio / (ratio + 1)
    $ratio = $riskTotal / $safeSum
    $score = [Math]::Min(1.0, $ratio / ($ratio + 1))
    return [Math]::Round($score, 4)
}

Python版:

import math

def calc_risk_score(f: dict) -> float:
    # Risk (環境要因)
    r_forest = f["forest"] * 3.0          # 採餌・隠蔽空間
    r_water = (f["w_river"] * 3.0 +       # 河川回廊
               f["w_stream"] * 0.3 +       # 小規模水系
               f["w_drain"] * 0.0)         # 人工水路(除外)
    r_farm = f["farmland"] * 0.8           # 誘引要因
    r_grass = f["grassland"] * 0.3         # 耕作放棄地等
    risk_env = r_forest + r_water + r_farm + r_grass

    # OSM観測バイアス補正
    env_t = max(f["env_total"], 1)
    density_penalty = max(0, 3.0 - math.log10(env_t + 1)) * 25.0

    risk_total = risk_env + density_penalty

    # Safe (人間活動の抑制要因)
    bldg_log = math.log10(max(f["building"], 1) + 1)
    safe_sum = (bldg_log * 15.0) + (f["residential"] * 5.0) + 1

    # Sigmoid正規化
    ratio = risk_total / safe_sum
    score = min(1.0, ratio / (ratio + 1))
    return round(score, 4)

スコア設計の根拠

各係数は「秋田市5地点の直感的順序との一致」で調整したヒューリスティックですが、要因の選定には生態学的な根拠があります。

要因 係数 根拠
forest ×3.0 熊の採餌空間・隠蔽場所。最も直接的な生息環境指標
wRiver ×3.0 河川回廊仮説:熊は河川沿いを移動経路として利用する
wStream ×0.3 小規模水系。移動経路としての機能は限定的
wDrain ×0.0 人工水路(用水路・排水路)。熊の行動と無関係
farmland ×0.8 農作物(特に果樹)が誘引要因。山裾の農地は出没の温床
grassland ×0.3 耕作放棄地の可能性。弱い生息環境指標
building(log) ×15.0 人間干渉の代理変数。対数減衰で「5000件と500件の差」を圧縮
residential ×5.0 住宅地ポリゴンは建物個数より広域の人間活動を示す
densityPenalty ×25.0 OSM観測バイアス補正。データ疎 ≈ 人跡未踏 ≈ 自然環境

Sigmoid正規化 score = ratio / (ratio + 1) を採用した理由:

  • 出力が0〜1に収まる(クランプ不要)
  • risk/safe比が1のとき score=0.5(直感的な中間点)
  • 極端な値で飽和する(risk=100, safe=1 でも score≈0.99、∞にならない)

スコアの解釈

範囲 ラベル 意味
> 0.5 HIGH 森林・河川が優勢、人間活動が少ない
0.25〜0.5 MID 都市と自然の境界域
< 0.25 LOW 都市部、建物密集

3.4 GeoJSON出力

$geojson = @{
    type = "FeatureCollection"
    features = $gjFeatures.ToArray()
} | ConvertTo-Json -Depth 10

# BOM無しUTF-8
# PowerShell 5.xの Out-File -Encoding UTF8 はBOM付きになるため
# System.IO.File を使用
[System.IO.File]::WriteAllText(
    $outPath, $geojson,
    [System.Text.UTF8Encoding]::new($false)
)

Python版:

import json

geojson = {
    "type": "FeatureCollection",
    "features": features_list,
}

with open(out_path, "w", encoding="utf-8") as f:
    json.dump(geojson, f, ensure_ascii=False, indent=2)

4. 実データ検証結果(秋田市5地点)

地点 Score Label forest wRiver wStream farmland building
秋田駅周辺 0.4631 MID 11 1 5 1 5,590
上北手百崎 0.8143 HIGH 32 2 1 171 5,260
太平山麓 0.6653 HIGH 2 4 1 9 291
御所野NT 0.5776 HIGH 2 1 47 44 5,242
仁別 0.9271 HIGH 1 4 2 5 0

スコア順序:

仁別(0.93) > 上北手百崎(0.81) > 太平山麓(0.67) > 御所野NT(0.58) > 秋田駅(0.46)

上北手百崎は2026年6月に実際に熊出没報告がある地域です。farmland=171(農地が広がる山裾)が高スコアの主因で、「山奥よりも山と人里の境界の方が遭遇リスクが高い」という知見と一致します。


5. クマダス出没データとの重ね合わせ

秋田県オープンデータカタログ(クマダス)から出没データを取得し、前年比較を行いました。

5.1 データ取得

# 秋田県CKAN: クマダスCSV直接ダウンロード
$csvUrl = "https://ckan.pref.akita.lg.jp/dataset/" +
    "050008_shizenhogoka_003/resource/" +
    "0678f9b3-4bf7-4212-9c0e-c0cb9b09b3cf/" +
    "download?user-download=true"
Invoke-WebRequest -Uri $csvUrl -OutFile $localCsv -TimeoutSec 120

$allRecords = Import-Csv $localCsv -Encoding UTF8
# CSVスキーマ:
# 出没情報ID, 情報種別, 市町村, 地番情報, 目撃日時,
# 獣種, 性別, 単独か親子, 頭数, 目撃時の状況, x(緯度), y(経度)

Python版:

import pandas as pd

url = ("https://ckan.pref.akita.lg.jp/dataset/"
       "050008_shizenhogoka_003/resource/"
       "0678f9b3-4bf7-4212-9c0e-c0cb9b09b3cf/"
       "download?user-download=true")
df = pd.read_csv(url, encoding="utf-8")

注意点として、獣種 フィールドには「ツキノワグマ」「イノシシ」「ニホンジカ」が混在しています。PowerShellではフィルタ条件 -match "グマ" で絞り込みます(「ツキノワグマ」は「クマ」にマッチしないため -match "クマ" では全件除外される罠があります)。Pythonでは df[df["獣種"].str.contains("グマ")] で同等のフィルタが可能です。

5.2 2025年 vs 2026年 同期間比較(1月〜5月)

指標 2025年 2026年 変化
総件数 646 1,321 +104.5%
秋田市 208 470 +126.0%
目撃 600 1,272 +112.0%
人身被害 1 2 +100.0%

データソースは2026年5月31日時点のスナップショットのため、6月以降のデータは含まれていません。公平な同期間比較のため1〜5月で切り出しています。

月別推移:

2025 2026 変化
1月 133 30 -103
2月 7 24 +17
3月 23 29 +6
4月 85 395 +310
5月 398 843 +445

2026年は4月以降の急増が顕著です。

5.3 市町村別の変化

市町村 2025 2026 変化率
秋田市 159 426 +168%
大仙市 75 152 +103%
横手市 41 102 +149%
男鹿市 4 48 +1,100%
北秋田市 49 44 -10%
鹿角市 42 35 -17%

男鹿市の急増(4→48件)は沿岸部への出没拡大を示唆し、北秋田市・鹿角市の減少は地域ごとのパターンの違いを示しています。

5.4 LLMによる考察自動生成

集計データをGroq Cloud(qwen3-32b)に投入し、考察を自動生成しました。プロンプトには「今後の予測は一切行わない」を明示指定しています。

$groqBody = @{
    model = "qwen/qwen3-32b"
    messages = @( @{ role = "user"; content = $prompt } )
    temperature = 0.3
} | ConvertTo-Json -Depth 10

$resp = Invoke-RestMethod -Uri "https://api.groq.com/openai/v1/chat/completions" `
    -Method POST `
    -Headers @{ Authorization = "Bearer $env:GROQ_API_KEY" } `
    -ContentType "application/json; charset=utf-8" `
    -Body ([System.Text.Encoding]::UTF8.GetBytes($groqBody))

$analysis = $resp.choices[0].message.content

Python版:

import requests, os

resp = requests.post(
    "https://api.groq.com/openai/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['GROQ_API_KEY']}"},
    json={
        "model": "qwen/qwen3-32b",
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.3,
    },
    timeout=120,
)
analysis = resp.json()["choices"][0]["message"]["content"]

以下はGroqが生成した考察テキスト(原文ママ)です:

出没件数の増減傾向

2026年1月〜5月の出没件数(1,321件)は、2025年同期間(646件)のほぼ2倍に達しています。特に4月(+310件)と5月(+445件)の増加が顕著で、年間全体の増加はこれらの2か月に集中しています。

月別推移のパターン変化

1月は2026年が2025年比で77%減少(30件 vs 133件)し、冬期の低調な活動が確認されます。一方、4月以降は2026年が急増しており、4月は2025年比465%増、5月は212%増と、春先の活動の高まりが全体の増加を牽引しています。

秋田市の特化分析

秋田市の出没件数は2025年(208件)から2026年(470件)で126%増加しています。県全体の増加率(104.5%)を上回っており、都市周辺への出没が加速していることが確認されます。

市町村別の分布変化

男鹿市(4→48件:+1,100%)、にかほ市(22→44件:+100%)が目立ち、沿岸部での活動拡大が見られます。一方、北秋田市(49→44件:-10%)、鹿角市(42→35件:-17%)は減少傾向にあり、地域ごとの活動パターンの違いが確認されます。

情報種別の構成比変化

「目撃」が2025年(600件)→2026年(1,272件)で212%増加し、全体の96%を占めています。痕跡(44→45件)、人身被害(1→2件)は微増にとどまり、構成比に大きな変化はありません。


6. QGISでの可視化

  1. QGIS起動 →「レイヤ」→「ベクタレイヤを追加」
  2. bear_risk_combined.geojson を読み込み
  3. フィルタ設定:
    • リスクスコア地点: "layer" = 'risk_score'
    • 出没地点: "layer" = 'sighting_2026'
  4. シンボロジ:
    • リスクスコア: scoreフィールドで段階色(赤→緑)
    • 出没地点: 半径2pxの赤丸マーカー

7. ボーナス: Leaflet.jsによるブラウザヒートマップ

QGISなしでも、ブラウザで確認できるHTMLヒートマップを自動生成します。

bear_map_generator.ps1 を実行すると bear_risk_map.html が生成されます。

機能:

  • 2026年出没ヒートマップ(赤系グラデーション)
  • 2025年出没ヒートマップ(青系、チェックボックスで切替)
  • 個別マーカー表示(クリックで日時・場所・状況を表示)
  • v4リスクスコア地点のオーバーレイ(スコア値をラベル表示)

Leaflet.js + leaflet-heat プラグインを使用し、CDNから読み込むためインストール不要です。

bear_risk_map_leaflet
生成されたLeafletヒートマップ。左上のチェックボックスで2025年/2026年の切替、個別マーカー表示、リスクスコア地点の表示を制御できる。

地図を見ると、v4リスクスコアの高い地点(上北手百崎 0.81、太平山麓 0.67)が、クマダスの実際の出没ヒートマップの濃い領域と一致していることが視覚的に確認できます。一方、秋田駅周辺(MID: 0.46)はヒートマップが薄く、スコアの妥当性を裏付けています。

なお、ヒートマップの2025年データ(青系)は通年(1月〜12月)、2026年データ(赤系)は1月〜5月のみ(CKANスナップショットの範囲)です。件数の直接比較には注意が必要です。前年比較の正確な同期間データはセクション5を参照してください。


8. 秋田県全域500mグリッド化設計(未実装)

本記事は5地点の点評価ですが、面的なリスクマップ生成も設計しています。

8.1 設計

秋田県 bbox: lat 38.85-40.55, lon 139.65-140.65
   ↓
500mグリッド生成(約 3,400 × 2,000 = 最大6,800セル)
   ↓
各セルでOverpass API取得(3クエリ × 6,800 = 20,400リクエスト)
   ↓
v4スコア計算
   ↓
Polygon GeoJSON生成
   ↓
ヒートマップ化

8.2 並列化とレート制限

# 500mグリッド生成(概算)
$latStep = 0.0045  # 約500m
$lonStep = 0.0056  # 約500m(緯度39度付近)

$jobs = @()
foreach ($lat in ($latMin..$latMax | ForEach-Object { $latMin + $_ * $latStep })) {
    foreach ($lon in ($lonMin..$lonMax | ForEach-Object { $lonMin + $_ * $lonStep })) {
        $jobs += @{ lat = $lat; lon = $lon }
    }
}

# PowerShell Jobで並列化(同時実行数を制限)
$maxParallel = 4
$waitBetween = 1  # 秒

Python版:

import numpy as np

lat_step = 0.0045   # 約500m
lon_step = 0.0056   # 約500m(緯度39度付近)
lat_min, lat_max = 38.85, 40.55
lon_min, lon_max = 139.65, 140.65

grid = [
    (lat, lon)
    for lat in np.arange(lat_min, lat_max, lat_step)
    for lon in np.arange(lon_min, lon_max, lon_step)
]
# concurrent.futures で並列化
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(fetch_and_score, grid))

8.3 キャッシュ設計

Overpass APIのレート制限を考慮し、取得済みセルをローカルにキャッシュします。

$cacheDir = ".\osm_cache"
$cacheKey = "$lat_$lon"
$cachePath = Join-Path $cacheDir "$cacheKey.json"

if (Test-Path $cachePath) {
    # キャッシュヒット
    $data = Get-Content $cachePath | ConvertFrom-Json
}
else {
    $data = Get-OSMData -Lat $lat -Lon $lon
    $data | ConvertTo-Json -Depth 5 | Out-File $cachePath
}

8.4 推定所要時間

  • 6,800セル × 3クエリ = 20,400リクエスト
  • 並列4 × 1秒間隔 = 約5,100秒(約85分)
  • キャッシュ込みで再実行は数分

Python版:

import json, os, hashlib

cache_dir = "./osm_cache"
os.makedirs(cache_dir, exist_ok=True)

def get_cached(lat, lon):
    key = f"{lat:.4f}_{lon:.4f}"
    path = os.path.join(cache_dir, f"{key}.json")
    if os.path.exists(path):
        with open(path) as f:
            return json.load(f)
    data = query_overpass(build_query(lat, lon))
    if data:
        with open(path, "w") as f:
            json.dump(data, f)
    return data

この段階に入ると、空間インデキシングとして扱えるようになります。LLMと組み合わせれば「この地点のリスク要因は?」という自然言語クエリに対して、キャッシュ済みのOSMデータから回答を生成する構成が可能です。


制限事項

本記事のスコアモデルはOSMデータに基づくヒューリスティックであり、統計的妥当性は検証されていません

  • OSMデータの網羅性・更新頻度は地域により大きく異なる
  • 重み係数は秋田市5地点の順序一致で調整したもの(他地域では未検証)
  • 熊の生態(食性・季節行動・個体密度・ブナの豊凶)は考慮していない
  • クマダスデータは2026年5月31日時点のスナップショット
  • Overpass APIの応答はサーバー負荷により不安定になることがある

実運用には、環境省や県の専門調査データとの照合が必要です。


まとめ

  • Python/PowerShellでOSMデータ取得→リスクスコア計算→GeoJSON/HTML出力まで構築できた
  • モックデータで動くモデルが実データで壊れるのは普遍的な教訓
  • OSMデータでは building件数の支配、waterway種別、データ疎密度パラドックスの3つが落とし穴
  • 秋田県オープンデータ(クマダス)とGroq APIで、データ比較と考察の自動生成も実現
  • 500mグリッド化は設計済み、次のステップとして実装可能


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?