Files
keinasystem/backend/apps/weather/views.py
Akira 2c515cca6f 気象データ基盤を実装
- 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>
2026-02-28 13:23:09 +09:00

350 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})