- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
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<Record<AutopilotRuleId, AutopilotRule>> =
|
|
{
|
|
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;
|
|
}
|