Files
ApiCore/ApiCore-Backend/src/phenology/phenology-phase.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 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]>
2026-10-14 20:00:46 +02:00

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,
};
}