Files
ApiCore/ApiCore-Backend/src/weather/weather.service.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:00:46 +02:00

278 lines
9.1 KiB
TypeScript

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<ApiaryWeather> {
const { latitude, longitude } = await this.getCoordinates(apiaryId);
return { daily: await this.fetchDailyWeather(latitude, longitude) };
}
async getTrachtprognose(apiaryId: string): Promise<Trachtprognose> {
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<DailyWeather[]> {
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<number | null> {
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 };
}
}