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, })