225 lines
8.1 KiB
Python
225 lines
8.1 KiB
Python
# ============================================================
|
||
# 遗传算法排产引擎(moduleId: engines-ga, SC-03,可重生)
|
||
# 多线订单排序 + 产线分配;最终班次占槽复用 RuleEngine 的确定性物化。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from random import Random
|
||
from time import monotonic
|
||
from typing import Any, Callable
|
||
|
||
from server.contracts import ScheduleResult
|
||
from server.engines.base import EngineParams
|
||
from server.engines.cp_engine import _due_minutes, _job_duration_min
|
||
from server.engines.queries import (
|
||
find_product_lines,
|
||
find_routing_steps,
|
||
find_workstation_for_operation,
|
||
)
|
||
from server.engines.rule_engine import RuleEngine
|
||
from server.timeutil import add_minutes, parse_dt, today0
|
||
|
||
World = dict[str, Any]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Candidate:
|
||
order: tuple[int, ...]
|
||
lines: tuple[int, ...]
|
||
score: float
|
||
|
||
|
||
def _problem_data(
|
||
world: World,
|
||
entries: list[dict],
|
||
params: EngineParams,
|
||
) -> tuple[list[list[int]], dict[tuple[int, int], int], list[int], list[int]]:
|
||
from server.aps_domain.params import level_weight
|
||
|
||
base_start = (
|
||
parse_dt(params.startDate + " 08:00")
|
||
if params.startDate
|
||
else add_minutes(today0(), 24 * 60)
|
||
)
|
||
if params.freezeWindowHours is not None and float(params.freezeWindowHours) > 0:
|
||
base_start = add_minutes(base_start, int(float(params.freezeWindowHours) * 60))
|
||
due_buffer = (
|
||
1.0
|
||
if params.deliveryBufferRatio is None
|
||
else max(0.5, min(1.0, float(params.deliveryBufferRatio)))
|
||
)
|
||
|
||
options: list[list[int]] = []
|
||
durations: dict[tuple[int, int], int] = {}
|
||
dues: list[int] = []
|
||
weights: list[int] = []
|
||
for idx, entry in enumerate(entries):
|
||
line_ids: list[int] = []
|
||
routing_steps = find_routing_steps(world, entry["item"]["productId"])
|
||
for link in find_product_lines(world, entry["item"]["productId"]):
|
||
line_id = int(link["lineId"])
|
||
if any(
|
||
find_workstation_for_operation(world, line_id, step["operationId"]) is None
|
||
for step in routing_steps
|
||
):
|
||
continue
|
||
line = next(line for line in world["lines"] if int(line["id"]) == line_id)
|
||
line_ids.append(line_id)
|
||
durations[(idx, line_id)] = _job_duration_min(world, entry["item"], line)
|
||
options.append(line_ids or [-1])
|
||
if not line_ids:
|
||
durations[(idx, -1)] = 1
|
||
dues.append(_due_minutes(entry["so"], base_start, due_buffer))
|
||
weight = int(round(level_weight(world, entry["so"].get("customerLevel")) * 100))
|
||
if entry["so"].get("isRush"):
|
||
weight += 200
|
||
if entry["so"].get("isForecast"):
|
||
weight = max(1, weight // 2)
|
||
weights.append(max(1, weight))
|
||
return options, durations, dues, weights
|
||
|
||
|
||
def _score(
|
||
order: tuple[int, ...],
|
||
lines: tuple[int, ...],
|
||
durations: dict[tuple[int, int], int],
|
||
dues: list[int],
|
||
weights: list[int],
|
||
) -> float:
|
||
cursor: dict[int, int] = {}
|
||
tardiness = 0
|
||
for idx in order:
|
||
line_id = lines[idx]
|
||
end = cursor.get(line_id, 0) + durations[(idx, line_id)]
|
||
cursor[line_id] = end
|
||
tardiness += max(0, end - dues[idx]) * weights[idx]
|
||
makespan = max(cursor.values(), default=0)
|
||
return float(tardiness * 1000 + makespan)
|
||
|
||
|
||
def _order_crossover(a: tuple[int, ...], b: tuple[int, ...], rng: Random) -> tuple[int, ...]:
|
||
if len(a) < 2:
|
||
return a
|
||
left, right = sorted(rng.sample(range(len(a)), 2))
|
||
child: list[int | None] = [None] * len(a)
|
||
child[left : right + 1] = a[left : right + 1]
|
||
remaining = [gene for gene in b if gene not in child]
|
||
pos = 0
|
||
for idx in list(range(right + 1, len(a))) + list(range(0, left)):
|
||
child[idx] = remaining[pos]
|
||
pos += 1
|
||
return tuple(int(gene) for gene in child if gene is not None)
|
||
|
||
|
||
def optimize_genetic_assignment(
|
||
world: World,
|
||
entries: list[dict],
|
||
params: EngineParams,
|
||
) -> tuple[list[dict], dict[str, Any]]:
|
||
"""确定性 GA:联合优化订单顺序和可选产线,返回 RuleEngine 可物化的条目。"""
|
||
started = monotonic()
|
||
count = len(entries)
|
||
meta: dict[str, Any] = {
|
||
"backend": "Genetic Algorithm",
|
||
"pipeline": "GA->shift-slot",
|
||
"placement": "shift-slot",
|
||
"seed": 42,
|
||
}
|
||
if count == 0:
|
||
meta.update({"status": "TRIVIAL", "wallTimeSec": 0.0, "objective": 0.0,
|
||
"population": 0, "generations": 0, "gap": None})
|
||
return entries, meta
|
||
|
||
rng = Random(42)
|
||
options, durations, dues, weights = _problem_data(world, entries, params)
|
||
population_size = max(12, min(48, count * 6))
|
||
max_generations = max(20, min(120, count * 15))
|
||
time_limit = max(0.05, float(params.timeLimitSeconds or 3.0))
|
||
generation_budget = max(1, min(max_generations, int(round(time_limit * 100))))
|
||
|
||
base_order = tuple(range(count))
|
||
base_lines = tuple(lines[0] for lines in options)
|
||
|
||
def make_candidate(order: tuple[int, ...], lines: tuple[int, ...]) -> _Candidate:
|
||
return _Candidate(order, lines, _score(order, lines, durations, dues, weights))
|
||
|
||
baseline = make_candidate(base_order, base_lines)
|
||
population = [baseline]
|
||
while len(population) < population_size:
|
||
order = list(base_order)
|
||
rng.shuffle(order)
|
||
lines = tuple(rng.choice(options[idx]) for idx in range(count))
|
||
population.append(make_candidate(tuple(order), lines))
|
||
|
||
completed_generations = 0
|
||
for generation in range(generation_budget):
|
||
population.sort(key=lambda candidate: candidate.score)
|
||
next_population = population[: max(2, population_size // 8)]
|
||
while len(next_population) < population_size:
|
||
contenders = rng.sample(population[: max(4, population_size // 2)], 4)
|
||
parent_a, parent_b = sorted(contenders, key=lambda candidate: candidate.score)[:2]
|
||
order = list(_order_crossover(parent_a.order, parent_b.order, rng))
|
||
lines = [
|
||
parent_a.lines[idx] if rng.random() < 0.5 else parent_b.lines[idx]
|
||
for idx in range(count)
|
||
]
|
||
if count > 1 and rng.random() < 0.35:
|
||
first, second = rng.sample(range(count), 2)
|
||
order[first], order[second] = order[second], order[first]
|
||
if rng.random() < 0.4:
|
||
gene = rng.randrange(count)
|
||
lines[gene] = rng.choice(options[gene])
|
||
next_population.append(make_candidate(tuple(order), tuple(lines)))
|
||
population = next_population
|
||
completed_generations = generation + 1
|
||
|
||
best = min(population, key=lambda candidate: candidate.score)
|
||
output: list[dict] = []
|
||
for idx in best.order:
|
||
entry = dict(entries[idx])
|
||
if best.lines[idx] >= 0:
|
||
entry["forcedLineId"] = best.lines[idx]
|
||
output.append(entry)
|
||
|
||
meta.update({
|
||
"status": "FEASIBLE",
|
||
"wallTimeSec": round(monotonic() - started, 4),
|
||
"timeLimitSec": time_limit,
|
||
"objective": best.score,
|
||
"baselineObjective": baseline.score,
|
||
"population": population_size,
|
||
"generations": completed_generations,
|
||
"generationBudget": generation_budget,
|
||
"gap": None,
|
||
})
|
||
return output, meta
|
||
|
||
|
||
class GeneticAlgorithmEngine(RuleEngine):
|
||
"""SC-03:GA 搜索顺序与产线,班次和硬约束由 RuleEngine 统一物化。"""
|
||
|
||
name = "GA"
|
||
supports_anytime = True
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(requested_type="GA")
|
||
|
||
def solve(
|
||
self,
|
||
world: World,
|
||
params: EngineParams,
|
||
next_id: Callable[[str], int],
|
||
) -> ScheduleResult:
|
||
entries, campaign_meta, source_count = self.collect_and_order(world, params)
|
||
ordered, solver_meta = optimize_genetic_assignment(world, entries, params)
|
||
return self.materialize_schedule(
|
||
world,
|
||
params,
|
||
next_id,
|
||
ordered,
|
||
campaign_meta,
|
||
source_count,
|
||
solver_meta=solver_meta,
|
||
)
|