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]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
function daysOf(
|
||||
year: number,
|
||||
from: string,
|
||||
count: number,
|
||||
meanTempC: number | null,
|
||||
): DailyMean[] {
|
||||
const start = Date.parse(`${year}-${from}T00:00:00.000Z`);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
day: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
meanTempC,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('grasslandTemperatureSum', () => {
|
||||
it('weights January 0.5, February 0.75 and March 1', () => {
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 1, 10),
|
||||
...daysOf(2026, '02-01', 1, 10),
|
||||
...daysOf(2026, '03-01', 1, 10),
|
||||
];
|
||||
|
||||
const january = grasslandTemperatureSum(days, 2026, '2026-01-01', 1);
|
||||
const february = grasslandTemperatureSum(days, 2026, '2026-02-01', 1);
|
||||
const march = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
expect(january.gts).toBe(5);
|
||||
// The days in between are missing, not zero: only the three count.
|
||||
expect(february.gts).toBe(12.5);
|
||||
expect(march.gts).toBe(22.5);
|
||||
});
|
||||
|
||||
it('negative daily means count 0', () => {
|
||||
const result = grasslandTemperatureSum(
|
||||
[...daysOf(2026, '03-01', 2, -4), ...daysOf(2026, '03-03', 1, 3)],
|
||||
2026,
|
||||
'2026-03-03',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.gts).toBe(3);
|
||||
});
|
||||
|
||||
it('is unusable with more than 10 % missing days', () => {
|
||||
const full = daysOf(2026, '01-01', 90, 5);
|
||||
const gappy = full.map((d, i) => (i < 10 ? { ...d, meanTempC: null } : d));
|
||||
const edge = full.map((d, i) => (i < 9 ? { ...d, meanTempC: null } : d));
|
||||
|
||||
const ok = grasslandTemperatureSum(full, 2026, '2026-03-31', 0.1);
|
||||
const bad = grasslandTemperatureSum(gappy, 2026, '2026-03-31', 0.1);
|
||||
|
||||
expect(ok).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
expect(bad).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 10,
|
||||
usable: false,
|
||||
});
|
||||
expect(grasslandTemperatureSum(edge, 2026, '2026-03-31', 0.1).usable).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('reaches 200 °C on the expected day', () => {
|
||||
// January 31 x 10 x 0.5 = 155; each February day adds 7.5: 200 on Feb 6.
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 31, 10),
|
||||
...daysOf(2026, '02-01', 10, 10),
|
||||
];
|
||||
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-05', 0.1).gts).toBe(
|
||||
192.5,
|
||||
);
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-06', 0.1).gts).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the last day with data and nothing before the year starts', () => {
|
||||
const days = daysOf(2026, '01-01', 10, 4);
|
||||
|
||||
expect(
|
||||
grasslandTemperatureSum(days, 2026, '2026-01-20', 1).throughDay,
|
||||
).toBe('2026-01-10');
|
||||
expect(grasslandTemperatureSum([], 2026, '2025-12-31', 1)).toEqual({
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts February 28th and March 1st with their own weights', () => {
|
||||
const days = [
|
||||
{ day: '2026-02-28', meanTempC: 10 },
|
||||
{ day: '2026-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
// 28 + 31 + 1 = 60 days from January 1st; 10 x 0.75 + 10 x 1.
|
||||
expect(result).toMatchObject({ gts: 17.5, expectedDays: 60 });
|
||||
});
|
||||
|
||||
it('knows February 29th in a leap year', () => {
|
||||
const days = [
|
||||
{ day: '2028-02-28', meanTempC: 10 },
|
||||
{ day: '2028-02-29', meanTempC: 10 },
|
||||
{ day: '2028-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2028, '2028-03-01', 1);
|
||||
|
||||
expect(result).toMatchObject({ gts: 25, expectedDays: 61 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
/** Month weights of the grassland temperature sum (DWD): January 0.5, February 0.75. */
|
||||
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>> = {
|
||||
1: 0.5,
|
||||
2: 0.75,
|
||||
};
|
||||
|
||||
export interface GtsResult {
|
||||
/** Sum of the positive daily means, weighted by month, from January 1st. */
|
||||
gts: number;
|
||||
/** The last day with a mean at or before the requested end; null if none. */
|
||||
throughDay: string | null;
|
||||
/** Days from January 1st to the requested end. */
|
||||
expectedDays: number;
|
||||
/** Of those, the days without a mean. */
|
||||
missingDays: number;
|
||||
/** False when more than `maxGapShare` of the days are missing. */
|
||||
usable: boolean;
|
||||
}
|
||||
|
||||
const monthFactor = (day: string): number =>
|
||||
GTS_MONTH_FACTORS[Number(day.slice(5, 7))] ?? 1;
|
||||
|
||||
/**
|
||||
* The grassland temperature sum (Grünlandtemperatursumme, DWD) of `year` up
|
||||
* to and including `throughDay`: every day's positive mean counts, January
|
||||
* with 0.5, February with 0.75, from March with 1; negative means count 0.
|
||||
* Days without a mean count as missing; with more than `maxGapShare` of them
|
||||
* the sum is not usable.
|
||||
*/
|
||||
export function grasslandTemperatureSum(
|
||||
days: readonly DailyMean[],
|
||||
year: number,
|
||||
throughDay: string,
|
||||
maxGapShare: number,
|
||||
): GtsResult {
|
||||
const first = `${year}-01-01`;
|
||||
const last = throughDay > `${year}-12-31` ? `${year}-12-31` : throughDay;
|
||||
if (last < first) {
|
||||
return {
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
};
|
||||
}
|
||||
const means = new Map<string, number>();
|
||||
for (const { day, meanTempC } of days) {
|
||||
if (meanTempC !== null && Number.isFinite(meanTempC)) {
|
||||
means.set(day, meanTempC);
|
||||
}
|
||||
}
|
||||
let gts = 0;
|
||||
let expectedDays = 0;
|
||||
let missingDays = 0;
|
||||
let lastDataDay: string | null = null;
|
||||
for (let day = first; day <= last; day = addDays(day, 1)) {
|
||||
expectedDays += 1;
|
||||
const mean = means.get(day);
|
||||
if (mean === undefined) {
|
||||
missingDays += 1;
|
||||
continue;
|
||||
}
|
||||
lastDataDay = day;
|
||||
gts += Math.max(0, mean) * monthFactor(day);
|
||||
}
|
||||
return {
|
||||
gts,
|
||||
throughDay: lastDataDay,
|
||||
expectedDays,
|
||||
missingDays,
|
||||
usable: missingDays / expectedDays <= maxGapShare,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { calibrateThresholds } from './phenology-calibration';
|
||||
|
||||
const year = (
|
||||
firstReportDay: Record<string, string>,
|
||||
gtsByDay: Record<string, number>,
|
||||
) => ({
|
||||
firstReportDay,
|
||||
gtsOn: (day: string): number | null => gtsByDay[day] ?? null,
|
||||
});
|
||||
|
||||
describe('calibrateThresholds', () => {
|
||||
it('uses the median GTS on the first report day of up to three previous years', () => {
|
||||
// Most recent year first; the fourth year is ignored.
|
||||
const years = [
|
||||
year({ KIRSCHE: '2025-04-10' }, { '2025-04-10': 300 }),
|
||||
year({ KIRSCHE: '2024-04-20' }, { '2024-04-20': 340 }),
|
||||
year({ KIRSCHE: '2023-04-05' }, { '2023-04-05': 280 }),
|
||||
year({ KIRSCHE: '2022-04-05' }, { '2022-04-05': 9999 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({
|
||||
KIRSCHE: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('averages the two middle values for an even count', () => {
|
||||
const years = [
|
||||
year({ APFEL: '2025-04-25' }, { '2025-04-25': 400 }),
|
||||
year({ APFEL: '2024-04-25' }, { '2024-04-25': 440 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['APFEL'])).toEqual({ APFEL: 420 });
|
||||
});
|
||||
|
||||
it('skips years without a report or without GTS and never invents a threshold', () => {
|
||||
const years = [
|
||||
year({}, {}),
|
||||
year({ KIRSCHE: '2024-04-20' }, {}),
|
||||
year({ HOLUNDER: '2023-05-30' }, { '2023-05-30': 700 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({});
|
||||
expect(calibrateThresholds([], ['KIRSCHE'])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PhenologyPhaseKey } from '../season/season-types';
|
||||
|
||||
/** At most this many previous years take part in the calibration. */
|
||||
export const CALIBRATION_YEARS = 3;
|
||||
|
||||
export interface CalibrationYear {
|
||||
/** First report day (YYYY-MM-DD) per phase in that year. */
|
||||
firstReportDay: Partial<Record<PhenologyPhaseKey, string>>;
|
||||
/** GTS of that year on a day; null if the weather data does not allow it. */
|
||||
gtsOn(day: string): number | null;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTS thresholds from the reports of the previous years: per phase the median
|
||||
* of the GTS on the first report day, over up to CALIBRATION_YEARS years
|
||||
* (`years` most recent first). A phase without any usable year gets no
|
||||
* threshold - nothing is invented.
|
||||
*/
|
||||
export function calibrateThresholds(
|
||||
years: readonly CalibrationYear[],
|
||||
phases: readonly PhenologyPhaseKey[],
|
||||
): Partial<Record<PhenologyPhaseKey, number>> {
|
||||
const thresholds: Partial<Record<PhenologyPhaseKey, number>> = {};
|
||||
const recent = years.slice(0, CALIBRATION_YEARS);
|
||||
for (const phase of phases) {
|
||||
const values: number[] = [];
|
||||
for (const year of recent) {
|
||||
const day = year.firstReportDay[phase];
|
||||
const gts = day === undefined ? null : year.gtsOn(day);
|
||||
if (gts !== null) {
|
||||
values.push(gts);
|
||||
}
|
||||
}
|
||||
if (values.length > 0) {
|
||||
thresholds[phase] = median(values);
|
||||
}
|
||||
}
|
||||
return thresholds;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import type { GtsResult } from './gts';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
seasonNoteFor,
|
||||
type PhaseInputs,
|
||||
} from './phenology-phase';
|
||||
|
||||
const fresh = (gts: number, throughDay = '2026-04-19'): GtsResult => ({
|
||||
gts,
|
||||
throughDay,
|
||||
expectedDays: 109,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
|
||||
function inputs(overrides: Partial<PhaseInputs> = {}): PhaseInputs {
|
||||
return {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
gts: fresh(250),
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('effectivePhaseOf', () => {
|
||||
it('takes the maximum of report, GTS and calendar', () => {
|
||||
// GTS: SALWEIDE; calendar (04-15): KIRSCHE; report: APFEL.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-18',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses GTS when it is ahead of the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ today: '2026-03-25', gts: fresh(250, '2026-03-24') }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'GTS',
|
||||
reportedPhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores revoked reports', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
expect(result.effectivePhase).toBe('KIRSCHE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('ignores reports dated in the future', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'LINDE', date: '2026-04-21', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
});
|
||||
|
||||
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', () => {
|
||||
// GTS is usable but only SALWEIDE has a threshold: KIRSCHE and later
|
||||
// phases come from the calendar.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(10, '2026-04-15'), today: '2026-04-16' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the calendar decide a phase that has a GTS threshold', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(10, '2026-04-15'),
|
||||
today: '2026-04-16',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 400 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.effectivePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('a calibrated threshold lets GTS reach KIRSCHE', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(320, '2026-04-09'),
|
||||
today: '2026-04-10',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 300 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the calendar for every phase without usable weather', () => {
|
||||
const gappy: GtsResult = { ...fresh(250), usable: false, missingDays: 30 };
|
||||
const result = effectivePhaseOf(inputs({ gts: gappy }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
note: 'WEATHER_GAPS',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use GTS older than 3 days', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(250, '2026-04-16'), today: '2026-04-20' }),
|
||||
);
|
||||
|
||||
expect(result.note).toBe('WEATHER_STALE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('flags missing coordinates and uses the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: null, hasCoordinates: false }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no phase before spring, with the calendar as source', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(5, '2026-01-30'), today: '2026-01-31' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: null,
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('never moves backwards unless a report was revoked', () => {
|
||||
const base = inputs({ previous: { phase: 'APFEL', source: 'REPORT' } });
|
||||
|
||||
const held = effectivePhaseOf(base);
|
||||
const released = effectivePhaseOf({ ...base, allowBackward: true });
|
||||
|
||||
expect(held).toMatchObject({
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
});
|
||||
expect(released.effectivePhase).toBe('KIRSCHE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectivePhaseOf edge cases', () => {
|
||||
it('still uses GTS whose last day is exactly 3 days old', () => {
|
||||
const base = {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 240 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
} satisfies Omit<PhaseInputs, 'gts'>;
|
||||
|
||||
const used = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-17') });
|
||||
const stale = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-16') });
|
||||
|
||||
expect(used).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
note: null,
|
||||
});
|
||||
expect(stale.note).toBe('WEATHER_STALE');
|
||||
expect(stale.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('reports the earliest of two reports of the same phase', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [
|
||||
{ phase: 'APFEL', date: '2026-04-18', revoked: false },
|
||||
{ phase: 'APFEL', date: '2026-04-12', revoked: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-12',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportsApplyingTo', () => {
|
||||
it('a report for all apiaries counts for each apiary', () => {
|
||||
const reports = [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL' as const,
|
||||
date: '2026-04-18',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'a',
|
||||
phase: 'KIRSCHE' as const,
|
||||
date: '2026-04-10',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'b',
|
||||
phase: 'LINDE' as const,
|
||||
date: '2026-04-11',
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(reportsApplyingTo('a', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
'KIRSCHE',
|
||||
]);
|
||||
expect(reportsApplyingTo('c', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seasonNoteFor', () => {
|
||||
const base = {
|
||||
useGts: true,
|
||||
hasCoordinates: true,
|
||||
today: '2026-04-20',
|
||||
lastDataDay: '2026-04-19' as string | null,
|
||||
gtsUsable: true,
|
||||
};
|
||||
|
||||
it.each([
|
||||
[{}, null],
|
||||
[{ useGts: false, hasCoordinates: false }, null],
|
||||
[{ hasCoordinates: false }, 'NO_COORDINATES'],
|
||||
[{ lastDataDay: null }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-16' }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-17' }, null],
|
||||
[{ gtsUsable: false }, 'WEATHER_GAPS'],
|
||||
[{ today: '2026-01-01', lastDataDay: null, gtsUsable: false }, null],
|
||||
])('%j -> %s', (override, note) => {
|
||||
expect(seasonNoteFor({ ...base, ...override })).toBe(note);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { daysBetween } from '../common/dates';
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhaseSourceKey,
|
||||
type PhenologyPhaseKey,
|
||||
type SeasonNote,
|
||||
} from '../season/season-types';
|
||||
import type { GtsResult } from './gts';
|
||||
|
||||
/** GTS whose last day with data is older than this many days is not used. */
|
||||
export const GTS_MAX_AGE_DAYS = 3;
|
||||
|
||||
export interface PhaseReport {
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export interface PhaseInputs {
|
||||
today: string;
|
||||
reports: readonly PhaseReport[];
|
||||
gts: GtsResult | null;
|
||||
hasCoordinates: boolean;
|
||||
/** GTS thresholds in force: configured plus calibrated. */
|
||||
thresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||
config: PhenologyConfig;
|
||||
previous: {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
source?: PhaseSourceKey | null;
|
||||
} | null;
|
||||
/** True after a report was revoked: the phase may fall back. */
|
||||
allowBackward: boolean;
|
||||
}
|
||||
|
||||
export interface EffectivePhase {
|
||||
reportedPhase: PhenologyPhaseKey | null;
|
||||
reportedOn: string | null;
|
||||
estimatedPhase: PhenologyPhaseKey | null;
|
||||
effectivePhase: PhenologyPhaseKey | null;
|
||||
phaseSource: PhaseSourceKey | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
phase: PhenologyPhaseKey;
|
||||
source: PhaseSourceKey;
|
||||
}
|
||||
|
||||
const rankOf = (phase: PhenologyPhaseKey): number =>
|
||||
PHENOLOGY_PHASES.indexOf(phase);
|
||||
|
||||
/** The higher phase; the first wins a tie. */
|
||||
function higher(a: Candidate | null, b: Candidate | null): Candidate | null {
|
||||
if (a === null || b === null) {
|
||||
return a ?? b;
|
||||
}
|
||||
return rankOf(b.phase) > rankOf(a.phase) ? b : a;
|
||||
}
|
||||
|
||||
/** Reports that count for an apiary: its own and those for all apiaries. */
|
||||
export function reportsApplyingTo<T extends { apiaryId: string | null }>(
|
||||
apiaryId: string,
|
||||
reports: readonly T[],
|
||||
): T[] {
|
||||
return reports.filter(
|
||||
(report) => report.apiaryId === null || report.apiaryId === apiaryId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the weather cannot decide the phase, if it cannot: no coordinates, the
|
||||
* last day with data too old, or too many gaps. Null if GTS is off or fine.
|
||||
*/
|
||||
export function seasonNoteFor(input: {
|
||||
useGts: boolean;
|
||||
hasCoordinates: boolean;
|
||||
today: string;
|
||||
lastDataDay: string | null;
|
||||
gtsUsable: boolean;
|
||||
}): SeasonNote {
|
||||
if (!input.useGts) {
|
||||
return null;
|
||||
}
|
||||
if (!input.hasCoordinates) {
|
||||
return 'NO_COORDINATES';
|
||||
}
|
||||
// January 1st: no day of the year has passed, there is nothing to judge.
|
||||
if (input.today.endsWith('-01-01')) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
input.lastDataDay === null ||
|
||||
daysBetween(input.lastDataDay, input.today) > GTS_MAX_AGE_DAYS
|
||||
) {
|
||||
return 'WEATHER_STALE';
|
||||
}
|
||||
return input.gtsUsable ? null : 'WEATHER_GAPS';
|
||||
}
|
||||
|
||||
/** The GTS that may decide today's phase; null if it is off, stale or gappy. */
|
||||
export function usableGts(
|
||||
inputs: Pick<PhaseInputs, 'today' | 'gts' | 'hasCoordinates' | 'config'>,
|
||||
): GtsResult | null {
|
||||
const { gts } = inputs;
|
||||
if (gts === null || !gts.usable || gts.throughDay === null) {
|
||||
return null;
|
||||
}
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: gts.throughDay,
|
||||
gtsUsable: gts.usable,
|
||||
});
|
||||
return inputs.config.useGts && note === null ? gts : null;
|
||||
}
|
||||
|
||||
function reported(inputs: PhaseInputs): {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
on: string | null;
|
||||
} {
|
||||
const yearStart = `${inputs.today.slice(0, 4)}-01-01`;
|
||||
const valid = inputs.reports.filter(
|
||||
(report) =>
|
||||
!report.revoked &&
|
||||
report.date >= yearStart &&
|
||||
report.date <= inputs.today,
|
||||
);
|
||||
let phase: PhenologyPhaseKey | null = null;
|
||||
for (const report of valid) {
|
||||
if (phase === null || rankOf(report.phase) > rankOf(phase)) {
|
||||
phase = report.phase;
|
||||
}
|
||||
}
|
||||
const dates = valid.filter((r) => r.phase === phase).map((r) => r.date);
|
||||
return { phase, on: dates.length > 0 ? dates.sort()[0] : null };
|
||||
}
|
||||
|
||||
function gtsCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gts: GtsResult | null,
|
||||
): Candidate | null {
|
||||
if (gts === null) {
|
||||
return null;
|
||||
}
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
const threshold = inputs.thresholds[phase];
|
||||
if (threshold !== undefined && gts.gts >= threshold) {
|
||||
best = { phase, source: 'GTS' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The latest calendar phase reached; phases with a GTS threshold are skipped while GTS decides. */
|
||||
function calendarCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gtsDecides: boolean,
|
||||
): Candidate | null {
|
||||
const year = inputs.today.slice(0, 4);
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
if (gtsDecides && inputs.thresholds[phase] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (`${year}-${inputs.config.calendarFallback[phase]}` <= inputs.today) {
|
||||
best = { phase, source: 'CALENDAR' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase of an apiary today: the maximum of the user's report, the GTS
|
||||
* estimate and the calendar (the calendar only for phases without a GTS
|
||||
* threshold, or everywhere while the weather cannot be used). Within a year it
|
||||
* only moves forward, unless `allowBackward` (a report was revoked). Before
|
||||
* the first phase of the year there is no phase, and the calendar is the source.
|
||||
*/
|
||||
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase {
|
||||
const gts = usableGts(inputs);
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: inputs.gts?.throughDay ?? null,
|
||||
gtsUsable: inputs.gts?.usable ?? false,
|
||||
});
|
||||
const report = reported(inputs);
|
||||
const reportCandidate: Candidate | null =
|
||||
report.phase === null ? null : { phase: report.phase, source: 'REPORT' };
|
||||
const estimate = higher(
|
||||
gtsCandidate(inputs, gts),
|
||||
calendarCandidate(inputs, gts !== null),
|
||||
);
|
||||
let chosen = higher(reportCandidate, estimate);
|
||||
|
||||
const previous = inputs.previous?.phase ?? null;
|
||||
if (
|
||||
previous !== null &&
|
||||
!inputs.allowBackward &&
|
||||
(chosen === null || rankOf(previous) > rankOf(chosen.phase))
|
||||
) {
|
||||
chosen = {
|
||||
phase: previous,
|
||||
source: inputs.previous?.source ?? chosen?.source ?? 'CALENDAR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
reportedPhase: report.phase,
|
||||
reportedOn: report.on,
|
||||
estimatedPhase: estimate?.phase ?? null,
|
||||
effectivePhase: chosen?.phase ?? null,
|
||||
phaseSource: chosen?.source ?? 'CALENDAR',
|
||||
note,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ApiaryPhenology } from '../../generated/prisma';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { seasonNoteFor } from './phenology-phase';
|
||||
|
||||
/**
|
||||
* The season state a stored ApiaryPhenology row stands for.
|
||||
*
|
||||
* `reportedOn` is not stored and stays null. `springSince` (the day FRUEHJAHR
|
||||
* began) is not stored either: in FRUEHJAHR it is `segmentSince`; later in the
|
||||
* year it is the calendar day of SALWEIDE, but not later than `segmentSince`;
|
||||
* before the spring it is null.
|
||||
*/
|
||||
export function seasonStateFromRow(
|
||||
row: Omit<ApiaryPhenology, 'updatedAt'>,
|
||||
config: PhenologyConfig,
|
||||
hasCoordinates: boolean,
|
||||
today: string,
|
||||
): SeasonState {
|
||||
const segmentSince = utcDay(row.segmentSince);
|
||||
const calendarSpring = `${row.year}-${config.calendarFallback.SALWEIDE}`;
|
||||
let springSince: string | null;
|
||||
if (row.segment === 'FRUEHJAHR') {
|
||||
springSince = segmentSince;
|
||||
} else if (row.segment === 'WINTERRUHE' && row.effectivePhase !== 'WINTER') {
|
||||
springSince = null;
|
||||
} else {
|
||||
springSince = calendarSpring < segmentSince ? calendarSpring : segmentSince;
|
||||
}
|
||||
const gtsThroughDay =
|
||||
row.gtsThroughDay === null ? null : utcDay(row.gtsThroughDay);
|
||||
return {
|
||||
apiaryId: row.apiaryId,
|
||||
year: row.year,
|
||||
phase: row.effectivePhase,
|
||||
phaseSource: row.phaseSource,
|
||||
reportedOn: null,
|
||||
segment: row.segment,
|
||||
segmentSince,
|
||||
springSince,
|
||||
gts: row.gts,
|
||||
gtsThroughDay,
|
||||
note: seasonNoteFor({
|
||||
useGts: config.useGts,
|
||||
hasCoordinates,
|
||||
today,
|
||||
lastDataDay: gtsThroughDay,
|
||||
gtsUsable: row.gts !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
interface Fixture {
|
||||
apiary: { latitude: number | null; longitude: number | null } | null;
|
||||
row: Record<string, unknown> | null;
|
||||
reports: Record<string, unknown>[];
|
||||
weather: { day: Date; meanTempC: number | null }[];
|
||||
}
|
||||
|
||||
function setup(fixture: Partial<Fixture> = {}) {
|
||||
const data: Fixture = {
|
||||
apiary: { latitude: 48.1, longitude: 9.9 },
|
||||
row: null,
|
||||
reports: [],
|
||||
weather: [],
|
||||
...fixture,
|
||||
};
|
||||
const upsert = jest.fn().mockResolvedValue(undefined);
|
||||
const record = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRaw = jest.fn().mockResolvedValue([{ id: 'apiary-1' }]);
|
||||
const tx = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: jest.fn(),
|
||||
$transaction: undefined,
|
||||
apiary: { findUnique: jest.fn().mockResolvedValue(data.apiary) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
active: true,
|
||||
actionTemplate: null,
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
}),
|
||||
},
|
||||
apiaryPhenology: {
|
||||
findUnique: jest.fn().mockResolvedValue(data.row),
|
||||
upsert,
|
||||
},
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue(data.reports) },
|
||||
apiaryWeatherDay: { findMany: jest.fn().mockResolvedValue(data.weather) },
|
||||
};
|
||||
const events = { record } as unknown as DomainEventsService;
|
||||
const service = new PhenologyStateService({} as PrismaService, events);
|
||||
return { service, tx, upsert, record, queryRaw };
|
||||
}
|
||||
|
||||
/** Every day of 2026 from January 1 to `through` at `mean` degrees. */
|
||||
function weather(through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (
|
||||
let t = d('2026-01-01').getTime();
|
||||
t <= d(through).getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
const asTx = (tx: unknown) => tx as Prisma.TransactionClient;
|
||||
|
||||
describe('PhenologyStateService.recomputeIn', () => {
|
||||
it('records SeasonPhaseChanged once per apiary and day on a change', async () => {
|
||||
// Stored: FRUEHJAHR since March 20th; today the report puts it on APFEL.
|
||||
const { service, tx, record } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'SALWEIDE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'FRUEHJAHR',
|
||||
segmentSince: d('2026-03-20'),
|
||||
},
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL',
|
||||
date: d('2026-04-18'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const first = await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
const second = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.state).toMatchObject({
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-20',
|
||||
});
|
||||
// Both runs report the same change (the mocked row stays unchanged); the
|
||||
// event carries the same dedupe key, so the outbox keeps one of them.
|
||||
expect(second.changed).toBe(true);
|
||||
const keys = record.mock.calls.map(
|
||||
([, event]: [unknown, { dedupeKey: string }]) => event.dedupeKey,
|
||||
);
|
||||
expect(keys).toEqual([
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
]);
|
||||
expect(record).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'apiary-1',
|
||||
payload: {
|
||||
apiaryId: 'apiary-1',
|
||||
phase: 'APFEL',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'SALWEIDE',
|
||||
previousSegment: 'FRUEHJAHR',
|
||||
source: 'REPORT',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records nothing when phase and segment stay', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: 800,
|
||||
gtsThroughDay: d('2026-04-19'),
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2026-04-15'),
|
||||
},
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.state.segmentSince).toBe('2026-04-15');
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('locks the apiary row before it reads the stored state', async () => {
|
||||
const { service, tx, queryRaw } = setup();
|
||||
const order: string[] = [];
|
||||
queryRaw.mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
});
|
||||
tx.apiaryPhenology.findUnique.mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
expect(order.slice(0, 2)).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('first row of the year equals the calendar state: no event', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(result.state).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
phase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-15',
|
||||
});
|
||||
const call = upsert.mock.calls[0] as [{ create: Record<string, unknown> }];
|
||||
expect(call[0].create).toMatchObject({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows going backwards only with allowBackward', async () => {
|
||||
const stored = {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: 'LINDE',
|
||||
effectivePhase: 'LINDE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SOMMER',
|
||||
segmentSince: d('2026-04-18'),
|
||||
};
|
||||
const held = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
const released = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const keep = await held.service.recomputeIn(
|
||||
asTx(held.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
const back = await released.service.recomputeIn(
|
||||
asTx(released.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
{ allowBackward: true },
|
||||
);
|
||||
|
||||
expect(keep.state.phase).toBe('LINDE');
|
||||
expect(keep.changed).toBe(false);
|
||||
expect(back.state.phase).toBe('KIRSCHE');
|
||||
expect(back.changed).toBe(true);
|
||||
expect(released.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws for an apiary that does not exist', async () => {
|
||||
const { service, tx } = setup({ apiary: null });
|
||||
|
||||
await expect(
|
||||
service.recomputeIn(asTx(tx), 'gone', '2026-04-20'),
|
||||
).rejects.toThrow('Apiary gone not found');
|
||||
});
|
||||
});
|
||||
|
||||
function weatherRange(from: string, through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (let t = d(from).getTime(); t <= d(through).getTime(); t += 86_400_000) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
describe('PhenologyStateService fix round 1', () => {
|
||||
const noCoordinates = { latitude: null, longitude: null };
|
||||
|
||||
it('filters the reports in SQL: own and those for all apiaries', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
const call = tx.phenologyReport.findMany.mock.calls[0] as [
|
||||
{ where: { revokedAt: null; OR: unknown } },
|
||||
];
|
||||
expect(call[0].where.revokedAt).toBeNull();
|
||||
expect(call[0].where.OR).toEqual([
|
||||
{ apiaryId: 'apiary-1' },
|
||||
{ apiaryId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recomputeAll reads the rule and the reports once, not per apiary', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
apiaryId: 'apiary-2',
|
||||
phase: 'LINDE',
|
||||
date: d('2026-04-18'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
$transaction: (fn: (t: unknown) => Promise<unknown>) => fn(tx),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
|
||||
const result = await all.recomputeAll('2026-04-20');
|
||||
|
||||
expect(result).toEqual({ apiaries: 2, changes: 1, failed: 0 });
|
||||
expect(prisma.automationRule.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phenologyReport.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.phenologyReport.findMany).not.toHaveBeenCalled();
|
||||
// The LINDE report of apiary-2 moved only apiary-2 (SOMMER).
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recomputeAll counts a failing apiary and goes on', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockImplementation((fn: (t: unknown) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
(all as unknown as { logger: { error: () => void } }).logger,
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(await all.recomputeAll('2026-04-20')).toMatchObject({
|
||||
apiaries: 1,
|
||||
failed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('pins the pre-spring state: phase null here, WINTER in the calendar, WINTERRUHE in both', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
);
|
||||
const calendar = calendarSeasonState(
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
DEFAULT_PHENOLOGY_CONFIG,
|
||||
);
|
||||
|
||||
expect(state.phase).toBeNull();
|
||||
expect(calendar.phase).toBe('WINTER');
|
||||
expect(state.segment).toBe('WINTERRUHE');
|
||||
expect(calendar.segment).toBe('WINTERRUHE');
|
||||
});
|
||||
|
||||
it('calibrates KIRSCHE from last year report and weather end to end', async () => {
|
||||
// 2025: 10 degrees all year; KIRSCHE reported 2025-04-10 -> GTS through
|
||||
// April 9th = 155 + 210 + 310 + 90 = 765. 2026: the same weather, so the
|
||||
// threshold is reached on 2026-04-10, five days before the calendar.
|
||||
const { service, tx } = setup({
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'KIRSCHE',
|
||||
date: d('2025-04-10'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: [
|
||||
...weatherRange('2025-01-01', '2025-12-31', 10),
|
||||
...weatherRange('2026-01-01', '2026-04-09', 10),
|
||||
],
|
||||
});
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-10',
|
||||
);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
gts: 765,
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('looks up the row of the new year at rollover, not last year', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2027-01-02',
|
||||
);
|
||||
|
||||
expect(tx.apiaryPhenology.findUnique).toHaveBeenCalledWith({
|
||||
where: { apiaryId_year: { apiaryId: 'apiary-1', year: 2027 } },
|
||||
});
|
||||
expect(state).toMatchObject({ year: 2027, phase: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { addDays, dayToDate } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { dailyDedupeKey, utcDay } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { segmentOf } from '../season/season-segment';
|
||||
import type {
|
||||
DailyMean,
|
||||
PhenologyPhaseKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import {
|
||||
CALIBRATION_YEARS,
|
||||
calibrateThresholds,
|
||||
type CalibrationYear,
|
||||
} from './phenology-calibration';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
usableGts,
|
||||
} from './phenology-phase';
|
||||
import { seasonStateFromRow } from './phenology-row';
|
||||
|
||||
export interface DatedReport {
|
||||
apiaryId: string | null;
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/** What recomputeAll reads once per run instead of once per apiary. */
|
||||
export interface PreloadedPhenology {
|
||||
config: PhenologyConfig;
|
||||
/** Reports of all apiaries (apiaryId null = all), revoked ones excluded. */
|
||||
reports: readonly DatedReport[];
|
||||
}
|
||||
|
||||
export interface RecomputeOptions {
|
||||
/** After a revoked report: the phase may fall back. */
|
||||
allowBackward?: boolean;
|
||||
preloaded?: PreloadedPhenology;
|
||||
}
|
||||
|
||||
export interface RecomputeResult {
|
||||
changed: boolean;
|
||||
state: SeasonState;
|
||||
}
|
||||
|
||||
export interface RecomputeAllResult {
|
||||
apiaries: number;
|
||||
changes: number;
|
||||
/** Apiaries whose recomputation failed; the next run tries again. */
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function groupByYear(days: readonly DailyMean[]): Map<number, DailyMean[]> {
|
||||
const byYear = new Map<number, DailyMean[]>();
|
||||
for (const day of days) {
|
||||
const year = Number(day.day.slice(0, 4));
|
||||
byYear.set(year, [...(byYear.get(year) ?? []), day]);
|
||||
}
|
||||
return byYear;
|
||||
}
|
||||
|
||||
/** The calibration input of one previous year. */
|
||||
function calibrationYear(
|
||||
year: number,
|
||||
reports: readonly DatedReport[],
|
||||
days: readonly DailyMean[],
|
||||
config: PhenologyConfig,
|
||||
): CalibrationYear {
|
||||
const firstReportDay: CalibrationYear['firstReportDay'] = {};
|
||||
for (const report of reports) {
|
||||
if (
|
||||
report.date.startsWith(`${year}-`) &&
|
||||
config.calibratePhases.includes(report.phase) &&
|
||||
(firstReportDay[report.phase] ?? '9999') > report.date
|
||||
) {
|
||||
firstReportDay[report.phase] = report.date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstReportDay,
|
||||
// The GTS the morning of that day would have shown: through the day before.
|
||||
gtsOn: (day) => {
|
||||
const result = grasslandTemperatureSum(
|
||||
days,
|
||||
year,
|
||||
addDays(day, -1),
|
||||
config.maxGapShare,
|
||||
);
|
||||
return result.usable ? result.gts : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and stores the season state of apiaries (ApiaryPhenology): the
|
||||
* phase from the user's reports, the GTS and the calendar (see
|
||||
* effectivePhaseOf), and the season segment. A change of phase or segment is
|
||||
* recorded as SeasonPhaseChanged (once per apiary and day).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStateService {
|
||||
private readonly logger = new Logger(PhenologyStateService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Recomputes one apiary for `today` (YYYY-MM-DD, Berlin) inside the
|
||||
* caller's transaction: locks the apiary row, then reads the stored state,
|
||||
* so two recomputations of one apiary run one after the other.
|
||||
*/
|
||||
async recomputeIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
options: RecomputeOptions = {},
|
||||
): Promise<RecomputeResult> {
|
||||
const exists = await lockRowForUpdate(tx, 'Apiary', apiaryId);
|
||||
const apiary = exists
|
||||
? await tx.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { latitude: true, longitude: true },
|
||||
})
|
||||
: null;
|
||||
if (apiary === null) {
|
||||
throw new Error(`Apiary ${apiaryId} not found`);
|
||||
}
|
||||
const year = Number(today.slice(0, 4));
|
||||
const config =
|
||||
options.preloaded?.config ?? (await readRule(tx, 'PHENOLOGY')).config;
|
||||
const stored = await tx.apiaryPhenology.findUnique({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
});
|
||||
const hasCoordinates =
|
||||
apiary.latitude !== null && apiary.longitude !== null;
|
||||
const useWeather = config.useGts && hasCoordinates;
|
||||
const reports = options.preloaded
|
||||
? reportsApplyingTo(apiaryId, options.preloaded.reports)
|
||||
: await this.loadReports(tx, apiaryId, year, today);
|
||||
const days = useWeather
|
||||
? await this.loadWeather(tx, apiaryId, year, today, config, reports)
|
||||
: [];
|
||||
const byYear = groupByYear(days);
|
||||
|
||||
const gts = useWeather
|
||||
? grasslandTemperatureSum(
|
||||
byYear.get(year) ?? [],
|
||||
year,
|
||||
addDays(today, -1),
|
||||
config.maxGapShare,
|
||||
)
|
||||
: null;
|
||||
const calibrated =
|
||||
useWeather && config.calibrateFromReports
|
||||
? calibrateThresholds(
|
||||
Array.from(
|
||||
{ length: CALIBRATION_YEARS },
|
||||
(_, i) => year - 1 - i,
|
||||
).map((previousYear) =>
|
||||
calibrationYear(
|
||||
previousYear,
|
||||
reports,
|
||||
byYear.get(previousYear) ?? [],
|
||||
config,
|
||||
),
|
||||
),
|
||||
config.calibratePhases,
|
||||
)
|
||||
: {};
|
||||
const phaseInputs = {
|
||||
today,
|
||||
reports: reports.filter((report) => report.date.startsWith(`${year}-`)),
|
||||
gts,
|
||||
hasCoordinates,
|
||||
thresholds: config.useGts
|
||||
? { ...config.gtsThresholds, ...calibrated }
|
||||
: {},
|
||||
config,
|
||||
previous: stored
|
||||
? { phase: stored.effectivePhase, source: stored.phaseSource }
|
||||
: null,
|
||||
allowBackward: options.allowBackward ?? false,
|
||||
};
|
||||
const effective = effectivePhaseOf(phaseInputs);
|
||||
const segment = segmentOf(effective.effectivePhase, config.swarmSeason);
|
||||
|
||||
// Without a stored row the calendar state is what the users saw so far.
|
||||
const baseline = calendarSeasonState(apiaryId, today, config);
|
||||
const previousPhase = stored
|
||||
? stored.effectivePhase
|
||||
: baseline.springSince === null
|
||||
? null
|
||||
: baseline.phase;
|
||||
const previousSegment = stored ? stored.segment : baseline.segment;
|
||||
const segmentSince =
|
||||
previousSegment === segment
|
||||
? stored
|
||||
? utcDay(stored.segmentSince)
|
||||
: baseline.segmentSince
|
||||
: today;
|
||||
// Pre-spring rows hold phase null; calendarSeasonState says WINTER there
|
||||
// (segment WINTERRUHE in both). A stored WINTER would block every later
|
||||
// phase under the never-backwards rule.
|
||||
const changed =
|
||||
previousPhase !== effective.effectivePhase || previousSegment !== segment;
|
||||
|
||||
const used = usableGts(phaseInputs);
|
||||
const data = {
|
||||
gts: used === null ? null : used.gts,
|
||||
gtsThroughDay: gts?.throughDay ? dayToDate(gts.throughDay) : null,
|
||||
missingDays: gts?.missingDays ?? 0,
|
||||
estimatedPhase: effective.estimatedPhase,
|
||||
reportedPhase: effective.reportedPhase,
|
||||
effectivePhase: effective.effectivePhase,
|
||||
phaseSource: effective.phaseSource,
|
||||
segment,
|
||||
segmentSince: dayToDate(segmentSince),
|
||||
};
|
||||
await tx.apiaryPhenology.upsert({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
create: { apiaryId, year, ...data },
|
||||
update: data,
|
||||
});
|
||||
if (changed) {
|
||||
await this.events.record(tx, {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
payload: {
|
||||
apiaryId,
|
||||
phase: effective.effectivePhase,
|
||||
segment,
|
||||
previousPhase,
|
||||
previousSegment,
|
||||
source: effective.phaseSource,
|
||||
},
|
||||
// By design at most one SeasonPhaseChanged per apiary and day: a second
|
||||
// change the same day updates the row but adds no event.
|
||||
dedupeKey: dailyDedupeKey('SeasonPhaseChanged', apiaryId, today),
|
||||
});
|
||||
}
|
||||
return {
|
||||
changed,
|
||||
state: seasonStateFromRow(
|
||||
{ apiaryId, year, ...data },
|
||||
config,
|
||||
hasCoordinates,
|
||||
today,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes every active apiary, one transaction each. A failing apiary is
|
||||
* logged and does not stop the others.
|
||||
*/
|
||||
async recomputeAll(today: string): Promise<RecomputeAllResult> {
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const preloaded: PreloadedPhenology = {
|
||||
config: (await readRule(this.prisma, 'PHENOLOGY')).config,
|
||||
reports: await this.loadReports(
|
||||
this.prisma,
|
||||
null,
|
||||
Number(today.slice(0, 4)),
|
||||
today,
|
||||
),
|
||||
};
|
||||
let done = 0;
|
||||
let changes = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of apiaries) {
|
||||
try {
|
||||
const result = await this.prisma.$transaction((tx) =>
|
||||
this.recomputeIn(tx, id, today, { preloaded }),
|
||||
);
|
||||
done += 1;
|
||||
changes += result.changed ? 1 : 0;
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(
|
||||
`Phenology of apiary ${id} failed: ${message}`,
|
||||
stack,
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { apiaries: done, changes, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports of the last years, revoked ones excluded: those that count for
|
||||
* the apiary (its own and those for all apiaries), or all when `apiaryId`
|
||||
* is null (the preload of recomputeAll).
|
||||
*/
|
||||
private async loadReports(
|
||||
db: DbClient,
|
||||
apiaryId: string | null,
|
||||
year: number,
|
||||
today: string,
|
||||
): Promise<DatedReport[]> {
|
||||
const rows = await db.phenologyReport.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
...(apiaryId === null
|
||||
? {}
|
||||
: { OR: [{ apiaryId }, { apiaryId: null }] }),
|
||||
date: {
|
||||
gte: dayToDate(`${year - CALIBRATION_YEARS}-01-01`),
|
||||
lte: dayToDate(today),
|
||||
},
|
||||
},
|
||||
select: { apiaryId: true, phase: true, date: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
apiaryId: row.apiaryId,
|
||||
phase: row.phase,
|
||||
date: utcDay(row.date),
|
||||
revoked: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The daily means of this year, and of earlier years that have reports to calibrate on. */
|
||||
private async loadWeather(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
year: number,
|
||||
today: string,
|
||||
config: PhenologyConfig,
|
||||
reports: readonly DatedReport[],
|
||||
): Promise<DailyMean[]> {
|
||||
const calibrate = config.calibrateFromReports;
|
||||
const earliest = calibrate
|
||||
? Math.min(
|
||||
year,
|
||||
...reports
|
||||
.filter((report) => config.calibratePhases.includes(report.phase))
|
||||
.map((report) => Number(report.date.slice(0, 4))),
|
||||
)
|
||||
: year;
|
||||
const rows = await tx.apiaryWeatherDay.findMany({
|
||||
where: {
|
||||
apiaryId,
|
||||
day: { gte: dayToDate(`${earliest}-01-01`), lt: dayToDate(today) },
|
||||
},
|
||||
select: { day: true, meanTempC: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
day: utcDay(row.day),
|
||||
meanTempC: row.meanTempC,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
import { PhenologyStep } from './phenology.step';
|
||||
|
||||
/**
|
||||
* The season state of apiaries (ApiaryPhenology): the service that computes
|
||||
* it and the first step of the daily evaluation. Imported by AppModule and
|
||||
* WorkerModule (the reports of Task 28 recompute through the service) and by
|
||||
* JobsModule (the step).
|
||||
*/
|
||||
@Module({
|
||||
imports: [DomainEventsModule],
|
||||
providers: [PhenologyStateService, PhenologyStep],
|
||||
exports: [PhenologyStateService, PhenologyStep],
|
||||
})
|
||||
export class PhenologyModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
/**
|
||||
* Step "phenology" of the daily evaluation (first, so the later steps see
|
||||
* today's season): recomputes the season state of every active apiary, one
|
||||
* transaction each. If apiaries failed, the step fails with the counters of
|
||||
* the others; the next run tries again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStep implements DailyEvaluationStep {
|
||||
readonly name = 'phenology';
|
||||
|
||||
constructor(private readonly state: PhenologyStateService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
const { apiaries, changes, failed } = await this.state.recomputeAll(today);
|
||||
const counts: JobResult = {
|
||||
phenologyApiaries: apiaries,
|
||||
seasonChanges: changes,
|
||||
};
|
||||
if (failed > 0) {
|
||||
throw new JobPartialFailure(counts, [
|
||||
`${failed} ${failed === 1 ? 'apiary' : 'apiaries'}`,
|
||||
]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user