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,
|
|||
|
|
)
|