feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
@@ -127,15 +129,247 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** A4-A10 follow with Task 27. */
|
||||
export const AUTOPILOT_RULES: Readonly<
|
||||
Partial<Record<AutopilotRuleId, AutopilotRule>>
|
||||
> = {
|
||||
A1: a1Inspection,
|
||||
A2: a2VarroaMeasure,
|
||||
A3: a3VarroaRecheck,
|
||||
/** 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',
|
||||
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
|
||||
}
|
||||
const candidates: AutopilotCandidate[] = [];
|
||||
for (const id of RULE_ORDER) {
|
||||
const rule = AUTOPILOT_RULES[id];
|
||||
if (rule === undefined || !configs.autopilot.config.rules[id]) {
|
||||
if (!configs.autopilot.config.rules[id]) {
|
||||
continue;
|
||||
}
|
||||
const candidate = rule(input, configs, today);
|
||||
const candidate = AUTOPILOT_RULES[id](input, configs, today);
|
||||
if (candidate !== null) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user