8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

気象庁API解体新書

Last updated at Posted at 2024-03-28

参考ページ

はじめに

仕事で気象庁APIを取り扱った。政府ドメイン(goドメイン)で信頼はできそうだが、クセが強いうえにまともにドキュメントがないのでまとめる。これは共有すべきナレッジだな

image.png

データの観察とマスタデータの作成

まずはマスタをつくっていく。このプログラムはいわゆるシーダーデータを作成し、その後に控える予報データ取得プログラムが、それを参照し、データを処理していく(以下のurlを見ると .../const/... と書いてあるね)

https://www.jma.go.jp/bosai/common/const/area.json
https://www.jma.go.jp/bosai/forecast/const/forecast_area.json
https://www.jma.go.jp/bosai/amedas/const/amedastable.json

プロパティ 階層
centers 地方予報区(11区)
offices 府県予報区(58区)
class10s 一次細分区域(142区)
class15s 市町村等をまとめた地域
class20s 二次細分区域

jma_area

いわゆる地方区分。生データでは center という名前で取り扱われているので、area と名付けた。

code name
010600 近畿地方

jma_prefecture

いわゆる都道府県(北海道と沖縄が、より分割されている)。生データでは office という名前で取り扱われているので、prefecture と名付けた。
image.png

code jma_area_id name
250000 010600 滋賀県
260000 010600 京都府
270000 010600 大阪府
280000 010600 兵庫県
290000 010600 奈良県
300000 010600 和歌山県

jma_region

ひとつの都道府県の、ある範囲(北部とか南部)。生データでは class10 という名前で取り扱われているので region と名付けた。

この region がキモの概念になる


code jma_prefecture_id name
280010 280000 南部
280020 280000 北部

jma_city

ひとつの都道府県のひとつの市区町村(一部市町村を分割している)。生データでは class20 という名前で取り扱われているので city と名付けた。

image.png

no. code jma_region_id name
1 2810000 280010 神戸市
2 2820200 280010 尼崎市
3 2820400 280010 西宮市
4 2820600 280010 芦屋市
5 2820700 280010 伊丹市
6 2821400 280010 宝塚市
7 2821700 280010 川西市
8 2821900 280010 三田市
9 2830100 280010 猪名川町
10 2821300 280010 西脇市
11 2822100 280010 丹波篠山市
12 2822300 280010 丹波市
13 2836500 280010 多可町
14 2822700 280010 宍粟市
15 2844200 280010 市川町
16 2844300 280010 福崎町
17 2844600 280010 神河町
18 2850100 280010 佐用町
19 2820300 280010 明石市
20 2821000 280010 加古川市
21 2821500 280010 三木市
22 2821600 280010 高砂市
23 2821800 280010 小野市
24 2822000 280010 加西市
25 2822800 280010 加東市
26 2838100 280010 稲美町
27 2838200 280010 播磨町
28 2820100 280010 姫路市
29 2820800 280010 相生市
30 2821200 280010 赤穂市
31 2822900 280010 たつの市
32 2846400 280010 太子町
33 2848100 280010 上郡町
34 2820500 280010 洲本市
35 2822400 280010 南あわじ市
36 2822600 280010 淡路市

class15(いくつかのcityの集まり)もあるんだけど、いわゆる「郡」かと思われる(但馬北部など)。これは普段づかいしないので親子関係を紐づけるとき、内部的に使いながらもテーブルのデータにはしない(郡を省略する)ことにする

jma_amedas

いわゆる気象観測所。生データでは amedas という名前で取り扱われているのでそのまま amedas と名付けた。

id jma_region_id
63518 280010
63576 280010
63571 280010
63383 280010

ER図

regionが肝ってことやな。
image.png

クセ強ポイント

amedasのグルーピングについて

  • 市区町村別のアメダスリスト forecast_area.json はリージョン単位でグルーピングされている
  • region に複数のアメダスがある場合、代表cityがある
  • ということは amedas が city へ紐づかないといけないが、class10(=region)へ紐づける
forecast_area.json
{
    :
  "280000": [
    {
      "class10": "280010",  ←region: 南部
      "amedas": [
        "63518",  ←amedas: 神戸
        "63576",  ←amedas: (欠番!)
        "63571",  ←amedas: 洲本
        "63383"  ←amedas: 姫路
      ],
      "class20": "2810000"  ←city: 神戸市(南部代表都市)
    },
    {
      "class10": "280020",  ←region: 北部
      "amedas": [
        "63051"  ←amedas: 豊岡
      ],
      "class20": "2820900"  ←city: 豊岡市(北部代表都市)
    }
  ]
      :
}

予報データについて

  • 天気予報ファイルは prefecture で取得する(例: 280000.json
    • 気温情報がアメダス単位で収録されている(アメダスは region に紐づいている)
    • アプリ側のターゲットとなる建物等の住所から region を特定できる
    • 神戸は南部なので、せめて兵庫県南部の気温を抽出したい
  • 「本日」の予報データは時間の経過とともに値の要素数が減っていく(あたりまえっちゃあたりまえか...)
280000.json(できれば市区町村単位で天気を取得したい)
[
  {
    "publishingOffice": "神戸地方気象台",
    "reportDatetime": "2024-03-28T17:00:00+09:00",
    "timeSeries": [
      {
        "areas": [
          {
            "area": {"name": "神戸", "code": "63518"},  ←region: 南部
            "temps": ["13", "19"]
          },
          {
            "area": {"name": "豊岡", "code": "63051"},  ←region: 北部
            "temps": ["12", "22"]
          },
          {
            "area": {"name": "洲本", "code": "63571"},  ←region: 南部
            "temps": ["14", "20"]
          },
          {
            "area": {"name": "姫路", "code": "63383"},  ←region: 南部
            "temps": ["14", "21"]
          }
        ]
      }
    ]
  }
      :
]

amedasマスタについて

  • アメダスidは市区町村と紐づかない ←:interrobang:
amedastable.json(アメダスの情報から市区町村と紐づかない。。。)
{
  "63051": {
    "type": "B",
    "elems": "11111111",
    "lat": [35, 32.1],
    "lon": [134, 49.3],
    "alt": 3,
    "kjName": "豊岡",
    "knName": "トヨオカ",
    "enName": "Toyooka"
  },
  "63518": {
    "type": "A",
    "elems": "11111111",
    "lat": [34, 41.8],
    "lon": [135, 12.7],
    "alt": 5,
    "kjName": "神戸",
    "knName": "コウベ",
    "enName": "Kobe"
  },
  "63571": {
    "type": "B",
    "elems": "11111011",
    "lat": [34, 18.6],
    "lon": [134, 50.9],
    "alt": 69,
    "kjName": "洲本",
    "knName": "スモト",
    "enName": "Sumoto"
  },
  "63383": {
    "type": "B",
    "elems": "11111011",
    "lat": [34, 50.3],
    "lon": [134, 40.2],
    "alt": 38,
    "kjName": "姫路",
    "knName": "ヒメジ",
    "enName": "Himeji"
  }
}

気象庁データについて

もう一度この絵を見てみよう。姫路市を選んで表示しても神戸市を選んで表示しても3つの都市が必ず表示される:thinking:。そして、表示された3つの都市以外の兵庫県南部の都市(南あわじ市 など)を表示してもこの3つの代表都市が表示される:thinking:。ということはアメダスと市区町村は紐づかず(アメダスマスタ全量の天気データはない)、結局リージョン単位なんだなということがわかる。それ以上のデータがほしければ金払って民間APIだね。

姫路市 南あわじ市
image.png image.png

アプリからの使われかたを考える

アプリにおける使われ方としては、建物Aを登録するときに、市区町村を気象庁APIベースのマスタからドロップダウンで選択させるのがいいだろうな。

マスタを保存する仕組みを作ろう

create root directory

console
mkdir jma_weather
cd jma_weather

venv

console
python -m venv venv311

create project

console
pip install django
django-admin startproject config .

create app

console
python manage.py startapp weather

update settings

config/settings.py
INSTALLED_APPS = [
        :
+   "weather",
]

create model

weather/models.py
from django.db import models

class JmaArea(models.Model):
    """
    an area in the JMA
    生データでは center という名前で取り扱われている

    Attributes:
        code (str): エリアコード
        name (str): エリア名
    """

    code = models.CharField(unique=True, max_length=6)
    name = models.CharField(max_length=100)


class JmaPrefecture(models.Model):
    """
    a prefecture in the JMA
    生データでは office という名前で取り扱われている

    Attributes:
        code (CharField): 都道府県コード
        jma_area (ForeignKey): FK to JmaArea
        name (CharField): 都道府県名
    """

    code = models.CharField(unique=True, max_length=6)
    jma_area = models.ForeignKey(JmaArea, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)


class JmaRegion(models.Model):
    """
    a region in the JMA
    生データでは class10 という名前で取り扱われている

    Attributes:
        code (str): リージョンコード
        jma_prefecture (JmaPrefecture): FK to JmaPrefecture
        name (str): リージョン名
    """

    code = models.CharField(unique=True, max_length=6)
    jma_prefecture = models.ForeignKey(JmaPrefecture, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)


class JmaCity(models.Model):
    """
    a city in the JMA
    生データでは class20 という名前で取り扱われている

    Attributes:
        code (str): 市区町村コード
        jma_region (JmaRegion): FK to JMA region
        name (str): 市区町村名
    """

    code = models.CharField(unique=True, max_length=7)
    jma_region = models.ForeignKey(JmaRegion, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)


class JmaAmedas(models.Model):
    """
    a AMeDas in the JMA

    Attributes:
        code (str): アメダス観測所コード
        jma_region (JmaRegion): FK to JMA region
    """

    code = models.CharField(unique=True, max_length=5)
    jma_region = models.ForeignKey(JmaRegion, on_delete=models.CASCADE)


class JmaWeatherCode(models.Model):
    """
    天気コードマスタ

    Attributes:
        code (CharField): "123"
        summary_code (CharField): "100"
        image (FileField): "100.svg"
        name (CharField): ""
        name_en (CharField): "CLEAR, FREQUENT SNOW FLURRIES LATER"
    """

    code = models.CharField(unique=True, max_length=3)
    summary_code = models.CharField(max_length=3)
    image = models.CharField(max_length=7)
    name = models.CharField(max_length=20)
    name_en = models.CharField(max_length=100)


class JmaWeather(models.Model):
    """
    日々の天気(1時間ごとにバッチで取得)

    See Also: https://www.jma.go.jp/bosai/forecast/#area_type=class20s&area_code=2820100

    Attributes:
        jma_region (JmaRegion): A ForeignKey field representing the region for which the weather data is recorded.
        reporting_date (DateField): A DateField representing the date on which the weather data is reported.
        jma_weather_code (JmaWeatherCode): A ForeignKey field representing the weather code for the recorded data.
        weather_text (CharField): A CharField representing the text description of the weather.
        wind_text (CharField): A CharField representing the text description of the wind conditions.
        wave_text (CharField): A CharField representing the text description of the wave conditions.
        avg_rain_probability (FloatField, optional): 降水確率
        avg_min_temperature (FloatField, optional): 最低気温
        avg_max_temperature (FloatField, optional): 最高気温
        avg_max_wind_speed (FloatField, optional): 最大風速

    """

    jma_region = models.ForeignKey(JmaRegion, on_delete=models.CASCADE)
    reporting_date = models.DateField()
    jma_weather_code = models.ForeignKey(JmaWeatherCode, on_delete=models.CASCADE)
    weather_text = models.CharField(max_length=255)
    wind_text = models.CharField(max_length=255)
    wave_text = models.CharField(max_length=255)
    avg_rain_probability = models.FloatField(null=True)
    avg_min_temperature = models.FloatField(null=True)
    avg_max_temperature = models.FloatField(null=True)
    avg_max_wind_speed = models.FloatField(null=True)


class JmaWarning(models.Model):
    """
    Model representing a JMA warning.

    See Also: https://www.jma.go.jp/bosai/warning/#area_type=class20s&area_code=2810000&lang=ja

    Attributes:
        jma_region (ForeignKey): The JMA region associated with the warning.
        warnings (CharField): The description of the warning.
    """

    jma_region = models.ForeignKey(JmaRegion, on_delete=models.CASCADE)
    warnings = models.CharField(max_length=100)
    

migration

python manage.py makemigrations weather
python manage.py migrate

create vo

weather/domain/valueobject/weather/jma.py
from dataclasses import dataclass


# マスタデータ
@dataclass
class JmaConstWeatherCode:
    """
    気象庁の定数マスタ(天気コード)

    Attributes:
        code (str): The code of the weather.
        image_day (str): The image path for day weather.
        image_night (str): The image path for night weather.
        summary_code (str): The summary code for the weather.
        name (str): The name of the weather.
        name_en (str): The English name of the weather.
    """

    code: str
    image_day: str
    image_night: str
    summary_code: str
    name: str
    name_en: str


@dataclass
class JmaConstGeographicArea:
    """
    気象庁の定数マスタ(area, prefecture, region, city_group, city)

    Attributes:
        code (str): The code of the constant.
        name (str): The name of the constant.
        children (list[str]): The list of children constants' codes.
        parent (str): The code of the parent constant.
    """

    code: str
    name: str
    children: list[str]
    parent: str


# 予報データ
class MeanCalculable:
    """
    平均を出すための生値とその平均値

    Args:
        float_list (list[float]): 生値
    """

    def __init__(self, float_list: list[float]) -> None:
        self.raw: list[float] = float_list
        self.mean: float = (
            round(sum(float_list) / len(float_list), 1) if float_list else 0
        )


@dataclass(frozen=True)
class Region:
    """
    A class representing a region.

    Attributes:
        code (str): "280010"
        name (str): "南部"
    """

    code: str
    name: str


@dataclass(frozen=True)
class SummaryText:
    """
    気象情報: サマリー情報

    Attributes:
        weather (str): "雨 所により 雷を伴い 非常に 激しく 降る"
        wind (str): "東の風 やや強く 海上 では 東の風 強く"
        wave (str): "1.5メートル ただし 淡路島南部 では 3メートル うねり を伴う"
    """

    weather: str
    wind: str
    wave: str


@dataclass(frozen=True)
class RainData:
    """
    気象情報: 降水確率

    Attributes:
        values (MeanCalculable): 生値とその平均値(時間帯平均)
        unit (str): "%"
    """

    values: MeanCalculable
    unit: str = "%"


@dataclass(frozen=True)
class TemperatureData:
    """
    気象情報: 気温

    Attributes:
        min_values (MeanCalculable): 生値とその平均値(region平均)
        max_values (MeanCalculable): 生値とその平均値(region平均)
        unit (str): ""
    """

    min_values: MeanCalculable
    max_values: MeanCalculable
    unit: str = ""


class WindData:
    """
    気象情報: 最大風速

    Attributes:
        values (MeanCalculable): 生値とその平均値(時間帯平均)
        unit (str): "以下" or "メートル毎秒"

    Notes: 風速値が9のばあい、その object に condition というキーが現れて "以下" という値が入るが
     表現を間引いて、平均値が9以下だったら "以下" にすることにした
    """

    def __init__(self, values: MeanCalculable):
        self.values = values
        self.unit = "以下" if self.values.mean <= 9 else "メートル毎秒"

    def __str__(self):
        return f"最大風速(時間帯平均)は {self.values.mean} {self.unit}"


@dataclass(frozen=True)
class WarningData:
    """
    A class representing warning data.

    Attributes:
        code (str): 14
        status (str): 継続
    """

    code: str
    status: str

create repository

weather/domain/repository/weather/jma.py
from weather.models import JmaPrefecture, JmaRegion, JmaAmedas


class JmaRepository:
    @staticmethod
    def get_amedas_code_list(prefecture_id: str, special_add_region_ids: dict) -> dict:
        amedas_code_in_region = {}
        jma_regions = JmaRegion.objects.filter(
            jma_prefecture=JmaPrefecture.objects.get(code=prefecture_id)
        ).prefetch_related("jmaamedas_set")
        for region in jma_regions:
            amedas_code_in_region[region.code] = [
                amedas.code for amedas in region.jmaamedas_set.all()
            ]
        if prefecture_id in special_add_region_ids:
            region = JmaRegion.objects.get(code=special_add_region_ids[prefecture_id])
            special_add_region_code = special_add_region_ids[prefecture_id]
            amedas_code_in_region[special_add_region_code] = list(
                JmaAmedas.objects.filter(jma_region=region).values_list(
                    "code", flat=True
                )
            )

        return amedas_code_in_region
        

create test

weather/tests/domain/valueobject/weather/test_jma.py
from datetime import datetime
from unittest import mock

from django.test import TestCase

from weather.management.commands import fetch_weather_forecast
from weather.management.commands.fetch_weather_forecast import (
    get_data,
    get_indexes,
)


class TestFetchWeatherForecast(TestCase):
    def setUp(self):
        # 天気予報取得バッチの 十勝地方、奄美地方 変換
        self.func = fetch_weather_forecast.update_prefecture_ids

    def test_update_prefecture_ids_with_invalid_id(self):
        prefecture_ids = ["014030", "032010", "082050", "460040"]
        updated_prefecture_ids, special_add_region_ids = self.func(prefecture_ids)

        self.assertListEqual(
            updated_prefecture_ids, ["032010", "082050", "014100", "460100"]
        )
        self.assertEqual(
            special_add_region_ids, {"014100": "014030", "460100": "460040"}
        )

    def test_update_prefecture_ids_without_invalid_id(self):
        prefecture_ids = ["014100", "032010", "082050", "460100"]
        updated_prefecture_ids, special_add_region_ids = self.func(prefecture_ids)

        self.assertListEqual(
            updated_prefecture_ids, ["014100", "032010", "082050", "460100"]
        )
        self.assertEqual(special_add_region_ids, {})

    def test_update_prefecture_ids_with_proxy_id(self):
        #  [("014030", "014100"), ("460040", "460100")]
        prefecture_ids = [
            "014030",
            "032010",
            "082050",
            "460040",
            "014100",
            "460100",
        ]
        updated_prefecture_ids, special_add_region_ids = self.func(prefecture_ids)

        self.assertListEqual(
            updated_prefecture_ids, ["032010", "082050", "014100", "460100"]
        )
        self.assertEqual(
            special_add_region_ids, {"014100": "014030", "460100": "460040"}
        )

    def test_update_prefecture_ids_with_empty_list(self):
        prefecture_ids = []
        updated_prefecture_ids, special_add_region_ids = self.func(prefecture_ids)

        self.assertListEqual(updated_prefecture_ids, [])
        self.assertEqual(special_add_region_ids, {})


class TestGetDataAndIndexes(TestCase):
    THREE_DAYS = 0
    TYPE_OVERVIEW = 0

    def setUp(self):
        self.target_date = datetime.strptime("2024-08-31", "%Y-%m-%d").date()
        self.mock_response_data = [
            {
                # This corresponds to the THREE_DAYS constant
                "timeSeries": [
                    {
                        "timeDefines": [
                            "2024-08-30T17:00:00+09:00",
                            "2024-08-31T00:00:00+09:00",
                            "2024-09-01T00:00:00+09:00",
                        ],
                        "areas": [{"area": {"name": "南部", "code": "280010"}}],
                    },
                    "dummy POPS",
                    "dummy TEMP",
                ]
            },
            "dummy a week forecast",
        ]
        self.mock_response = mock.MagicMock()
        self.mock_response.status_code = 200
        self.mock_response.json.return_value = self.mock_response_data

    @mock.patch("requests.get")
    def test_get_data(self, mock_requests_get):
        # Mocking requests.get to return the prepared data
        mock_requests_get.return_value = self.mock_response

        url = "http://test.url"  # This url doesn't matter as we mock the requests.get
        data_time_series = get_data(url)

        self.assertEqual(
            self.mock_response_data[TestGetDataAndIndexes.THREE_DAYS]["timeSeries"],
            data_time_series,
        )

    def test_get_indexes(self):
        indexes = get_indexes(
            data_time_defines=self.mock_response_data[TestGetDataAndIndexes.THREE_DAYS][
                "timeSeries"
            ][TestGetDataAndIndexes.TYPE_OVERVIEW]["timeDefines"],
            desired_date=self.target_date,
        )

        self.assertEqual(indexes, [1])
        

create import_weather_master_manual.py

weather/management/commands/import_weather_master_manual.py
import os
import shutil

import requests
from django.core.management.base import BaseCommand

from soil_analysis.domain.valueobject.weather.jma import JmaConstWeatherCode
from soil_analysis.models import (
    JmaWeatherCode,
)


class Command(BaseCommand):
    help = "Import jma weather code master from manual data."

    def handle(self, *args, **options):
        """
        任意の都市の天気ページの `ページのソースを表示` から `TELOPS=` でページ内検索して data に当て込む
        https://www.jma.go.jp/bosai/forecast/#area_type=class20s&area_code=2810000

        Args:
            *args: Additional positional arguments.
            **options: Additional keyword arguments.
        """
        data = {
            100: ["100.svg", "500.svg", "100", "\u6674", "CLEAR"],
            101: [
                "101.svg",
                "501.svg",
                "100",
                "\u6674\u6642\u3005\u66C7",
                "PARTLY CLOUDY",
            ],
            102: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u4E00\u6642\u96E8",
                "CLEAR, OCCASIONAL SCATTERED SHOWERS",
            ],
            103: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u6642\u3005\u96E8",
                "CLEAR, FREQUENT SCATTERED SHOWERS",
            ],
            104: [
                "104.svg",
                "504.svg",
                "400",
                "\u6674\u4E00\u6642\u96EA",
                "CLEAR, SNOW FLURRIES",
            ],
            105: [
                "104.svg",
                "504.svg",
                "400",
                "\u6674\u6642\u3005\u96EA",
                "CLEAR, FREQUENT SNOW FLURRIES",
            ],
            106: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u4E00\u6642\u96E8\u304B\u96EA",
                "CLEAR, OCCASIONAL SCATTERED SHOWERS OR SNOW FLURRIES",
            ],
            107: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u6642\u3005\u96E8\u304B\u96EA",
                "CLEAR, FREQUENT SCATTERED SHOWERS OR SNOW FLURRIES",
            ],
            108: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u4E00\u6642\u96E8\u304B\u96F7\u96E8",
                "CLEAR, OCCASIONAL SCATTERED SHOWERS AND/OR THUNDER",
            ],
            110: [
                "110.svg",
                "510.svg",
                "100",
                "\u6674\u5F8C\u6642\u3005\u66C7",
                "CLEAR, PARTLY CLOUDY LATER",
            ],
            111: [
                "110.svg",
                "510.svg",
                "100",
                "\u6674\u5F8C\u66C7",
                "CLEAR, CLOUDY LATER",
            ],
            112: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5F8C\u4E00\u6642\u96E8",
                "CLEAR, OCCASIONAL SCATTERED SHOWERS LATER",
            ],
            113: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5F8C\u6642\u3005\u96E8",
                "CLEAR, FREQUENT SCATTERED SHOWERS LATER",
            ],
            114: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5F8C\u96E8",
                "CLEAR,RAIN LATER",
            ],
            115: [
                "115.svg",
                "515.svg",
                "400",
                "\u6674\u5F8C\u4E00\u6642\u96EA",
                "CLEAR, OCCASIONAL SNOW FLURRIES LATER",
            ],
            116: [
                "115.svg",
                "515.svg",
                "400",
                "\u6674\u5F8C\u6642\u3005\u96EA",
                "CLEAR, FREQUENT SNOW FLURRIES LATER",
            ],
            117: [
                "115.svg",
                "515.svg",
                "400",
                "\u6674\u5F8C\u96EA",
                "CLEAR,SNOW LATER",
            ],
            118: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5F8C\u96E8\u304B\u96EA",
                "CLEAR, RAIN OR SNOW LATER",
            ],
            119: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5F8C\u96E8\u304B\u96F7\u96E8",
                "CLEAR, RAIN AND/OR THUNDER LATER",
            ],
            120: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u671D\u5915\u4E00\u6642\u96E8",
                "OCCASIONAL SCATTERED SHOWERS IN THE MORNING AND EVENING, CLEAR DURING THE DAY",
            ],
            121: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u671D\u306E\u5185\u4E00\u6642\u96E8",
                "OCCASIONAL SCATTERED SHOWERS IN THE MORNING, CLEAR DURING THE DAY",
            ],
            122: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5915\u65B9\u4E00\u6642\u96E8",
                "CLEAR, OCCASIONAL SCATTERED SHOWERS IN THE EVENING",
            ],
            123: [
                "100.svg",
                "500.svg",
                "100",
                "\u6674\u5C71\u6CBF\u3044\u96F7\u96E8",
                "CLEAR IN THE PLAINS, RAIN AND THUNDER NEAR MOUTAINOUS AREAS",
            ],
            124: [
                "100.svg",
                "500.svg",
                "100",
                "\u6674\u5C71\u6CBF\u3044\u96EA",
                "CLEAR IN THE PLAINS, SNOW NEAR MOUTAINOUS AREAS",
            ],
            125: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5348\u5F8C\u306F\u96F7\u96E8",
                "CLEAR, RAIN AND THUNDER IN THE AFTERNOON",
            ],
            126: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u663C\u9803\u304B\u3089\u96E8",
                "CLEAR, RAIN IN THE AFTERNOON",
            ],
            127: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u5915\u65B9\u304B\u3089\u96E8",
                "CLEAR, RAIN IN THE EVENING",
            ],
            128: [
                "112.svg",
                "512.svg",
                "300",
                "\u6674\u591C\u306F\u96E8",
                "CLEAR, RAIN IN THE NIGHT",
            ],
            130: [
                "100.svg",
                "500.svg",
                "100",
                "\u671D\u306E\u5185\u9727\u5F8C\u6674",
                "FOG IN THE MORNING, CLEAR LATER",
            ],
            131: [
                "100.svg",
                "500.svg",
                "100",
                "\u6674\u660E\u3051\u65B9\u9727",
                "FOG AROUND DAWN, CLEAR LATER",
            ],
            132: [
                "101.svg",
                "501.svg",
                "100",
                "\u6674\u671D\u5915\u66C7",
                "CLOUDY IN THE MORNING AND EVENING, CLEAR DURING THE DAY",
            ],
            140: [
                "102.svg",
                "502.svg",
                "300",
                "\u6674\u6642\u3005\u96E8\u3067\u96F7\u3092\u4F34\u3046",
                "CLEAR, FREQUENT SCATTERED SHOWERS AND THUNDER",
            ],
            160: [
                "104.svg",
                "504.svg",
                "400",
                "\u6674\u4E00\u6642\u96EA\u304B\u96E8",
                "CLEAR, SNOW FLURRIES OR OCCASIONAL SCATTERED SHOWERS",
            ],
            170: [
                "104.svg",
                "504.svg",
                "400",
                "\u6674\u6642\u3005\u96EA\u304B\u96E8",
                "CLEAR, FREQUENT SNOW FLURRIES OR SCATTERED SHOWERS",
            ],
            181: [
                "115.svg",
                "515.svg",
                "400",
                "\u6674\u5F8C\u96EA\u304B\u96E8",
                "CLEAR, SNOW OR RAIN LATER",
            ],
            200: ["200.svg", "200.svg", "200", "\u66C7", "CLOUDY"],
            201: [
                "201.svg",
                "601.svg",
                "200",
                "\u66C7\u6642\u3005\u6674",
                "MOSTLY CLOUDY",
            ],
            202: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u4E00\u6642\u96E8",
                "CLOUDY, OCCASIONAL SCATTERED SHOWERS",
            ],
            203: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u6642\u3005\u96E8",
                "CLOUDY, FREQUENT SCATTERED SHOWERS",
            ],
            204: [
                "204.svg",
                "204.svg",
                "400",
                "\u66C7\u4E00\u6642\u96EA",
                "CLOUDY, OCCASIONAL SNOW FLURRIES",
            ],
            205: [
                "204.svg",
                "204.svg",
                "400",
                "\u66C7\u6642\u3005\u96EA",
                "CLOUDY FREQUENT SNOW FLURRIES",
            ],
            206: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u4E00\u6642\u96E8\u304B\u96EA",
                "CLOUDY, OCCASIONAL SCATTERED SHOWERS OR SNOW FLURRIES",
            ],
            207: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u6642\u3005\u96E8\u304B\u96EA",
                "CLOUDY, FREQUENT SCCATERED SHOWERS OR SNOW FLURRIES",
            ],
            208: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u4E00\u6642\u96E8\u304B\u96F7\u96E8",
                "CLOUDY, OCCASIONAL SCATTERED SHOWERS AND/OR THUNDER",
            ],
            209: ["200.svg", "200.svg", "200", "\u9727", "FOG"],
            210: [
                "210.svg",
                "610.svg",
                "200",
                "\u66C7\u5F8C\u6642\u3005\u6674",
                "CLOUDY, PARTLY CLOUDY LATER",
            ],
            211: [
                "210.svg",
                "610.svg",
                "200",
                "\u66C7\u5F8C\u6674",
                "CLOUDY, CLEAR LATER",
            ],
            212: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5F8C\u4E00\u6642\u96E8",
                "CLOUDY, OCCASIONAL SCATTERED SHOWERS LATER",
            ],
            213: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5F8C\u6642\u3005\u96E8",
                "CLOUDY, FREQUENT SCATTERED SHOWERS LATER",
            ],
            214: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5F8C\u96E8",
                "CLOUDY, RAIN LATER",
            ],
            215: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u5F8C\u4E00\u6642\u96EA",
                "CLOUDY, SNOW FLURRIES LATER",
            ],
            216: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u5F8C\u6642\u3005\u96EA",
                "CLOUDY, FREQUENT SNOW FLURRIES LATER",
            ],
            217: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u5F8C\u96EA",
                "CLOUDY, SNOW LATER",
            ],
            218: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5F8C\u96E8\u304B\u96EA",
                "CLOUDY, RAIN OR SNOW LATER",
            ],
            219: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5F8C\u96E8\u304B\u96F7\u96E8",
                "CLOUDY, RAIN AND/OR THUNDER LATER",
            ],
            220: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u671D\u5915\u4E00\u6642\u96E8",
                "OCCASIONAL SCCATERED SHOWERS IN THE MORNING AND EVENING, CLOUDY DURING THE DAY",
            ],
            221: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u671D\u306E\u5185\u4E00\u6642\u96E8",
                "CLOUDY OCCASIONAL SCCATERED SHOWERS IN THE MORNING",
            ],
            222: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5915\u65B9\u4E00\u6642\u96E8",
                "CLOUDY, OCCASIONAL SCCATERED SHOWERS IN THE EVENING",
            ],
            223: [
                "201.svg",
                "601.svg",
                "200",
                "\u66C7\u65E5\u4E2D\u6642\u3005\u6674",
                "CLOUDY IN THE MORNING AND EVENING, PARTLY CLOUDY DURING THE DAY,",
            ],
            224: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u663C\u9803\u304B\u3089\u96E8",
                "CLOUDY, RAIN IN THE AFTERNOON",
            ],
            225: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u5915\u65B9\u304B\u3089\u96E8",
                "CLOUDY, RAIN IN THE EVENING",
            ],
            226: [
                "212.svg",
                "212.svg",
                "300",
                "\u66C7\u591C\u306F\u96E8",
                "CLOUDY, RAIN IN THE NIGHT",
            ],
            228: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u663C\u9803\u304B\u3089\u96EA",
                "CLOUDY, SNOW IN THE AFTERNOON",
            ],
            229: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u5915\u65B9\u304B\u3089\u96EA",
                "CLOUDY, SNOW IN THE EVENING",
            ],
            230: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u591C\u306F\u96EA",
                "CLOUDY, SNOW IN THE NIGHT",
            ],
            231: [
                "200.svg",
                "200.svg",
                "200",
                "\u66C7\u6D77\u4E0A\u6D77\u5CB8\u306F\u9727\u304B\u9727\u96E8",
                "CLOUDY, FOG OR DRIZZLING ON THE SEA AND NEAR SEASHORE",
            ],
            240: [
                "202.svg",
                "202.svg",
                "300",
                "\u66C7\u6642\u3005\u96E8\u3067\u96F7\u3092\u4F34\u3046",
                "CLOUDY, FREQUENT SCCATERED SHOWERS AND THUNDER",
            ],
            250: [
                "204.svg",
                "204.svg",
                "400",
                "\u66C7\u6642\u3005\u96EA\u3067\u96F7\u3092\u4F34\u3046",
                "CLOUDY, FREQUENT SNOW AND THUNDER",
            ],
            260: [
                "204.svg",
                "204.svg",
                "400",
                "\u66C7\u4E00\u6642\u96EA\u304B\u96E8",
                "CLOUDY, SNOW FLURRIES OR OCCASIONAL SCATTERED SHOWERS",
            ],
            270: [
                "204.svg",
                "204.svg",
                "400",
                "\u66C7\u6642\u3005\u96EA\u304B\u96E8",
                "CLOUDY, FREQUENT SNOW FLURRIES OR SCATTERED SHOWERS",
            ],
            281: [
                "215.svg",
                "215.svg",
                "400",
                "\u66C7\u5F8C\u96EA\u304B\u96E8",
                "CLOUDY, SNOW OR RAIN LATER",
            ],
            300: ["300.svg", "300.svg", "300", "\u96E8", "RAIN"],
            301: [
                "301.svg",
                "701.svg",
                "300",
                "\u96E8\u6642\u3005\u6674",
                "RAIN, PARTLY CLOUDY",
            ],
            302: [
                "302.svg",
                "302.svg",
                "300",
                "\u96E8\u6642\u3005\u6B62\u3080",
                "SHOWERS THROUGHOUT THE DAY",
            ],
            303: [
                "303.svg",
                "303.svg",
                "400",
                "\u96E8\u6642\u3005\u96EA",
                "RAIN,FREQUENT SNOW FLURRIES",
            ],
            304: ["300.svg", "300.svg", "300", "\u96E8\u304B\u96EA", "RAINORSNOW"],
            306: ["300.svg", "300.svg", "300", "\u5927\u96E8", "HEAVYRAIN"],
            308: [
                "308.svg",
                "308.svg",
                "300",
                "\u96E8\u3067\u66B4\u98A8\u3092\u4F34\u3046",
                "RAINSTORM",
            ],
            309: [
                "303.svg",
                "303.svg",
                "400",
                "\u96E8\u4E00\u6642\u96EA",
                "RAIN,OCCASIONAL SNOW",
            ],
            311: [
                "311.svg",
                "711.svg",
                "300",
                "\u96E8\u5F8C\u6674",
                "RAIN,CLEAR LATER",
            ],
            313: [
                "313.svg",
                "313.svg",
                "300",
                "\u96E8\u5F8C\u66C7",
                "RAIN,CLOUDY LATER",
            ],
            314: [
                "314.svg",
                "314.svg",
                "400",
                "\u96E8\u5F8C\u6642\u3005\u96EA",
                "RAIN, FREQUENT SNOW FLURRIES LATER",
            ],
            315: ["314.svg", "314.svg", "400", "\u96E8\u5F8C\u96EA", "RAIN,SNOW LATER"],
            316: [
                "311.svg",
                "711.svg",
                "300",
                "\u96E8\u304B\u96EA\u5F8C\u6674",
                "RAIN OR SNOW, CLEAR LATER",
            ],
            317: [
                "313.svg",
                "313.svg",
                "300",
                "\u96E8\u304B\u96EA\u5F8C\u66C7",
                "RAIN OR SNOW, CLOUDY LATER",
            ],
            320: [
                "311.svg",
                "711.svg",
                "300",
                "\u671D\u306E\u5185\u96E8\u5F8C\u6674",
                "RAIN IN THE MORNING, CLEAR LATER",
            ],
            321: [
                "313.svg",
                "313.svg",
                "300",
                "\u671D\u306E\u5185\u96E8\u5F8C\u66C7",
                "RAIN IN THE MORNING, CLOUDY LATER",
            ],
            322: [
                "303.svg",
                "303.svg",
                "400",
                "\u96E8\u671D\u6669\u4E00\u6642\u96EA",
                "OCCASIONAL SNOW IN THE MORNING AND EVENING, RAIN DURING THE DAY",
            ],
            323: [
                "311.svg",
                "711.svg",
                "300",
                "\u96E8\u663C\u9803\u304B\u3089\u6674",
                "RAIN, CLEAR IN THE AFTERNOON",
            ],
            324: [
                "311.svg",
                "711.svg",
                "300",
                "\u96E8\u5915\u65B9\u304B\u3089\u6674",
                "RAIN, CLEAR IN THE EVENING",
            ],
            325: [
                "311.svg",
                "711.svg",
                "300",
                "\u96E8\u591C\u306F\u6674",
                "RAIN, CLEAR IN THE NIGHT",
            ],
            326: [
                "314.svg",
                "314.svg",
                "400",
                "\u96E8\u5915\u65B9\u304B\u3089\u96EA",
                "RAIN, SNOW IN THE EVENING",
            ],
            327: [
                "314.svg",
                "314.svg",
                "400",
                "\u96E8\u591C\u306F\u96EA",
                "RAIN,SNOW IN THE NIGHT",
            ],
            328: [
                "300.svg",
                "300.svg",
                "300",
                "\u96E8\u4E00\u6642\u5F37\u304F\u964D\u308B",
                "RAIN, EXPECT OCCASIONAL HEAVY RAINFALL",
            ],
            329: [
                "300.svg",
                "300.svg",
                "300",
                "\u96E8\u4E00\u6642\u307F\u305E\u308C",
                "RAIN, OCCASIONAL SLEET",
            ],
            340: ["400.svg", "400.svg", "400", "\u96EA\u304B\u96E8", "SNOWORRAIN"],
            350: [
                "300.svg",
                "300.svg",
                "300",
                "\u96E8\u3067\u96F7\u3092\u4F34\u3046",
                "RAIN AND THUNDER",
            ],
            361: [
                "411.svg",
                "811.svg",
                "400",
                "\u96EA\u304B\u96E8\u5F8C\u6674",
                "SNOW OR RAIN, CLEAR LATER",
            ],
            371: [
                "413.svg",
                "413.svg",
                "400",
                "\u96EA\u304B\u96E8\u5F8C\u66C7",
                "SNOW OR RAIN, CLOUDY LATER",
            ],
            400: ["400.svg", "400.svg", "400", "\u96EA", "SNOW"],
            401: [
                "401.svg",
                "801.svg",
                "400",
                "\u96EA\u6642\u3005\u6674",
                "SNOW, FREQUENT CLEAR",
            ],
            402: [
                "402.svg",
                "402.svg",
                "400",
                "\u96EA\u6642\u3005\u6B62\u3080",
                "SNOWTHROUGHOUT THE DAY",
            ],
            403: [
                "403.svg",
                "403.svg",
                "400",
                "\u96EA\u6642\u3005\u96E8",
                "SNOW,FREQUENT SCCATERED SHOWERS",
            ],
            405: ["400.svg", "400.svg", "400", "\u5927\u96EA", "HEAVYSNOW"],
            406: ["406.svg", "406.svg", "400", "\u98A8\u96EA\u5F37\u3044", "SNOWSTORM"],
            407: ["406.svg", "406.svg", "400", "\u66B4\u98A8\u96EA", "HEAVYSNOWSTORM"],
            409: [
                "403.svg",
                "403.svg",
                "400",
                "\u96EA\u4E00\u6642\u96E8",
                "SNOW, OCCASIONAL SCCATERED SHOWERS",
            ],
            411: [
                "411.svg",
                "811.svg",
                "400",
                "\u96EA\u5F8C\u6674",
                "SNOW,CLEAR LATER",
            ],
            413: [
                "413.svg",
                "413.svg",
                "400",
                "\u96EA\u5F8C\u66C7",
                "SNOW,CLOUDY LATER",
            ],
            414: ["414.svg", "414.svg", "400", "\u96EA\u5F8C\u96E8", "SNOW,RAIN LATER"],
            420: [
                "411.svg",
                "811.svg",
                "400",
                "\u671D\u306E\u5185\u96EA\u5F8C\u6674",
                "SNOW IN THE MORNING, CLEAR LATER",
            ],
            421: [
                "413.svg",
                "413.svg",
                "400",
                "\u671D\u306E\u5185\u96EA\u5F8C\u66C7",
                "SNOW IN THE MORNING, CLOUDY LATER",
            ],
            422: [
                "414.svg",
                "414.svg",
                "400",
                "\u96EA\u663C\u9803\u304B\u3089\u96E8",
                "SNOW, RAIN IN THE AFTERNOON",
            ],
            423: [
                "414.svg",
                "414.svg",
                "400",
                "\u96EA\u5915\u65B9\u304B\u3089\u96E8",
                "SNOW, RAIN IN THE EVENING",
            ],
            425: [
                "400.svg",
                "400.svg",
                "400",
                "\u96EA\u4E00\u6642\u5F37\u304F\u964D\u308B",
                "SNOW, EXPECT OCCASIONAL HEAVY SNOWFALL",
            ],
            426: [
                "400.svg",
                "400.svg",
                "400",
                "\u96EA\u5F8C\u307F\u305E\u308C",
                "SNOW, SLEET LATER",
            ],
            427: [
                "400.svg",
                "400.svg",
                "400",
                "\u96EA\u4E00\u6642\u307F\u305E\u308C",
                "SNOW, OCCASIONAL SLEET",
            ],
            450: [
                "400.svg",
                "400.svg",
                "400",
                "\u96EA\u3067\u96F7\u3092\u4F34\u3046",
                "SNOW AND THUNDER",
            ],
        }

        # Download svg
        url_base = "https://www.jma.go.jp/bosai/forecast/img/"
        download_dir = os.path.expanduser("~/Downloads/images/")
        os.makedirs(download_dir, exist_ok=True)
        for code in data.keys():
            svg_name = f"{code}.svg"
            url = f"{url_base}{svg_name}"

            response = requests.get(url, stream=True)

            if response.status_code == 200:
                with open(os.path.join(download_dir, svg_name), "wb") as f:
                    response.raw.decode_content = True
                    shutil.copyfileobj(response.raw, f)
                print(f"Downloaded {url}")
            else:
                print(f"Error downloading {url}")

        # Clear
        JmaWeatherCode.objects.all().delete()

        weather_code_list = [
            JmaConstWeatherCode(key, *values) for key, values in data.items()
        ]

        jma_weather_code_list = [
            JmaWeatherCode(
                code=x.code,
                image=x.image_day,
                summary_code=x.summary_code,
                name=x.name,
                name_en=x.name_en,
            )
            for x in weather_code_list
        ]
        JmaWeatherCode.objects.bulk_create(jma_weather_code_list)

        self.stdout.write(
            self.style.SUCCESS(
                "Successfully imported all jma weather code master from manual data."
            )
        )
        
console
jma_weather> python manage.py import_weather_master_manual
  Successfully imported all jma weather code master from manual data.

create import_weather_master.py

weather/management/commands/import_weather_master.py
import sys

import requests
from django.core.management.base import BaseCommand

from weather.domain.valueobject.weather.jma import JmaConstGeographicArea
from weather.models import (
    JmaArea,
    JmaRegion,
    JmaPrefecture,
    JmaCity,
    JmaAmedas,
)


def get_data_from_url(url: str):
    try:
        # Obtain the response from the URL
        response = requests.get(url)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:
        print(
            f"データの取得でエラーが発生しました。URL: {url} エラー詳細: {e}",
            file=sys.stderr,
        )
        sys.exit(1)
    # Parse the JSON response into a Python dictionary
    return response.json()


class Command(BaseCommand):
    help = "Import jma const master from data."

    def handle(self, *args, **options):
        """
        Args:
            *args: Additional positional arguments (unused).
            **options: Additional keyword arguments (unused).
        """
        # Part1: from const
        url = "https://www.jma.go.jp/bosai/common/const/area.json"
        raw_data = get_data_from_url(url)

        # Create and save JmaArea: 010600 近畿地方
        JmaArea.objects.all().delete()
        jma_area_list = [
            JmaConstGeographicArea(
                code=code,
                name=data["name"],
                children=data.get("children", []),
                parent=data.get("parent"),
            )
            for code, data in raw_data["centers"].items()
        ]
        JmaArea.objects.bulk_create(
            [JmaArea(code=vo.code, name=vo.name) for vo in jma_area_list]
        )
        jma_area_cache = {obj.code: obj for obj in JmaArea.objects.all()}

        # Create and save JmaPrefecture: 280000 兵庫県
        JmaPrefecture.objects.all().delete()
        jma_prefecture_list = [
            JmaConstGeographicArea(
                code=code,
                name=data["name"],
                children=data.get("children", []),
                parent=data.get("parent"),
            )
            for code, data in raw_data["offices"].items()
        ]
        JmaPrefecture.objects.bulk_create(
            [
                JmaPrefecture(
                    code=vo.code, name=vo.name, jma_area=jma_area_cache.get(vo.parent)
                )
                for vo in jma_prefecture_list
            ]
        )
        jma_prefecture_cache = {obj.code: obj for obj in JmaPrefecture.objects.all()}

        # Create and save JmaRegion: 280010 南部
        JmaRegion.objects.all().delete()
        jma_region_list = [
            JmaConstGeographicArea(
                code=code,
                name=data["name"],
                children=data.get("children", []),
                parent=data.get("parent"),
            )
            for code, data in raw_data["class10s"].items()
        ]
        JmaRegion.objects.bulk_create(
            [
                JmaRegion(
                    code=vo.code,
                    name=vo.name,
                    jma_prefecture=jma_prefecture_cache.get(vo.parent),
                )
                for vo in jma_region_list
            ]
        )
        jma_region_cache = {obj.code: obj for obj in JmaRegion.objects.all()}

        # Create JmaCityGroup
        jma_city_group_list = [
            JmaConstGeographicArea(
                code=code,
                name=data["name"],
                children=data.get("children", []),
                parent=data.get("parent"),
            )
            for code, data in raw_data["class15s"].items()
        ]
        jma_city_group_cache = {
            jma_city_group.code: jma_city_group
            for jma_city_group in jma_city_group_list
        }

        # Create and save JmaCity with parents via JmaCityGroup: 2820100 姫路市
        JmaCity.objects.all().delete()
        jma_city_list = [
            JmaConstGeographicArea(
                code=code,
                name=data["name"],
                children=data.get("children", []),
                parent=data.get("parent"),
            )
            for code, data in raw_data["class20s"].items()
        ]
        JmaCity.objects.bulk_create(
            [
                JmaCity(
                    code=vo.code,
                    name=vo.name,
                    jma_region=jma_region_cache.get(
                        jma_city_group_cache.get(vo.parent).parent
                    ),
                )
                for vo in jma_city_list
            ]
        )

        # Part2: from forecast_area.json
        url = "https://www.jma.go.jp/bosai/forecast/const/forecast_area.json"
        raw_data = get_data_from_url(url)

        JmaAmedas.objects.all().delete()
        jma_amedas_list = []
        for prefecture_code, entries in raw_data.items():
            for entry in entries:
                region_code = entry["class10"]
                for amedas_code in entry["amedas"]:
                    jma_amedas_list.append(
                        JmaAmedas(
                            code=amedas_code,
                            jma_region=jma_region_cache.get(region_code),
                        )
                    )
        JmaAmedas.objects.bulk_create(jma_amedas_list)

        self.stdout.write(
            self.style.SUCCESS("jma const master data import has been completed.")
        )

console
jma_weather> python manage.py import_weather_master
  jma const master data import has been completed.

予報と警報を保存する仕組みを作ろう

警報の名称

複数の日の天気情報が混ざってる

これを取り出すの頭使ったな...データ構造としてひねくれとる

regionの気温はアメダスの平均

image.png

※(鬼畜の仕様)十勝地方と奄美地方は天気の取得が失敗する!

たまたま台風の注意報を確認したくて奄美地方の建物作って天気を取得してみたら処理が事故った。調査してみると代理のprefectureに天気予報が収録されている。area, prefecture, region, city の関係をしっかりと捉えていたから事故ったときにピンときてソースを見るというアタリがついた。こ、これがドキュメント化されていないというのは鬼畜の所業だぜ :japanese_ogre:
(天気予報のページからソースを開いて...)

create fetch_weather_forecast.py

weather/management/commands/fetch_weather_forecast.py
from datetime import datetime, date

import requests
from django.core.management import BaseCommand

from weather.domain.repository.weather.jma import JmaRepository
from weather.domain.valueobject.weather.jma import (
    WindData,
    MeanCalculable,
    Region,
    SummaryText,
    RainData,
    TemperatureData,
)
from weather.models import (
    JmaWeather,
    JmaRegion,
    JmaWeatherCode,
)

THREE_DAYS = 0

TYPE_OVERVIEW = 0
TYPE_RAIN = 1
TYPE_TEMPERATURE = 2
TYPE_WIND = 1

WIND_SPEED = 3
LAND = 0


def update_prefecture_ids(prefecture_ids: list[str]) -> tuple[list[str], dict]:
    """
    気象庁特別ルール
    "014030" があって "014100" がないときに "014030""014100" に置換 / 十勝地方 → 釧路・根室地方
    "460040" があって "460100" がないときに "460040""460100" に置換 / 奄美地方 → 鹿児島県(奄美地方除く)

    Args:
        prefecture_ids (list[str]): The list of JMA prefecture ids to be updated.

    Returns:
        tuple[list[str], dict]: JMA prefecture ids and special add region ids.

    Example:
        prefecture_ids = ["014030", "460040"]
        updated_ids, special_add_region_ids = update_prefecture_ids(prefecture_ids)
        print(updated_ids)  # Output: ["014100", "460100"]
        print(special_add_region_ids)  # Output: {"014100": "014030", "460100": "460040"}
    """
    pairs_to_check = [("014030", "014100"), ("460040", "460100")]
    special_add_region_ids = {}

    for invalid_id, proxy_id in pairs_to_check:
        if invalid_id in prefecture_ids:
            if proxy_id not in prefecture_ids:
                prefecture_ids.append(proxy_id)
            prefecture_ids.remove(invalid_id)
            special_add_region_ids[proxy_id] = invalid_id
    return prefecture_ids, special_add_region_ids


def get_data(url: str):
    response = requests.get(url)
    response.raise_for_status()
    data = response.json()
    return data[THREE_DAYS]["timeSeries"]


def get_indexes(data_time_defines, desired_date: date) -> list[int]:
    """
    get_indexes する箇所が複数あるので独立さした

    Args:
        data_time_defines: ["2024-08-31", "2024-09-01", "2024-09-01"]
        desired_date: "2024-09-01"

    Returns: [1, 2]
    """
    return [
        i
        for i, date_str in enumerate(data_time_defines)
        if datetime.fromisoformat(date_str).date() == desired_date
    ]


class Command(BaseCommand):
    help = "get weather forecast"

    def handle(self, *args, **options):
        jma_weather_list: list[JmaWeather] = []

        # TODO: 例えば建物の jma_prefecture_ids をdbから取得(重複を削って)
        jma_prefecture_ids = ["280000", "050000", "130000", "014030", "460040"]
        jma_prefecture_ids, special_add_region_ids = update_prefecture_ids(
            jma_prefecture_ids
        )

        if not jma_prefecture_ids:
            raise Exception("facility is empty")

        region_master = {x.code: x for x in JmaRegion.objects.all()}
        weather_code_master = {x.code: x for x in JmaWeatherCode.objects.all()}

        # 日付のリストだけ取得
        url = f"https://www.jma.go.jp/bosai/forecast/data/forecast/{jma_prefecture_ids[0]}.json"
        time_series_data = get_data(url)
        target_date_list = [
            datetime.fromisoformat(date_str).date()
            for date_str in time_series_data[TYPE_OVERVIEW]["timeDefines"]
        ]

        JmaWeather.objects.all().delete()
        for target_date in target_date_list:
            for prefecture_id in jma_prefecture_ids:
                print(f"{target_date}{prefecture_id=}")
                forecasts_by_region = {}

                print("  風速:")
                url = f"https://www.jma.go.jp/bosai/probability/data/probability/{prefecture_id}.json"
                time_series_data = get_data(url)

                indexes = get_indexes(
                    data_time_defines=time_series_data[TYPE_WIND]["timeDefines"],
                    desired_date=target_date,
                )
                for region_data in time_series_data[TYPE_WIND]["areas"]:
                    region_code = region_data["code"]
                    forecasts_by_region.setdefault(region_code, {})
                    time_cells_wind_data = region_data["properties"][WIND_SPEED][
                        "timeCells"
                    ]
                    wind_data = WindData(
                        values=MeanCalculable(
                            [
                                int(time_cell["locals"][LAND]["value"])
                                for i, time_cell in enumerate(time_cells_wind_data)
                                if i in indexes
                            ]
                        )
                    )
                    forecasts_by_region[region_code]["wind_data"] = wind_data
                    print(f"    {region_code}{wind_data}")

                print("  天気サマリ:")
                url = f"https://www.jma.go.jp/bosai/forecast/data/forecast/{prefecture_id}.json"
                time_series_data = get_data(url)

                indexes = get_indexes(
                    data_time_defines=time_series_data[TYPE_OVERVIEW]["timeDefines"],
                    desired_date=target_date,
                )
                if len(indexes) != 1:
                    print(f"    要素数は必ず1になります{len(indexes)}")
                    continue
                index = indexes.pop()
                for region_data in time_series_data[TYPE_OVERVIEW]["areas"]:
                    region = Region(
                        code=region_data["area"]["code"],
                        name=region_data["area"]["name"],
                    )
                    forecasts_by_region.setdefault(region.code, {})

                    # weather_code
                    forecasts_by_region[region.code]["weather_code"] = region_data[
                        "weatherCodes"
                    ][index]

                    # summary_text を3種
                    summary_text = SummaryText(
                        weather=(
                            region_data["weathers"][index]
                            if "weathers" in region_data
                            and index < len(region_data["weathers"])
                            else "なし"
                        ),
                        wind=(
                            region_data["winds"][index]
                            if "winds" in region_data
                            and index < len(region_data["winds"])
                            else "なし"
                        ),
                        wave=(
                            region_data["waves"][index]
                            if "waves" in region_data
                            and index < len(region_data["waves"])
                            else "なし"
                        ),
                    )
                    forecasts_by_region[region.code]["summary_text"] = summary_text
                    summary_text_merge = f"{summary_text.weather[:4]}{summary_text.wind[:4]}{summary_text.wave[:4]}"
                    print(
                        f"    {region.code} は |{region_data['weatherCodes'][index]}{summary_text_merge}"
                    )

                print("  降水確率:")
                indexes = get_indexes(
                    data_time_defines=time_series_data[TYPE_RAIN]["timeDefines"],
                    desired_date=target_date,
                )
                for region_data in time_series_data[TYPE_RAIN]["areas"]:
                    region = Region(
                        code=region_data["area"]["code"],
                        name=region_data["area"]["name"],
                    )
                    forecasts_by_region.setdefault(region.code, {})
                    rain_data = RainData(
                        values=MeanCalculable(
                            [
                                int(time_cell)
                                for i, time_cell in enumerate(region_data["pops"])
                                if i in indexes
                            ]
                        )
                    )
                    forecasts_by_region[region.code]["rain_data"] = rain_data
                    print(
                        f"    {region.code}{rain_data.values.mean} {rain_data.unit}"
                    )

                print("  気温:")
                amedas_code_in_region = JmaRepository.get_amedas_code_list(
                    prefecture_id, special_add_region_ids
                )
                indexes = get_indexes(
                    data_time_defines=time_series_data[TYPE_TEMPERATURE]["timeDefines"],
                    desired_date=target_date,
                )
                for region_data in time_series_data[TYPE_OVERVIEW]["areas"]:
                    region = Region(
                        code=region_data["area"]["code"],
                        name=region_data["area"]["name"],
                    )
                    forecasts_by_region.setdefault(region.code, {})

                    amedas_min_temps: list[float] = []
                    amedas_max_temps: list[float] = []
                    if indexes:
                        min_index, max_index = indexes
                        for amedas_data in time_series_data[TYPE_TEMPERATURE]["areas"]:
                            amedas_code = amedas_data["area"]["code"]
                            if amedas_code not in amedas_code_in_region.get(
                                region.code
                            ):
                                continue
                            amedas_min_temps.append(
                                float(amedas_data["temps"][min_index])
                            )
                            amedas_max_temps.append(
                                float(amedas_data["temps"][max_index])
                            )
                    temperature_data = TemperatureData(
                        min_values=MeanCalculable(amedas_min_temps),
                        max_values=MeanCalculable(amedas_max_temps),
                    )
                    forecasts_by_region[region.code][
                        "temperature_data"
                    ] = temperature_data
                    msg1 = f"    {region.code} の最低気温 {temperature_data.min_values.mean} {temperature_data.unit}"
                    msg2 = f"最高気温 {temperature_data.max_values.mean} {temperature_data.unit}"
                    print(f"{msg1} / {msg2}")

                # 天気・風・波・降水確率・気温 を1日分としてガッチャンコ
                for region_data in time_series_data[TYPE_OVERVIEW]["areas"]:
                    region = Region(
                        code=region_data["area"]["code"],
                        name=region_data["area"]["name"],
                    )

                    weather_code = forecasts_by_region[region.code]["weather_code"]
                    summary_text = forecasts_by_region[region.code]["summary_text"]
                    rain_data = forecasts_by_region[region.code]["rain_data"]
                    temperature_data = forecasts_by_region[region.code][
                        "temperature_data"
                    ]
                    wind_data = forecasts_by_region[region.code]["wind_data"]
                    jma_weather_list.append(
                        JmaWeather(
                            jma_region=region_master.get(region.code),
                            reporting_date=target_date,
                            jma_weather_code=weather_code_master.get(weather_code),
                            weather_text=summary_text.weather,
                            wind_text=summary_text.wind,
                            wave_text=summary_text.wave,
                            avg_rain_probability=(
                                None
                                if len(rain_data.values.raw) == 0
                                else rain_data.values.mean
                            ),
                            avg_min_temperature=(
                                None
                                if len(temperature_data.min_values.raw) == 0
                                else temperature_data.min_values.mean
                            ),
                            avg_max_temperature=(
                                None
                                if len(temperature_data.max_values.raw) == 0
                                else temperature_data.max_values.mean
                            ),
                            avg_max_wind_speed=(
                                None
                                if len(wind_data.values.raw) == 0
                                else wind_data.values.mean
                            ),
                        )
                    )
        JmaWeather.objects.bulk_create(jma_weather_list)

        self.stdout.write(
            self.style.SUCCESS("weather forecast data retrieve has been completed.")
        )

console
dev\portfolio> python manage.py fetch_weather_forecast
2024-09-01 の prefecture_id='280000'
  風速:
    280010 の 最大風速(時間帯平均)は 9.0 以下
    280020 の 最大風速(時間帯平均)は 9.0 以下
  天気サマリ:
    280010 は |200|くもり |北西の風|1メート|
    280020 は |200|くもり |北の風 |1.5メ|
  降水確率:
    280010 の 40.0 %
    280020 の 40.0 %
  気温:
    280010 の最低気温 0 ℃ / 最高気温 0 ℃
    280020 の最低気温 0 ℃ / 最高気温 0 ℃

2024-09-02 の prefecture_id='280000'
  風速:
    280010 の 最大風速(時間帯平均)は 9.0 以下
    280020 の 最大風速(時間帯平均)は 9.0 以下
  天気サマリ:
    280010 は |313|雨 昼前|南西の風|1メート|
    280020 は |203|くもり |南の風 |1メート|
  降水確率:
    280010 の 40.0 %
    280020 の 50.0 %
  気温:
    280010 の最低気温 24.3 ℃ / 最高気温 33.0 ℃
    280020 の最低気温 24.0 ℃ / 最高気温 33.0 ℃

2024-09-03 の prefecture_id='280000'
  風速:
    280010 の 最大風速(時間帯平均)は 0 以下
    280020 の 最大風速(時間帯平均)は 0 以下
  天気サマリ:
    280010 は |201|くもり |北の風 |0.5メ|
    280020 は |201|くもり |北の風|1メート|
  降水確率:
    280010 の 0 %
    280020 の 0 %
  気温:
    280010 の最低気温 0 ℃ / 最高気温 0 ℃
    280020 の最低気温 0 ℃ / 最高気温 0 ℃
    

create fetch_weather_warning.py

weather/management/commands/fetch_weather_warning.py
import requests
from django.core.management import BaseCommand

from weather.domain.valueobject.weather.jma import WarningData
from weather.models import JmaWarning, JmaRegion

LATEST_WARNING = 0
M_TARGET_WARNINGS = {
    "02": "暴風雪警報",
    "03": "大雨警報",
    "04": "洪水警報",
    "05": "暴風警報",
    "06": "大雪警報",
    "07": "波浪警報",
    "08": "高潮警報",
    "10": "大雨注意報",
    "12": "大雪注意報",
    "13": "風雪注意報",
    "14": "雷注意報",
    "15": "強風注意報",
    "16": "波浪注意報",
    "17": "融雪注意報",
    "18": "洪水注意報",
    "19": "高潮注意報",
    "20": "濃霧注意報",
    "21": "乾燥注意報",
    "22": "なだれ注意報",
    "23": "低温注意報",
    "24": "霜注意報",
    "25": "着氷注意報",
    "26": "着雪注意報",
    "27": "その他の注意報",
    "32": "暴風雪特別警報",
    "33": "大雨特別警報",
    "35": "暴風特別警報",
    "36": "大雪特別警報",
    "37": "波浪特別警報",
    "38": "高潮特別警報",
}


def get_data(url: str):
    response = requests.get(url)
    response.raise_for_status()
    return response.json()


class Command(BaseCommand):
    help = "get weather warning"

    def handle(self, *args, **options):
        # TODO: facilityテーブルから prefecture_ids を取得
        jma_prefecture_ids = ["280000", "050000", "130000", "014030", "460040"]

        if not jma_prefecture_ids:
            raise Exception("facility is empty")

        region_master = {x.code: x for x in JmaRegion.objects.all()}

        JmaWarning.objects.all().delete()
        jma_warning_list: list[JmaWarning] = []
        for prefecture_id in jma_prefecture_ids:
            url = (
                f"https://www.jma.go.jp/bosai/warning/data/warning/{prefecture_id}.json"
            )
            latest_warning_data = get_data(url)["areaTypes"][LATEST_WARNING]

            for region_data in latest_warning_data["areas"]:
                region_code = region_data["code"]

                warning_data_list = [
                    WarningData(code=x["code"], status=x["status"])
                    for x in region_data["warnings"]
                    if "code" in x
                ]

                if not warning_data_list:
                    continue

                warnings: list[str] = list(
                    set(
                        [
                            M_TARGET_WARNINGS[warning_data.code]
                            for warning_data in warning_data_list
                        ]
                    )
                )
                jma_warning_list.append(
                    JmaWarning(
                        jma_region=region_master.get(region_code),
                        warnings=",".join(warnings),
                    )
                )
            JmaWarning.objects.bulk_create(jma_warning_list)

        self.stdout.write(
            self.style.SUCCESS("weather warning data retrieve has been completed.")
        )

console
jma_weather> python manage.py fetch_weather_warning 
  280000/280010 の 雷注意報
  280000/280020 の 雷注意報, 高潮注意報
  050000/050010 の 雷注意報
  050000/050020 の 雷注意報
  130000/130010 の 大雨警報, 雷注意報
  130000/130020 の 雷注意報, 波浪注意報
  130000/130030 の 雷注意報, 波浪注意報
  weather warning data retrieve has been completed.

さいごに

まぁリージョンごとに予報データをしまうことができたのであとはタテモノと紐づけるなりしてよりよいものを作ることはできるだろう。予報と警報はひとつのバッチでもいいかもしれない。仕事では、天気は日次バッチ、警報はリアルタイムという要件だったので割れただけだ

Next Action

8
7
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
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?