import type { AutopilotRuleId } from '../automation/rule-config'; import { addDays, daysBetween } from '../common/dates'; import { inspectionDueOn } from '../season/inspection-due'; import { isQueenAged } from '../season/queen-age'; import { PHENOLOGY_PHASES } from '../season/season-types'; import { varroaMeasurementDue } from '../season/varroa-due'; import type { AutopilotCandidate, AutopilotConfigs, AutopilotInput, AutopilotRule, } from './autopilot.types'; /** * A3 only follows treatments that ended at most this many days ago, so old * history without a later measurement does not produce suggestions. */ const RECHECK_MAX_AGE_DAYS = 60; /** A1: an inspection is due (or nearly) and the colony is not in winter rest. */ const INSPECTION_LEAD_DAYS = 2; const a1Inspection: AutopilotRule = (input, configs, today) => { if (!configs.interval.active) { return null; } const due = inspectionDueOn( { createdOn: input.colony.createdOn, lastInspectedOn: input.lastInspection?.date ?? null, }, input.season, configs.interval.config, ); if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) { return null; } return { rule: 'A1', ruleKey: 'INSPECTION_INTERVAL', intent: 'INSPECT', mode: 'DO', reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE', evidence: { colonyId: input.colony.id, inspectionId: input.lastInspection?.id ?? null, lastInspectedOn: input.lastInspection?.date ?? null, dueOn: due.dueOn, intervalDays: due.intervalDays, segment: input.season.segment, }, dueDate: due.dueOn, priority: due.dueOn < today ? 'HIGH' : 'NORMAL', anchor: input.lastInspection?.id ?? 'none', requiredPermission: 'inspections:read', }; }; /** A2: the varroa measurement interval ran out, or the winter window is open. */ const a2VarroaMeasure: AutopilotRule = (input, configs, today) => { const due = varroaMeasurementDue( { createdOn: input.colony.createdOn, lastMeasuredOn: input.lastVarroa?.date ?? null, lastMeasurementId: input.lastVarroa?.id ?? null, }, input.season, configs.autopilot.config, today, ); if (due === null || due.dueOn > today) { return null; } return { rule: 'A2', ruleKey: 'SEASON_AUTOPILOT', intent: 'VARROA_MEASURE', mode: 'DO', reasonCode: due.kind === 'WINTER_WINDOW' ? 'VARROA_WINTER_MEASURE' : 'VARROA_MEASURE_DUE', evidence: { colonyId: input.colony.id, measurementId: input.lastVarroa?.id ?? null, lastMeasuredOn: input.lastVarroa?.date ?? null, dueOn: due.dueOn, intervalDays: configs.autopilot.config.varroaMeasureIntervalDays, }, dueDate: due.dueOn, priority: 'NORMAL', anchor: due.anchor, requiredPermission: 'varroa:read', }; }; /** A3: a treatment ended long enough ago and nothing was measured since. */ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => { const treatment = input.lastTreatment; if ( treatment === null || input.varroaSinceLastTreatmentEnd || daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS ) { return null; } const recheckAfterDays = configs.varroa.config.recheckAfterDays; const dueOn = addDays(treatment.endDay, recheckAfterDays); if (dueOn > today) { return null; } return { rule: 'A3', ruleKey: 'SEASON_AUTOPILOT', intent: 'VARROA_RECHECK', mode: 'DO', reasonCode: 'VARROA_RECHECK_DUE', evidence: { colonyId: input.colony.id, treatmentId: treatment.id, endedOn: treatment.endDay, recheckOn: dueOn, }, dueDate: dueOn, priority: 'NORMAL', anchor: treatment.id, // The action is a varroa measurement. requiredPermission: 'varroa:read', }; }; /** The phase window of A7: from KIRSCHE up to, not including, LINDE. */ const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE'); const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE'); /** The season facts the texts of A4, A5, A7 and A10 quote. */ function seasonEvidence(input: AutopilotInput): { phase: string | null; phaseSource: string | null; } { return { phase: input.season.phase, phaseSource: input.season.phaseSource, }; } /** A4: the honey flow is over and nothing was treated since. */ const a4SummerTreatment: AutopilotRule = (input, _configs, today) => { const { season } = input; if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) { return null; } return { rule: 'A4', ruleKey: 'SEASON_AUTOPILOT', intent: 'SUMMER_TREATMENT', mode: 'PLAN', reasonCode: 'SUMMER_TREATMENT_DUE', evidence: { colonyId: input.colony.id, ...seasonEvidence(input), since: season.segmentSince, reportedOn: season.reportedOn, }, dueDate: today, priority: 'HIGH', anchor: String(season.year), requiredPermission: 'treatments:read', }; }; /** A5: late summer or autumn and no feeding since the honey flow ended. */ const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => { const { season } = input; if ( (season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') || input.feedingsSinceLateSummer > 0 ) { return null; } return { rule: 'A5', ruleKey: 'SEASON_AUTOPILOT', intent: 'FEED', mode: 'DO', reasonCode: 'AUTUMN_FEED_MISSING', evidence: { colonyId: input.colony.id, ...seasonEvidence(input), since: season.segmentSince, }, dueDate: today, priority: 'NORMAL', anchor: String(season.year), requiredPermission: 'feedings:read', }; }; /** A6: low feed at the last inspection (or a critical colony card), not fed since. */ const a6LowFeed: AutopilotRule = (input, _configs, today) => { if ( input.season.segment === 'WINTERRUHE' || input.feedingsSinceInspection > 0 ) { return null; } const { lastInspection, colony } = input; const inspectionFeed = lastInspection?.feedStatus ?? null; const isInspectionLow = lastInspection !== null && (inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL'); if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') { return null; } return { rule: 'A6', ruleKey: 'SEASON_AUTOPILOT', intent: 'FEED', mode: 'DO', reasonCode: 'FEED_LOW', evidence: { colonyId: colony.id, inspectionId: lastInspection?.id ?? null, inspectedOn: isInspectionLow ? lastInspection.date : null, condition: isInspectionLow ? inspectionFeed : 'CRITICAL', source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD', }, dueDate: today, priority: 'NORMAL', anchor: lastInspection?.id ?? 'none', requiredPermission: 'inspections:read', }; }; /** A7: the cherry blooms and the colony covers enough frames for a honey super. */ const a7HoneySuper: AutopilotRule = (input, configs, today) => { const phaseIndex = input.season.phase === null ? -1 : PHENOLOGY_PHASES.indexOf(input.season.phase); // Only an inspection of this season says how strong the colony is now. const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`; const inspection = input.lastInspection !== null && input.lastInspection.date >= seasonStart ? input.lastInspection : null; const frames = inspection?.colonyStrength ?? null; if ( phaseIndex < HONEY_SUPER_FROM_PHASE || phaseIndex >= HONEY_SUPER_UNTIL_PHASE || frames === null || frames < configs.autopilot.config.honeySuperMinFrames ) { return null; } return { rule: 'A7', ruleKey: 'SEASON_AUTOPILOT', intent: 'HONEY_SUPER', mode: 'DO', reasonCode: 'HONEY_SUPER_CHECK', evidence: { colonyId: input.colony.id, inspectionId: inspection?.id ?? null, frames, ...seasonEvidence(input), }, dueDate: today, priority: 'NORMAL', anchor: String(input.season.year), requiredPermission: 'inspections:read', }; }; /** A8: the queen has reached the age the beekeeper plans to replace her at. */ const a8QueenReplace: AutopilotRule = (input, configs, today) => { const { queen, season } = input; if ( queen === null || (season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') || !isQueenAged( queen.hatchYear, today, configs.autopilot.config.queenMaxAgeYears, ) ) { return null; } return { rule: 'A8', ruleKey: 'SEASON_AUTOPILOT', intent: 'QUEEN_REPLACE', mode: 'PLAN', reasonCode: 'QUEEN_OLD', evidence: { colonyId: input.colony.id, queenId: queen.id, hatchYear: queen.hatchYear, }, dueDate: today, priority: 'NORMAL', anchor: `${queen.id}-${season.year}`, requiredPermission: 'queens:read', }; }; /** A9: autumn and no wintering-in entry for the winter that begins now. */ const a9WinteringIn: AutopilotRule = (input, _configs, today) => { const { season } = input; const isRecorded = input.winterChecks.some( (check) => check.winterYear === season.year && check.season === 'AUTUMN', ); if (season.segment !== 'HERBST' || isRecorded) { return null; } return { rule: 'A9', ruleKey: 'SEASON_AUTOPILOT', intent: 'WINTER_CHECK', mode: 'DO', reasonCode: 'WINTER_CHECK_AUTUMN_MISSING', evidence: { colonyId: input.colony.id, winterYear: season.year }, dueDate: today, priority: 'NORMAL', anchor: `${season.year}-AUTUMN`, requiredPermission: 'winter-checks:read', }; }; /** A10: spring, wintered in last autumn, not yet wintered out. */ const a10WinteringOut: AutopilotRule = (input, _configs, today) => { const { season } = input; const winterYear = season.year - 1; const has = (kind: 'AUTUMN' | 'SPRING') => input.winterChecks.some( (check) => check.winterYear === winterYear && check.season === kind, ); if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) { return null; } return { rule: 'A10', ruleKey: 'SEASON_AUTOPILOT', intent: 'WINTER_CHECK', mode: 'DO', reasonCode: 'WINTER_CHECK_SPRING_MISSING', evidence: { colonyId: input.colony.id, winterYear, ...seasonEvidence(input), }, dueDate: today, priority: 'NORMAL', anchor: `${winterYear}-SPRING`, requiredPermission: 'winter-checks:read', }; }; export const AUTOPILOT_RULES: Readonly> = { A1: a1Inspection, A2: a2VarroaMeasure, A3: a3VarroaRecheck, A4: a4SummerTreatment, A5: a5AutumnFeeding, A6: a6LowFeed, A7: a7HoneySuper, A8: a8QueenReplace, A9: a9WinteringIn, A10: a10WinteringOut, }; const RULE_ORDER: readonly AutopilotRuleId[] = [ 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', ]; /** * The candidates for one colony, always in the order A1...A10 so concurrent * syncs take the proposal locks in the same order. A switched-off autopilot * or rule yields nothing. */ export function evaluateAutopilot( input: AutopilotInput, configs: AutopilotConfigs, today: string, ): AutopilotCandidate[] { if (!configs.autopilot.active) { return []; } const candidates: AutopilotCandidate[] = []; for (const id of RULE_ORDER) { if (!configs.autopilot.config.rules[id]) { continue; } const candidate = AUTOPILOT_RULES[id](input, configs, today); if (candidate !== null) { candidates.push(candidate); } } // The recheck after a treatment is the measurement: A2 would ask twice. return candidates.some((candidate) => candidate.rule === 'A3') ? candidates.filter((candidate) => candidate.rule !== 'A2') : candidates; }