From 2c515cca6fbaab76d305fa7dc1e932b3b1e6d424 Mon Sep 17 00:00:00 2001 From: Akira Date: Sat, 28 Feb 2026 13:23:09 +0900 Subject: [PATCH] =?UTF-8?q?=E6=B0=97=E8=B1=A1=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E5=9F=BA=E7=9B=A4=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/weather 新規作成(WeatherRecord モデル、5種APIエンドポイント) - GET /api/weather/records/ 日次データ一覧 - GET /api/weather/summary/ 月別・年間集計 - GET /api/weather/gdd/ 有効積算温度(GDD)計算 - GET /api/weather/similarity/ 類似年分析(開花・収穫予測の基礎) - POST /api/weather/sync/ Windmill向け日次更新(APIキー認証) - management command: fetch_weather(初回一括・差分取得) - Crop.base_temp フィールド追加(GDD基準温度、default=0.0℃) - docker-compose.yml: MAIL_API_KEY 環境変数を追加(ローカルテスト修正) - requirements.txt: requests>=2.31 追加 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 39 +- .../plans/migrations/0004_crop_base_temp.py | 18 + backend/apps/plans/models.py | 1 + backend/apps/weather/__init__.py | 0 backend/apps/weather/admin.py | 12 + backend/apps/weather/apps.py | 7 + backend/apps/weather/management/__init__.py | 0 .../weather/management/commands/__init__.py | 0 .../management/commands/fetch_weather.py | 163 ++++++++ .../apps/weather/migrations/0001_initial.py | 33 ++ backend/apps/weather/migrations/__init__.py | 0 backend/apps/weather/models.py | 20 + backend/apps/weather/serializers.py | 16 + backend/apps/weather/urls.py | 13 + backend/apps/weather/views.py | 349 ++++++++++++++++++ backend/keinasystem/settings.py | 1 + backend/keinasystem/urls.py | 1 + backend/requirements.txt | 1 + docker-compose.yml | 1 + 19 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 backend/apps/plans/migrations/0004_crop_base_temp.py create mode 100644 backend/apps/weather/__init__.py create mode 100644 backend/apps/weather/admin.py create mode 100644 backend/apps/weather/apps.py create mode 100644 backend/apps/weather/management/__init__.py create mode 100644 backend/apps/weather/management/commands/__init__.py create mode 100644 backend/apps/weather/management/commands/fetch_weather.py create mode 100644 backend/apps/weather/migrations/0001_initial.py create mode 100644 backend/apps/weather/migrations/__init__.py create mode 100644 backend/apps/weather/models.py create mode 100644 backend/apps/weather/serializers.py create mode 100644 backend/apps/weather/urls.py create mode 100644 backend/apps/weather/views.py diff --git a/CLAUDE.md b/CLAUDE.md index a0f2668..f93a84a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Keina System - Claude 向けガイド -> **最終更新**: 2026-02-25 -> **現在のフェーズ**: Phase 1 (MVP) - 基本機能実装完了、試験中 +> **最終更新**: 2026-02-28 +> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加 ## 📌 このファイルの目的 @@ -73,8 +73,13 @@ keinasystem_t02/ │ │ ├── views.py # インポート機能、CRUD API │ │ └── urls.py │ ├── plans/ # 作付け計画アプリ -│ │ ├── models.py # Plan, Crop, Variety +│ │ ├── models.py # Plan, Crop(+base_temp), Variety │ │ └── views.py # 作付け計画API、集計API +│ ├── weather/ # 気象データアプリ +│ │ ├── models.py # WeatherRecord (1日1行) +│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity +│ │ ├── urls.py +│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得 │ └── reports/ # 申請書生成アプリ │ ├── views.py # PDF生成API │ └── templates/ # PDF用HTMLテンプレート @@ -126,7 +131,8 @@ Plan (作付け計画) └── unique_together = ['field', 'year'] Crop (作物マスタ) -└── 米、トウモロコシ、エンドウ、野菜、その他 +├── name(米、トウモロコシ、エンドウ、野菜、その他) +└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加 Variety (品種マスタ) ├── crop (FK to Crop) @@ -151,6 +157,16 @@ MailEmail (受信メール記録) MailNotificationToken (フィードバックURL用トークン) ├── email (OneToOne FK to MailEmail) └── token (UUID, unique) + +WeatherRecord (日次気象記録) +├── date (DateField, unique) +├── temp_mean, temp_max, temp_min (気温℃) +├── sunshine_h (日照時間h) +├── precip_mm (降水量mm) +├── wind_max (最大風速m/s) +└── pressure_min (最低気圧hPa) +※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API +※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入) ``` ### 重要な設計判断 @@ -254,6 +270,20 @@ MailNotificationToken (フィードバックURL用トークン) - Backend: `POST /api/auth/change-password/`(JWT認証、`ChangePasswordView` in `keinasystem/urls.py`) - Frontend: `/settings/password` ページ - Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣) +9. **気象データ基盤**(Windmill連携): + - Django `apps/weather` アプリ(WeatherRecord: 1日1行、2016-01-01〜) + - データソース: Open-Meteo archive API(窪川 lat=33.213, lon=133.133) + - Windmill向けAPI(APIキー認証): `POST /api/weather/sync/`(upsert、単一/リスト両対応) + - フロントエンド向けAPI(JWT認証): + - `GET /api/weather/records/?year=&start=&end=` 日次レコード一覧 + - `GET /api/weather/summary/?year=` 月別・年間サマリー(猛暑日・冬日数含む) + - `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度(GDD) + - `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較) + - 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]` + - Windmill フロー: `u/admin/weather_sync.flow`(ローカル作成済み、本番デプロイ要) + - `Crop.base_temp`(GDD計算の基準温度、default=0.0℃)をCropモデルに追加 + - **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full` + - **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測) ### 🚧 既知の課題・技術的負債 @@ -375,6 +405,7 @@ docker-compose exec backend python manage.py migrate ## 📝 更新履歴 +- 2026-02-28: 気象データ基盤を実装。`apps/weather` Django app(WeatherRecord, GDD API, 類似年分析API)、Windmill フロー `u/admin/weather_sync.flow`、管理コマンド `fetch_weather`。`Crop.base_temp` 追加(GDD基準温度)。初回データ投入は `fetch_weather --full` - 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加 - 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md` - 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加 diff --git a/backend/apps/plans/migrations/0004_crop_base_temp.py b/backend/apps/plans/migrations/0004_crop_base_temp.py new file mode 100644 index 0000000..d4bd8cc --- /dev/null +++ b/backend/apps/plans/migrations/0004_crop_base_temp.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0 on 2026-02-28 04:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plans', '0003_variety_on_delete_set_null'), + ] + + operations = [ + migrations.AddField( + model_name='crop', + name='base_temp', + field=models.FloatField(default=0.0, verbose_name='有効積算温度 基準温度(℃)'), + ), + ] diff --git a/backend/apps/plans/models.py b/backend/apps/plans/models.py index 2d58cc8..5d797f8 100644 --- a/backend/apps/plans/models.py +++ b/backend/apps/plans/models.py @@ -4,6 +4,7 @@ from apps.fields.models import Field class Crop(models.Model): name = models.CharField(max_length=100, unique=True, verbose_name="作物名") + base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)") class Meta: verbose_name = "作物マスタ" diff --git a/backend/apps/weather/__init__.py b/backend/apps/weather/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/weather/admin.py b/backend/apps/weather/admin.py new file mode 100644 index 0000000..852df98 --- /dev/null +++ b/backend/apps/weather/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import WeatherRecord + + +@admin.register(WeatherRecord) +class WeatherRecordAdmin(admin.ModelAdmin): + list_display = ['date', 'temp_mean', 'temp_max', 'temp_min', + 'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min'] + list_filter = ['date'] + search_fields = ['date'] + ordering = ['-date'] + date_hierarchy = 'date' diff --git a/backend/apps/weather/apps.py b/backend/apps/weather/apps.py new file mode 100644 index 0000000..5a4c9e0 --- /dev/null +++ b/backend/apps/weather/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WeatherConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.weather' + verbose_name = '気象データ' diff --git a/backend/apps/weather/management/__init__.py b/backend/apps/weather/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/weather/management/commands/__init__.py b/backend/apps/weather/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/weather/management/commands/fetch_weather.py b/backend/apps/weather/management/commands/fetch_weather.py new file mode 100644 index 0000000..849345e --- /dev/null +++ b/backend/apps/weather/management/commands/fetch_weather.py @@ -0,0 +1,163 @@ +""" +気象データを Open-Meteo API から取得して DB に保存する。 + +使い方: + # 差分取得(最終レコードの翌日〜昨日) + python manage.py fetch_weather + + # 全件取得(初回インポート) + python manage.py fetch_weather --full + + # 期間指定 + python manage.py fetch_weather --start-date 2024-01-01 --end-date 2024-12-31 +""" + +import datetime +import requests + +from django.core.management.base import BaseCommand, CommandError +from apps.weather.models import WeatherRecord + + +LATITUDE = 33.213 +LONGITUDE = 133.133 +TIMEZONE = 'Asia/Tokyo' +FULL_START = '2016-01-01' + +OPEN_METEO_URL = 'https://archive-api.open-meteo.com/v1/archive' +DAILY_VARS = [ + 'temperature_2m_mean', + 'temperature_2m_max', + 'temperature_2m_min', + 'sunshine_duration', + 'precipitation_sum', + 'wind_speed_10m_max', + 'surface_pressure_min', +] + + +def fetch_from_api(start_date: str, end_date: str) -> list[dict]: + """Open-Meteo から daily データを取得し、WeatherRecord 形式のリストで返す。""" + params = { + 'latitude': LATITUDE, + 'longitude': LONGITUDE, + 'start_date': start_date, + 'end_date': end_date, + 'daily': DAILY_VARS, + 'timezone': TIMEZONE, + } + resp = requests.get(OPEN_METEO_URL, params=params, timeout=30) + if resp.status_code != 200: + raise CommandError(f'Open-Meteo API エラー: {resp.status_code} {resp.text[:200]}') + + data = resp.json().get('daily', {}) + dates = data.get('time', []) + + if not dates: + return [] + + sunshine_raw = data.get('sunshine_duration', []) + results = [] + for i, d in enumerate(dates): + # 日照: 秒 → 時間 + sun_sec = sunshine_raw[i] + sunshine_h = round(sun_sec / 3600, 2) if sun_sec is not None else None + + results.append({ + 'date': d, + 'temp_mean': data['temperature_2m_mean'][i], + 'temp_max': data['temperature_2m_max'][i], + 'temp_min': data['temperature_2m_min'][i], + 'sunshine_h': sunshine_h, + 'precip_mm': data['precipitation_sum'][i], + 'wind_max': data['wind_speed_10m_max'][i], + 'pressure_min': data['surface_pressure_min'][i], + }) + return results + + +def upsert_records(records: list[dict], stdout=None) -> int: + """WeatherRecord に upsert し、保存件数を返す。""" + saved = 0 + for item in records: + record, created = WeatherRecord.objects.get_or_create(date=item['date']) + for field in ['temp_mean', 'temp_max', 'temp_min', + 'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']: + val = item.get(field) + if val is not None: + setattr(record, field, val) + record.save() + saved += 1 + if stdout and created: + stdout.write(f' new: {item["date"]}') + return saved + + +class Command(BaseCommand): + help = '気象データを Open-Meteo から取得して DB に保存する' + + def add_arguments(self, parser): + parser.add_argument( + '--full', + action='store_true', + help=f'2016-01-01 から昨日まで全件取得(初回インポート用)', + ) + parser.add_argument( + '--start-date', + type=str, + help='取得開始日 (YYYY-MM-DD)。省略時は最終レコードの翌日', + ) + parser.add_argument( + '--end-date', + type=str, + help='取得終了日 (YYYY-MM-DD)。省略時は昨日', + ) + + def handle(self, *args, **options): + yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat() + + # 終了日 + end_date = options.get('end_date') or yesterday + + # 開始日の決定 + if options['full']: + start_date = FULL_START + elif options.get('start_date'): + start_date = options['start_date'] + else: + # 最終レコードの翌日を自動算出 + last = WeatherRecord.objects.order_by('-date').first() + if last: + start_date = (last.date + datetime.timedelta(days=1)).isoformat() + else: + start_date = FULL_START + self.stdout.write( + self.style.WARNING('DB にデータがないため 2016-01-01 から取得します。') + ) + + if start_date > end_date: + self.stdout.write(self.style.SUCCESS('すでに最新の状態です。取得をスキップします。')) + return + + self.stdout.write(f'取得期間: {start_date} 〜 {end_date}') + + # Open-Meteo は 1回のリクエストで最大1年分程度が安定。 + # 長期間の場合は年単位で分割して取得する。 + start = datetime.date.fromisoformat(start_date) + end = datetime.date.fromisoformat(end_date) + total_saved = 0 + + current = start + while current <= end: + chunk_end = min( + datetime.date(current.year, 12, 31), + end + ) + self.stdout.write(f' → {current} 〜 {chunk_end} を取得中...') + records = fetch_from_api(current.isoformat(), chunk_end.isoformat()) + saved = upsert_records(records, stdout=None) + total_saved += saved + self.stdout.write(f' {saved} 件保存') + current = datetime.date(chunk_end.year + 1, 1, 1) + + self.stdout.write(self.style.SUCCESS(f'完了: 合計 {total_saved} 件を保存しました。')) diff --git a/backend/apps/weather/migrations/0001_initial.py b/backend/apps/weather/migrations/0001_initial.py new file mode 100644 index 0000000..3cfb4c3 --- /dev/null +++ b/backend/apps/weather/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0 on 2026-02-28 04:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='WeatherRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(unique=True, verbose_name='日付')), + ('temp_mean', models.FloatField(blank=True, null=True, verbose_name='平均気温(℃)')), + ('temp_max', models.FloatField(blank=True, null=True, verbose_name='最高気温(℃)')), + ('temp_min', models.FloatField(blank=True, null=True, verbose_name='最低気温(℃)')), + ('sunshine_h', models.FloatField(blank=True, null=True, verbose_name='日照時間(h)')), + ('precip_mm', models.FloatField(blank=True, null=True, verbose_name='降水量(mm)')), + ('wind_max', models.FloatField(blank=True, null=True, verbose_name='最大風速(m/s)')), + ('pressure_min', models.FloatField(blank=True, null=True, verbose_name='最低気圧(hPa)')), + ], + options={ + 'verbose_name': '気象記録', + 'verbose_name_plural': '気象記録', + 'ordering': ['date'], + }, + ), + ] diff --git a/backend/apps/weather/migrations/__init__.py b/backend/apps/weather/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/weather/models.py b/backend/apps/weather/models.py new file mode 100644 index 0000000..c4b8a6a --- /dev/null +++ b/backend/apps/weather/models.py @@ -0,0 +1,20 @@ +from django.db import models + + +class WeatherRecord(models.Model): + date = models.DateField(unique=True, verbose_name="日付") + temp_mean = models.FloatField(null=True, blank=True, verbose_name="平均気温(℃)") + temp_max = models.FloatField(null=True, blank=True, verbose_name="最高気温(℃)") + temp_min = models.FloatField(null=True, blank=True, verbose_name="最低気温(℃)") + sunshine_h = models.FloatField(null=True, blank=True, verbose_name="日照時間(h)") + precip_mm = models.FloatField(null=True, blank=True, verbose_name="降水量(mm)") + wind_max = models.FloatField(null=True, blank=True, verbose_name="最大風速(m/s)") + pressure_min = models.FloatField(null=True, blank=True, verbose_name="最低気圧(hPa)") + + class Meta: + verbose_name = "気象記録" + verbose_name_plural = "気象記録" + ordering = ['date'] + + def __str__(self): + return str(self.date) diff --git a/backend/apps/weather/serializers.py b/backend/apps/weather/serializers.py new file mode 100644 index 0000000..b595d13 --- /dev/null +++ b/backend/apps/weather/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from .models import WeatherRecord + + +class WeatherRecordSerializer(serializers.ModelSerializer): + class Meta: + model = WeatherRecord + fields = '__all__' + + +class WeatherSyncSerializer(serializers.ModelSerializer): + """Windmill からの POST 用(id不要)""" + class Meta: + model = WeatherRecord + fields = ['date', 'temp_mean', 'temp_max', 'temp_min', + 'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min'] diff --git a/backend/apps/weather/urls.py b/backend/apps/weather/urls.py new file mode 100644 index 0000000..54cc87f --- /dev/null +++ b/backend/apps/weather/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # Windmill 向け(APIキー認証) + path('sync/', views.WeatherSyncView.as_view(), name='weather-sync'), + + # フロントエンド向け(JWT認証) + path('records/', views.WeatherRecordListView.as_view(), name='weather-records'), + path('summary/', views.WeatherSummaryView.as_view(), name='weather-summary'), + path('gdd/', views.WeatherGDDView.as_view(), name='weather-gdd'), + path('similarity/', views.WeatherSimilarityView.as_view(), name='weather-similarity'), +] diff --git a/backend/apps/weather/views.py b/backend/apps/weather/views.py new file mode 100644 index 0000000..380f5aa --- /dev/null +++ b/backend/apps/weather/views.py @@ -0,0 +1,349 @@ +import secrets +import math +import datetime +from statistics import mean, stdev + +from django.conf import settings +from django.db.models import Avg, Sum, Max, Min, Count, Q +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import BasePermission, IsAuthenticated + +from .models import WeatherRecord +from .serializers import WeatherRecordSerializer, WeatherSyncSerializer + + +# ────────────────────────────────────────────── +# 認証クラス +# ────────────────────────────────────────────── + +class WeatherAPIKeyPermission(BasePermission): + """X-API-Key ヘッダーで認証(Windmill向け)""" + + def has_permission(self, request, view): + key = request.headers.get('X-API-Key', '') + expected = getattr(settings, 'MAIL_API_KEY', '') # 既存キーを流用 + if not key or not expected: + return False + return secrets.compare_digest(key, expected) + + +# ────────────────────────────────────────────── +# Windmill 向け: 日次データ同期 (POST) +# ────────────────────────────────────────────── + +class WeatherSyncView(APIView): + """ + POST /api/weather/sync/ + Windmill から気象データを受け取り upsert する。 + 単一オブジェクト、またはリスト形式どちらでも受け付ける。 + """ + permission_classes = [WeatherAPIKeyPermission] + + def post(self, request): + data = request.data + if isinstance(data, dict): + data = [data] + + saved = 0 + errors = [] + for item in data: + date_val = item.get('date') + if not date_val: + errors.append({'item': item, 'error': 'date is required'}) + continue + try: + record, _ = WeatherRecord.objects.get_or_create(date=date_val) + for field in ['temp_mean', 'temp_max', 'temp_min', + 'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']: + val = item.get(field) + if val is not None: + setattr(record, field, float(val)) + record.save() + saved += 1 + except Exception as e: + errors.append({'item': item, 'error': str(e)}) + + result = {'saved': saved} + if errors: + result['errors'] = errors + code = status.HTTP_201_CREATED if saved > 0 else status.HTTP_400_BAD_REQUEST + return Response(result, status=code) + + +# ────────────────────────────────────────────── +# フロントエンド向け: 日次レコード一覧 (GET) +# ────────────────────────────────────────────── + +class WeatherRecordListView(APIView): + """ + GET /api/weather/records/?year=2025 + GET /api/weather/records/?start=2025-01-01&end=2025-12-31 + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + qs = WeatherRecord.objects.all() + + year = request.query_params.get('year') + start = request.query_params.get('start') + end = request.query_params.get('end') + + if year: + qs = qs.filter(date__year=int(year)) + if start: + qs = qs.filter(date__gte=start) + if end: + qs = qs.filter(date__lte=end) + + serializer = WeatherRecordSerializer(qs, many=True) + return Response(serializer.data) + + +# ────────────────────────────────────────────── +# フロントエンド向け: 月別サマリー (GET) +# ────────────────────────────────────────────── + +class WeatherSummaryView(APIView): + """ + GET /api/weather/summary/?year=2025 + 月別集計(平均気温、降水量合計、日照合計、猛暑日・冬日数など) + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + year = request.query_params.get('year') + if not year: + return Response({'error': 'year parameter is required'}, status=status.HTTP_400_BAD_REQUEST) + + qs = WeatherRecord.objects.filter(date__year=int(year)) + + monthly = [] + for month in range(1, 13): + mqs = qs.filter(date__month=month) + agg = mqs.aggregate( + temp_mean_avg=Avg('temp_mean'), + temp_max_avg=Avg('temp_max'), + temp_min_avg=Avg('temp_min'), + precip_total=Sum('precip_mm'), + sunshine_total=Sum('sunshine_h'), + wind_max=Max('wind_max'), + ) + hot_days = mqs.filter(temp_max__gte=35).count() + cold_days = mqs.filter(temp_min__lt=0).count() + rainy_days = mqs.filter(precip_mm__gte=1.0).count() + + monthly.append({ + 'month': month, + **{k: (round(v, 1) if v is not None else None) for k, v in agg.items()}, + 'hot_days': hot_days, + 'cold_days': cold_days, + 'rainy_days': rainy_days, + }) + + # 年間サマリー + year_agg = qs.aggregate( + temp_mean_avg=Avg('temp_mean'), + precip_total=Sum('precip_mm'), + sunshine_total=Sum('sunshine_h'), + ) + annual = {k: (round(v, 1) if v is not None else None) for k, v in year_agg.items()} + annual['hot_days'] = qs.filter(temp_max__gte=35).count() + annual['cold_days'] = qs.filter(temp_min__lt=0).count() + + return Response({'year': int(year), 'monthly': monthly, 'annual': annual}) + + +# ────────────────────────────────────────────── +# 積算温度 (GDD) (GET) +# ────────────────────────────────────────────── + +class WeatherGDDView(APIView): + """ + GET /api/weather/gdd/?start_date=2025-05-01&base_temp=10&end_date=2025-09-30 + + 起算日から指定日(省略時は昨日)までの有効積算温度を返す。 + 日積算温度 = max(0, 平均気温 - 基準温度) + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + start_date = request.query_params.get('start_date') + if not start_date: + return Response({'error': 'start_date is required'}, status=status.HTTP_400_BAD_REQUEST) + + base_temp = float(request.query_params.get('base_temp', 0.0)) + end_date = request.query_params.get('end_date') or ( + datetime.date.today() - datetime.timedelta(days=1) + ).isoformat() + + records = WeatherRecord.objects.filter( + date__gte=start_date, + date__lte=end_date, + temp_mean__isnull=False, + ).values('date', 'temp_mean') + + cumulative = 0.0 + daily_records = [] + for r in records: + daily_gdd = max(0.0, r['temp_mean'] - base_temp) + cumulative += daily_gdd + daily_records.append({ + 'date': r['date'], + 'temp_mean': r['temp_mean'], + 'daily_gdd': round(daily_gdd, 2), + 'cumulative_gdd': round(cumulative, 2), + }) + + return Response({ + 'start_date': start_date, + 'end_date': end_date, + 'base_temp': base_temp, + 'total_gdd': round(cumulative, 2), + 'records': daily_records, + }) + + +# ────────────────────────────────────────────── +# 類似年分析 (GET) +# ────────────────────────────────────────────── + +def _year_feature(records_qs, month_from, month_to, day_to): + """ + 指定した月/日の範囲の特徴量ベクトルを返す。 + month_from=1, month_to=2, day_to=28 なら 1/1〜2/28 のデータを使う。 + 返り値: (mean_temp, total_precip, total_sunshine) または None + """ + # 1/1 から month_to/day_to まで + qs = records_qs.filter( + Q(date__month__lt=month_to) | + Q(date__month=month_to, date__day__lte=day_to) + ) + agg = qs.aggregate( + mean_temp=Avg('temp_mean'), + total_precip=Sum('precip_mm'), + total_sunshine=Sum('sunshine_h'), + ) + if any(v is None for v in agg.values()): + return None + return (agg['mean_temp'], agg['total_precip'], agg['total_sunshine']) + + +class WeatherSimilarityView(APIView): + """ + GET /api/weather/similarity/?year=2026 + + 今年の 1/1〜今日 の気象パターンと過去年を比較し、 + 最も似た上位3年とその年の残期間の実績データを返す。 + 開花・収穫予測の参考情報として使う。 + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + target_year = int(request.query_params.get('year', datetime.date.today().year)) + today = datetime.date.today() + + # 比較基準日(今年の場合は昨日まで、過去年は12/31まで) + if target_year == today.year: + compare_end = today - datetime.timedelta(days=1) + else: + compare_end = datetime.date(target_year, 12, 31) + + limit_month = compare_end.month + limit_day = compare_end.day + + # 対象年の特徴量 + target_qs = WeatherRecord.objects.filter(date__year=target_year) + target_feat = _year_feature(target_qs, 1, limit_month, limit_day) + if target_feat is None: + return Response({'error': f'{target_year} 年のデータが不足しています'}, status=status.HTTP_400_BAD_REQUEST) + + # 全年リスト(対象年を除く) + all_years = ( + WeatherRecord.objects + .exclude(date__year=target_year) + .values_list('date__year', flat=True) + .distinct() + .order_by('date__year') + ) + + year_features = {} + for yr in all_years: + qs = WeatherRecord.objects.filter(date__year=yr) + feat = _year_feature(qs, 1, limit_month, limit_day) + if feat is not None: + year_features[yr] = feat + + if not year_features: + return Response({'error': '比較可能な過去データがありません'}, status=status.HTTP_404_NOT_FOUND) + + # 正規化: 各特徴量の平均・標準偏差 + all_feats = list(year_features.values()) + [target_feat] + n_features = 3 + feat_means = [mean(f[i] for f in all_feats) for i in range(n_features)] + feat_stds = [] + for i in range(n_features): + vals = [f[i] for f in all_feats] + sd = stdev(vals) if len(vals) > 1 else 1.0 + feat_stds.append(sd if sd > 0 else 1.0) + + def normalize(feat): + return tuple((feat[i] - feat_means[i]) / feat_stds[i] for i in range(n_features)) + + target_norm = normalize(target_feat) + + distances = {} + for yr, feat in year_features.items(): + norm = normalize(feat) + dist = math.sqrt(sum((target_norm[i] - norm[i]) ** 2 for i in range(n_features))) + distances[yr] = dist + + top3 = sorted(distances.items(), key=lambda x: x[1])[:3] + + # 類似年それぞれの月別統計を返す + result_years = [] + for yr, dist in top3: + qs = WeatherRecord.objects.filter(date__year=yr) + monthly = [] + for month in range(1, 13): + mqs = qs.filter(date__month=month) + agg = mqs.aggregate( + temp_mean_avg=Avg('temp_mean'), + temp_max_avg=Avg('temp_max'), + precip_total=Sum('precip_mm'), + sunshine_total=Sum('sunshine_h'), + ) + hot_days = mqs.filter(temp_max__gte=35).count() + storm_days = mqs.filter(wind_max__gte=10, precip_mm__gte=30).count() + monthly.append({ + 'month': month, + **{k: (round(v, 1) if v is not None else None) for k, v in agg.items()}, + 'hot_days': hot_days, + 'storm_days': storm_days, + }) + + feat = year_features[yr] + result_years.append({ + 'year': yr, + 'distance': round(dist, 3), + 'features': { + 'mean_temp': round(feat[0], 2), + 'total_precip': round(feat[1], 1), + 'total_sunshine': round(feat[2], 1), + }, + 'monthly': monthly, + }) + + target_feat_dict = { + 'mean_temp': round(target_feat[0], 2), + 'total_precip': round(target_feat[1], 1), + 'total_sunshine': round(target_feat[2], 1), + } + + return Response({ + 'target_year': target_year, + 'comparison_period': f'1/1〜{limit_month}/{limit_day}', + 'target_features': target_feat_dict, + 'similar_years': result_years, + }) diff --git a/backend/keinasystem/settings.py b/backend/keinasystem/settings.py index 5d4b45d..e969a68 100644 --- a/backend/keinasystem/settings.py +++ b/backend/keinasystem/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ 'apps.plans', 'apps.reports', 'apps.mail', + 'apps.weather', ] MIDDLEWARE = [ diff --git a/backend/keinasystem/urls.py b/backend/keinasystem/urls.py index 5f28999..4d7a81b 100644 --- a/backend/keinasystem/urls.py +++ b/backend/keinasystem/urls.py @@ -56,4 +56,5 @@ urlpatterns = [ path('api/auth/jwt/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'), path('api/mail/', include('apps.mail.urls')), + path('api/weather/', include('apps.weather.urls')), ] diff --git a/backend/requirements.txt b/backend/requirements.txt index e2d51d7..0439825 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ odfpy==1.4 WeasyPrint>=60.1 gunicorn>=21.0,<22.0 setuptools<75 +requests>=2.31 diff --git a/docker-compose.yml b/docker-compose.yml index 7cbcc80..7f92bd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: DB_PORT: 5432 SECRET_KEY: ${SECRET_KEY} DEBUG: "True" + MAIL_API_KEY: ${MAIL_API_KEY} ports: - "8000:8000" volumes: