Scheduling Deployments¶
Problem Statement¶
Schedule resource-heavy deployments on customer-facing hardware at low-traffic times so that customers are least likely to be impacted.
Full example scenario
As a DevOps engineer, you are responsible for scheduling deployments throughout the day while minimizing the maximum load on the servers at any given time. You have data on the expected load during the day from regular customer usage, which consumes part of the available load on the servers. Additionally, you have several deployments planned, each with its own load requirements and duration. The challenge is to schedule these deployments such that the load deviation on the servers is minimized, while ensuring the load never surpasses 100%.
All deployments have to be executed, and each deployment’s start time must fall within its own start window.
The total load at any given time (customer load plus deployment load) must never exceed server capacity.
Deployments are non-preemptive: once started, a deployment runs to completion without pausing.
The customer load can be found in
scheduling_deployments_base_load.csv, with the columnsTime,Customer Load.The deployments can be found in
scheduling_deployments_deployments.csv, with the columnsDeployment ID,Deployment Load,Deployment Duration,Deployment Start Window Start,Deployment Start Window End.
What an optimal solution looks like
Customer load (grey) plus every deployment's contribution, hour by hour.
The four hours marked in red — 6, 8, 17 and 21 — all land on the peak of 65%. Hover or focus a bar for its exact customer/deployment split.
This is the baseline plan, before the D8 → D2 precedence requirement introduced in the Changing the model tab. Under that requirement D2 moves to hour 16, but the peak stays at 65%.
When each deployment runs
Same hour axis as above, one bar per deployment's active window. The row a bar sits in carries no meaning of its own — it only keeps overlapping deployments apart. A deployment is placed in the lowest row that's already free by its start hour, so unrelated deployments don't each get a permanent row.
D1: Deployment #1 · D2: Deployment #2 · … · D10: Deployment #10
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same deployment scheduling problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
As a DevOps engineer, I need to schedule a batch of deployments across the day on our servers, and I want to keep the load on the servers as even as possible throughout the day — not spike anywhere. We already have regular customer traffic taking up part of server capacity throughout the day, and each deployment adds its own load for a while once it starts.
This is a scheduling problem with a load-leveling objective — deployments are jobs with a start-time decision, customer traffic is a fixed background load, and the goal is some notion of “as even as possible.” Let me search the knowledge base for how similar load-balancing schedules are usually modeled before asking anything.
Good, scheduling problems in the knowledge base confirm this is a start-time assignment problem. Before I can even talk about the objective, I need to know how the day is discretized — that determines the whole variable structure, so it has to be the first question.
Good context from the knowledge base. Let me start building the specification with you. I’ll ask questions one at a time to make sure I capture everything correctly.
Question 1 of ~8: How is the day divided for scheduling purposes? For example:
Fixed time slots of equal length (e.g., 1-hour slots, 30-minute slots, 15-minute slots)
Continuous time with any start time possible
Predefined windows (e.g., morning, afternoon, evening)
Which best describes your setup, and how long is the scheduling horizon (e.g., a single 24-hour day, a business-hours window like 08:00–20:00)?
It’s a single 24-hour day, divided into 24 one-hour slots (hour 0 through hour 23). Each deployment has its own load requirement, a duration in hours, and a window of hours during which it’s allowed to start — once started it has to run to completion without pausing (non-preemptive). Also, one hard requirement: the total load on the server (customer load plus any deployments running) must never exceed 100% capacity at any hour.
That answers the discretization question and volunteers three more pieces on top of it: the per-deployment fields (load, duration, window), the non-preemptive rule, and a hard capacity cap. All four go straight into the specification. What’s still missing is the objective itself — “as even as possible” is directional, not a formula, so that has to be the next question, and it changes which auxiliary variable the model needs.
Got it. Here is what I have captured so far:
Question 2 of ~7: Regarding the objective — “keeping the load as even as possible” — which approach best matches your goal?
Minimize the peak load across all hours (i.e., minimize the single worst hour — a min-max approach)
Minimize the total load variance across hours (minimize how much each hour deviates from the average)
Minimize the sum of excess load above a target threshold (e.g., penalize every hour that goes above 70% capacity)
A combination of the above
Option 1 is the most common and computationally efficient for this type of problem. Which fits your needs best?
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
Modeler asked for the load and deployment figures as files rather than as numbers typed into the chat. Attach both CSVs to the conversation and it reads the real shape of your data instead of guessing at it — entity names, column headers, units and all.
scheduling_deployments_base_load.csv:
Time,Customer Load
0,20
1,25
2,30
3,22
4,18
5,15
6,20
7,35
8,50
9,60
10,55
11,45
12,40
13,38
14,35
15,33
16,30
17,40
18,50
19,55
20,60
21,45
22,35
23,25
scheduling_deployments_deployments.csv:
Deployment ID,Deployment Load,Deployment Duration,Deployment Start Window Start,Deployment Start Window End
D1,10,2,0,8
D2,15,1,8,16
D3,20,3,15,22
D4,25,2,6,12
D5,30,1,0,23
D6,10,1,0,8
D7,20,2,5,13
D8,15,3,9,15
D9,25,2,14,21
D10,30,1,0,23
scheduling_deployments_base_load.csv and scheduling_deployments_deployments.csv attached
Here’s the customer load by hour, and the list of planned deployments. Load figures are in percent of server capacity (0-100). Deployment Duration is in hours, and Deployment Start Window Start/End are the earliest/latest hour the deployment may start. For the objective, let’s go with option 1, minimize the peak load.
Reading both attachments. The base-load file is one row per hour, 24 rows, values already in percent — matching the unit the user described, so no conversion is needed. The deployments file is one row per deployment with the load, duration and window fields already named as expected, so this maps directly onto the “Deployments” entity the specification still needs. One thing stands out: window_end for several deployments (D3=22, D5=23, D10=23) sits close to or at the last hour of the day, and combined with a multi-hour duration that could push a deployment’s finish past hour 23. That is a real ambiguity worth surfacing rather than resolving silently.
The specification is updated with all the data. One important clarification before we finalize:
Question 3 of ~5: Regarding the deployment start window — specifically the Window End value — which interpretation is correct?
Latest start hour: The deployment may start as late as
window_end, and can finish beyond it (e.g., a deployment with duration 3 starting at hour 22 would run through hours 22, 23, and… hour 24, which is outside the day)Must fully complete within the window: The deployment must both start and finish within the window, meaning the latest valid start is
window_end - duration + 1Must fully complete within the 24-hour day: The start window is respected as-is, but no deployment may run past hour 23
For example, deployment D3 has duration=3 and window_end=22 — should it be allowed to start at hour 22 and run through hours 22, 23, and a hypothetical hour 24, or must it start no later than hour 21 to finish within the day?
Where your files live
Everything in the conversation — the files you attached and every artifact Modeler generates from them — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The load and deployment data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
A batch of deployments must be scheduled across a single 24-hour day on a server. The day is divided into 24 one-hour slots (hour 0 through hour 23). The server already carries a known customer traffic load at each hour. Each deployment adds its own load for a fixed duration once it starts. The model decides the start hour for each deployment.
2. Objective
Minimize the peak total server load — customer load plus all deployment loads — across all 24 hours of the day.
3. Constraints
C1 — Deployments schedule within their window. Each deployment must start within its specified earliest and latest allowed start hour. The effective latest start is capped further so that the deployment still finishes within hour 23, even if the raw window would allow a later start.
C2 — Non-preemptive execution. Once a deployment starts, it runs continuously for its full duration without interruption. It occupies consecutive hours from its start hour through start + duration − 1, so a deployment cannot be paused and resumed later to dodge a busy hour.
C3 — Every deployment is scheduled exactly once. Every deployment in the list must be assigned exactly one start hour within its valid window — none may be skipped or split.
C4 — The hard capacity cap. At every hour, the sum of the customer load and the load from all deployments currently running must not exceed 100% of server capacity. This is non-negotiable regardless of how the peak-load objective trades off against it.
C5 — The peak is the worst hour, not an average. An auxiliary quantity representing the peak load must be at least as large as the total load at every hour, so that minimizing it drives every hourly load down rather than just the average.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Time slot |
hour (0–23) |
24 one-hour slots spanning a single day |
Customer load |
hour, load (% of capacity) |
Ranges from 15% (hour 5) to 60% (hours 9 and 20) |
Deployment |
id, load (%), duration (hours), window start, window end |
10 deployments (D1–D10), supplied in |
Total deployment load-hours across the day is 350 (an average of about 14.6% of capacity per hour), against a customer load that already peaks at 60% twice a day — so the schedule has to route most deployments around those two hours to keep the combined peak low.
5. Output
The peak total server load achieved, as a percentage of capacity.
The assigned start hour for every deployment.
The full 24-hour total load profile (customer load plus running deployments, hour by hour).
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — the full 10-deployment dataset. |
A feasible, optimal schedule: every deployment inside its window, no hour past 23, capacity never exceeded, and the reported peak equals the actual maximum of the load profile. |
T2 |
Peak load is correctly minimized. |
The reported peak equals |
T3 |
Capacity is respected even with the heaviest deployments overlapping peak customer hours (D4=25%, D5=30%, D9=25%, D10=30%). |
No hour exceeds 100% total load. |
T4 |
Infeasible — a deployment is forced onto an already-saturated hour (a 50%-load deployment forced to start at hour 9, where customer load is already 60%). |
Reported as infeasible; no schedule is returned. |
T5 |
Single deployment, one-hour window — the assignment is forced. |
The deployment lands at its only valid start hour, and the load profile reflects its load at exactly that hour and nowhere else. |
7. Out of scope
Multiple servers or server pools; the model schedules against a single server’s capacity.
Preemptive or pausable deployments.
Dependencies or ordering constraints between deployments.
Variable deployment load; load is constant for the full run duration once a deployment starts.
Soft constraints or penalty-based capacity violations — the 100% cap is hard.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version that came out of the run-and-fix loop in the Cloud solve
tab, after Modeler ran the tests against it.
"""
Deployment Scheduling Optimizer
Schedules a batch of server deployments across a 24-hour day to minimize the peak
total server load (customer traffic + running deployments) while respecting each
deployment's allowed start window, non-preemptive execution, and a hard 100%
capacity cap at every hour.
Implements the specification in scheduling_deployments_modeler_spec.rst.
"""
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
class DeploymentScheduler:
"""
Optimizer that assigns a start hour to each deployment in order to
minimize the peak server load across all 24 hours of the day.
"""
HOURS = list(range(24)) # Hour indices 0-23
def __init__(self, env: gp.Env):
"""Initialize the scheduler with a Gurobi environment."""
self.env = env
self.model: gp.Model = None
# Input data
self.base_load: dict[int, float] = {} # {hour: customer_load_%}
self.deployments: pd.DataFrame = None # deployment records
# Decision variable containers
self._x: dict = {} # x[d, t] = 1 if deployment d starts at hour t
self._peak: gp.Var = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.model is not None:
self.model.dispose()
return False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def set_base_load(self, base_load_df: pd.DataFrame):
"""
Load the 24-hour customer traffic profile.
Parameters
----------
base_load_df : DataFrame with columns [hour (int), customer_load (float)]
"""
self.base_load = dict(zip(base_load_df["hour"], base_load_df["customer_load"]))
def set_deployments(self, deployments_df: pd.DataFrame):
"""
Load the deployment list.
Parameters
----------
deployments_df : DataFrame with columns
deployment_id, load, duration, window_start, window_end
"""
self.deployments = deployments_df.copy()
# Compute effective latest start: must finish within hour 23 (C1).
self.deployments["eff_window_end"] = self.deployments.apply(
lambda r: min(r["window_end"], 23 - r["duration"] + 1), axis=1
)
# ------------------------------------------------------------------
# Model building
# ------------------------------------------------------------------
def build_model(self):
"""Create the Gurobi model, variables, constraints, and objective."""
self.model = gp.Model("deployment_scheduling", env=self.env)
self._create_variables()
self._add_constraints()
self._set_objective()
def _create_variables(self):
"""
Binary start-assignment variables and peak-load auxiliary variable.
x[d, t] = 1 iff deployment d starts at hour t (t within its valid window).
peak = peak total server load across all hours (the value we minimize).
"""
deployments = self.deployments
# Binary assignment variables — one per (deployment, feasible start hour) pair
for _, row in deployments.iterrows():
d = row["deployment_id"]
for t in range(int(row["window_start"]), int(row["eff_window_end"]) + 1):
self._x[d, t] = self.model.addVar(
vtype=GRB.BINARY, name=f"x_{d}_t{t}"
)
# Continuous peak load variable (% of capacity, bounded by 0 and 100)
self._peak = self.model.addVar(lb=0.0, ub=100.0, name="peak_load")
def _add_constraints(self):
"""Add all business constraints to the model."""
deployments = self.deployments
# C3: Each deployment must be assigned exactly one start hour.
for _, row in deployments.iterrows():
d = row["deployment_id"]
valid_starts = range(int(row["window_start"]), int(row["eff_window_end"]) + 1)
self.model.addConstr(
gp.quicksum(self._x[d, t] for t in valid_starts) == 1,
name=f"assign_{d}"
)
# C4 & C5: At every hour h, total load <= 100 and peak >= total load.
for h in self.HOURS:
# Total deployment load running at hour h:
# deployment d running at h iff it was started at some t where t <= h < t + duration
# (C2, non-preemptive execution, is encoded structurally by this condition).
deployment_load_h = gp.quicksum(
row["load"] * self._x[row["deployment_id"], t]
for _, row in deployments.iterrows()
for t in range(int(row["window_start"]), int(row["eff_window_end"]) + 1)
if t <= h < t + row["duration"]
)
total_load_h = self.base_load[h] + deployment_load_h
# C4: Hard capacity cap of 100%.
self.model.addConstr(total_load_h <= 100.0, name=f"cap_h{h}")
# C5: Peak is >= total load at every hour.
self.model.addConstr(self._peak >= total_load_h, name=f"peak_h{h}")
def _set_objective(self):
"""Minimize the peak server load across all hours."""
self.model.setObjective(self._peak, GRB.MINIMIZE)
# ------------------------------------------------------------------
# Solving
# ------------------------------------------------------------------
def solve(self) -> dict:
"""
Run the optimizer and return the solution dictionary.
Returns
-------
dict with keys:
status (int) Gurobi status code
is_optimal (bool) True if an optimal solution was found
objective_value (float|None) Minimized peak load, or None
start_hours (dict) {deployment_id: start_hour} when optimal
hourly_load_profile (list[float]) 24-entry total load profile when optimal
"""
self.model.optimize()
status = self.model.Status
is_optimal = status == GRB.OPTIMAL
if not is_optimal:
return {
"status": status,
"is_optimal": False,
"objective_value": None,
"start_hours": {},
"hourly_load_profile": [],
}
# Extract start hours
start_hours = {}
for _, row in self.deployments.iterrows():
d = row["deployment_id"]
for t in range(int(row["window_start"]), int(row["eff_window_end"]) + 1):
if self._x[d, t].X > 0.5:
start_hours[d] = t
break
# Build hourly load profile
hourly_load_profile = []
for h in self.HOURS:
deployment_load = sum(
row["load"]
for _, row in self.deployments.iterrows()
if start_hours[row["deployment_id"]] <= h
< start_hours[row["deployment_id"]] + row["duration"]
)
hourly_load_profile.append(self.base_load[h] + deployment_load)
return {
"status": status,
"is_optimal": True,
"objective_value": self._peak.X,
"start_hours": start_hours,
"hourly_load_profile": hourly_load_profile,
}
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def display_results(self, solution: dict):
"""Print a formatted summary of the scheduling solution."""
if not solution["is_optimal"]:
print(f"No optimal solution found. Gurobi status: {solution['status']}")
return
print("=" * 55)
print(" Deployment Scheduling — Optimal Solution")
print("=" * 55)
print(f" Peak server load : {solution['objective_value']:.2f}%")
print()
print(" Deployment start assignments:")
for d, t in sorted(solution["start_hours"].items()):
row = self.deployments[self.deployments["deployment_id"] == d].iloc[0]
end = t + int(row["duration"]) - 1
print(f" {d:>4s} start={t:>2d} end={end:>2d} "
f"(load={row['load']}%, dur={row['duration']}h)")
print()
print(" Hourly total load profile:")
print(f" {'Hour':>4s} {'Base':>6s} {'Deploy':>8s} {'Total':>7s}")
print(" " + "-" * 32)
for h, total in enumerate(solution["hourly_load_profile"]):
deploy = total - self.base_load[h]
print(f" {h:>4d} {self.base_load[h]:>5.1f}% {deploy:>7.1f}% {total:>6.1f}%")
print("=" * 55)
# ----------------------------------------------------------------------
# Sample data
# ----------------------------------------------------------------------
BASE_LOAD_DATA = pd.DataFrame({
"hour": list(range(24)),
"customer_load": [
20, 25, 30, 22, 18, 15, 20, 35,
50, 60, 55, 45, 40, 38, 35, 33,
30, 40, 50, 55, 60, 45, 35, 25,
],
})
DEPLOYMENTS_DATA = pd.DataFrame({
"deployment_id": ["D1","D2","D3","D4","D5","D6","D7","D8","D9","D10"],
"load": [10, 15, 20, 25, 30, 10, 20, 15, 25, 30 ],
"duration": [2, 1, 3, 2, 1, 1, 2, 3, 2, 1 ],
"window_start": [0, 8, 15, 6, 0, 0, 5, 9, 14, 0 ],
"window_end": [8, 16, 22, 12, 23, 8, 13, 15, 21, 23 ],
})
# ----------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------
if __name__ == "__main__":
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 1)
env.setParam("TimeLimit", 60)
env.start()
with DeploymentScheduler(env) as scheduler:
scheduler.set_base_load(BASE_LOAD_DATA)
scheduler.set_deployments(DEPLOYMENTS_DATA)
scheduler.build_model()
solution = scheduler.solve()
scheduler.display_results(solution)
Alongside the implementation, Modeler writes a pytest suite with one class
of tests per scenario in the specification. Each test class references its
scenario identifier, and uses the small hard-coded load and deployment data and
a short solver time limit so the suite runs in well under a second.
"""
Unit tests for the DeploymentScheduler optimizer.
Covers all test scenarios defined in the specification:
T1 - Standard dataset: optimal scheduling
T2 - Peak load is correctly minimized
T3 - Capacity constraint always respected
T4 - Infeasible case
T5 - Single deployment edge case
"""
import time
import pytest
import pandas as pd
import gurobipy as gp
from scheduling_deployments_modeler import DeploymentScheduler
TOLERANCE = 1e-6
TIME_LIMIT = 10 # seconds per test
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
def make_env() -> gp.Env:
"""Create a silent Gurobi environment with a short time limit."""
env = gp.Env(empty=True)
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", TIME_LIMIT)
env.start()
return env
# Full 10-deployment dataset (T1, T2, T3)
FULL_BASE_LOAD = pd.DataFrame({
"hour": list(range(24)),
"customer_load": [
20, 25, 30, 22, 18, 15, 20, 35,
50, 60, 55, 45, 40, 38, 35, 33,
30, 40, 50, 55, 60, 45, 35, 25,
],
})
FULL_DEPLOYMENTS = pd.DataFrame({
"deployment_id": ["D1","D2","D3","D4","D5","D6","D7","D8","D9","D10"],
"load": [10, 15, 20, 25, 30, 10, 20, 15, 25, 30 ],
"duration": [2, 1, 3, 2, 1, 1, 2, 3, 2, 1 ],
"window_start": [0, 8, 15, 6, 0, 0, 5, 9, 14, 0 ],
"window_end": [8, 16, 22, 12, 23, 8, 13, 15, 21, 23 ],
})
def run_full_model():
"""Helper: solve the full dataset and return (scheduler, solution, env)."""
env = make_env()
scheduler = DeploymentScheduler(env)
scheduler.set_base_load(FULL_BASE_LOAD)
scheduler.set_deployments(FULL_DEPLOYMENTS)
scheduler.build_model()
solution = scheduler.solve()
return scheduler, solution, env
# ---------------------------------------------------------------------------
# T1: Standard dataset — optimal scheduling
# ---------------------------------------------------------------------------
class TestT1StandardDataset:
"""T1: Full 10-deployment dataset must yield a valid optimal schedule."""
def setup_method(self):
start = time.time()
self.scheduler, self.solution, self.env = run_full_model()
self.elapsed = time.time() - start
def teardown_method(self):
self.scheduler.model.dispose()
self.env.dispose()
def test_t1_completes_within_time_limit(self):
"""T1: Solve must finish within TIME_LIMIT seconds."""
assert self.elapsed < TIME_LIMIT, (
f"Test took {self.elapsed:.1f}s, exceeding limit of {TIME_LIMIT}s"
)
def test_t1_is_optimal(self):
"""T1: Solver must find an optimal solution."""
assert self.solution["is_optimal"] is True
def test_t1_objective_positive(self):
"""T1: Peak load must be a positive finite value."""
assert self.solution["objective_value"] is not None
assert self.solution["objective_value"] > 0
def test_t1_all_deployments_assigned(self):
"""T1: Every deployment must appear in start_hours."""
expected_ids = set(FULL_DEPLOYMENTS["deployment_id"])
assert set(self.solution["start_hours"].keys()) == expected_ids
def test_t1_start_hours_within_window(self):
"""T1: Every assigned start hour must be within the deployment's valid window."""
deps = self.scheduler.deployments
for _, row in deps.iterrows():
d = row["deployment_id"]
t = self.solution["start_hours"][d]
eff_end = int(row["eff_window_end"])
assert int(row["window_start"]) <= t <= eff_end, (
f"{d}: start={t} outside window [{row['window_start']}, {eff_end}]"
)
def test_t1_no_deployment_runs_past_hour_23(self):
"""T1: No deployment may run past hour 23."""
deps = self.scheduler.deployments
for _, row in deps.iterrows():
d = row["deployment_id"]
t = self.solution["start_hours"][d]
assert t + int(row["duration"]) - 1 <= 23, (
f"{d}: runs until hour {t + row['duration'] - 1}"
)
def test_t1_hourly_profile_has_24_entries(self):
"""T1: hourly_load_profile must contain exactly 24 values."""
assert len(self.solution["hourly_load_profile"]) == 24
def test_t1_objective_matches_max_profile(self):
"""T1: objective_value must equal max(hourly_load_profile) within tolerance."""
peak = max(self.solution["hourly_load_profile"])
assert abs(self.solution["objective_value"] - peak) <= TOLERANCE, (
f"objective={self.solution['objective_value']:.4f}, max_profile={peak:.4f}"
)
# ---------------------------------------------------------------------------
# T2: Peak load is correctly minimized
# ---------------------------------------------------------------------------
class TestT2PeakCorrectness:
"""T2: The peak value must equal the profile maximum and profile must be >= base load."""
def setup_method(self):
start = time.time()
self.scheduler, self.solution, self.env = run_full_model()
self.elapsed = time.time() - start
def teardown_method(self):
self.scheduler.model.dispose()
self.env.dispose()
def test_t2_completes_within_time_limit(self):
"""T2: Solve must finish within TIME_LIMIT seconds."""
assert self.elapsed < TIME_LIMIT
def test_t2_max_profile_equals_objective(self):
"""T2: max(hourly_load_profile) must equal objective_value within tolerance."""
assert self.solution["is_optimal"]
peak = max(self.solution["hourly_load_profile"])
assert abs(peak - self.solution["objective_value"]) <= TOLERANCE
def test_t2_profile_never_below_base_load(self):
"""T2: Every hourly total must be >= the base customer load (deployments add load only)."""
assert self.solution["is_optimal"]
base = FULL_BASE_LOAD.set_index("hour")["customer_load"]
for h, total in enumerate(self.solution["hourly_load_profile"]):
assert total >= base[h] - TOLERANCE, (
f"Hour {h}: total={total:.2f} < base={base[h]:.2f}"
)
# ---------------------------------------------------------------------------
# T3: Capacity constraint always respected
# ---------------------------------------------------------------------------
class TestT3CapacityRespected:
"""T3: Total load must never exceed 100% at any hour."""
def setup_method(self):
start = time.time()
self.scheduler, self.solution, self.env = run_full_model()
self.elapsed = time.time() - start
def teardown_method(self):
self.scheduler.model.dispose()
self.env.dispose()
def test_t3_completes_within_time_limit(self):
"""T3: Solve must finish within TIME_LIMIT seconds."""
assert self.elapsed < TIME_LIMIT
def test_t3_no_hour_exceeds_100_percent(self):
"""T3: Hard capacity cap of 100% must hold at every hour."""
assert self.solution["is_optimal"]
for h, total in enumerate(self.solution["hourly_load_profile"]):
assert total <= 100.0 + TOLERANCE, (
f"Hour {h}: total load {total:.2f}% exceeds 100%"
)
# ---------------------------------------------------------------------------
# T4: Infeasible case
# ---------------------------------------------------------------------------
class TestT4Infeasible:
"""T4: A deployment forced onto an over-saturated hour must be detected as infeasible."""
def test_t4_infeasible_detected(self):
"""T4: Model must return is_optimal=False and no objective when infeasible."""
# Base load at hour 9 is 60%; a 50% deployment forced there gives 110% > 100%
base_load = pd.DataFrame({
"hour": list(range(24)),
"customer_load": [20] * 9 + [60] + [20] * 14, # hour 9 = 60%
})
deployments = pd.DataFrame({
"deployment_id": ["D_big"],
"load": [50], # 50% + 60% base = 110% — infeasible
"duration": [1],
"window_start": [9],
"window_end": [9], # forced to start at hour 9
})
start = time.time()
env = make_env()
try:
with DeploymentScheduler(env) as scheduler:
scheduler.set_base_load(base_load)
scheduler.set_deployments(deployments)
scheduler.build_model()
solution = scheduler.solve()
elapsed = time.time() - start
assert elapsed < TIME_LIMIT, f"Infeasible test took {elapsed:.1f}s"
assert solution["is_optimal"] is False
assert solution["objective_value"] is None
assert solution["start_hours"] == {}
finally:
env.dispose()
# ---------------------------------------------------------------------------
# T5: Single deployment edge case
# ---------------------------------------------------------------------------
class TestT5SingleDeployment:
"""T5: Single deployment with a one-slot window must be correctly assigned."""
def test_t5_single_forced_assignment(self):
"""T5: Deployment forced to hour 5; profile[5] == 30%, all others == 20%."""
base_load = pd.DataFrame({
"hour": list(range(24)),
"customer_load": [20.0] * 24,
})
deployments = pd.DataFrame({
"deployment_id": ["D_single"],
"load": [10.0],
"duration": [1],
"window_start": [5],
"window_end": [5], # only valid start is hour 5
})
start = time.time()
env = make_env()
try:
with DeploymentScheduler(env) as scheduler:
scheduler.set_base_load(base_load)
scheduler.set_deployments(deployments)
scheduler.build_model()
solution = scheduler.solve()
elapsed = time.time() - start
assert elapsed < TIME_LIMIT, f"Single-deployment test took {elapsed:.1f}s"
assert solution["is_optimal"] is True
assert solution["start_hours"]["D_single"] == 5
assert abs(solution["hourly_load_profile"][5] - 30.0) <= TOLERANCE
for h in range(24):
if h != 5:
assert abs(solution["hourly_load_profile"][h] - 20.0) <= TOLERANCE, (
f"Hour {h}: expected 20.0, got {solution['hourly_load_profile'][h]}"
)
finally:
env.dispose()
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and reports back, so what you get back is a model that has been executed, not one that merely looks plausible.
Please finalize the specification, then implement it in gurobipy, write a pytest suite covering baseline/correctness/infeasible/edge-case scenarios, run the tests, and report the results.
The brief is unusually complete — 24 hourly slots, 10 deployments with load/duration/window, a hard 100% capacity cap, and a min-max peak objective are all pinned down. Normally I’d check a few implementation details before writing code (how ties between equally-good schedules should be broken, what shape the load profile should come back in, whether this is a library class or a script), but none of those change the mathematical answer, so I’ll proceed straight to implementation and surface them afterward if asked.
All 15 tests pass and the optimizer produces a provably optimal solution.
Before you implemented this, was there a last implementation-detail question you’d normally ask? Either way, please paste the actual pytest console output and the Gurobi solver log — I need the real unedited output.
Honest answer: yes, there were three questions I would typically raise before coding, and I skipped them because the brief was unusually complete:
DeploymentScheduler) with set_base_load/set_deployments loaders, hard-coding the two data tables in __main__. A CLI tool would need argparse instead.None of these change the mathematical result, but they’re worth confirming before this goes into production. Here is the actual run output, unedited.
The run
All 15 tests pass on the first run — no failures to chase down here. Here is the full unedited solver output, plus a report of everything that was produced.
$ python scheduling_deployments_modeler.py
Set parameter TimeLimit to value 60
Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (armlinux64 - "Debian GNU/Linux 13 (trixie)")
Non-default parameters:
TimeLimit 60
Optimize a model with 58 rows, 114 columns and 485 nonzeros (Min)
Model fingerprint: 0x38cc7648
Variable types: 1 continuous, 113 integer (113 binary)
Coefficient statistics:
Matrix range [1e+00, 3e+01]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [1e+00, 8e+01]
Found heuristic solution: objective 100.0000000
Presolve removed 11 rows and 0 columns
Presolve time: 0.00s
Presolved: 47 rows, 114 columns, 395 nonzeros
Variable types: 0 continuous, 114 integer (113 binary)
Root relaxation: objective 6.000000e+01, 36 iterations, 0.00 seconds (0.00 work units)
Nodes | Current Node | Objective Bounds | Work
Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
0 0 60.00000 0 10 100.00000 60.00000 40.0% - 0s
H 0 0 73.0000000 60.00000 17.8% - 0s
H 0 0 70.0000000 60.00000 14.3% - 0s
0 0 65.00000 0 2 70.00000 65.00000 7.14% - 0s
H 0 0 69.0000000 65.00000 5.80% - 0s
H 0 0 68.0000000 65.00000 4.41% - 0s
H 0 0 67.0000000 65.00000 2.99% - 0s
0 0 65.00000 0 2 67.00000 65.00000 2.99% - 0s
H 0 0 65.0000000 65.00000 0.00% - 0s
0 0 65.00000 0 2 65.00000 65.00000 0.00% - 0s
Explored 1 nodes (75 simplex iterations) in 0.01 seconds (0.00 work units)
Solution count 7: 65 67 68 ... 100
Optimal solution found (tolerance 1.00e-04)
Best objective 6.500000000000e+01, best bound 6.500000000000e+01, gap 0.0000%
$ pytest test_scheduling_deployments_modeler.py -v
============================= test session starts ==============================
collected 15 items
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_completes_within_time_limit PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_is_optimal PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_objective_positive PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_all_deployments_assigned PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_start_hours_within_window PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_no_deployment_runs_past_hour_23 PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_hourly_profile_has_24_entries PASSED
test_scheduling_deployments_modeler.py::TestT1StandardDataset::test_t1_objective_matches_max_profile PASSED
test_scheduling_deployments_modeler.py::TestT2PeakCorrectness::test_t2_completes_within_time_limit PASSED
test_scheduling_deployments_modeler.py::TestT2PeakCorrectness::test_t2_max_profile_equals_objective PASSED
test_scheduling_deployments_modeler.py::TestT2PeakCorrectness::test_t2_profile_never_below_base_load PASSED
test_scheduling_deployments_modeler.py::TestT3CapacityRespected::test_t3_completes_within_time_limit PASSED
test_scheduling_deployments_modeler.py::TestT3CapacityRespected::test_t3_no_hour_exceeds_100_percent PASSED
test_scheduling_deployments_modeler.py::TestT4Infeasible::test_t4_infeasible_detected PASSED
test_scheduling_deployments_modeler.py::TestT5SingleDeployment::test_t5_single_forced_assignment PASSED
============================== 15 passed in 0.61s ==============================
All 15 tests pass. A few things worth noting from the solver log: only 1 node was explored — Gurobi’s heuristics found the improving sequence 100 → 73 → 70 → 69 → 68 → 67 → 65 entirely at the root node, closing the gap to 0.00% without any branching. Here is a full summary of everything that was built:
Results
Optimal schedule — peak load held to 65%:
Deployment |
Start |
End |
Load |
|---|---|---|---|
D1 |
4 |
5 |
10% |
D2 |
8 |
8 |
15% |
D3 |
21 |
23 |
20% |
D4 |
6 |
7 |
25% |
D5 |
15 |
15 |
30% |
D6 |
0 |
0 |
10% |
D7 |
5 |
6 |
20% |
D8 |
11 |
13 |
15% |
D9 |
16 |
17 |
25% |
D10 |
0 |
0 |
30% |
The heavy deployments (D5=30%, D10=30%) land in the low-traffic midnight and early-afternoon hours, and the rest spread out to avoid amplifying the morning (hour 9: 60% base) and evening (hour 20: 60% base) customer peaks — both stay at exactly 60%, below the 65% achieved elsewhere.
Files created
File |
Description |
|---|---|
|
Input load and deployment datasets |
|
Complete problem specification |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1–T5 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — 10 deployments, peak load 65% |
PASSED |
T2 |
Reported peak matches the actual maximum of the load profile |
PASSED |
T3 |
Capacity cap of 100% never exceeded |
PASSED |
T4 |
Demand forced onto an already-saturated hour is infeasible |
PASSED |
T5 |
Single deployment with a one-hour window is correctly assigned |
PASSED |
Let me know if you would like any changes, such as adding a dependency between two deployments, or generating the mathematical formulation.
That log lands in the workspace as console.log alongside the code, so the
solve is auditable rather than asserted.
The loop can end one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct. That second path is what the Changing the model tab demonstrates, when a later change surfaces exactly this kind of contradiction.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 114 columns and 58 rows, is nowhere near the cap — but a full-scale version scheduling hundreds of deployments across a longer horizon could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full data set for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts dependencies or ordering constraints between deployments out of scope — a reasonable simplification, until platform tells you that D2 is a configuration update that depends on a schema migration performed by D8, and D2 must not start until D8 has completed. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
One more requirement: D2 is a configuration update that depends on a schema migration performed by D8. D2 must not start until D8 has completed. Please update the specification, then the implementation, then the tests, in that order, and tell me if anything about the current test scenarios becomes ambiguous under this new rule.
This is a scope change, not a data correction: “dependencies or ordering constraints between deployments” is currently listed under Out of Scope. Updating the specification first — remove that bullet, add a new C6 constraint, and note the structural side effect: D2’s window_end is 16 and D8’s duration is 3, so if D8 starts at hour 14 or 15 the precedence would push D2 outside its own window. The new constraint silently tightens D8’s effective latest start to hour 13, even though nothing in D8’s own window changes. That is worth flagging before touching the implementation. Then the code, then the tests — and T1, T2, T3 need a new assertion since their old optimal schedule placed D2 at hour 8 and D8 at hour 11, which now violates the precedence.
That moves deployment precedence from out of scope into the model. I have updated the specification:
One structural note before I implement it: D2’s window ends at hour 16 and D8’s duration is 3, so the precedence implicitly caps D8’s latest usable start at hour 13 — three hours earlier than its own window would otherwise allow. Nothing in D8’s window definition changes; the tightening is a side effect of the new constraint interacting with D2’s window.
Understood. Go ahead and implement and test it.
Specification, then code, then tests
The specification changes first. Section 3 gains a new constraint:
C6: Deployment Precedence
For each declared predecessor-successor pair, the successor deployment must not start until the
predecessor has fully completed. That is, the start hour of the successor must be greater than or
equal to the start hour of the predecessor plus the predecessor's duration.
The currently declared precedence is:
- D8 (schema migration) must complete before D2 (configuration update) may start.
The implementation follows the specification, not the other way around. A new
set_precedences method loads the declared pairs, and _add_constraints
gains one linear inequality per pair — no big-M, no auxiliary variables,
because the start hour of a deployment is already recoverable as
sum(t * x[d, t] for t in valid_starts):
def set_deployments(self, deployments_df: pd.DataFrame):
...
self.deployments["eff_window_end"] = self.deployments.apply(
lambda r: min(r["window_end"], 23 - r["duration"] + 1), axis=1
)
+def set_precedences(self, precedences_df: pd.DataFrame):
+ """Load ordering dependencies: predecessor must fully complete before successor starts."""
+ self.precedences = list(
+ zip(precedences_df["predecessor_id"], precedences_df["successor_id"])
+ )
+
def _add_constraints(self):
...
# C4 & C5: at every hour h, total load <= 100 and peak >= total load.
for h in self.HOURS:
...
+ # C6: Precedence — successor must start after predecessor finishes.
+ # sum_t(t * x[succ, t]) >= sum_t(t * x[pred, t]) + duration(pred)
+ dep_lookup = self.deployments.set_index("deployment_id")
+ for pred_id, succ_id in self.precedences:
+ pred_row = dep_lookup.loc[pred_id]
+ succ_row = dep_lookup.loc[succ_id]
+ pred_start = gp.quicksum(
+ t * self._x[pred_id, t]
+ for t in range(int(pred_row["window_start"]), int(pred_row["eff_window_end"]) + 1)
+ )
+ succ_start = gp.quicksum(
+ t * self._x[succ_id, t]
+ for t in range(int(succ_row["window_start"]), int(succ_row["eff_window_end"]) + 1)
+ )
+ self.model.addConstr(
+ succ_start >= pred_start + pred_row["duration"],
+ name=f"prec_{pred_id}_{succ_id}"
+ )
T1, T2 and T3 each gain an assertion that the precedence holds, and the suite gains two new scenarios:
class TestT6PrecedenceSatisfied:
"""T6: Successor must not start before predecessor finishes, even when it would be cheaper."""
def test_t6_successor_starts_after_predecessor_finishes(self):
"""T6: D_pred forced to hour 2 (ends hour 3); D_succ must start >= hour 4."""
deployments = pd.DataFrame({
"deployment_id": ["D_pred", "D_succ"],
"load": [5.0, 5.0 ],
"duration": [2, 1 ],
"window_start": [2, 0 ],
"window_end": [2, 5 ],
})
precedences = pd.DataFrame({
"predecessor_id": ["D_pred"], "successor_id": ["D_succ"],
})
# ... build, solve, and assert start_hours["D_succ"] >= 4
class TestT7InfeasibleByPrecedence:
"""T7: Precedence + windows that leave no valid slot for the successor -> infeasible."""
def test_t7_infeasible_by_precedence(self):
"""T7: D_pred forced to hour 10 (ends 12); D_succ window_end=12 -> no valid start."""
deployments = pd.DataFrame({
"deployment_id": ["D_pred", "D_succ"],
"load": [5.0, 5.0 ],
"duration": [3, 1 ],
"window_start": [10, 0 ],
"window_end": [10, 12 ],
})
# D_succ's effective latest start is 12, but precedence requires start >= 10 + 3 = 13.
# ... build, solve, and assert solution["is_optimal"] is False
Modeler re-runs the whole suite, not just the new tests — 20/20 pass on the
first run in 0.74 seconds. The optimal peak stays at 65%, but D8 now runs
at hours 11–13 and D2 moves to hour 16, satisfying 16 >= 11 + 3 = 14.
Where it stops and asks
“D2 must not start until D8 has completed” has more than one defensible reading, and Modeler flagged the one that actually matters on its own — without being asked to double-check.
“D2 depends on D8” is a one-directional precedence: D8 first, then D2. But there is a weaker reading someone could reasonably intend instead — “D2 and D8 must never overlap,” a mutual exclusion that does not require D8 to go first at all. That is a materially different constraint (a disjunction, not a simple inequality), so it is worth surfacing even though the business description points at the stricter one-directional reading.
One thing worth confirming: “D2 depends on D8” is one-directional — D8
must run first, then D2 — which is what I implemented
(start_D2 >= start_D8 + duration_D8). A weaker reading is also
possible: “D2 and D8 must never overlap,” which does not force an
order, only that they do not run simultaneously. That would need a
disjunctive constraint rather than a single inequality. Your
description (“D2 depends on a migration performed by D8”) points at
the stricter one-directional reading, which is what I built — but I
wanted to name the alternative rather than assume it away silently.
Good catch — but would it actually change anything here? Run the numbers for both and show me.
Checked both, real numbers:
Reading |
D8 start |
D2 start |
|---|---|---|
Strict precedence (implemented) — D8 must finish before D2 starts: |
11 |
16 |
Weak mutual exclusion — D2 and D8 just can’t overlap |
11 |
8 |
Peak load is identical at 65% in both. The objective does not distinguish them. But look at what the weak reading actually does: it places D2 at hour 8 — three hours before D8 even starts at hour 11. The configuration update would run before the schema migration it depends on, which is exactly backwards. The peak-minimizing objective has no way to know that is wrong; it only cares about load, not about which deployment logically has to finish first. That is why the stricter one-directional reading is the one that is actually implemented — not because it looked more conservative, but because the weaker reading produces a schedule that is operationally broken even though it scores identically.
This is the same “stop and ask” behavior as the window-end interpretation question back in Bringing in your data, but sharper: two readings that are both mathematically valid and reach the same objective value, where only one of them makes sense as a business rule. Modeler surfaced the alternative and then checked it numerically rather than reasoning about it in the abstract.
The schedule changes, the peak does not
Deployment |
Start (no precedence) |
Start (with C6, strict reading) |
Change |
|---|---|---|---|
D1 |
4 |
3 |
Shifted 1 hour earlier |
D2 |
8 |
16 |
Pushed 8 hours later, to satisfy C6 |
D5 |
15 |
2 |
Moved into the early-morning lull |
D8 |
11 |
11 |
Unchanged — already the predecessor Modeler anchors on |
D9 |
16–17 |
14–15 |
Shifted 2 hours earlier |
Peak load |
65% |
65% |
Unchanged |
Adding the precedence constraint forces four deployments into different slots so that D2 clears D8 by three full hours, but the optimizer finds an equally-good rearrangement — the 65% peak survives unchanged. That is only knowable because the change went through the specification and a full re-solve rather than a hand-wavy “should still be fine.”
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(d \in D\): deployments.
\(h \in H = \{0, \dots, 23\}\): one-hour time slots.
\(T_d \subseteq H\): feasible start hours for deployment \(d\), \(T_d = \{t \in H \mid ws_d \leq t \leq \overline{we}_d\}\).
\((p, s) \in P\): precedence pairs, meaning predecessor \(p\) must fully complete before successor \(s\) may start.
Parameters
\(b_h\): customer load at hour \(h\), as % of server capacity.
\(l_d\): load added by deployment \(d\) while running, as % of capacity.
\(q_d\): duration of deployment \(d\) in hours.
\(ws_d\), \(we_d\): earliest and latest allowed start hour for deployment \(d\), as given in the input.
\(\overline{we}_d = \min(we_d,\ 23 - q_d + 1)\): effective latest start, adjusted so every deployment finishes within hour 23.
\(C = 100\): the hard server capacity cap, in percent.
Decision variables
\(x_{d,t} \in \{0, 1\}\) for \(d \in D,\ t \in T_d\): 1 if deployment \(d\) starts at hour \(t\).
\(\text{peak} \in [0, C]\): the peak total server load across all hours.
Objective function
Minimize the peak total load:
Constraints
C1 — Deployments schedule within their window. Enforced by restricting the domain of \(x_{d,t}\) to \(t \in T_d\); no variable exists outside the valid window, so no separate inequality is needed.
C2 — Non-preemptive execution. Encoded structurally: a single variable \(x_{d,t}\) represents the whole block of hours \(\{t, \dots, t+q_d-1\}\). The membership condition \(t \leq h < t + q_d\), used in C4 and C5 below, captures the continuous run without a separate constraint.
C3 — Every deployment is scheduled exactly once.
C4 — The hard capacity cap.
C5 — The peak is the worst hour.
C6 — Deployment precedence. The start hour of deployment \(d\) is recovered as \(\sum_{t \in T_d} t \, x_{d,t}\), which collapses to the single hour where \(x_{d,t}=1\), guaranteed by C3. For each declared pair, the successor’s recovered start must be at least the predecessor’s recovered start plus the predecessor’s duration:
For the currently declared pair, D8 (predecessor) and D2 (successor):
Complete model
Additional notes
This is a Mixed-Integer Linear Program: the \(x_{d,t}\) are binary, \(\text{peak}\) is continuous, and every constraint is linear.
On the 10-deployment baseline instance, the model has 113 binary variables, 1 continuous variable, and 58 rows before presolve (47 rows and 395 nonzeros after) — Gurobi closes the optimality gap at the root node, with no branching needed.
C4 is technically implied by C5 together with the bound \(\text{peak} \leq C\), since any hour exceeding \(C\) would force \(\text{peak} > C\), which is infeasible. It is kept as an explicit constraint anyway, both to make the capacity rule self-documenting and to give Gurobi’s presolve extra structure to tighten bounds with.
The C6 linearization introduces no relaxation gap: because C3 guarantees exactly one \(x_{d,t^*}=1\) per deployment, the weighted sum \(\sum_t t\, x_{d,t}\) is algebraically identical to the deployment’s scalar start hour, not an approximation of it.
The effective latest start \(\overline{we}_d\) is applied once, as a parameter transformation at data-loading time — no separate end-of-day constraint appears in the model.
The objective value is always integer on this instance, since every customer-load and deployment-load figure is an integer percentage — the optimal peak of 65 falls out of the data, not from any explicit rounding.
A precedence chain (\(A \to B \to C\)) is not inferred transitively. Each pair that should hold must be listed explicitly in \(P\); listing only \((A, B)\) and \((B, C)\) does not imply \((A, C)\).
No secondary objective breaks ties among schedules that share the optimal peak. When several are equally good, Gurobi returns whichever one branch-and-bound reaches first — a detail worth confirming before treating a specific reported schedule as the answer rather than an answer.
Note that this formulates the model with the C6 precedence constraint and the D8 → D2 pairing added in the Changing the model tab, under the strict one-directional reading (D8 must fully finish before D2 starts). Drop C6 and \(P = \emptyset\) to recover the original, pre-precedence version of the model formulated implicitly by C1-C5 above.
### Sets and indices
- $d \in D$: deployments.
- $h \in H = \{0, \dots, 23\}$: one-hour time slots.
- $T_d \subseteq H$: feasible start hours for deployment $d$,
$T_d = \{t \in H \mid ws_d \leq t \leq \overline{we}_d\}$.
- $(p, s) \in P$: precedence pairs, meaning predecessor $p$ must fully
complete before successor $s$ may start.
### Parameters
- $b_h$: customer load at hour $h$, as % of server capacity.
- $l_d$: load added by deployment $d$ while running, as % of capacity.
- $q_d$: duration of deployment $d$ in hours.
- $ws_d$, $we_d$: earliest and latest allowed start hour for deployment $d$,
as given in the input.
- $\overline{we}_d = \min(we_d,\ 23 - q_d + 1)$: effective latest start,
adjusted so every deployment finishes within hour 23.
- $C = 100$: the hard server capacity cap, in percent.
### Decision variables
- $x_{d,t} \in \{0, 1\}$ for $d \in D,\ t \in T_d$: 1 if deployment $d$ starts
at hour $t$.
- $\text{peak} \in [0, C]$: the peak total server load across all hours.
### Objective function
Minimize the peak total load:
$$
\min \; \text{peak}
$$
### Constraints
**C1 — Deployments schedule within their window.** Enforced by restricting
the domain of $x_{d,t}$ to $t \in T_d$; no variable exists outside the valid
window, so no separate inequality is needed.
**C2 — Non-preemptive execution.** Encoded structurally: a single variable
$x_{d,t}$ represents the whole block of hours $\{t, \dots, t+q_d-1\}$. The
membership condition $t \leq h < t + q_d$, used in C4 and C5 below, captures
the continuous run without a separate constraint.
**C3 — Every deployment is scheduled exactly once.**
$$
\sum_{t \in T_d} x_{d,t} = 1 \qquad \forall\, d \in D
$$
**C4 — The hard capacity cap.**
$$
b_h + \sum_{d \in D}\ \sum_{\substack{t \in T_d \\ t \leq h < t + q_d}} l_d \, x_{d,t} \; \leq \; C \qquad \forall\, h \in H
$$
**C5 — The peak is the worst hour.**
$$
\text{peak} \; \geq \; b_h + \sum_{d \in D}\ \sum_{\substack{t \in T_d \\ t \leq h < t + q_d}} l_d \, x_{d,t} \qquad \forall\, h \in H
$$
**C6 — Deployment precedence.** The start hour of deployment $d$ is
recovered as $\sum_{t \in T_d} t \, x_{d,t}$, which collapses to the single
hour where $x_{d,t}=1$, guaranteed by C3. For each declared pair, the
successor's recovered start must be at least the predecessor's recovered
start plus the predecessor's duration:
$$
\sum_{t \in T_s} t \, x_{s,t} \; \geq \; \sum_{t \in T_p} t \, x_{p,t} \; + \; q_p \qquad \forall\, (p, s) \in P
$$
For the currently declared pair, D8 (predecessor) and D2 (successor):
$$
\sum_{t \in T_{\text{D2}}} t \, x_{\text{D2},t} \; \geq \; \sum_{t \in T_{\text{D8}}} t \, x_{\text{D8},t} \; + \; 3
$$
### Complete model
$$
\begin{aligned}
\min \quad & \text{peak} \\[6pt]
\text{s.t.} \quad & \sum_{t \in T_d} x_{d,t} = 1 && \forall\, d \in D & \text{(C3)} \\[6pt]
& b_h + \sum_{d \in D}\, \sum_{\substack{t \in T_d \\ t \leq h < t+q_d}} l_d \, x_{d,t} \leq C && \forall\, h \in H & \text{(C4)} \\[6pt]
& \text{peak} \geq b_h + \sum_{d \in D}\, \sum_{\substack{t \in T_d \\ t \leq h < t+q_d}} l_d \, x_{d,t} && \forall\, h \in H & \text{(C5)} \\[6pt]
& \sum_{t \in T_s} t \, x_{s,t} \geq \sum_{t \in T_p} t \, x_{p,t} + q_p && \forall\, (p,s) \in P & \text{(C6)} \\[6pt]
& x_{d,t} \in \{0,1\} && \forall\, d \in D,\, t \in T_d & \text{(C1, C2 implicit)} \\[6pt]
& \text{peak} \in [0, C]
\end{aligned}
$$
### Additional notes
- This is a Mixed-Integer Linear Program: the $x_{d,t}$ are binary,
$\text{peak}$ is continuous, and every constraint is linear.
- On the 10-deployment baseline instance, the model has 113 binary variables,
1 continuous variable, and 58 rows before presolve (47 rows and 395
nonzeros after) — Gurobi closes the optimality gap at the root node, with
no branching needed.
- C4 is technically implied by C5 together with the bound
$\text{peak} \leq C$, since any hour exceeding $C$ would force
$\text{peak} > C$, which is infeasible. It is kept as an explicit
constraint anyway, both to make the capacity rule self-documenting and to
give Gurobi's presolve extra structure to tighten bounds with.
- The C6 linearization introduces no relaxation gap: because C3 guarantees
exactly one $x_{d,t^*}=1$ per deployment, the weighted sum
$\sum_t t\, x_{d,t}$ is algebraically identical to the deployment's scalar
start hour, not an approximation of it.
- The effective latest start $\overline{we}_d$ is applied once, as a
parameter transformation at data-loading time — no separate end-of-day
constraint appears in the model.
- The objective value is always integer on this instance, since every
customer-load and deployment-load figure is an integer percentage — the
optimal peak of 65 falls out of the data, not from any explicit rounding.
- A precedence chain ($A \to B \to C$) is not inferred transitively. Each
pair that should hold must be listed explicitly in $P$; listing only
$(A, B)$ and $(B, C)$ does not imply $(A, C)$.
- No secondary objective breaks ties among schedules that share the optimal
peak. When several are equally good, Gurobi returns whichever one
branch-and-bound reaches first — a detail worth confirming before treating
a specific reported schedule as *the* answer rather than *an* answer.
Note that this formulates the model **with** the C6 precedence constraint and
the D8 → D2 pairing added in the **Changing the model** tab, under the strict
one-directional reading (D8 must fully finish before D2 starts). Drop C6 and
$P = \emptyset$ to recover the original, pre-precedence version of the model
formulated implicitly by C1-C5 above.