Skip to content

●  How to use · Allocation & Packing

Bed allocation

Assign patient stays to beds, respecting gender rules, required equipment and room preferences, without double-booking.

What it decides

For every stay, the solver picks a bed, or no bed at a medium penalty.

Minimal example

from datetime import date

from optimaru import bed_allocation as ba
from optimaru.bed_allocation.models import BedModel, BedPlanModel, DepartmentModel, RoomModel, StayModel

plan = BedPlanModel(
    departments=[DepartmentModel(id="D1", specialty_to_priority={"cardiology": 1})],
    rooms=[
        RoomModel(id="R1", department_id="D1", capacity=2, gender_limitation="SAME_GENDER",
                  equipment=["oxygen"]),
        RoomModel(id="R2", department_id="D1", capacity=1),
    ],
    beds=[
        BedModel(id="B1", room_id="R1"),
        BedModel(id="B2", room_id="R1", index_in_room=1),
        BedModel(id="B3", room_id="R2"),
    ],
    stays=[
        StayModel(id="S1", patient_gender="MALE", patient_age=64, specialty="cardiology",
                  patient_required_equipment=["oxygen"],
                  arrival_date=date(2026, 1, 5), departure_date=date(2026, 1, 8)),
        StayModel(id="S2", patient_gender="FEMALE", patient_age=71, specialty="cardiology",
                  arrival_date=date(2026, 1, 6), departure_date=date(2026, 1, 9)),
    ],
)

result = ba.solve(plan, time_limit_seconds=2.0, random_seed=42)
print(result.score_hard, result.score_medium, result.score_soft)
print({stay.id: stay.bed_id for stay in result.stays})

The same use case as a Jupyter notebook, with charts and saved outputs: examples/bed-allocation.ipynb ↗

Input parameters

DepartmentModel

Field Meaning
id required Department id. specialty_to_priority Specialties the department offers, and their priority. 1 is highest. minimum_age Youngest age the department takes. maximum_age Oldest age the department takes.

RoomModel

Field Meaning
id required Room id. department_id required Owning department. capacity required Beds in the room. gender_limitation ANY_GENDER, MALE_ONLY, FEMALE_ONLY or SAME_GENDER. equipment Equipment in the room.

BedModel

Field Meaning
id required Bed id. room_id required Owning room. index_in_room Bed number within the room.

StayModel

Field Meaning
id required Stay id. patient_gender Patient gender. MALE by default. patient_age required Patient age. arrival_date required First night. departure_date required Last night. Nights are counted as departure minus arrival plus one. specialty Specialty needed. patient_required_equipment Equipment the room must have. patient_preferred_equipment Equipment the room should have. patient_preferred_maximum_room_capacity Largest room the patient would like.

BedPlanModel

Field Meaning
departments required Departments. rooms required Rooms. beds required Beds. stays required Stays to place.

Solve parameters

What you pass to the solve call. Time limit, seed and parallel count are here.

Parameter Default Meaning
time_limit_seconds None Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default.
random_seed None Seed for the search. Left unset, it is the config's value, 0 by default.
parallel_solver_count None Independent parallel starts. Each gets its own seed and the full budget, and the best result wins. Left unset, it is the config's value, 1 by default.
stop_at_first_feasible False Stop as soon as hard 0 is reached.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
config None A SolverConfig as the base configuration. Keyword arguments here override it for this solve only; the object you pass is not changed.
constraints None Which constraints to score, and how. Left unset, the defaults apply.
All search options (22)

Set these on a SolverConfig and pass it as config=.

Parameter Default Meaning
best_score_limit — Stop early once the best score reaches this value. By default it never fires.
change_move_weight 0.75 Relative share of change moves. 0 disables them.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
late_acceptance_size 0 Late-acceptance history length. 0 picks it from the instance size.
nearby_probability 0.4 Probability of drawing move partners from nearby entities. 0 disables it.
nearby_size 20 How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
parallel_solver_count 1 Independent parallel starts, each with its own seed and the full budget. The best one wins.
random_seed 0 Seed for the search.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_max_ruin_count 60 Absolute cap on entities removed per kick.
ruin_recreate_max_ruin_fraction 0.3 Largest share of the solution one kick removes.
ruin_recreate_min_ruin_fraction 0.1 Smallest share of the solution one kick removes.
ruin_recreate_operator_decay 0.8 How fast operator weights adapt.
ruin_recreate_polish_steps 5000 Hill-climbing moves that polish each kick. Not counted against step_count_limit.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] Operator rewards for a new best, a better, an accepted and a rejected result.
ruin_recreate_unimproved_step_trigger 5000 Steps without a new best before a kick.
spent_limit_seconds 30.0 Time budget in seconds, from 0 to 1e9.
step_count_limit 0 Stop after this many steps. 0 means no limit.
swap_move_weight 0.25 Relative share of swap moves. 0 disables them.
unimproved_spent_limit_seconds 0.0 Stop after this many seconds without a new best. 0 means no limit.
unimproved_step_count_limit 0 Stop after this many steps without a new best. 0 means no limit.

What comes back

solve takes the model and returns the solved model.

StayModel

Field Meaning
bed_id Assigned bed. None means unassigned, including no bed.

BedPlanModel

Field Meaning
score_hard Hard score. 0 means feasible. score_medium Medium score: nights without a bed, negated. score_soft Soft score.

Constraints

Pick them by calling their classes from optimaru.bed_allocation.constraints, such as BedConflictCost(). Only what you pass is scored; pass nothing to keep the defaults. A list may name a constraint more than once, and each pick is scored on its own. Each one takes these parameters:

Parameter Default Meaning
weight None Multiplier on the constraint's cost, in units of the level in effect. Left unset, the default weight below applies. A constraint whose default weight is 0 is off until you give it a weight.
level None "hard", "medium" or "soft" moves the constraint onto that level, as do Level.HARD, Level.MEDIUM and Level.SOFT. Left unset, it keeps the level below. This use case has a medium level, and any constraint may move onto it.
exponent 1.0 Sign-preserving power on the cost, above 0, taken after offset. 2.0 squares it, so concentrated violations cost much more. It applies per unit, such as a vehicle or a day, or to the total of a constraint scored as one sum.
offset 0.0 Subtracted from the cost x before the exponent, per unit or total like the exponent. It is in the cost's own measure: seconds, minutes, a count and so on. Any finite number of either sign, keyword only. clip decides what happens below it.
clip True True makes offset a threshold: only the part beyond it counts, max(0, x - offset) ** exponent, so a cost at or below it adds nothing. False takes the literal (x - offset) ** exponent, sign kept: a unit below the offset counts negative, as a reward, even one whose cost is 0. On a level that counts toward hard, clip=False with a non-zero offset raises ValueError, since hard 0 must mean no violation. Keyword only.

A medium constraint weighs 1 in medium units by default. The search compares solutions on medium * 1e9 + soft, so medium outranks soft while the soft total stays below 1e9. With level, any constraint moves onto any of the three levels: level="medium" puts it on medium, and "hard" or "soft" takes a medium one off it. A weight counts in units of the level in effect.

This continues the minimal example above.

from optimaru.bed_allocation.constraints import (
    BedConflictCost, PreferredPatientEquipmentCost, RequiredPatientEquipmentCost,
    UnassignedStaysCost, get_constraints,
)

picked = get_constraints([
    BedConflictCost(), RequiredPatientEquipmentCost(), UnassignedStaysCost(),
    PreferredPatientEquipmentCost(weight=100),
])
custom = ba.solve(plan, time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score_hard, custom.score_medium, custom.score_soft)
Name Level Default weight What it does
bed_conflict_cost Hard 1000 Nights in which two stays use the same bed.
female_in_male_room_cost Hard 50 Nights a female patient spends in a MALE_ONLY room.
male_in_female_room_cost Hard 50 Nights a male patient spends in a FEMALE_ONLY room.
department_min_age_cost Hard 100 Nights a patient below the department minimum age spends there.
department_max_age_cost Hard 100 Nights a patient above the department maximum age spends there.
required_patient_equipment_cost Hard 50 Room assignments missing required equipment, valued by the number of required items the room has. See the limits below.
unassigned_stays_cost Medium 1 Nights spent without a bed.
preferred_max_room_capacity_cost Soft 8 Nights spent in a room larger than the patient's preference.
department_specialty_cost Soft 10 Nights in a department that does not offer the specialty at all.
department_specialty_priority_cost Soft 10 Nights in a department offering the specialty below first priority, valued by priority minus one.
preferred_patient_equipment_cost Soft 50 Nights in a room missing preferred equipment, valued by the items it lacks.
mixed_gender_room_cost Hard 1000 Nights in which men and women share a SAME_GENDER room.

How constraints work →

Starting from an existing solution

This needs the latest release: pip install -U optimaru.

with_initial_solution(problem, solution) returns a copy of problem whose search starts from solution, the model a solve returned or that model as a JSON dict.

This continues the minimal example above.

seeded = ba.with_initial_solution(plan, result.model_dump(mode="json"))
again = ba.solve(seeded, time_limit_seconds=2.0, random_seed=43)
print(again.score_hard, again.score_medium, again.score_soft)
  • Stays are matched by id.
  • A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the stays it leaves out are constructed.
  • With time_limit_seconds=0 the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this: step_count_limit = 0 means no limit.
  • A stay with no bed has no bed id to carry, so it comes back unassigned and construction places it again.
  • Unknown ids raise ValueError.

Exceptions and limits

  • required_patient_equipment_cost fires when any required item is missing, and is valued by the required items the room has. A room with none of them costs 0, so check the result when equipment matters.
  • Dates become day counts. A stay's nights are departure minus arrival plus one.