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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 20:00:46 +02:00
1 parent 22382d53a2
commit 1971b2d9c4
90 files changed
+8431 -155

No files matched your search

+122
View File
@@ -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 });
});
});
+77
View File
@@ -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;
}
}