import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { WeatherHistoryConfig, weatherHistoryConfig, roundPoint, } from './history/weather-history.config'; const PAST_DAYS = 14; const FORECAST_DAYS = 7; const RECENT_WINDOW_DAYS = 7; const FETCH_TIMEOUT_MS = 8_000; const GOOD_TEMP_THRESHOLD_C = 18; const GOOD_PRECIPITATION_THRESHOLD_MM = 15; const POOR_TEMP_THRESHOLD_C = 12; const POOR_PRECIPITATION_THRESHOLD_MM = 30; // Rule-of-thumb minimums for opening a hive without unduly stressing the // colony or making the inspection unpleasant/risky for the beekeeper: // below ~14-15°C bees cluster and brood chills when the hive is opened, // noticeable rain and strong wind both make bees more defensive and the // frames harder to handle safely. const INSPECTION_MIN_TEMP_C = 15; const INSPECTION_MAX_PRECIPITATION_MM = 1; const INSPECTION_MAX_WIND_KMH = 25; export interface DailyWeather { date: string; tempMaxC: number | null; tempMinC: number | null; precipitationMm: number | null; windSpeedMaxKmh: number | null; // WMO weather interpretation code (Open-Meteo `weather_code`); null when // the provider omits it. weatherCode: number | null; // null when a required value is missing (rather than a misleading guess). inspectionSuitable: boolean | null; } export interface ApiaryWeather { daily: DailyWeather[]; } export type TrachtprognoseRating = 'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT' | 'UNBEKANNT'; export interface Trachtprognose { rating: TrachtprognoseRating; season: string; reasoning: string[]; historicalAverageYieldKg: number | null; } interface OpenMeteoDaily { time: string[]; temperature_2m_max: (number | null)[]; temperature_2m_min: (number | null)[]; precipitation_sum: (number | null)[]; wind_speed_10m_max: (number | null)[]; weather_code?: (number | null)[]; } interface OpenMeteoResponse { daily?: OpenMeteoDaily; } @Injectable() export class WeatherService { // Same variables as the history source (one place for URL and key); a bad // setting stops the process at startup. private readonly config: WeatherHistoryConfig = weatherHistoryConfig(); constructor(private readonly prisma: PrismaService) {} async getWeather(apiaryId: string): Promise { const { latitude, longitude } = await this.getCoordinates(apiaryId); return { daily: await this.fetchDailyWeather(latitude, longitude) }; } async getTrachtprognose(apiaryId: string): Promise { const { latitude, longitude } = await this.getCoordinates(apiaryId); const daily = await this.fetchDailyWeather(latitude, longitude); const today = new Date().toISOString().slice(0, 10); const recentDays = daily .filter((day) => day.date <= today) .slice(-RECENT_WINDOW_DAYS); const historicalAverageYieldKg = await this.getHistoricalAverageYield(apiaryId); return this.computeTrachtprognose(recentDays, historicalAverageYieldKg); } private async getCoordinates( apiaryId: string, ): Promise<{ latitude: number; longitude: number }> { const apiary = await this.prisma.apiary.findUnique({ where: { id: apiaryId }, select: { latitude: true, longitude: true }, }); if (!apiary) { throw new NotFoundException('Apiary not found'); } if (apiary.latitude === null || apiary.longitude === null) { throw new BadRequestException( 'Apiary has no coordinates; weather data requires latitude and longitude', ); } return { latitude: apiary.latitude, longitude: apiary.longitude }; } private async fetchDailyWeather( latitude: number, longitude: number, ): Promise { const url = new URL(this.config.forecastUrl); // 0.01 degrees: no exact stand location leaves the app. const point = roundPoint(latitude, longitude); url.searchParams.set('latitude', String(point.lat)); url.searchParams.set('longitude', String(point.lon)); url.searchParams.set( 'daily', 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code', ); url.searchParams.set('wind_speed_unit', 'kmh'); url.searchParams.set('past_days', String(PAST_DAYS)); url.searchParams.set('forecast_days', String(FORECAST_DAYS)); url.searchParams.set('timezone', 'auto'); if (this.config.apiKey) { url.searchParams.set('apikey', this.config.apiKey); } let response: Response; try { response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { 'User-Agent': this.config.userAgent }, }); } catch { throw new BadRequestException('Wetterdaten konnten nicht geladen werden'); } if (!response.ok) { throw new BadRequestException('Wetterdaten konnten nicht geladen werden'); } const body = (await response.json()) as OpenMeteoResponse; if (!body.daily) { return []; } const { time, temperature_2m_max, temperature_2m_min, precipitation_sum, wind_speed_10m_max, weather_code, } = body.daily; return time.map((date, index) => { const tempMaxC = temperature_2m_max[index] ?? null; const precipitationMm = precipitation_sum[index] ?? null; const windSpeedMaxKmh = wind_speed_10m_max[index] ?? null; return { date, tempMaxC, tempMinC: temperature_2m_min[index] ?? null, precipitationMm, windSpeedMaxKmh, weatherCode: weather_code?.[index] ?? null, inspectionSuitable: tempMaxC === null || precipitationMm === null || windSpeedMaxKmh === null ? null : tempMaxC >= INSPECTION_MIN_TEMP_C && precipitationMm <= INSPECTION_MAX_PRECIPITATION_MM && windSpeedMaxKmh <= INSPECTION_MAX_WIND_KMH, }; }); } private getSeason(month: number): { season: string; inBloom: boolean } { if (month >= 3 && month <= 5) { return { season: 'Frühtracht', inBloom: true }; } if (month >= 6 && month <= 7) { return { season: 'Sommertracht', inBloom: true }; } if (month === 8) { return { season: 'Spättracht', inBloom: true }; } return { season: 'Winterruhe', inBloom: false }; } private async getHistoricalAverageYield( apiaryId: string, ): Promise { const currentMonth = new Date().getMonth() + 1; const harvests = await this.prisma.harvest.findMany({ where: { apiaryId }, select: { date: true, netWeight: true }, }); const sameMonthYields = harvests .filter((harvest) => harvest.date.getMonth() + 1 === currentMonth) .map((harvest) => harvest.netWeight) .filter((weight): weight is number => weight !== null); if (sameMonthYields.length === 0) { return null; } const total = sameMonthYields.reduce((sum, weight) => sum + weight, 0); return Math.round((total / sameMonthYields.length) * 10) / 10; } private computeTrachtprognose( recentDays: DailyWeather[], historicalAverageYieldKg: number | null, ): Trachtprognose { const { season, inBloom } = this.getSeason(new Date().getMonth() + 1); const reasoning: string[] = []; if (!inBloom) { reasoning.push(`Aktuell keine Haupttrachtperiode (${season}).`); return { rating: 'KEINE_TRACHT', season, reasoning, historicalAverageYieldKg, }; } const validTemps = recentDays .map((day) => day.tempMaxC) .filter((value): value is number => value !== null); const avgTempMax = validTemps.length ? validTemps.reduce((sum, value) => sum + value, 0) / validTemps.length : null; const totalPrecipitation = recentDays.reduce( (sum, day) => sum + (day.precipitationMm ?? 0), 0, ); let rating: TrachtprognoseRating = 'UNBEKANNT'; if (avgTempMax === null) { reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.'); } else if ( avgTempMax >= GOOD_TEMP_THRESHOLD_C && totalPrecipitation < GOOD_PRECIPITATION_THRESHOLD_MM ) { rating = 'GUT'; reasoning.push( `Warmes, trockenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) begünstigt Bienenflug.`, ); } else if ( avgTempMax < POOR_TEMP_THRESHOLD_C || totalPrecipitation > POOR_PRECIPITATION_THRESHOLD_MM ) { rating = 'SCHWACH'; reasoning.push( `Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`, ); } else { rating = 'MAESSIG'; reasoning.push( `Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`, ); } reasoning.push(`Saison: ${season}.`); if (historicalAverageYieldKg !== null) { reasoning.push( `Historischer Ernteschnitt für diesen Zeitraum an diesem Standort: ${historicalAverageYieldKg} kg.`, ); } return { rating, season, reasoning, historicalAverageYieldKg }; } }