Skip to content

Ordering constraints

A policy says what it would like to order. Real operations add rules: a supplier's minimum, whole cases, a truck's capacity, the space in a chiller. Ordering constraints turn the requested order into a feasible one, and the event table records what each rule changed.

Where constraints act

flowchart LR
    P[policy.predict] -->|requested| CB[callbacks]
    CB -->|callback adjusted| C1[constraint 1] --> C2[constraint 2] --> C3[…]
    C3 -->|constrained = accepted| S[supply / pipeline]

Constraints run after order callbacks and before the order is placed, in the order you list them. They apply to every policy, so an operational rule is written once.

Built-in constraints

Each takes a single value for all SKUs or a dict with one value per SKU, and a mode: "raise" stops the run when an order breaks the rule, "adjust" fixes the order.

Constraint Rule for a positive order \(q\) "adjust" does
MinimumOrderQuantity(m) \(q \ge m\) \(q \leftarrow m\)
OrderMultiple(k) \(q\) is a multiple of \(k\) \(q \leftarrow k \lceil q / k \rceil\)
MaximumOrderQuantity(M) \(q \le M\) \(q \leftarrow M\)
ShelfSpaceLimit(C) \(q \le \max\bigl(0,\ C - (\mathit{OH} + P)\bigr)\) \(q \leftarrow\) the free space

Zero orders are always allowed. MaximumOrderQuantity and ShelfSpaceLimit are capacity rules: when they cut an order, the event table's capacity_violation_flag is set.

from stockcast.core import MaximumOrderQuantity, MinimumOrderQuantity, OrderMultiple

constraints = [
    MinimumOrderQuantity(12, mode="adjust"),     # supplier minimum
    OrderMultiple(6, mode="adjust"),             # cases of 6
    MaximumOrderQuantity(60, mode="adjust"),     # at most 10 cases per order
]

Pass the list to the engine with order_constraints=constraints. Outside the engine, for example in a production loop, wrap it as OrderingConstraints(constraints) and call its apply method.

Final check

After the sequence runs, Stockcast checks the final quantity against every constraint. If an adjustment by one rule breaks another, the run stops with a message naming the SKU and the rule. For example, OrderMultiple rounds up to a full case while ShelfSpaceLimit cuts down to the free space. When space is tight, no order satisfies both, and the run stops instead of accepting an impossible quantity. When you need a rule such as "whole cases that still fit", write it as one constraint (below).

Audit trail

Every event table row records the order at each stage:

Column Meaning
requested_order_quantity what the policy asked for
callback_adjusted_order_quantity after callbacks
constrained_order_quantity after constraints
order_quantity what was placed (equal to the constrained quantity)
constraint_adjustment_units constrained minus callback-adjusted
constraint_binding_flag whether any constraint changed the order
binding_constraints names of the rules that changed it, joined by |
capacity_violation_flag whether a capacity rule cut the order

The metrics capacity_violation_count and capacity_violation_rate summarise the last column.

Custom constraints

Subclass OrderingConstraint, give it a unique name, and implement apply. It receives the current OrderDecision and a ConstraintContext (copies of the state before demand and of the policy, and the decision period), and returns a ConstraintResult with the new decision and one audit row per SKU:

Setup: the tea shop from the Walkthrough
import numpy as np
import pandas as pd

from stockcast.core import InventoryStateDataFrame, SimulationEngine
from stockcast.policies import OrderUpToPolicy
from stockcast.utils import DemandGenerator

sku = "tea_250g"
opening_date = pd.Timestamp("2026-01-05")
lead_time, review_period = 2, 4
horizon = lead_time + review_period

demand = DemandGenerator(
    [sku], first_date=opening_date + pd.Timedelta(days=1), freq="D",
    random_seed=3, negative_demand_handling="clip_zero",
).seasonal(n_periods=56, base=6.0, amplitude=2.0, season_length=7, std=2.0)
demand["y"] = demand["y"].round()

inventory = InventoryStateDataFrame.from_observed(
    pd.DataFrame({"unique_id": [sku], "date": [opening_date], "on_hand": [30.0]}),
    allow_backorders=False,   # some pages step this state by hand
)

paths = np.random.default_rng(42).poisson(6.0, size=(10_000, horizon))


def tea_policy(probability):
    """Order-up-to policy whose target is the given quantile of 6-day demand."""
    target = pd.DataFrame({
        "unique_id": [sku],
        "target": [np.quantile(paths.sum(axis=1), probability)],
        "date": [opening_date + pd.Timedelta(days=horizon)],   # the last day it covers
    })
    return OrderUpToPolicy(
        lead_time=lead_time, review_period=review_period, freq="D",
        service_level=probability, allow_backorders=False,
    ).fit(target, target_column="target")


policy = tea_policy(0.95)
run_settings = dict(random_seed=3)
result = SimulationEngine().run(
    policy=policy, demand_source=demand, inventory=inventory, **run_settings,
)
import numpy as np

from stockcast.core import (
    ConstraintResult, OrderDecision, OrderingConstraint, SimulationEngine,
)


class WholeCasesThatFit(OrderingConstraint):
    """Round up to whole cases, then drop cases that do not fit on the shelf."""

    name = "whole_cases_that_fit"

    def __init__(self, case_size, shelf_space):
        self.case_size, self.shelf_space = case_size, shelf_space

    def apply(self, order, context):
        frame = order.get_dataframe()
        state = context.inventory.inventory_position().set_index("unique_id")
        occupied = frame["unique_id"].map(state["on_hand"] + state["total_in_transit"])
        requested = frame["order_quantity"].to_numpy(dtype=float)

        cases = np.ceil(requested / self.case_size)
        fitting = np.floor(np.maximum(self.shelf_space - occupied, 0) / self.case_size)
        accepted = np.minimum(cases, fitting) * self.case_size
        frame["order_quantity"] = accepted

        changed = ~np.isclose(requested, accepted)
        audit = pd.DataFrame({
            "unique_id": frame["unique_id"],
            "requested_order_quantity": requested,
            "constrained_order_quantity": accepted,
            "constraint_adjustment_units": accepted - requested,
            "constraint_binding_flag": changed,
            "capacity_violation_flag": changed & (accepted < requested),
            "binding_constraints": [self.name if flag else "" for flag in changed],
        })
        decision = OrderDecision(frame, lead_time=order.lead_time)
        return ConstraintResult(decision, audit)

    def to_manifest(self):
        return {**super().to_manifest(), "case_size": self.case_size,
                "shelf_space": self.shelf_space}


result = SimulationEngine().run(
    policy=policy, demand_source=demand, inventory=inventory,
    order_constraints=[WholeCasesThatFit(case_size=6, shelf_space=40)],
    **run_settings,
)
events = result.to_event_frame()
events.loc[events["requested_order_quantity"] > 0,
           ["date", "requested_order_quantity", "order_quantity",
            "capacity_violation_flag"]].head(4)
         date  requested_order_quantity  order_quantity  capacity_violation_flag
0  2026-01-06                      16.0             6.0                     True
4  2026-01-10                      37.0            30.0                     True
8  2026-01-14                      21.0            12.0                     True
12 2026-01-18                      41.0            30.0                     True

A shelf of 40 packs cannot hold what a target of 46 asks for. On 6 January, 30 packs already fill the shelf, so only one case of 6 fits; the event table shows every cut. to_manifest stores the rule's settings in the run manifest. Implement validate(order, context) too if the rule should also check the final order after later constraints; the base class checks the basic shape.

See also: Notebook 05c: extension points · Notebook 09: a full experiment