aps-agent/server/engines/ga_engine.py

225 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 遗传算法排产引擎(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,
)