Skip to content

●  사용방법 · 스케줄링

생산 라인 순서 계획

포장 작업을 생산 라인에 나누고 순서를 정합니다. 제품을 바꿀 때 드는 청소 시간과 여러 라인을 오가는 작업자를 고려합니다.

무엇을 정하나요

포장 작업마다 생산 라인과 그 라인에서의 순서를 정합니다. 기본 풀이에서는 라인별 작업자도 다시 배정합니다.

최소 실행 예제

from datetime import date, datetime, timedelta

from optimaru import food_packaging as fp
from optimaru.food_packaging.models import (
    JobModel, LineModel, OperatorModel, PackagingScheduleModel, ProductModel,
)

start = datetime(2026, 1, 5, 8)
minutes = timedelta(minutes=1)

def job(id, product_id, hours):
    return JobModel(id=id, name=id, product_id=product_id, duration=timedelta(hours=hours),
                    min_start_time=start, ideal_end_time=start + timedelta(hours=12),
                    max_end_time=start + timedelta(hours=24))

problem = PackagingScheduleModel(
    from_date=date(2026, 1, 5),
    to_date=date(2026, 1, 7),
    products=[
        ProductModel(id="milk", name="Milk", cleaning_durations={"milk": 0 * minutes, "juice": 30 * minutes}),
        ProductModel(id="juice", name="Juice", cleaning_durations={"juice": 0 * minutes, "milk": 45 * minutes}),
    ],
    operators=[OperatorModel(id="O1"), OperatorModel(id="O2")],
    lines=[
        LineModel(id="L1", name="Line 1", start_date_time=start, operator_id="O1"),
        LineModel(id="L2", name="Line 2", start_date_time=start, operator_id="O2"),
    ],
    jobs=[job("J1", "milk", 3), job("J2", "juice", 2), job("J3", "milk", 4), job("J4", "juice", 3)],
)

core = fp.solve(problem.to_core(), time_limit_seconds=2.0, random_seed=42)
solved = PackagingScheduleModel.from_core(core)
print(solved.score_hard, solved.score_medium, solved.score_soft)
print({line.id: (line.operator_id, line.job_ids) for line in solved.lines})
print([(j.id, j.line_id, j.start_production_date_time, j.end_date_time) for j in solved.jobs])

같은 유스케이스를 그림과 실행 결과가 담긴 Jupyter 노트북으로도 볼 수 있습니다. examples/food-packaging.ipynb ↗

입력 파라미터

ProductModel

필드 의미
id 필수 입력 제품 id입니다. name 필수 입력 제품 이름입니다. cleaning_durations 바로 앞에 만든 제품의 id별 청소 시간입니다.

OperatorModel

필드 의미
id 필수 입력 작업자 id입니다.

LineModel

필드 의미
id 필수 입력 라인 id입니다. name 필수 입력 라인 이름입니다. start_date_time 필수 입력 라인이 시작하는 시각입니다. operator_id 라인을 맡은 작업자입니다.

JobModel

필드 의미
id 필수 입력 작업 id입니다. name 필수 입력 작업 이름입니다. product_id 필수 입력 만드는 제품입니다. duration 필수 입력 생산 시간입니다. min_start_time 필수 입력 가장 이른 시작 시각입니다. 값은 보관하지만 이를 읽는 제약은 없습니다. ideal_end_time 필수 입력 작업이 끝나면 좋은 시각입니다. max_end_time 필수 입력 작업이 반드시 끝나야 하는 시각입니다. pinned 작업을 지금 위치에 고정합니다.

PackagingScheduleModel

필드 의미
from_date 필수 입력 기간의 첫날입니다. 이 날짜를 포함합니다. to_date 필수 입력 기간의 끝입니다. 이 날짜는 포함하지 않습니다. products 필수 입력 제품 목록입니다. operators 필수 입력 작업자 목록입니다. lines 필수 입력 생산 라인 목록입니다. jobs 필수 입력 포장 작업 목록입니다.

풀이 파라미터

풀이를 호출할 때 넘기는 값입니다. 시간 제한, 시드, 병렬 수가 여기에 있습니다.

파라미터 기본값 의미
time_limit_seconds None 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다. 지정하지 않으면 설정의 spent_limit_seconds를 쓰며, 기본값은 30입니다.
random_seed None 탐색에 쓰는 난수 시드입니다. 지정하지 않으면 설정값을 쓰며, 기본값은 0입니다.
parallel_solver_count None 독립적으로 동시에 돌리는 탐색 수입니다. 탐색마다 시드가 다르고 시간은 똑같이 쓰며, 가장 좋은 결과를 고릅니다. 지정하지 않으면 설정값을 쓰며, 기본값은 1입니다.
stop_at_first_feasible False 필수 점수가 0이 되는 즉시 멈춥니다.
on_new_best None 새 최고 해를 찾을 때마다 부르는 콜백입니다. 콜백이 예외를 내면 풀이를 멈추고 그 예외를 그대로 냅니다.
config None 기본으로 쓸 SolverConfig입니다. 여기서 준 키워드 인자는 이번 풀이에만 이 값을 덮어쓰며, 넘긴 객체 자체는 바뀌지 않습니다.
constraints None 점수에 넣을 제약과 그 설정입니다. 지정하지 않으면 기본 제약을 씁니다.
탐색 옵션 전체 (26)

SolverConfig에 값을 넣어 config=로 넘기세요.

파라미터 기본값 의미
best_score_limit — 최고 점수가 이 값에 닿으면 일찍 멈춥니다. 기본값으로는 작동하지 않습니다.
full_assert False 증분 점수를 전체 재계산과 매번 비교합니다. 느리므로 디버깅할 때만 씁니다.
late_acceptance_size 0 늦은 수락(late acceptance) 기록의 길이입니다. 0이면 문제 크기에 맞춰 정합니다.
list_swap_move_weight 0.25 목록 교환 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
nearby_probability 0.8 이동 상대를 가까운 항목에서 고를 확률입니다. 0이면 쓰지 않습니다.
nearby_size 10 항목마다 유지할 이웃 수입니다. 0의 의미는 유스케이스마다 달라 자동 선택이거나 끄는 값입니다.
on_new_best None 새 최고 해를 찾을 때마다 부르는 콜백입니다. 콜백이 예외를 내면 풀이를 멈추고 그 예외를 그대로 냅니다.
operator_rounds 0 탐색과 작업자 재배정을 번갈아 도는 횟수입니다. 0이면 자동으로 정합니다.
or_opt_move_weight 0.07 짧은 구간을 옮기는 Or-opt 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
parallel_solver_count 1 독립적으로 동시에 돌리는 탐색 수입니다. 탐색마다 시드가 다르고 시간은 똑같이 쓰며, 가장 좋은 결과를 고릅니다.
random_seed 0 탐색에 쓰는 난수 시드입니다.
relocate_move_weight 0.55 재배치 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
ruin_recreate True 탐색이 정체되면 적응형 파괴·재구성을 실행합니다.
ruin_recreate_max_ruin_count 60 한 번에 제거하는 항목 수의 절대 상한입니다.
ruin_recreate_max_ruin_fraction 0.3 한 번에 제거하는 해의 최대 비율입니다.
ruin_recreate_min_ruin_fraction 0.1 한 번에 제거하는 해의 최소 비율입니다.
ruin_recreate_operator_decay 0.8 연산자 가중치가 적응하는 속도입니다.
ruin_recreate_polish_steps 5000 실행 후 다듬는 언덕 오르기 이동 수입니다. step_count_limit에는 세지 않습니다.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] 새 최고, 개선, 수락, 거절 결과에 주는 연산자 보상입니다.
ruin_recreate_unimproved_step_trigger 5000 파괴·재구성을 실행하기까지 새 최고 해 없이 지나는 걸음 수입니다.
spent_limit_seconds 30.0 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다.
step_count_limit 0 이 걸음 수에 도달하면 멈춥니다. 0이면 제한이 없습니다.
tail_swap_move_weight 0.05 두 목록의 뒷부분을 맞바꾸는 이동(2-opt*)의 상대적 비중입니다. 0이면 쓰지 않습니다.
two_opt_move_weight 0.08 2-opt 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
unimproved_spent_limit_seconds 0.0 새 최고 해 없이 이 시간(초)이 지나면 멈춥니다. 0이면 제한이 없습니다.
unimproved_step_count_limit 0 새 최고 해 없이 이 걸음 수가 지나면 멈춥니다. 0이면 제한이 없습니다.

돌아오는 결과

solve는 엔진 객체를 받아 그 자리에서 풀고 돌려줍니다. 결과는 from_core로 변환해 읽습니다.

LineModel

필드 의미
job_ids 라인의 작업을 순서대로 나열한 목록입니다. 배정의 기준은 이 목록입니다. operator_id 풀이 후의 작업자입니다.

JobModel

필드 의미
line_id 작업이 배정된 라인입니다. start_cleaning_date_time 청소를 시작하는 시각입니다. start_production_date_time 생산을 시작하는 시각입니다. end_date_time 작업이 끝나는 시각입니다.

PackagingScheduleModel

필드 의미
score_hard 필수 점수입니다. 0이면 가능한 해입니다. score_medium 중간 점수입니다. 미배정 작업과 이상적 종료 시각 초과를 나타냅니다. score_soft 선호 점수입니다.

제약

optimaru.food_packaging.constraints에서 클래스를 가져와 MaxEndTimeCost()처럼 호출해서 넘깁니다. 넘긴 제약만 점수에 들어가고, 아무것도 넘기지 않으면 기본 제약을 씁니다. 목록에 같은 제약을 여러 번 넣을 수 있으며, 넣은 것마다 따로 점수에 들어갑니다. 제약마다 다음 파라미터를 받습니다.

파라미터 기본값 의미
weight None 제약 비용에 곱하는 값이며, 적용되는 수준의 단위로 셉니다. 지정하지 않으면 아래 기본 가중치를 씁니다. 기본 가중치가 0인 제약은 가중치를 줘야 작동합니다.
level None "hard", "medium", "soft"를 주면 제약을 그 수준으로 옮깁니다. Level.HARD, Level.MEDIUM, Level.SOFT도 됩니다. 지정하지 않으면 아래 수준을 그대로 씁니다. 이 유스케이스에는 중간 수준이 있어서 어느 제약이든 중간으로 옮길 수 있습니다.
exponent 1.0 부호를 유지한 채 비용에 거듭제곱을 적용하며, 0보다 커야 합니다. offset을 반영한 뒤에 적용합니다. 2.0이면 제곱이라 한곳에 몰린 위반의 비용이 크게 늘어납니다. 차량이나 날짜 같은 단위마다 적용하고, 합계 하나로 계산하는 제약에는 그 합계에 적용합니다.
offset 0.0 거듭제곱 전에 비용 x에서 빼는 값입니다. exponent처럼 단위마다, 합계로 계산하는 제약은 그 합계에 적용하며, 비용과 같은 척도(초, 분, 개수 등)로 셉니다. 부호와 상관없이 유한한 수면 되고, 키워드로만 줄 수 있습니다. 이 값 아래에서 어떻게 계산할지는 clip이 정합니다.
clip True True면 offset을 문턱값으로 씁니다. 넘은 부분만 계산하므로(max(0, x - offset) ** exponent) 그 이하의 비용은 아무것도 더하지 않습니다. False면 부호를 유지한 채 (x - offset) ** exponent를 그대로 계산하므로, 오프셋보다 작은 단위는 비용이 0이어도 음수, 곧 보상으로 들어갑니다. 하드 점수에 들어가는 수준에서는 clip=False에 0이 아닌 오프셋을 주면 ValueError가 납니다. 하드 0이 위반 없음이라는 뜻을 지키기 위해서입니다. 키워드로만 줄 수 있습니다.

중간 제약의 기본 가중치는 중간 수준 단위로 1입니다. 탐색은 중간 값에 1e10을 곱해 선호 값과 합친 점수로 해를 비교하므로, 선호 합계가 1e10보다 작은 동안에는 중간 수준이 선호보다 우선합니다. level로 어느 제약이든 세 수준 가운데 하나로 옮길 수 있습니다. level="medium"이면 중간으로 옮기고, "hard"나 "soft"면 중간 제약을 필수나 선호로 옮깁니다. 가중치는 적용되는 수준의 단위로 셉니다.

위의 최소 실행 예제에 이어서 실행합니다.

from optimaru.food_packaging.constraints import (
    AfterIdealEndTimeCost, MaxEndTimeCost, SquaredMakespanCost, UnassignedJobsCost,
    get_constraints,
)

picked = get_constraints([
    MaxEndTimeCost(), UnassignedJobsCost(), AfterIdealEndTimeCost(), SquaredMakespanCost(weight=2),
])
custom = fp.solve(problem.to_core(), time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score)
이름 수준 기본 가중치 하는 일
max_end_time_cost 필수 1 작업이 max_end_time을 넘긴 시간(분)에 벌점을 줍니다.
after_ideal_end_time_cost 중간 1 작업이 ideal_end_time을 넘긴 시간(분)에 벌점을 줍니다.
squared_makespan_cost 선호 1 라인별 가동 시간의 제곱을 줄입니다. 그래서 작업이 라인마다 고르게 나뉩니다.
operator_cleaning_conflict_cost 필수 1 같은 작업자가 맡은 라인끼리 청소 시간이 겹치는 만큼(분) 벌점을 줍니다.
unassigned_jobs_cost 중간 1 어느 라인에도 없는 작업의 생산 시간(분)을 벌점으로 줍니다. 긴 작업을 뺄수록 손해가 큽니다.

제약 사용법 →

기존 해에서 시작하기

최신 버전이 필요합니다. pip install -U optimaru로 올려 주세요.

with_initial_solution(problem, solution)은 라인의 job_ids와 operator_id를 해의 값으로 바꾼 새 문제를 돌려줍니다.

위의 최소 실행 예제에 이어서 실행합니다.

seeded = fp.with_initial_solution(problem, solved)  # solved.model_dump(mode="json")도 됩니다
better = fp.solve(seeded.to_core(), time_limit_seconds=2.0, random_seed=42)
print(better.score)
  • 완전한 해를 넣으면 구성 단계를 건너뛰고 그 해에서 바로 탐색을 시작합니다. 일부만 넣으면 넣은 부분은 그대로 두고, 빠진 작업만 새로 구성합니다.
  • 고정된 작업은 문제에 있는 그대로 둡니다. 해의 라인은 고정된 작업으로 시작해야 합니다.
  • 모르는 id, 두 번 나온 작업, 옮겨진 고정 작업, 제품 사이의 청소 시간이 없는 이웃 작업은 ValueError를 냅니다.
  • constraints= 없이 푸는 기본 풀이에서는 넣어 준 작업자도 바뀔 수 있습니다. improve_operator_assignment가 탐색 시간이 없어도 탐색 뒤에 실행되어, 청소 충돌이 확실히 줄어들면 작업자를 바꿉니다.
  • 기본 풀이에서 시간 제한이 1ms 이하면 구성 단계 전에 돌아오므로 빠진 작업은 미배정으로 남습니다. 완전한 해는 넣은 그대로 돌아오지만, 일부만 있는 해를 채우려면 시간을 더 줘야 합니다.
  • step_count_limit = 0은 제한이 없다는 뜻입니다.

예외와 제한

  • min_start_time과 priority는 작업에 저장되지만 이를 읽는 제약은 없습니다.
  • 이웃할 수 있는 제품 쌍은 같은 제품끼리를 포함해 cleaning_durations에 모두 적어 주세요. 값이 없는 쌍이 이웃하게 되면 풀이 중에 ValueError가 납니다.
  • 작업자는 탐색 구간 사이에 언덕 오르기로 다시 배정되며, 기본 풀이에서만 그렇습니다.