- 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]>
278 lines
9.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|