import { PHENOLOGY_PHASES, type PhenologyPhaseKey, } from '../../season/season-types'; import { bool, childScope, enumList, enumValue, has, isCleared, makeParser, monthDay, numberIn, report, type Scope, } from './parse-helpers'; import type { PhenologyConfig } from './rule-config.types'; /** Upper limit of a growing-degree-day threshold; far above any phase. */ const MAX_GTS_THRESHOLD = 5000; const MAX_GAP_SHARE = 0.5; export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = { useGts: true, gtsThresholds: { SALWEIDE: 200 }, calibrateFromReports: true, calibratePhases: ['KIRSCHE', 'APFEL'], calendarFallback: { SALWEIDE: '03-20', KIRSCHE: '04-15', APFEL: '04-28', HOLUNDER: '05-30', LINDE: '06-20', TRACHTENDE: '08-01', HERBST: '09-15', WINTER: '11-15', }, swarmSeason: { from: 'KIRSCHE', until: 'LINDE' }, maxGapShare: 0.1, }; function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] { // Without the group the defaults apply; with it, only the phases given // have a threshold - leaving one out removes it. if (!has(scope, 'gtsThresholds')) { return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds }; } const thresholds = childScope(scope, 'gtsThresholds'); const result: PhenologyConfig['gtsThresholds'] = {}; for (const phase of PHENOLOGY_PHASES) { if (isCleared(thresholds, phase)) { continue; } const value = numberIn( thresholds, phase, Number.NaN, 0, MAX_GTS_THRESHOLD, { exclusiveMin: true }, ); if (!Number.isNaN(value)) { result[phase] = value; } } return result; } /** * The calendar dates of the phases must not run backwards in phase order. * One violation sends the whole map back to the defaults, so it stays * consistent; the issue names a key the caller actually sent. */ function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] { const calendar = childScope(scope, 'calendarFallback'); const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback; const before = scope.issues.length; const result = { ...defaults }; for (const phase of PHENOLOGY_PHASES) { result[phase] = monthDay(calendar, phase, defaults[phase]); } if (scope.issues.length > before) { return { ...defaults }; } for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) { const previous = PHENOLOGY_PHASES[index - 1]; const phase = PHENOLOGY_PHASES[index]; if (result[phase] < result[previous]) { const culprit = has(calendar, phase) ? phase : previous; report( calendar, culprit, `Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`, ); return { ...defaults }; } } return result; } function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] { const season = childScope(scope, 'swarmSeason'); const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason; const from: PhenologyPhaseKey = enumValue( season, 'from', defaults.from, PHENOLOGY_PHASES, ); const until: PhenologyPhaseKey = enumValue( season, 'until', defaults.until, PHENOLOGY_PHASES, ); if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) { report( season, has(season, 'from') ? 'from' : 'until', 'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.', ); return { ...defaults }; } return { from, until }; } export const phenologyParser = makeParser( 'PHENOLOGY', DEFAULT_PHENOLOGY_CONFIG, (scope) => { const d = DEFAULT_PHENOLOGY_CONFIG; return { useGts: bool(scope, 'useGts', d.useGts), gtsThresholds: readThresholds(scope), calibrateFromReports: bool( scope, 'calibrateFromReports', d.calibrateFromReports, ), calibratePhases: enumList( scope, 'calibratePhases', d.calibratePhases, PHENOLOGY_PHASES, ), calendarFallback: readCalendar(scope), swarmSeason: readSwarmSeason(scope), maxGapShare: numberIn( scope, 'maxGapShare', d.maxGapShare, 0, MAX_GAP_SHARE, ), }; }, );