feat: Wetterintegration + Trachtprognose pro Bienenstand

Neues WeatherModule: liest Bienenstand-Koordinaten und ruft die freie
Open-Meteo-API ab (kein API-Key nötig), liefert 14 Tage Wetterhistorie
+ 7-Tage-Vorhersage sowie eine heuristische Trachtprognose (Saison +
Wetter der letzten 7 Tage + historischer Ernteschnitt für denselben
Kalendermonat am selben Standort - explizit kein ML-Modell, siehe
TASKS.md Abschnitt 26).

- GET /api/v1/apiaries/:id/weather, GET .../trachtprognose, beide
  ueber die bestehende apiaries:read-Berechtigung abgesichert (kein
  neuer Permission-Key nötig, gleiches Muster wie die bestehenden
  Colony-Sub-Controller).
- Bienenstände ohne hinterlegte Koordinaten liefern 400 statt eines
  stillen Fallbacks.
- test/weather.e2e-spec.ts ruft bewusst die echte Open-Meteo-API auf
  (kostenlos, kein Auth, stabil genug für einen einzelnen Testlauf) -
  validiert damit den echten Response-Vertrag statt nur ein Mock.
- TASKS.md: beide Punkte abgehakt, stale Duplikat-Zeilen im
  Später-Abschnitt bereinigt.

Verifiziert: tsc, lint, build, unit (228/228), volle e2e-Suite
163/163 gruen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 09:43:08 +02:00
1 parent 335c572dfc
commit ce9cea1b94
7 files changed
+568 -7

No files matched your search

+2
View File
@@ -37,6 +37,7 @@ import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WeatherModule } from './weather/weather.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -81,6 +82,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
WeatherModule,
],
controllers: [],
providers: [
@@ -0,0 +1,23 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { WeatherService } from './weather.service';
@ApiTags('weather')
@ApiBearerAuth()
@Controller('apiaries')
export class WeatherController {
constructor(private readonly weatherService: WeatherService) {}
@RequirePermission('apiaries:read')
@Get(':id/weather')
getWeather(@Param('id') id: string) {
return this.weatherService.getWeather(id);
}
@RequirePermission('apiaries:read')
@Get(':id/trachtprognose')
getTrachtprognose(@Param('id') id: string) {
return this.weatherService.getTrachtprognose(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { WeatherController } from './weather.controller';
import { WeatherService } from './weather.service';
@Module({
controllers: [WeatherController],
providers: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,202 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { WeatherService } from './weather.service';
import { PrismaService } from '../prisma/prisma.service';
function openMeteoResponse(
days: {
date: string;
tempMax: number | null;
tempMin: number | null;
precipitation: number | null;
}[],
): unknown {
return {
daily: {
time: days.map((day) => day.date),
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
precipitation_sum: days.map((day) => day.precipitation),
},
};
}
describe('WeatherService', () => {
let weatherService: WeatherService;
let prisma: {
apiary: { findUnique: jest.Mock };
harvest: { findMany: jest.Mock };
};
let fetchMock: jest.SpiedFunction<typeof fetch>;
beforeEach(() => {
prisma = {
apiary: { findUnique: jest.fn() },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
};
weatherService = new WeatherService(prisma as unknown as PrismaService);
fetchMock = jest.spyOn(global, 'fetch');
});
afterEach(() => {
fetchMock.mockRestore();
});
describe('getWeather', () => {
it('throws NotFoundException when the apiary does not exist', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(weatherService.getWeather('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('throws BadRequestException when the apiary has no coordinates', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: null,
longitude: null,
});
await expect(
weatherService.getWeather('apiary-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('returns daily weather mapped from the Open-Meteo response', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily).toEqual([
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
]);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.toString()).toContain('latitude=52.5');
expect(url.toString()).toContain('longitude=13.4');
});
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(new Response('', { status: 500 }));
await expect(
weatherService.getWeather('apiary-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('getTrachtprognose', () => {
const realDate = Date;
function mockToday(isoDate: string) {
const fixed = new realDate(isoDate);
jest
.spyOn(global, 'Date')
.mockImplementation((...args: unknown[]) =>
args.length ? new realDate(...(args as [string])) : fixed,
);
}
afterEach(() => {
jest.restoreAllMocks();
});
it('returns KEINE_TRACHT outside the bloom season', async () => {
mockToday('2026-01-15');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('KEINE_TRACHT');
expect(result.season).toBe('Winterruhe');
});
it('returns GUT for warm, dry weather during bloom season', async () => {
mockToday('2026-06-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
const days = Array.from({ length: 21 }, (_, index) => ({
date: `2026-06-${String(index + 1).padStart(2, '0')}`,
tempMax: 24,
tempMin: 14,
precipitation: 0,
}));
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('GUT');
expect(result.season).toBe('Sommertracht');
});
it('returns SCHWACH for cold, wet weather during bloom season', async () => {
mockToday('2026-04-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
const days = Array.from({ length: 21 }, (_, index) => ({
date: `2026-04-${String(index + 1).padStart(2, '0')}`,
tempMax: 8,
tempMin: 2,
precipitation: 10,
}));
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('SCHWACH');
expect(result.season).toBe('Frühtracht');
});
it('includes the historical average yield when past harvests exist for the same month', async () => {
mockToday('2026-06-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
prisma.harvest.findMany.mockResolvedValue([
{ date: new realDate('2025-06-15'), netWeight: 20 },
{ date: new realDate('2024-06-20'), netWeight: 30 },
{ date: new realDate('2025-09-01'), netWeight: 100 },
]);
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.historicalAverageYieldKg).toBe(25);
});
});
});
@@ -0,0 +1,215 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const PAST_DAYS = 14;
const FORECAST_DAYS = 7;
const RECENT_WINDOW_DAYS = 7;
const GOOD_TEMP_THRESHOLD_C = 18;
const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
const POOR_TEMP_THRESHOLD_C = 12;
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
export interface DailyWeather {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
}
export interface ApiaryWeather {
daily: DailyWeather[];
}
export type TrachtprognoseRating =
'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT';
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)[];
}
interface OpenMeteoResponse {
daily?: OpenMeteoDaily;
}
@Injectable()
export class WeatherService {
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(OPEN_METEO_FORECAST_URL);
url.searchParams.set('latitude', String(latitude));
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum',
);
url.searchParams.set('past_days', String(PAST_DAYS));
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
url.searchParams.set('timezone', 'auto');
const response = await fetch(url);
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 } =
body.daily;
return time.map((date, index) => ({
date,
tempMaxC: temperature_2m_max[index] ?? null,
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm: precipitation_sum[index] ?? null,
}));
}
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 = 'MAESSIG';
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 {
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 };
}
}