- 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]>
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|