- 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>
350 lines
14 KiB
Python
350 lines
14 KiB
Python
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,
|
||
})
|