Skip to content

Commit 95e8596

Browse files
authored
Merge pull request #186 from Vhivi:0.7.6-period-balance-performance
fix(backend): restore scheduling performance with bounded period balancing
2 parents f34b70e + c461bee commit 95e8596

4 files changed

Lines changed: 158 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
77

88
## [Unreleased]
99

10+
## [0.7.6] - 2026-03-15
11+
12+
### Fixed
13+
14+
- Resolved a planning infeasibility regression where global hour balancing was accidentally constrained against leave-only constants instead of total paid hours.
15+
- Updated balancing logic to use paid hours consistently (worked shifts plus paid leave) and enforce per-period balance with a configurable capped gap.
16+
- Improved generation performance for continuity-heavy monthly chunks by adding configurable solver stop criteria (`relative_gap_limit`) while keeping the existing time limit.
17+
- Added a backend regression test to ensure planning generation remains feasible with asymmetric paid-leave distributions.
18+
1019
## [0.7.5] - 2026-03-13
1120

1221
### Changed

backend/app.py

Lines changed: 60 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,16 @@ def generate_planning(
366366
agents, vacations, week_schedule, dayOff, previous_week_schedule, initial_shifts
367367
):
368368
model = cp_model.CpModel()
369+
solver_config = config.get("solver", {})
370+
max_time_seconds = int(solver_config.get("max_time_seconds", 600))
371+
global_max_gap = int(solver_config.get("global_max_gap", 240))
372+
period_max_gap = int(solver_config.get("period_max_gap", 240))
373+
relative_gap_limit = float(solver_config.get("relative_gap_limit", 0.10))
374+
num_search_workers = int(solver_config.get("num_search_workers", 0))
375+
optimize_period_balance = bool(
376+
solver_config.get("optimize_period_balance", False)
377+
)
378+
period_balance_weight = int(solver_config.get("period_balance_weight", 2))
369379

370380
weeks_split = split_into_weeks(week_schedule) # Divide week_schedule into weeks
371381

@@ -529,14 +539,10 @@ def generate_planning(
529539
########################################################
530540
# Staff leave
531541
date_format_full = "%d-%m-%Y"
532-
total_hours = {}
542+
leave_paid_hours_by_day = defaultdict(int)
533543

534544
for agent in agents:
535545
agent_name = agent["name"]
536-
total_hours[agent_name] = (
537-
0 # Initialise the total number of hours for the agent
538-
)
539-
540546
# Retrieve leave information if available
541547
if "vacations" in agent and isinstance(agent["vacations"], list):
542548
for vac in agent["vacations"]:
@@ -566,9 +572,11 @@ def generate_planning(
566572
planning[(agent_name, day_str, vacation)] == 0
567573
)
568574

569-
# Calculating hours for days off (7 hours from Monday to Saturday)
575+
# Paid leave hours (Monday to Saturday): 7h * 10
570576
if day_date.weekday() < 6: # Monday (0) to Saturday (5)
571-
total_hours[agent_name] += 70 # 7 hours * 10
577+
leave_paid_hours_by_day[(agent_name, day_str)] = (
578+
conge_duration
579+
)
572580

573581
# If the leave starts on a Monday, add the unavailability from the previous weekend
574582
if vacation_start.weekday() == 0: # Monday (0)
@@ -769,22 +777,19 @@ def generate_planning(
769777
# (Put here all the [soft] constraints that influence the overall objective)
770778

771779
########################################################
772-
# Balance constraint: all agents must have a similar volume of working hours
773-
workload_hours = {}
780+
# Balance constraint: all agents must have a similar volume of paid hours
781+
# (worked shifts + paid leave)
782+
paid_hours = {}
774783
for agent in agents:
775784
agent_name = agent["name"]
776-
workload_hours[agent_name] = sum(
785+
paid_hours[agent_name] = cp_model.LinearExpr.Sum(
786+
list(
777787
planning[(agent_name, day, "Jour")] * jour_duration
778788
+ planning[(agent_name, day, "Nuit")] * nuit_duration
779789
+ planning[(agent_name, day, "CDP")] * cdp_duration
780-
+
781-
# Leave duration added for each day of leave detected
782-
(
783-
conge_duration
784-
if is_vacation_day(agent_name, day, dayOff) and not is_weekend(day)
785-
else 0
786-
)
790+
+ leave_paid_hours_by_day[(agent_name, day)]
787791
for day in week_schedule
792+
)
788793
)
789794

790795
# Impose a limit on the difference between the minimum and maximum hours worked by employees
@@ -795,13 +800,13 @@ def generate_planning(
795800
0, 10000, "max_hours"
796801
) # Upper limit - Adjust terminals (*10) if necessary
797802

798-
# Constraint on balancing hours worked between agents
799-
for agent_name in total_hours:
800-
model.Add(min_hours <= total_hours[agent_name])
801-
model.Add(total_hours[agent_name] <= max_hours)
803+
# Constraint on balancing paid hours between agents
804+
for agent_name in paid_hours:
805+
model.Add(min_hours <= paid_hours[agent_name])
806+
model.Add(paid_hours[agent_name] <= max_hours)
802807

803808
# Constraining the difference between max_hours and min_hours for global equilibrium
804-
model.Add(max_hours - min_hours <= 240) # Adjust flexibility if necessary (*10)
809+
model.Add(max_hours - min_hours <= global_max_gap)
805810
########################################################
806811

807812
########################################################
@@ -810,36 +815,38 @@ def generate_planning(
810815
# Split week_schedule into monthly or single periods
811816
periods = split_by_month_or_period(week_schedule)
812817

813-
for period in periods:
818+
period_balancing_terms = []
819+
for period_idx, period in enumerate(periods):
814820
period_total_hours = {}
815821
for agent in agents:
816822
agent_name = agent["name"]
817-
# Calculation of total hours per agent over the period
823+
# Calculation of total paid hours per agent over the period
818824
period_total_hours[agent_name] = cp_model.LinearExpr.Sum(
819825
list(
820826
planning[(agent_name, day, "Jour")] * jour_duration
821827
+ planning[(agent_name, day, "Nuit")] * nuit_duration
822828
+ planning[(agent_name, day, "CDP")] * cdp_duration
829+
+ leave_paid_hours_by_day[(agent_name, day)]
823830
for day in period
824831
)
825832
)
826833

827-
# Define min_hours and max_hours for balancing
828-
min_hours = model.NewIntVar(
829-
0, 100000, "min_hours_period"
830-
) # Lower limit - Adjust terminals (*10) if necessary
831-
max_hours = model.NewIntVar(
832-
0, 100000, "max_hours_period"
833-
) # Upper limit - Adjust terminals (*10) if necessary
834-
835-
# Add constraints for each agent
836-
for agent_name in total_hours:
837-
model.Add(min_hours <= total_hours[agent_name])
838-
model.Add(total_hours[agent_name] <= max_hours)
839-
840-
# Limiting the time difference between agents
841-
max_difference = 240 # Adjust flexibility if necessary (*10)
842-
model.Add(max_hours - min_hours <= max_difference)
834+
min_period_hours = model.NewIntVar(
835+
0, 100000, f"min_hours_period_{period_idx}"
836+
)
837+
max_period_hours = model.NewIntVar(
838+
0, 100000, f"max_hours_period_{period_idx}"
839+
)
840+
for agent in agents:
841+
agent_name = agent["name"]
842+
model.Add(min_period_hours <= period_total_hours[agent_name])
843+
model.Add(period_total_hours[agent_name] <= max_period_hours)
844+
period_gap = model.NewIntVar(0, 100000, f"period_gap_{period_idx}")
845+
model.Add(period_gap == max_period_hours - min_period_hours)
846+
model.Add(period_gap <= period_max_gap)
847+
period_balancing_terms.append(period_gap)
848+
849+
period_balancing_objective = cp_model.LinearExpr.Sum(period_balancing_terms)
843850
########################################################
844851

845852
########################################################
@@ -1125,18 +1132,25 @@ def generate_planning(
11251132
)
11261133
)
11271134

1128-
# Maximize the overall objective, with a strong preference for preferred shifts and balancing hours
1129-
model.Maximize(
1135+
# Maximize the overall objective, with a strong preference for preferred shifts.
1136+
objective = (
11301137
objective_preferred_vacations
11311138
+ objective_other_vacations
11321139
+ penalized_vacations
11331140
- weekend_balancing_objective
1134-
# (variance_weight * total_variance) # ! Temporarily suspended similar hours constraint
11351141
)
1142+
if optimize_period_balance:
1143+
objective -= period_balance_weight * period_balancing_objective
1144+
# (variance_weight * total_variance) # ! Temporarily suspended similar hours constraint
1145+
model.Maximize(objective)
11361146

11371147
# Solver
11381148
solver = cp_model.CpSolver()
1139-
solver.parameters.max_time_in_seconds = 600 # Limit of resolution time in seconds
1149+
if num_search_workers > 0:
1150+
solver.parameters.num_search_workers = num_search_workers
1151+
if relative_gap_limit > 0:
1152+
solver.parameters.relative_gap_limit = relative_gap_limit
1153+
solver.parameters.max_time_in_seconds = max_time_seconds
11401154
status = solver.Solve(model)
11411155
# Log the solution
11421156
print(
@@ -1146,6 +1160,8 @@ def generate_planning(
11461160
solver.NumConflicts(),
11471161
" branches :",
11481162
solver.NumBranches(),
1163+
" execution time :",
1164+
f"{solver.WallTime():.4f} seconds",
11491165
)
11501166

11511167
# Results

backend/config.json

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,24 @@
9797
},
9898
"holidays": [
9999
"01-01",
100-
"21-04",
100+
"06-04",
101101
"01-05",
102102
"08-05",
103-
"29-05",
104-
"09-06",
103+
"14-05",
104+
"25-05",
105105
"14-07",
106106
"15-08",
107107
"01-11",
108108
"11-11",
109109
"25-12"
110-
]
110+
],
111+
"solver": {
112+
"max_time_seconds": 600,
113+
"relative_gap_limit": 0.1,
114+
"num_search_workers": 0,
115+
"global_max_gap": 240,
116+
"period_max_gap": 240,
117+
"optimize_period_balance": false,
118+
"period_balance_weight": 2
119+
}
111120
}

backend/tests/test_constraints.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,82 @@ def test_agent_training(setup_correct_data):
479479
assert ("Mer. 03-01", "Nuit") not in result["Agent5"]
480480
assert ("Ven. 05-01", "Jour") not in result["Agent6"]
481481
assert ("Ven. 05-01", "Nuit") not in result["Agent6"]
482+
483+
484+
def test_generate_planning_paid_leave_hours_balancing_regression():
485+
"""
486+
Regression test: paid leave hours must be included in balancing with worked hours.
487+
488+
With a strong paid-leave asymmetry, the solver should still find a solution when
489+
worked shifts can compensate the global balance.
490+
"""
491+
492+
agents = [
493+
{
494+
"name": "Agent1",
495+
"unavailable": [],
496+
"training": [],
497+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
498+
"vacations": [{"start": "06-01-2025", "end": "11-01-2025"}],
499+
},
500+
{
501+
"name": "Agent2",
502+
"unavailable": [],
503+
"training": [],
504+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
505+
"vacations": [],
506+
},
507+
{
508+
"name": "Agent3",
509+
"unavailable": [],
510+
"training": [],
511+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
512+
"vacations": [],
513+
},
514+
{
515+
"name": "Agent4",
516+
"unavailable": [],
517+
"training": [],
518+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
519+
"vacations": [],
520+
},
521+
{
522+
"name": "Agent5",
523+
"unavailable": [],
524+
"training": [],
525+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
526+
"vacations": [],
527+
},
528+
{
529+
"name": "Agent6",
530+
"unavailable": [],
531+
"training": [],
532+
"preferences": {"preferred": ["Jour", "Nuit", "CDP"], "avoid": []},
533+
"vacations": [],
534+
},
535+
]
536+
vacations = ["Jour", "Nuit", "CDP"]
537+
week_schedule = [
538+
"Lun. 06-01",
539+
"Mar. 07-01",
540+
"Mer. 08-01",
541+
"Jeu. 09-01",
542+
"Ven. 10-01",
543+
"Sam. 11-01",
544+
"Dim. 12-01",
545+
]
546+
547+
result = generate_planning(
548+
agents,
549+
vacations,
550+
week_schedule,
551+
dayOff={},
552+
previous_week_schedule=[],
553+
initial_shifts={},
554+
)
555+
556+
assert isinstance(result, dict)
557+
assert "info" not in result
482558

483559
######
484560
# Test failed, possible bug in the generate_planning function (constraint not respected or too soft)

0 commit comments

Comments
 (0)