Files
vac-optimizer/src/app/services/planning-objectives.ts
T

309 lines
9.9 KiB
TypeScript

export type OptimizationElements =
| 'leave-balances'
| 'public-holiday-weeks'
| 'time-off-periods'
| 'work-sequences'
| 'leave-placements';
export type OptimizationCondition =
| 'at-risk'
| 'complete'
| 'internal'
| 'non-empty'
| 'supporting';
export type OptimizationMeasure =
| 'unplaced-quantity'
| 'public-holiday-week-value'
| 'non-working-day-gain'
| 'work-sequence-length'
| 'work-sequence-deficit'
| 'placement-count';
export type OptimizationAggregation = 'sum' | 'count' | 'maximum';
export type OptimizationDirection = 'minimize' | 'maximize';
export type OptimizationUnit = 'working-days' | 'calendar-days' | 'count';
interface OptimizationConfigurationBase {
elements: OptimizationElements;
conditions: readonly OptimizationCondition[];
aggregation: OptimizationAggregation;
direction: OptimizationDirection;
}
export type OptimizationConfiguration = OptimizationConfigurationBase &
(
| { measure: 'work-sequence-deficit'; thresholdDays: number }
| { measure: Exclude<OptimizationMeasure, 'work-sequence-deficit'>; thresholdDays?: never }
);
/** Each row accepts any subset of its conditions and both comparison directions. */
export const OPTIMIZATION_CATALOG = [
{
elements: 'leave-balances',
measure: 'unplaced-quantity',
conditions: ['at-risk'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'public-holiday-weeks',
measure: 'public-holiday-week-value',
conditions: ['complete'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'calendar-days',
},
{
elements: 'time-off-periods',
measure: 'non-working-day-gain',
conditions: [],
aggregations: ['sum', 'count', 'maximum'],
unit: 'calendar-days',
},
{
elements: 'work-sequences',
measure: 'work-sequence-length',
conditions: ['internal', 'non-empty'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'work-sequences',
measure: 'work-sequence-deficit',
conditions: ['internal', 'non-empty'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'leave-placements',
measure: 'placement-count',
conditions: ['supporting'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'count',
},
] as const;
export type OptimizationValidation =
| { valid: true; configuration: OptimizationConfiguration }
| {
valid: false;
code: 'invalid-optimization-configuration' | 'unsupported-optimization-configuration';
message: string;
};
export function validateOptimizationConfiguration(value: unknown): OptimizationValidation {
const invalid = (message: string): OptimizationValidation => ({
valid: false,
code: 'invalid-optimization-configuration',
message,
});
const unsupported = (message: string): OptimizationValidation => ({
valid: false,
code: 'unsupported-optimization-configuration',
message,
});
if (!value || typeof value !== 'object' || Array.isArray(value))
return invalid('An optimization configuration must be an object.');
const config = value as Record<string, unknown>;
const fields = [
'elements',
'conditions',
'measure',
'aggregation',
'direction',
'thresholdDays',
];
if (Object.keys(config).some((key) => !fields.includes(key)))
return unsupported('The optimization configuration contains an unsupported field.');
if (config['direction'] !== 'minimize' && config['direction'] !== 'maximize')
return invalid('The optimization direction must be minimize or maximize.');
if (
!Array.isArray(config['conditions']) ||
[...config['conditions']].some((condition) => typeof condition !== 'string') ||
new Set(config['conditions']).size !== config['conditions'].length
)
return invalid('Optimization conditions must be a list of distinct conditions.');
const row = OPTIMIZATION_CATALOG.find(
(item) => item.elements === config['elements'] && item.measure === config['measure'],
);
if (!row) return unsupported('The planning elements and measure are not compatible.');
const conditions: readonly string[] = row.conditions;
if (config['conditions'].some((condition) => !conditions.includes(condition)))
return unsupported('A condition is not supported for these planning elements.');
const aggregations: readonly string[] = row.aggregations;
if (typeof config['aggregation'] !== 'string' || !aggregations.includes(config['aggregation']))
return unsupported('The aggregation is not supported for this measure.');
if (config['measure'] === 'work-sequence-deficit') {
if (!Number.isSafeInteger(config['thresholdDays']) || Number(config['thresholdDays']) <= 0)
return invalid('The deficit threshold must be a positive integer in working days.');
} else if ('thresholdDays' in config)
return unsupported('Only the work-sequence deficit accepts a working-day threshold.');
return { valid: true, configuration: value as OptimizationConfiguration };
}
export function optimizationUnit(configuration: OptimizationConfiguration): OptimizationUnit {
return configuration.aggregation === 'count'
? 'count'
: OPTIMIZATION_CATALOG.find(
(row) =>
row.elements === configuration.elements &&
row.measure === configuration.measure,
)!.unit;
}
export const OPTIMIZATION_PRESETS = {
unplacedAtRisk: {
elements: 'leave-balances',
conditions: ['at-risk'],
measure: 'unplaced-quantity',
aggregation: 'sum',
direction: 'minimize',
},
holidayWeekValue: {
elements: 'public-holiday-weeks',
conditions: ['complete'],
measure: 'public-holiday-week-value',
aggregation: 'sum',
direction: 'maximize',
},
nonWorkingDayGain: {
elements: 'time-off-periods',
conditions: [],
measure: 'non-working-day-gain',
aggregation: 'sum',
direction: 'maximize',
},
longestWorkSequence: {
elements: 'work-sequences',
conditions: [],
measure: 'work-sequence-length',
aggregation: 'maximum',
direction: 'minimize',
},
shortSequenceDeficit: {
elements: 'work-sequences',
conditions: ['internal', 'non-empty'],
measure: 'work-sequence-deficit',
thresholdDays: 5,
aggregation: 'sum',
direction: 'minimize',
},
supportingPlacements: {
elements: 'leave-placements',
conditions: ['supporting'],
measure: 'placement-count',
aggregation: 'count',
direction: 'minimize',
},
} as const satisfies Record<string, OptimizationConfiguration>;
/** Concrete observations from one plan; they carry no rule identity or display name. */
export interface OptimizationObservations {
leaveBalances: readonly { unplacedQuantity: number; atRisk: boolean }[];
publicHolidayWeeks: readonly { value: number; complete: boolean }[];
timeOffPeriods: readonly { nonWorkingDates: readonly string[] }[];
workSequences: readonly { length: number; internal: boolean }[];
leavePlacements: readonly { supporting: boolean }[];
}
// Calendar geometry reuses frozen date arrays across candidate plans.
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
export function evaluateOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
): number {
const validation = validateOptimizationConfiguration(configuration);
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
const selected = (condition: OptimizationCondition) =>
configuration.conditions.includes(condition);
let values: number[];
switch (configuration.elements) {
case 'leave-balances':
values = observations.leaveBalances
.filter((balance) => !selected('at-risk') || balance.atRisk)
.map((balance) => balance.unplacedQuantity);
break;
case 'public-holiday-weeks':
values = observations.publicHolidayWeeks
.filter((week) => !selected('complete') || week.complete)
.map((week) => week.value);
break;
case 'time-off-periods':
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
const cached = nonWorkingDateCounts.get(nonWorkingDates);
if (cached !== undefined) return cached;
const count = new Set(nonWorkingDates).size;
if (Object.isFrozen(nonWorkingDates))
nonWorkingDateCounts.set(nonWorkingDates, count);
return count;
});
break;
case 'work-sequences':
values = observations.workSequences
.filter(
(sequence) =>
(!selected('internal') || sequence.internal) &&
(!selected('non-empty') || sequence.length > 0),
)
.map((sequence) =>
configuration.measure === 'work-sequence-deficit'
? sequence.length > 0
? Math.max(0, configuration.thresholdDays - sequence.length)
: 0
: sequence.length,
);
break;
case 'leave-placements':
values = observations.leavePlacements
.filter((placement) => !selected('supporting') || placement.supporting)
.map(() => 1);
}
switch (configuration.aggregation) {
case 'count':
return values.length;
case 'sum':
return values.reduce((total, value) => total + value, 0);
case 'maximum':
return values.reduce((maximum, value) => Math.max(maximum, value), 0);
}
}
export interface ComparableObjective {
id: string;
direction: OptimizationDirection;
value: number | string;
}
/** Negative means the left candidate is preferred. Strings retain lexical tie-break ordering. */
export function compareObjectiveValues(
direction: OptimizationDirection,
left: number | string,
right: number | string,
): number {
if (left === right) return 0;
const difference =
typeof left === 'number' && typeof right === 'number'
? left - right
: left < right
? -1
: left > right
? 1
: 0;
return direction === 'minimize' ? difference : -difference;
}
export function firstDifferentObjective(
left: readonly ComparableObjective[],
right: readonly ComparableObjective[],
): number {
return left.findIndex((objective, index) => objective.value !== right[index]?.value);
}
export function compareObjectiveLists(
left: readonly ComparableObjective[],
right: readonly ComparableObjective[],
): number {
const index = firstDifferentObjective(left, right);
return index < 0
? 0
: compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
}