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>; 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( 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, ): 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, }; }