気象データ基盤を実装
- 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 <noreply@anthropic.com>
This commit is contained in:
39
CLAUDE.md
39
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` を追加。セッション推奨フローにマスタードキュメント参照を追加
|
||||
|
||||
18
backend/apps/plans/migrations/0004_crop_base_temp.py
Normal file
18
backend/apps/plans/migrations/0004_crop_base_temp.py
Normal file
@@ -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='有効積算温度 基準温度(℃)'),
|
||||
),
|
||||
]
|
||||
@@ -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 = "作物マスタ"
|
||||
|
||||
0
backend/apps/weather/__init__.py
Normal file
0
backend/apps/weather/__init__.py
Normal file
12
backend/apps/weather/admin.py
Normal file
12
backend/apps/weather/admin.py
Normal file
@@ -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'
|
||||
7
backend/apps/weather/apps.py
Normal file
7
backend/apps/weather/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WeatherConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.weather'
|
||||
verbose_name = '気象データ'
|
||||
0
backend/apps/weather/management/__init__.py
Normal file
0
backend/apps/weather/management/__init__.py
Normal file
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
@@ -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} 件を保存しました。'))
|
||||
33
backend/apps/weather/migrations/0001_initial.py
Normal file
33
backend/apps/weather/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/weather/migrations/__init__.py
Normal file
0
backend/apps/weather/migrations/__init__.py
Normal file
20
backend/apps/weather/models.py
Normal file
20
backend/apps/weather/models.py
Normal file
@@ -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)
|
||||
16
backend/apps/weather/serializers.py
Normal file
16
backend/apps/weather/serializers.py
Normal file
@@ -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']
|
||||
13
backend/apps/weather/urls.py
Normal file
13
backend/apps/weather/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
349
backend/apps/weather/views.py
Normal file
349
backend/apps/weather/views.py
Normal file
@@ -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,
|
||||
})
|
||||
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'apps.plans',
|
||||
'apps.reports',
|
||||
'apps.mail',
|
||||
'apps.weather',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
|
||||
@@ -9,3 +9,4 @@ odfpy==1.4
|
||||
WeasyPrint>=60.1
|
||||
gunicorn>=21.0,<22.0
|
||||
setuptools<75
|
||||
requests>=2.31
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
DB_PORT: 5432
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEBUG: "True"
|
||||
MAIL_API_KEY: ${MAIL_API_KEY}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user