● 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 specialty_ to_ priority minimum_ age maximum_ age RoomModel
| Field | Meaning |
|---|
id required department_ id required capacity required gender_ limitation ANY_GENDER, MALE_ONLY, FEMALE_ONLY or SAME_GENDER.equipment BedModel
| Field | Meaning |
|---|
id required room_ id required index_ in_ room StayModel
| Field | Meaning |
|---|
id required patient_ gender MALE by default.patient_ age required arrival_ date required departure_ date required specialty patient_ required_ equipment patient_ preferred_ equipment patient_ preferred_ maximum_ room_ capacity BedPlanModel
| Field | Meaning |
|---|
departments required rooms required beds required stays required Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
time_ | None | Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default. |
random_ | None | Seed for the search. Left unset, it is the config's value, 0 by default. |
parallel_ | 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_ | False | Stop as soon as hard 0 is reached. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
on_ | 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_ | — | Stop early once the best score reaches this value. By default it never fires. |
change_ | 0.75 | Relative share of change moves. 0 disables them. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
nearby_ | 0.4 | Probability of drawing move partners from nearby entities. 0 disables it. |
nearby_ | 20 | How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case. |
on_ | None | Callback run on each new best solution. If it raises, the solve stops and raises the same exception. |
parallel_ | 1 | Independent parallel starts, each with its own seed and the full budget. The best one wins. |
random_ | 0 | Seed for the search. |
ruin_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 60 | Absolute cap on entities removed per kick. |
ruin_ | 0.3 | Largest share of the solution one kick removes. |
ruin_ | 0.1 | Smallest share of the solution one kick removes. |
ruin_ | 0.8 | How fast operator weights adapt. |
ruin_ | 5000 | Hill-climbing moves that polish each kick. Not counted against step_count_limit. |
ruin_ | [33.0, 9.0, 3.0, 0.0] | Operator rewards for a new best, a better, an accepted and a rejected result. |
ruin_ | 5000 | Steps without a new best before a kick. |
spent_ | 30.0 | Time budget in seconds, from 0 to 1e9. |
step_ | 0 | Stop after this many steps. 0 means no limit. |
swap_ | 0.25 | Relative share of swap moves. 0 disables them. |
unimproved_ | 0.0 | Stop after this many seconds without a new best. 0 means no limit. |
unimproved_ | 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 None means unassigned, including no bed.BedPlanModel
| Field | Meaning |
|---|
score_ hard score_ medium score_ soft 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_ | Hard | 1000 | Nights in which two stays use the same bed. |
female_ | Hard | 50 | Nights a female patient spends in a MALE_ONLY room. |
male_ | Hard | 50 | Nights a male patient spends in a FEMALE_ONLY room. |
department_ | Hard | 100 | Nights a patient below the department minimum age spends there. |
department_ | Hard | 100 | Nights a patient above the department maximum age spends there. |
required_ | Hard | 50 | Room assignments missing required equipment, valued by the number of required items the room has. See the limits below. |
unassigned_ | Medium | 1 | Nights spent without a bed. |
preferred_ | Soft | 8 | Nights spent in a room larger than the patient's preference. |
department_ | Soft | 10 | Nights in a department that does not offer the specialty at all. |
department_ | Soft | 10 | Nights in a department offering the specialty below first priority, valued by priority minus one. |
preferred_ | Soft | 50 | Nights in a room missing preferred equipment, valued by the items it lacks. |
mixed_ | Hard | 1000 | Nights in which men and women share a SAME_GENDER room. |
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=0the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this:step_count_limit = 0means 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_costfires 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.