SupplyChainSimulation
SupplyChainSimulation is a package to model and simulate supply chains.
The simulation is built around a loop that keeps track of orders and inventory movements:
- receive_inventory
- place_orders
- receive_orders
- send_inventory
Each step can be customized by applying different policies. The policies can also be optimized to improve the supply chain performance.
Installation
SupplyChainSimulation can be installed using the Julia package manager. From the Julia REPL, type ] to enter the Pkg REPL mode and run
pkg> add SupplyChainSimulationGetting started
The first step to use SupplyChainSimulation is to define the supply chain components. This is done by specifying the products, suppliers, storage locations, and customers.
In the example below we define one product, one supplier, one storage location, and one customer.
horizon = 20
product = Product("product")
supplier = Supplier("supplier")
storage = Storage("storage")
add_product!(storage, product; unit_holding_cost=1.0)
customer = Customer("customer")
l1 = Lane(storage, customer)
l2 = Lane(supplier, storage)The second step is to define the starting states. The initial states represent the supply chain network at the start of the simulation. More than one initial state can be defined to represent potential different situations that have to be simulated or optimized. For example we could have several demand scenarios. In our example, we will create 10 such scenarios with different demand from the customer.
n() = begin
network = SupplyChain(horizon)
add_supplier!(network, supplier)
add_storage!(network, storage)
add_customer!(network, customer)
add_product!(network, product)
add_lane!(network, l1)
add_lane!(network, l2)
add_demand!(network, customer, product, rand(Poisson(10), horizon) * 1.0; sales_price=1.0, lost_sales_cost=1.0)
return network
end
initial_states = [n() for i in 1:10]The third step is to define the policies that we want to use. In this example we use an order up to policy that will place an order to replenish the inventory back to a given value.
policy = OnHandUptoOrderingPolicy(0)
policies = Dict((l2, product) => policy)The next step is to run the simulation or the optimization (depending on whether you already know the policies you want to use or whether you want to find the best policies). In our example we will search the best policy by running the optimizer.
optimize!(policies, initial_states...)
final_states = [simulate(initial_state, policies) for initial_state in initial_states]The final step is to analyze the results. There are various function we can call to get information such as the orders that have been placed, the inventory on hand at any time, and more. There are also plotting functions which provide the information in a graphical way. In our example we will plot the amount of inventory at the storage location over time.
plot_inventory_onhand(final_states, storage, product)The resulting plot is shown below.

Policies
The package comes with several policies predefined, including:
- QuantityOrderingPolicy: Orders a given quantity specific to each time period. The quantity ordered is the same across scenarios and irrespective of the current inventory position.
- ProductQuantityOrderingPolicy: Orders a given quantity at a given time period.
- OnHandUptoOrderingPolicy: Orders up to a given number based on the number of units on hand; no matter what is on order.
- NetUptoOrderingPolicy: Orders up to a given number based on the net number of units (on hand + in transit + on order - on backlog).
- NetSSOrderingPolicy: Orders up to a given number based on the net number of units (on hand + in transit + on order - on backlog) if the net inventory is below a threshold.
- ForwardCoverageOrderingPolicy: Orders inventory to cover the coming periods based on the mean forecasted demand.
- BackwardCoverageOrderingPolicy: Orders inventory to cover the coming periods based on past demand.
- SingleOrderOrderingPolicy: Places a single order at a given time.
Creating a new policy
Creating a new policy is easy and can be done in two steps:
- Create a new struct to represent your policy. This struct can hold the data you need to compute the orders. For example, let's say that we want to create a policy that orders always the same amount. We will create the following strut
mutable struct SameOrderPolicy <: InventoryOrderingPolicy
order::Int64
end- Implement the following functions:
function get_parameters(policy::SameOrderPolicy)
return [policy.order]
end
function set_parameters!(policy::SameOrderPolicy, values::Array{Float64, 1})
policy.order = Int.(round.(values[1])) # sets the value of the order to the value provided by the optimizer
end
function get_order(policy::SameOrderPolicy, state, env, location, lane, product, time)
return policy.order # returns the order when running the policy during a simulation
endOnce the policy is defined it can be used either as part of a simulation run where the order is defined by you, or as part of an optimization run where the optimizer will find the best order to minimize the cost function.
Common models
In this section we will review some common inventory models and how to implement them with SupplyChainSimulation.jl.
Economic Ordering Quantity (EOQ)
The Economic Ordering Quantity helps us balance the cost of ordering and the cost of holding inventory by optimizing the order quantity. The higher the ordering cost, the less often we want to order and therefore the bigger the quantity we want to order. The higher the holding cost, the less inventory we want to order and therefore the smaller the quantity we want to order. The EOQ is the order quantity that best balances these two costs. In the example below, we will consider a demand of 10 units per period, an ordering cost of 10 and a holding cost of .1 per unit per period.
We can get the EOQ by running the following code.
eoq_quantity(10, 10, 0.1)The answer is approximately 44.7.
We can also use the simulation as follows.
horizon = 50
product = Product("product")
supplier = Supplier("supplier")
storage = Storage("storage")
add_product!(storage, product; unit_holding_cost=0.1)
customer = Customer("customer")
l1 = Lane(storage, customer)
l2 = Lane(supplier, storage, fixed_cost=10)
n() = begin
network = SupplyChain(horizon)
add_supplier!(network, supplier)
add_storage!(network, storage)
add_customer!(network, customer)
add_product!(network, product)
add_lane!(network, l1)
add_lane!(network, l2)
add_demand!(network, customer, product, repeat([10.0], horizon); sales_price=1.0, lost_sales_cost=1.0)
return network
end
policy = NetSSOrderingPolicy(0, 0)
policies = Dict((l2, product) => policy)
initial_states = [n() for i in 1:1]
optimize!(policies, initial_states...)
println(policy)The result is a policy (0, 40). We order 40 units when the inventory goes down to 0. This matches the EOQ we computed above.
Safety Stock
Let's extend the example above by making the demand stochastic and have a lead time of 2 period for the storage replenishment. The code is very similar to that above and looks as follows.
horizon = 50
product = Product("product")
supplier = Supplier("supplier")
storage = Storage("storage")
add_product!(storage, product; unit_holding_cost=0.1)
customer = Customer("customer")
l1 = Lane(storage, customer)
l2 = Lane(supplier, storage; fixed_cost=10, time=2)
n() = begin
network = SupplyChain(horizon)
add_supplier!(network, supplier)
add_storage!(network, storage)
add_customer!(network, customer)
add_product!(network, product)
add_lane!(network, l1)
add_lane!(network, l2)
add_demand!(network, customer, product, rand(Poisson(10), horizon) * 1.0; sales_price=1.0, lost_sales_cost=1.0)
return network
end
policy = NetSSOrderingPolicy(0, 0)
policies = Dict((l2, product) => policy)
initial_states = [n() for i in 1:20]
optimize!(policies, initial_states...)
println(policy)Now the best policy is (20, 60). The safety stock of 20 helps avoid stock out while the reorder quantity stays 40 units above the safety stock as in the EOQ example above.
Beer game
The beer game is a common supply chain setup used to teach inventory management. The supply chain is composed of 5 entities: a customer, a retailer, a wholesaler, a factory and a supplier. There is a lead time between each echelon in the supply chain. The question is how best to manage this supply chain.
We can mode this setup with SupplyChainSimulation.jl as follows.
product = Product("product")
customer = Customer("customer")
retailer = Storage("retailer")
add_product!(retailer, product; initial_inventory=20, unit_holding_cost=0.1)
wholesaler = Storage("wholesaler")
add_product!(wholesaler, product; initial_inventory=20, unit_holding_cost=0.1)
factory = Storage("factory")
add_product!(factory, product; initial_inventory=20, unit_holding_cost=0.1)
supplier = Supplier("supplier")
horizon = 20
l = Lane(retailer, customer; unit_cost=0)
l2 = Lane(wholesaler, retailer; unit_cost=0, time=2)
l3 = Lane(factory, wholesaler; unit_cost=0, time=2)
l4 = Lane(supplier, factory; unit_cost=0, time=4)
policy2 = NetUptoOrderingPolicy(0)
policy3 = NetUptoOrderingPolicy(0)
policy4 = NetUptoOrderingPolicy(0)
policies = Dict(
(l2, product) => policy2,
(l3, product) => policy3,
(l4, product) => policy4)
n() = begin
network = SupplyChain(horizon)
add_supplier!(network, supplier)
add_storage!(network, factory)
add_storage!(network, wholesaler)
add_storage!(network, retailer)
add_customer!(network, customer)
add_product!(network, product)
add_lane!(network, l)
add_lane!(network, l2)
add_lane!(network, l3)
add_lane!(network, l4)
add_demand!(network, customer, product, rand(Poisson(10), horizon) * 1.0; sales_price=1.0, lost_sales_cost=1.0)
return network
end
initial_states = [n() for i in 1:30]
optimize!(policies, initial_states...)The optimizer will then run and return the best policies.
If you run this code you will see that the policies do extremely well with no bullwhip effect. Inventory management solved? Not fully. Let's note that (1) the policies are tuned to a specific scenario (albeit stochastic) and (2) the optimizer optimizes across echelons (as if the whole supply chain is integrated). This is a best case scenario. Different setups can be tested. For example you can add more scenarios or you can change the policies to limit what they can see. Depending on the setup the bullwhip effect can be more or less strong. Being able to simulate these results is on key advantage of using SupplyChainSimulation.jl.
API
SupplyChainSimulation.BackwardCoverageOrderingPolicy — Type
Orders inventory to cover a target net inventory level extrapolated from the recent demand this location has itself experienced - via get_past_outbound_orders(state, location, ...), which (despite the "outbound" name) returns quantities ordered from location by its own downstream customer(s), i.e. location's local demand signal. This is not location's own order history (that would be get_past_outbound_orders called with the upstream node as location), and not true end-customer demand - a mid-chain node in this model never sees that directly, same as in real distribution networks. With cover[1] weighting the most recent period's local demand, the effective target is last_demand * (sum(cover[1:end-1]) + cover[end]) + cover[end] (for the common 2-element case, last_demand * (cover[1]+cover[2]) + cover[2]). Any tuning where that multiplier exceeds 1 is naive, undamped trend extrapolation: a one-period uptick in local demand is projected forward as if it were a lasting shift, inflating this period's target by more than the uptick itself - the "demand signal processing" bullwhip mechanism documented by Lee, Padmanabhan, and Whang (1997), not a feedback loop on the policy's own past decisions. optimize! searching this policy family can still converge on such unstable, amplifying multipliers, since nothing here caps the gain.
SupplyChainSimulation.Env — Type
Contains information about the environment of the simulation, including the network configuration.
SupplyChainSimulation.ForwardCoverageOrderingPolicy — Type
Orders inventory to cover the coming periods based on the mean forecasted demand.
SupplyChainSimulation.NetSSOrderingPolicy — Type
Orders up to a given number based on the net number of units (on hand + in transit + on order - on backlog) if the net inventory is below a threshold.
SupplyChainSimulation.NetUptoOrderingPolicy — Type
Orders up to a given number based on the net number of units (on hand + in transit + on order - on backlog).
SupplyChainSimulation.OnHandUptoOrderingPolicy — Type
Orders up to a given number based on the number of units on hand; no matter what is on order.
SupplyChainSimulation.OrderLine — Type
Not parametrized on origin/destination type (unlike a first attempt at this struct): OrderLine collections are inherently heterogeneous (a single Vector{OrderLine}/Set{OrderLine} holds order lines between every pairing of node types in the network), so a caller constructing one from an abstractly-typed value (e.g. trip.route.origin::ConcreteNode) can never supply a statically-known concrete O/D anyway - parametrizing just left the type parameter unresolved (OrderLine{O,D} where O<:Node), which is worse than a plain abstract field: every downstream use (push!, Set membership, dispatch) then has to deal with an unresolved existential type instead of a single well-known concrete struct.
SupplyChainSimulation.ProductQuantityOrderingPolicy — Type
Orders a given quantity at a given time period.
SupplyChainSimulation.QuantityOrderingPolicy — Type
Orders a given quantity specific to each time period.
SupplyChainSimulation.SimMetrics — Type
Running (incrementally-updated) cost and quantity totals for a simulation run.
Unlike the get_total_* functions in Reporting.jl - which recompute these totals by scanning State's historical_* arrays after the fact - SimMetrics is updated inline, at the point a fill, drop, or order placement actually happens during simulate. Reading a total back out is then O(1) instead of an O(horizon) (or worse) scan.
Critically, accumulation into SimMetrics does not depend on Env.record_history: it always happens, so optimize! can score candidate policies from state.metrics alone without needing the per-period history arrays that only exist to support detailed reporting/visualization after a run. See Env.record_history for what that flag does and does not disable.
SupplyChainSimulation.SingleOrderOrderingPolicy — Type
Places a single order at a given time.
SupplyChainSimulation.State — Type
Contains information about the historical and current state of the simulation, including inventory positions and pending orders.
SupplyChainSimulation.TariffContext — Type
Tariff bookkeeping shared by State and Env. Computed independently by each - State is constructed before Env (see simulate(supplychain, policies)), so there's nothing to cache one on the other - but it's cheap (O(products + suppliers)) and only non-trivial at all when the supply chain actually has tariffs (needs_tracking below): every consumer of is_relevant elsewhere checks it before doing any tariff-specific work, so an untariffed supply chain pays for one Bool per product and a 1-element origin_countries and nothing else.
origin_countries[1] is always nothing - the "unknown provenance" bucket for initial inventory/arrivals and any Supplier without a country set (see _build_tariff_context) - mirroring the same convention in SupplyChainOptimization.jl's re-export tariff handling. get_tariff_rate never charges a tariff against it (see its isnothing checks), so unknown-provenance inventory is simply never taxed on re-export.
declared_value_by_origin[product_index, origin_country_index] is the average unit_cost for that product across every Supplier in that origin country - used only for a re-export (Storage-origin) shipment, where cohort tracking knows which country a unit came from but not which specific source (see _remove_on_hand_origin!). A Supplier-origin shipment instead uses that exact supplier's own unit_cost directly (see _static_tariff_cost, Simulation.jl) - there's no ambiguity about the source in that case. Plants are never a shipping origin in this simulator (see get_locations), so they never contribute here.
SupplyChainSimulation.Trip — Type
A trip is the basis of transportation in the simulation. It follows a route with a given departure time.
SupplyChainSimulation._cohort_tariff_cost — Method
_cohort_tariff_cost(state, origin, destination, product, by_origin)::Float64Ad-valorem re-export tariff cost for a Storage-origin shipment, summed across by_origin's origin-country breakdown of the units actually shipped (see _remove_on_hand_origin!): for each origin country with units > 0, get_tariff_rate(...) * declared_value * units, where declared_value is the average unit_cost for product across every Supplier in that origin country (state.tariff_context.declared_value_by_origin - see TariffContext, since cohort tracking only knows which country a unit came from, not which specific source). The unknown-provenance bucket (nothing) is always skipped - see get_tariff_rate.
A lane whose destination is in the same country as origin (the storage itself, not any cohort's tag) never crosses a border, so it's never tariffed regardless of what any cohort is tagged with - otherwise goods that already paid duty entering origin's country would pay it again on every purely domestic delivery out of that storage. Mirrors SupplyChainOptimization.jl's identical guard on its re-export tariff handling.
SupplyChainSimulation._fill_past_outbound_orders_by_index! — Method
get_past_outbound_orders!(past_orders, state, location, product, time)::Array{Union{Missing, Int64}, 1}Same as get_past_outbound_orders, but fills the caller-provided past_orders buffer in place instead of allocating a fresh one - step_back is implicitly length(past_orders). BackwardCoverageOrderingPolicy.get_order (Policy.jl) calls this with a buffer it owns and reuses across every call (cover's length, and therefore this buffer's size, never changes after construction), since a fresh zeros(...) allocation on every single get_order call - called ~15000 trials x 30 scenarios x every period in optimize!'s search - showed up as a real, avoidable chunk of allocation profiling.
SupplyChainSimulation._node_country — Method
_node_country(node)::Union{Nothing, String}A ConcreteNode's Location.country (see SupplyChainModeling.Location), or nothing if the node has no location set (Union{Location, Missing}) or its location has no country set.
SupplyChainSimulation._remove_in_transit_origin! — Method
_remove_in_transit_origin!(state, li, pi, time, quantity)::Vector{Int64}Removes quantity units' worth of origin-country provenance from in_transit_by_origin[li, pi][time], consuming countries in a fixed (origin_country_index) order - there's no age/batch ordering to preserve here the way _remove_on_hand_origin! has (a single (location, product, time) in-transit bucket isn't itself split by arrival batch, see in_transit_inventory). Used by receive_inventory! to split a partially- accepted arrival (the rest overflows to next period, see _record_overflow_by_index!) into "accepted" and "still in transit" breakdowns. Returns the breakdown of what was removed.
SupplyChainSimulation._remove_on_hand_origin! — Method
_remove_on_hand_origin!(state, si, pi, quantity)::Vector{Int64}Removes quantity units' worth of origin-country provenance from on_hand_by_origin[si, pi], in lockstep with _remove_on_hand_by_index!'s own FIFO (oldest-age-first) walk - within an age, countries are consumed in a fixed order (tariff_context.origin_country_index's ordering). Returns the breakdown of what was removed (sums to quantity, under the same on_hand_totals[si, pi] >= quantity precondition _remove_on_hand_by_index! already relies on) - this is what a Storage-origin shipment tags its in-transit addition with (see send_inventory!/_cohort_tariff_cost, Simulation.jl).
Called separately from, but consuming the same ages as, the plain _remove_on_hand_by_index! - independent because they walk two separate arrays (onhandbyorigin vs. onhandinventory/onhand_totals), each fully self-consistent as long as nothing else mutates either between the two calls (true here: this code is single-threaded and sequential).
SupplyChainSimulation._search_bounds — Method
_search_bounds(params, j)::Tuple{Float64,Float64}params[:SearchRange] is either a single (lo, hi) tuple, applied to every dimension alike (the original, still-default behavior), or a Vector of (lo, hi) tuples, one per dimension - e.g. so a dimensionless gain parameter and an inventory-level parameter can get genuinely different exploration ranges instead of being forced to share one box sized for whichever needs to be bigger. This is the single indirection every params[:SearchRange][1|2] use below goes through, so both forms work without duplicating the branch at each call site.
SupplyChainSimulation._static_origin_breakdown — Method
_static_origin_breakdown(state, country, quantity)::Vector{Int64}A one-hot origin-country breakdown: all quantity units attributed to country (or the unknown-provenance bucket if country is nothing). Used for a Supplier-origin shipment, where every unit ships from the same static country - no cohort tracking needed (see TariffContext's docstring).
SupplyChainSimulation._static_tariff_cost — Method
_static_tariff_cost(state, origin, destination, product, quantity)::Float64Ad-valorem tariff cost for quantity units of product shipped directly from origin (a Supplier) to destination: rate * declared_value * quantity, where declared_value is origin's own unit_cost for product - exact, since a Supplier-origin shipment has no ambiguity about which source the units came from (contrast _cohort_tariff_cost, for a Storage-origin shipment that can blend units from several countries). 0.0 whenever origin/destination has no country set, origin has no unit_cost for product, or no Tariff applies between the two countries for product - see get_tariff_rate.
SupplyChainSimulation.bayesopt_optimize — Method
bayesopt_optimize(f, x0, options)Minimizes f via Bayesian optimization (Surrogates.jl's Kriging surrogate, optimized with Expected Improvement), translating this package's optimize!(...; method=:bayesopt, bayesopt_options=...) options (see optimize!'s docstring) into Surrogates.jl's API.
An earlier version of this function used BayesianOptimization.jl with a GaussianProcesses.jl backend, but BayesianOptimization.jl's registered releases only support SpecialFunctions versions incompatible with this package's other dependencies (CMAEvolutionStrategy/Distributions already require a modern SpecialFunctions) - an unsatisfiable Pkg.resolve() the first time this was actually tried in CI, not something visible from reading either package's docs. Surrogates.jl (part of the actively-maintained SciML ecosystem) has no SpecialFunctions dependency at all, so it doesn't hit the same conflict.
Unlike :custom/:cma_es/:nelder_mead - all of which spend their entire evaluation budget on real (expensive) calls to f - Bayesian optimization fits a cheap surrogate model of f from the points observed so far and uses it to pick each next point to actually evaluate, trading surrogate-fitting cost for far fewer real evaluations. That trade only pays off when real evaluations are expensive relative to the surrogate (true here: f is a full discrete-event simulation) and the parameter count is small (the surrogate model's own cost grows with both dimensions and points observed) - both hold for the problems this package has been benchmarked against so far (2-6 parameters), but neither is guaranteed for every caller, which is why this method exists alongside the other three rather than replacing any of them.
x0 isn't used as a search seed the way the other methods use it: the initial Kriging surrogate is always built from its own space-filling (Sobol sequence) design over [lower, upper], so a caller's starting guess doesn't bias where that initial exploration lands - the same reasoning the previous BayesianOptimization.jl version's docstring gave, still true here.
Surrogates.jl represents a point as a plain Float64 when there's only one parameter, but as a Tuple once there's more than one (see its own test suite) - point_to_vec bridges either representation back to the plain Vector{Float64} f expects.
Returns the best point actually observed (argmin over every point measured, across both the initial design and the EI loop), the same "trust only what was actually measured" principle :custom/:cma_es/:nelder_mead all follow by construction.
Default :upper is 200.0, not 5000.0 - see cma_es_optimize's docstring for the shared reasoning (the same benchmark/compare_optimizers.jl run that found :cma_es regressing on beergame found :bayesopt far worse: 300x :custom's cost on newsvendor, roughly 10 orders of magnitude worse on beergame).
Unlike :cma_es, tightening the box only closed part of :bayesopt's gap (newsvendor: 2.79e6 down to 8.7e4, still ~10x :custom's 9.0e3; beergame: 3.97e13 down to 2.6e9, still ~2e5x :custom's -1.2e4) - both runs' own convergence tables showed the reported cost identical from 10% to 100% of the evaluation budget, meaning the search stopped improving almost immediately regardless of box size. Reading Surrogates.jl's own `surrogateoptimize!(..., EI(), ...)` source (this function no longer calls it - see below) turned up two compounding causes, neither fixed by a tighter box alone:
- The Kriging surrogate's
theta(length-scale) andp(smoothness, defaulting to2.0- i.e. assumes an infinitely smooth function) are estimated once at construction and never re-estimated:update!(the functionsurrogate_optimize!uses to add each new point) explicitly reuses the sametheta/pforever. This package's cost functions are the opposite of smooth -ceil(Int, deficit)order-quantity truncation (seeBackwardCoverageOrderingPolicy/ForwardCoverageOrderingPolicyin Policy.jl) and inventory clamps make them full of kinks - so a "this looks smooth" fit from the first ~30 points stays wrong for the rest of the run instead of correcting itself as evidence accumulates. surrogate_optimize!'s early-termination check isnew_EI_max < 1e-6 * (maximum(krig.y) - minimum(krig.y))- relative to the observed y-range, not an absolute tolerance. In 6 dimensions, even a[0, 200]box lets a handful of the initial Sobol samples land in badly-overordered territory with catastrophic cost, which alone inflates that range into the millions- which inflates the absolute EI the search needs to clear to keep going
Both point to the same underlying issue: Surrogates.jl's basic Kriging + EI() recipe, used as-is, isn't built for a highly non-smooth, wide-dynamic-range objective. Rather than a tighter box, this function reimplements surrogate_optimize!'s EI loop directly (same acquisition function, verified against Surrogates.jl's own source) with two changes: theta/p are re-estimated by rebuilding the Kriging surrogate from every point observed so far (not just appending via update!) on a geometric schedule (whenever the point count has at least doubled since the last rebuild - bounding the number of expensive full refits to O(log(maxfevals)) the same way IPOP-CMA-ES's restarts bound their growth, rather than paying a full refit's O(points^3) cost every single iteration), and the internal early-termination check is dropped in favor of an explicit :ei_iterations cap (default 200, matching this function's own default :maxfevals): a first version of this fix dropped the early-termination check without replacing the runtime bound it happened to also provide, and a benchmark/compare_optimizers.jl run (which forces :maxfevals up to 3000 to match the other three methods) ran for 30+ minutes and had to be cancelled - num_new_samples (default 100) candidates scored against a growing Kriging surrogate on every one of ~3000 iterations is expensive regardless of how correct the acquisition function is. Past :ei_iterations real evaluations, this function switches to plain space-filling samples evaluated directly with no surrogate/EI cost - still real, budget-spending evaluations, just without per-point acquisition cost whose marginal value is small once the surrogate already has hundreds of points.
SupplyChainSimulation.bboptimize — Method
bboptimize(f, x0, params; seed_candidates=nothing, verbose=false)seed_candidates (default nothing, matching every existing caller's behavior byte-for-byte) optionally replaces the first length(seed_candidates) members of the initial random candidate_pool with given points instead - e.g. quadratic_surrogate_optimum's predicted optimum, giving the population a genuinely gradient-informed starting guess alongside its otherwise-random members. Seeded candidates are still just population members like any other: nothing about the search treats them specially past initialization, so a bad seed costs nothing beyond one wasted pool slot.
verbose (default false) controls whether progress is printed to stdout
- matching
cma_es_options[:verbosity]'s default-off convention for the
other optimizer methods, so calling optimize!() doesn't print by default.
SupplyChainSimulation.cma_es_optimize — Method
cma_es_optimize(f, x0, options)Minimizes f starting from x0 with CMAEvolutionStrategy.jl, translating this package's optimize!(...; method=:cma_es, cma_es_options=...) options (see optimize!'s docstring) into CMAEvolutionStrategy.minimize's keyword arguments. Split out of optimize! so the CMA-ES-specific option handling (scalar-vs-vector bounds, the sigma0 default, forwarding seed only when given) doesn't clutter the method-dispatch branch above.
IPOP-CMA-ES-style restarts (Auger & Hansen, 2005): each of :restarts additional runs beyond the first multiplies the population size (:incpopsize, default 2x) from the previous run's, giving later restarts more diversity/global search power. CMAEvolutionStrategy.minimize often converges - via its own internal TolFun/TolX checks - well before exhausting the evaluation budget it's given; restarting with a bigger population instead of just accepting an early convergence spends that otherwise-unused budget on a better chance to escape whatever local optimum the smaller population settled into, rather than being wasted. Each restart gets an even share of :maxfevals, starts from the same x0 (never a random point or the previous restart's incumbent - a caller who already has a good starting guess loses nothing, matching nelder_mead_optimize's restarts), and the best result across every restart wins - restarting can only find something at least as good as the very first run, never worse.
Default :upper is 200.0, not the 5000.0 every other *_optimize function here defaults to: benchmark/compare_optimizers.jl's first real head-to-head comparison (5 trials x 3000 evals) showed :cma_es regressing badly against :custom on the 6-parameter beergame problem (positive/bad training cost vs. :custom's negative/good) while tied on the 2-parameter newsvendor problem - traced to sigma0's default of (upper-lower)/4, which comes out to 1250 against the shared 5000.0 box, an enormous step size relative to where these policies' real parameters live (order-up-to/ coverage levels in the tens to low hundreds, confirmed by both the sensitivity-report smoke tests' bounds and this fix's own validation run). Re-running the same comparison with upper=200.0 (sigma0=50) turned `:cmaesinto the best or tied-best method on both problems - unlike:custom's search dynamics (population-based, selection-driven, tolerant of an oversized box) orneldermeadoptimize's (anchored to the incumbent, not the raw box), CMA-ES's sampling distribution is directly sized bysigma0, so an oversized box translates directly into an oversized initial search radius. Still fully overridable via:upper/:sigma0` for callers whose real parameter scale genuinely is in the thousands.
SupplyChainSimulation.eoq_cost_rate — Method
eoq_cost_rate(demand_rate, ordering_cost, holding_cost_rate)
Computes the total cost per time period of ordering the economic ordering quantity.
See also [`eoq_quantity`](@ref).SupplyChainSimulation.eoq_interval — Method
eoq_interval(demand_rate, ordering_cost, holding_cost_rate)
Computes at what interval the economic ordering quantity is ordered.
See also [`eoq_quantity`](@ref).SupplyChainSimulation.eoq_quantity — Method
eoq_quantity(demand_rate, ordering_cost, holding_cost_rate, backlog_cost_rate)
Computes the economic ordering quantity that minimizes overall costs (ordering costs + holding costs) while meeting demand.SupplyChainSimulation.eoq_quantity — Method
eoq_quantity(demand_rate, ordering_cost, holding_cost_rate)
Computes the economic ordering quantity that minimizes overall costs (ordering costs + holding costs) while meeting demand.SupplyChainSimulation.find_next_departure — Method
find_next_departure(env, destination, time, due_date)Finds the earliest trip bound for destination that departs at or after time and still arrives by due_date, or nothing if none do. Only scans the [time, due_date] window of env.departures[destination], instead of the full, unbounded list of trips ever bound for destination.
@inlined for the same reason as the 3-argument method above - see its docstring.
SupplyChainSimulation.find_next_departure — Method
find_next_departure(env, destination, time)Finds the earliest trip bound for destination that departs at or after time, or nothing if none remain within the horizon. Walks forward through env.departures[destination] (indexed directly by period) instead of filtering the full, unbounded list of trips ever bound for destination.
@inlined: this returns Union{Trip, Nothing}, and Trip isn't isbits (it holds route::Lane and policies::Union{Missing, Dict}, both reference fields - see Trip's definition). A Union with a non-isbits member can't use Julia's compact isbits-union representation, so across a real, non-inlined call boundary Julia has to box the Trip on every successful match. Inlining keeps the Union-typed value local to the (already concretely-typed) caller instead, letting the compiler union-split it without boxing - allocation profiling found this as the largest single allocation site in a full beer_game() run.
SupplyChainSimulation.flush_pending_as_lost! — Method
flush_pending_as_lost!(state::State)A customer order line's due_date equals the period it was created in (see place_orders(..., ::Customer, ...)), and the explicit drop site in send_inventory! only recognizes it as expired - and calls record_drop! - once due_date < time. An order line created in the very last period of the horizon never sees a later period, so that check never fires for it: it simply stays in pending_outbound_order_lines forever.
get_total_lost_sales doesn't notice this gap because it isn't event-driven: it diffs the set of every order ever placed against the set of every order ever filled, so a never-filled last-period order line shows up as lost regardless of whether anything explicitly marked it as dropped. state.metrics.lost_sales, being event-driven, needs this equivalent one-time sweep over whatever is still pending when the horizon ends, so it accounts for exactly the same order lines - no more, no less: anything already recorded via the duedate < time path was already deleted from pendingoutboundorderlines, so there is no overlap/double-count between that path and this one.
SupplyChainSimulation.get_horizon — Method
get_horizon(state::State)
Gets the number of steps in the simulation.SupplyChainSimulation.get_in_transit_inventory — Method
get_in_transit_inventory(state::State, to::Location, product::Product, time::Int64)::Int64
Gets the number of units of a product in transit to a location at a given time.SupplyChainSimulation.get_inbound_orders — Method
get_inbound_orders(state::State, location::Location, product::Product, time::Int64)::Int64
Gets the number of units of a product on order to a location (but not yet shipped there) at a given time.SupplyChainSimulation.get_lane_policies — Method
get_lane_policies(supplychain, policies)Builds one Product => policy dict per lane. The per-period policies dict attached to each Trip only ever depends on the lane, not the period, so sharing a single dict across every period of a lane (instead of rebuilding an identical one per (lane, period) pair) avoids horizon-many redundant Dict allocations per lane.
Iterates policies itself - typically sparse (most networks don't define every lane/product combination) - and groups its entries by lane, instead of the reverse: probing haskey(policies, (l, p)) for every (lane, product) combination regardless of how many are actually defined. That inverted approach was O(lanes x products); this is O(lanes + length(policies)). A (lane, product) entry for a lane/product outside supplychain.lanes/supplychain.products is silently dropped, matching the old behavior (which could only ever see pairs drawn from those two collections in the first place).
Stores each policy object directly, rather than pre-wrapping it in a FunctionWrapper closure via wrap_get_order - that type erasure went through a ccall-based trampoline, which forced boxing of every argument crossing the boundary (Storage, Lane, Env, Product). place_orders (Simulation.jl) hand-writes a union split (an explicit isa chain, each branch followed by a type assertion) inline at its get_order call site, rather than calling get_order(policy, ...) directly on the abstractly- typed policy it reads out of this dict: Storage, Lane, Env, and Product are immutable (non-isbits) structs, so a plain call on an abstractly-typed policy still boxes them the same way FunctionWrapper did - the boxing came from the abstract-typed dynamic dispatch itself, not from the ccall trampoline specifically. The union split has to be written inline in place_orders, not factored into a helper function: Julia's isa+type-assert narrowing is local to the function scope it happens in, so calling out to a helper with policy still abstractly typed re-introduces the exact same dynamic dispatch (confirmed the hard way - factoring it into a dispatch_get_order helper measured identically to no split at all, since the call into that helper was itself still dynamic).
SupplyChainSimulation.get_locations — Method
get_locations(supplychain)
Gets all the locations (storages, customers, and suppliers - not
plants) in the supplychain, as the same cached Vector every call (see
`get_location_index` in SupplyChainModeling.jl).SupplyChainSimulation.get_metrics — Method
get_metrics(state::State)::SimMetricsGets the running cost/quantity totals accumulated so far for state. See SimMetrics.
SupplyChainSimulation.get_order — Method
Lane-based fallback for direct/external callers that only have a Lane, not a Trip, in hand (e.g. calling get_order outside place_orders). place_orders itself calls the Trip-taking overload above instead.
SupplyChainSimulation.get_order — Method
Fast path for place_orders' internal dispatch (Simulation.jl), which already has the full Trip - not just its route::Lane - in hand: reads trip.lane_index directly instead of hashing trip.route through state.lane_index on every call. CPU profiling of the Profile workflow found that lookup as real self-time in simulate()'s hot loop even after state.lane_index itself was cached (see get_lane_index, SupplyChainModeling.jl) - Trip.lane_index (Model-Transportation.jl) resolves it once at Env construction instead.
SupplyChainSimulation.get_outbound_orders — Method
get_outbound_orders(state::State, location::Location, product::Product, time::Int64)::Int64
Gets the number of units of a product on order at a location (and not yet shipped out) at a given time.SupplyChainSimulation.get_overflow_inventory — Method
get_overflow_inventory(state::State, to::Storage, product::Product, time::Int64)::Int64Gets the number of units of product held in temporary overflow storage at to at time.
SupplyChainSimulation.get_parameters — Method
get_parameters(policy::BackwardCoverageOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::ForwardCoverageOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::NetSSOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::NetUptoOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::OnHandUptoOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::ProductQuantityOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_parameters — Method
get_parameters(policy::QuantityOrderingPolicy)
Gets the parameters for the policy.SupplyChainSimulation.get_past_outbound_orders — Method
get_past_outbound_orders(state::State, location::ConcreteNode, product::Product, time::Int64, step_back::Int64)::Array{Union{Missing, Int64}, 1}Gets, for each of the step_back periods before time, the quantity of product that was ordered from location (i.e. location was the origin) - missing for any period before the simulation started.
Reads state.outbound_order_quantities, which is only populated when some policy declares required_lookback(policy) > 0 (see Policy.jl/Env); for a location/product nothing was ever recorded for (either because no such policy is in play, or simply because no order ever originated there), every period reads back as 0, matching what an exhaustive scan would have found.
SupplyChainSimulation.get_total_demand — Method
get_total_demand(state)
Gets the total demand (in unit) for the state.SupplyChainSimulation.get_total_holding_costs — Method
get_total_holding_costs(state)
Gets the total holding costs for the state.SupplyChainSimulation.get_total_lost_sales — Method
get_total_lost_sales(state)
Gets the total lost sales (in unit) for the state.SupplyChainSimulation.get_total_overflow_costs — Method
get_total_overflow_costs(state)Gets the total cost of inventory that exceeded a storage's maximumunits and had to be held in temporary overflow storage (see `recordoverflow!`), rather than being lost.
SupplyChainSimulation.get_total_sales — Method
get_total_sales(state)
Gets the total sales (in unit) for the state.SupplyChainSimulation.get_total_tariff_costs — Method
get_total_tariff_costs(state)Gets the total ad-valorem tariff cost charged across every filled order line (see record_fill!/_static_tariff_cost/_cohort_tariff_cost, Simulation.jl). 0.0 for a supply chain with no Tariffs registered.
SupplyChainSimulation.get_total_trip_unit_costs — Method
get_total_trip_unit_costs(state)
Gets the total transportation unit costs.SupplyChainSimulation.get_trips — Method
get_trips(supplychain, policies)Builds every Trip the simulation can ever depart on: one per (lane, period) pair the lane is actually allowed to ship in (see Lane.can_ship).
Resolves each lane's lane_policies[l] and lane_index (its position in supplychain.lanes - see Trip.lane_index) once per lane via enumerate rather than once per (lane, period) pair - enumerate(supplychain.lanes) hands out the exact same numbering get_lane_index caches, with no Dict lookup needed at all, so this doesn't even need to call it.
SupplyChainSimulation.get_used_lanes — Method
get_used_lanes(state)Gets the distinct lanes actually shipped on during the run - useful for spotting lanes that were built into the network but never used, or for breaking transportation cost/volume down by route.
Note: distinct lanes, not distinct trips - state.historical_transportation holds one entry per (lane, departure) actually shipped on, so a lane running every period of the horizon still collapses to a single entry here.
SupplyChainSimulation.metrics_cost_function — Method
metrics_cost_function(s::State)An alternative to hand-rolling s -> -get_total_sales(s) + get_total_lost_sales(s) + ... as a cost_function for optimize!: reads s.metrics (see SimMetrics) instead of scanning s's historical_* arrays the way the equivalent get_total_* functions in Reporting.jl do. Since SimMetrics is always kept up to date regardless of Env.record_history, this is the cost function to pass (together with record_history=false) to get optimize!'s full per-trial-simulation speedup.
Not optimize!'s default: its default is left exactly as it always was (scanning history, with record_history defaulting to true) so existing callers - including ones whose cost_function reads get_total_* directly - see byte-for-byte identical behavior. Summing the same costs in a different order (event order here, Set/Dict iteration order for the get_total_* scan) is only equal up to floating-point rounding, not bit-for-bit, and optimize!'s search is sensitive enough to that
- many float comparisons deciding which candidate "wins" over thousands of
evaluations - that swapping the default out from under existing callers could shift what they converge to. Opt in explicitly once you don't need exact reproducibility against a pre-existing baseline.
SupplyChainSimulation.nelder_mead_optimize — Method
nelder_mead_optimize(f, x0, options)Minimizes f with iterated-local-search-style multi-start Nelder-Mead (Optim.jl), translating this package's optimize!(...; method=:nelder_mead, nelder_mead_options=...) options (see optimize!'s docstring). One run is seeded at x0; each of options[:restarts] more perturbs the current incumbent best by a random step scaled to options[:restart_scale] times the box's range in each dimension, rather than drawing a fresh uniformly random point from the whole box. Each run gets an even share of :maxfevals; the best result across all runs wins.
Earlier versions of this function drew every restart uniformly from the whole [lower, upper] box. benchmark/compareoptimizers.jl's beergame comparison (6 parameters, vs. newsvendor's 2) showed this was actively harmful, not just inefficient: a uniform sample of a 6-dimensional box is overwhelmingly likely to land far from any good region, so nearly every restart wasted its whole budget converging to a poor local optimum instead of ever improving on the x0-seeded run - visible as wildly inconsistent, often much worse than :custom/:cma_es results. Perturbing around the incumbent instead keeps restarts a local refinement/escape-a-nearby-local-optimum mechanism, which does not need to get harder as dimensionality grows the way "land anywhere useful in the full box" does - at the cost of being less able to find a distant better region than a true global restart could. x0 itself is never perturbed away from, so a caller that already has a good starting guess loses nothing.
Candidates are clamped into [lower, upper] before being passed to f rather than handled via Optim.jl's Fminbox - plain NelderMead needs no gradient, and Fminbox's log-barrier approach is built around methods that have one, so a barrier around a derivative-free simplex search isn't a well-supported combination.
SupplyChainSimulation.optimize! — Method
optimize!(supplychain::SupplyChain, lane_policies, initial_states...; cost_function, record_history, method, cma_es_options)
Optimizes the inventory policies in the supply chain by simulating the inventory movement starting from the initial states and costing the results with the cost function.
`record_history` controls whether each of the (typically thousands of) trial
simulations `optimize!` runs archives its full per-period history (see
`Env.record_history`). It defaults to `true`, matching every trial simulation's
behavior before `record_history` existed, so existing callers - including custom
`cost_function`s that call the history-scanning `get_total_*` functions in
Reporting.jl - keep working unchanged. Pass `record_history=false` once your
`cost_function` only reads `state.metrics` (see `SimMetrics` and
`metrics_cost_function`) to skip that per-trial bookkeeping.
This is independent of `BackwardCoverageOrderingPolicy`: it reads
`state.outbound_order_quantities` instead of the history-scanning
`get_total_*` functions, and that index is maintained regardless of
`record_history` (see `required_lookback`/`Env.needs_outbound_order_index`),
so `record_history=false` is safe with that policy too.
`customer_backlog` (default `false`, matching every trial simulation's
behavior before this field existed) is passed straight through to `Env` -
see its docstring there for what it changes.
`params` is typed `Dict{Symbol}` (any value type), not `Dict{Symbol,
Float64}`: the internal defaults it gets merged with already mix value
types (`:SearchRange => (-0.0, 5000.0)` is a `Tuple`, `:NumDimensions` an
`Int`), so a caller overriding `:SearchRange` - e.g. to narrow the search
around a known-reasonable scale - was never actually representable as a
`Dict{Symbol, Float64}` in the first place. `:SearchRange` itself may
also be a `Vector` of per-dimension `(lo, hi)` tuples instead of one
shared tuple - see `_search_bounds`'s docstring below.
`method` selects the search algorithm used to minimize `cost_function` over the
policies' parameters:
- `:custom` (the default): the original hand-rolled differential-evolution-style
optimizer in this file (`bboptimize`/`params`). Kept as the default so existing
callers see byte-for-byte identical behavior.
- `:cma_es`: Covariance Matrix Adaptation Evolution Strategy, via the
CMAEvolutionStrategy.jl package. Tuned via `cma_es_options` (see below) instead
of `params`, since CMA-ES's options aren't all `Float64` (bounds are vectors,
some are integers) and `params` is typed `Dict{Symbol, Float64}`.
- `:nelder_mead`: multi-start Nelder-Mead simplex search, via Optim.jl. Tuned via
`nelder_mead_options` (see below). A deliberately different kind of algorithm
from the other two (a deterministic local search with restarts, vs. their
population-based stochastic search) - worth trying because
benchmark/compare_optimizers.jl showed :cma_es converging (and then plateauing)
well before exhausting its evaluation budget on the newsvendor benchmark,
suggesting a cheaper local refinement with restarts to escape local optima
might match its quality in even less time, or find a better optimum with the
leftover budget spent on more restarts instead of one long population search.
- `:bayesopt`: Bayesian optimization (Kriging surrogate + Expected Improvement
acquisition function), via Surrogates.jl. Tuned via `bayesopt_options` (see
below). Worth trying specifically because every
evaluation here is a full discrete-event simulation (expensive) over a small
number of parameters (2-6 in the benchmarks so far) - exactly the regime
where trading cheap surrogate-model fitting for far fewer real evaluations
pays off, unlike the other three methods, which all spend thousands of real
evaluations even though :cma_es's own convergence curve suggests the actual
information content of these problems is exhausted far earlier.
`cma_es_options` (only used when `method === :cma_es`) accepts:
- `:lower`, `:upper`: box constraints, each either a scalar (broadcast to every
parameter) or a per-parameter vector. Default `0.0`/`5000.0`, matching
`:custom`'s default `:SearchRange`.
- `:maxfevals`: total evaluation budget across all restarts combined. Default
`15000`, matching `:custom`'s default `:MaxFuncEvals`, so the methods are
comparable under the same budget.
- `:sigma0`: initial global step size (CMA-ES's `s0`). Defaults to a quarter of
the bounds' range, a commonly-used rule of thumb when there's no prior on
where in the box the optimum sits.
- `:restarts`: number of *additional* CMA-ES runs beyond the first, each with a
bigger population than the last (IPOP-CMA-ES - see `cma_es_optimize`'s
docstring for why). Default `3`. Each gets an even share of `:maxfevals`.
- `:incpopsize`: population-size multiplier applied per restart. Default `2`.
- `:popsize`, `:seed`, `:verbosity`: passed straight through to
`CMAEvolutionStrategy.minimize` when given; otherwise left at that package's
own defaults (`:verbosity` defaults to `0` here instead, since `:custom`
already prints its own progress and CMA-ES's is redundant in that context).
`:popsize`, if given, is the *first* restart's population size - later
restarts still scale up from it by `:incpopsize` each time.
`bayesopt_options` (only used when `method === :bayesopt`) accepts:
- `:lower`, `:upper`: box constraints, same shape/defaults as `cma_es_options`.
- `:maxfevals`: total real simulation-evaluation budget, split between the
initial space-filling design and the guided acquisition-function search (see
`:initializer_iterations`). Default `200` - deliberately nowhere near the
other methods' `15000`: Bayesian optimization trades a much smaller number of
expensive real evaluations for the cost of fitting/querying a Kriging
surrogate at every step, so giving it thousands of evaluations would make
that per-step surrogate cost (which grows with the number of points
observed) dominate, not help - see `bayesopt_optimize`'s docstring.
- `:initializer_iterations`: how many of `:maxfevals` are spent on an initial
space-filling (Sobol sequence) design before the acquisition-guided search
begins, rather than on `:maxfevals` itself. Default `min(5 * length(x0),
maxfevals ÷ 2)`.
- `:num_new_samples`: how many candidate points Expected Improvement
evaluates *on the cheap surrogate* per real iteration before picking the
single best one to actually cost with `f` - this does not consume any of
`:maxfevals` itself (see `bayesopt_optimize`'s docstring for why only one
real evaluation happens per iteration regardless of this value). Default
`100`.
`custom_options` (only used when `method === :custom`) accepts:
- `:surrogate_seed_samples`: if greater than `0`, spends that many real
evaluations up front fitting `quadratic_surrogate_optimum` (a quadratic
response surface over `params[:SearchRange]`, see its docstring) and
seeds one member of `bboptimize`'s initial population with that
surrogate's predicted optimum instead of a purely random point.
Default `0` (disabled) - every existing caller sees byte-for-byte
identical behavior, since this option didn't exist before.
`nelder_mead_options` (only used when `method === :nelder_mead`) accepts:
- `:lower`, `:upper`: box constraints, same shape/defaults as `cma_es_options`.
Plain Nelder-Mead is unconstrained, so out-of-box candidates are clamped back
into range before being costed, rather than using Optim.jl's `Fminbox` (a
log-barrier wrapper built for gradient-based methods, not a natural fit for a
derivative-free simplex search).
- `:maxfevals`: total evaluation budget across all restarts combined. Default
`15000`, matching the other two methods.
- `:restarts`: number of *additional* Nelder-Mead runs beyond the one seeded at
`x0`, each getting an even share of `:maxfevals`. Default `5` (6 runs total).
Each restart perturbs the current incumbent best by a random step (see
`:restart_scale`), not a fresh uniformly random point in the whole box - see
`nelder_mead_optimize`'s docstring for why a global-random-restart version of
this was actively harmful on a 6-parameter problem.
- `:restart_scale`: restart perturbation size, as a fraction of `:upper - :lower`
in each dimension. Default `0.2`.SupplyChainSimulation.plot_inventory_movement — Function
plot_inventory_movement(state, product)
Plots the inventory movement of a product through the supply chain through time.Requires PlotlyJS to be loaded (see this file's top-of-file note).
SupplyChainSimulation.plot_inventory_onhand — Function
plot_inventory_onhand(state::State, location::Location, product)
Plots the inventory on hand of a product at a location over time.Requires PlotlyJS to be loaded (see this file's top-of-file note).
SupplyChainSimulation.plot_orders — Function
plot_orders(state::State, locations::Array{L, 1}, product) where L <: ConcreteNode
Plots outbound orders for a product across multiple locations over time.Requires PlotlyJS to be loaded (see this file's top-of-file note).
SupplyChainSimulation.quadratic_surrogate_optimum — Method
quadratic_surrogate_optimum(f, lower, upper; samples)Fits a quadratic response surface y ≈ c0 + b'x + x'Ax to samples real evaluations of f (a space-filling Sobol design over the box [lower, upper]), via ordinary least squares, then returns that surrogate's own closed-form predicted optimum (solving ∇y = b + 2Ax = 0, i.e. `x* = -A
b), clamped into[lower, upper]`.
Built as a small, standalone alternative to :bayesopt's Kriging surrogate (see bayesopt_optimize's docstring for the two compounding bugs that approach hit on this package's non-smooth cost functions: stale hyperparameters, and an early-termination check sensitive to a few catastrophic-cost outliers) - a quadratic response surface has no hyperparameters to estimate at all, and its gradient/optimum are exact closed-form linear algebra rather than an iterative fit, so neither failure mode applies. The tradeoff is a much cruder model: a single global quadratic can't capture multiple local optima or genuinely non-quadratic structure the way a Kriging surrogate (in principle) can - this is meant to seed one promising candidate for another search to actually validate against the real f, not to be trusted as a standalone optimizer.
Requires samples to be at least the number of quadratic-model coefficients (1 + 2n + n(n-1)/2 for n = length(lower) parameters: one intercept, n linear terms, n squared terms, n(n-1)/2 cross terms) - fewer than that makes X \ ys an underdetermined least-squares solve with no real predictive meaning. Falls back to the best point actually observed among the sampled evaluations (rather than trusting an ill-posed solve) if A turns out non-invertible.
SupplyChainSimulation.receive_orders! — Method
receive_orders!(state::State, env::Env, orders)
Receives the orders that have been placed.SupplyChainSimulation.record_drop! — Method
record_drop!(state::State, order_line::OrderLine, pi::Int64)Incrementally updates state.metrics for an order_line that has expired (its due date passed) without being fulfilled - a lost sale, if it was bound for a customer. Non-customer-bound order lines (e.g. internal replenishment) never contribute to lost sales, matching get_total_lost_sales's isa(ol.destination, Customer) filter.
pi is order_line.product's index - see record_fill!'s docstring for why it's a parameter rather than resolved locally. flush_pending_as_lost!'s call site has no such shared pi available (it walks every pending order line across every location/product), so it resolves one there instead.
Must be called exactly once per dropped order line: either at the explicit due_date < time expiry site in send_inventory!, or, for order lines still pending when the horizon ends (which never reach that check - see flush_pending_as_lost!), once at the end of simulate.
SupplyChainSimulation.record_fill! — Function
record_fill!(state::State, env::Env, order_line::OrderLine, pi::Int64, tariff_cost::Float64=0.0)Incrementally updates state.metrics for an order_line that has just been fulfilled (its trip has been assigned and the shipment sent), and, if env.record_history is set, mirrors the same fact into state.historical_transportation for later reporting/visualization.
pi is order_line.product's index, already resolved by the caller's send_inventory! (same reasoning as record_placement!'s pi - every order line passed to one send_inventory! call shares the same product). order_line.destination still varies per order line, so that half of state.demand's index stays local to this function.
tariff_cost is whatever the caller already computed (_static_tariff_cost for a Supplier-origin shipment, _cohort_tariff_cost for a Storage-origin one, or 0.0 for an untariffed product) - stashed onto order_line.tariff_cost too, not just state.metrics.tariff_costs, so get_total_tariff_costs (Reporting.jl) can independently scan historical_filled_orders afterward - unlike every other cost bucket here, a tariff's origin-country breakdown is a run-time-only fact with no static field to recompute it from.
Must be called exactly once per fulfilled order line - matches the site of each push!(state.filled_orders, order_line) in send_inventory!.
SupplyChainSimulation.record_overflow! — Method
record_overflow!(state::State, to::Storage, product::Product, time::Int64, quantity::Int64)Records that quantity units of product could not be received into to's on-hand inventory at time because it would have exceeded maximum_units, and are being held in temporary overflow storage instead (see get_total_overflow_costs).
SupplyChainSimulation.record_placement! — Method
record_placement!(state::State, env::Env, order_line::OrderLine, pi::Int64)Records order_line into state.outbound_order_quantities (see get_past_outbound_orders), if env.needs_outbound_order_index - i.e. only if some policy in this run actually declared required_lookback(policy) > 0 (see Policy.jl). A no-op otherwise: nothing reads this index, so nothing gets written into it (the backing arrays are always allocated regardless - see outbound_order_quantities's field doc in State.jl - this flag only gates whether anything is ever written there).
pi is order_line.product's index, already resolved by the caller's place_orders (both methods place every order for one fixed product - order_line.product == product always - so pi is the same for every order line a given place_orders call records). order_line.origin varies per order line (a place_orders(ConcreteNode, ...) call can place orders on several different lanes/origins in one call), so that half of the lookup stays local to this function.
Must be called exactly once per order line, at the push!(state.placed_orders, order) site in each place_orders method.
SupplyChainSimulation.required_lookback — Method
required_lookback(policy::BackwardCoverageOrderingPolicy)::Intget_order below looks back length(policy.cover) periods via get_past_outbound_orders, so that's exactly how far Env needs to keep outbound_order_quantities for this policy's (location, product).
SupplyChainSimulation.required_lookback — Method
required_lookback(policy::InventoryOrderingPolicy)::IntThe number of past periods' outbound order quantities (see get_past_outbound_orders) policy needs to make its ordering decisions, or 0 (the default, for every policy except BackwardCoverageOrderingPolicy) if it doesn't look at history at all.
Env uses this at construction time to decide whether State needs to maintain outbound_order_quantities for the run: recording it is skipped entirely - no allocation, no per-order bookkeeping - unless some policy in play actually declares a nonzero requirement here. Override this for any future policy that needs to look backward the way BackwardCoverageOrderingPolicy does.
SupplyChainSimulation.reset! — Method
reset!(state::State)Resets state's mutable containers (on-hand/in-transit/overflow inventory, pending order lines, filled/placed orders, and history) back to the pristine condition of a freshly constructed State for the same supply chain: only the initial on-hand inventory and in-transit arrivals configured on the supply chain, with no pending orders and no history.
state.supply_chain and state.demand are read-only for the duration of a simulation and are left untouched. This lets the same State be re-simulated many times - as optimize! does across thousands of policy evaluations - by resetting its mutable containers in place instead of deepcopy-ing the (potentially large, read-only) supply chain network on every evaluation.
Any order lines passed via the pending_outbound_order_lines keyword at construction time are a one-time seed and are not restored by reset!.
SupplyChainSimulation.safe_round_int — Method
safe_round_int(value::Float64)::Int64Rounds value to the nearest Int64, or 0 if value is non-finite or too large to represent as an Int64 (Int(round(value)) throws InexactError in both cases). set_parameters! feeds the optimizer's raw candidate values straight into policy fields; BlackBoxOptim's mutation/crossover can propose a trial outside the nominal SearchRange (see optimize!), and over thousands of evaluations one occasionally lands far enough out to overflow here - the same class of "degenerate optimizer candidate" already guarded against on the read side in ForwardCoverageOrderingPolicy/ BackwardCoverageOrderingPolicy's get_order.
SupplyChainSimulation.sensitivity_analysis — Method
sensitivity_analysis(lane_policies, supplychains...; cost_function, record_history, options)Runs a global (Sobol) sensitivity analysis of cost_function against every policy parameter across lane_policies, via sobol_indices above - a different question from optimize!'s "what's the best policy?": "which policy parameters actually matter for cost?" Useful as a triage step before spending an optimize! budget tuning parameters that barely move the outcome, or for understanding why a policy landed where it did.
Shares optimize!'s setup (Env construction, sorted policy keys, x0 assembly, the same minimize!-based evaluator - see optimize!'s comment on why sorted_keys' order has to be deterministic) rather than reimplementing it, so the parameter ordering S1/ST are reported in exactly matches what optimize! itself searches over.
Restores every policy's original parameters before returning: minimize! mutates each policy's parameters as a side effect of evaluating a candidate x (see its definition above) - without explicitly restoring x0 afterward, this function, despite being a read-only diagnostic, would otherwise leave every policy set to whatever the last of sobol_indices' many sampled points happened to be, rather than left unchanged as a caller would expect.
Returns a NamedTuple (parameter_labels, S1, ST): parameter_labels[i] identifies which policy (by the lane(s)/product it's attached to) and which parameter index within that policy the i'th entry of S1/ST (first-order and total-order Sobol indices) belongs to. A parameter's S1 close to 0 means it barely affects cost_function on its own within the given bounds; ST also captures its interactions with other parameters, so ST >> S1 for a parameter flags that it mostly matters together with another one, not on its own.
options accepts:
:lower,:upper: box constraints, each either a scalar (broadcast to every parameter) or a per-parameter vector - same shape/defaults (0.0/5000.0) asoptimize!'s other*_optionsdicts.:samples: controls evaluation cost - needssamples * (length(x0) + 2)real evaluations (seesobol_indices). Default1000.
SupplyChainSimulation.simulate — Method
simulate(env::Env, policies, initial_state::State)
Simulates the supply chain for horizon steps, starting from `initial_state`.
`initial_state` must already reflect the desired starting condition, as produced
by `State(supply_chain)` or by `reset!(state)`. It is simulated *in place* and
returned rather than copied, so callers that want to run multiple simulations
from the same starting point (e.g. `optimize!` evaluating many policy
candidates) can reuse the same `State` via `reset!` instead of paying for a
`deepcopy` of the whole (read-only) supply chain network on every evaluation.SupplyChainSimulation.snapshot_state! — Method
snapshot_state!(state::State, time, record_history::Bool, customer_backlog::Bool)Closes out period time: charges holding cost for everything currently on hand and backlog cost (raw units, see SimMetrics.backlog) for everything currently outstanding (both always, regardless of record_history - this is the incremental counterpart of get_total_holding_costs's history scan, see SimMetrics), and, only when record_history is true, archives a per-period on-hand snapshot plus this period's filled/placed orders into state's historical_* arrays for later reporting/visualization.
When record_history is false the archiving - including the Dict copy of on-hand inventory and the per-period Set handoffs - is skipped entirely, and state.filled_orders/state.placed_orders are just cleared in place for the next period instead of being swapped for fresh, permanently-retained Sets.
customer_backlog must be passed the same value as the Env this state is being simulated under (see Env.customer_backlog): when false (the default), a Customer order still pending here is one period away from being dropped as a lost sale (see place_orders(..., ::Customer, ...)), not a genuine backlog, and is excluded from SimMetrics.backlog accordingly; when true, Customer orders queue like any other node's and are counted the same way.
SupplyChainSimulation.sobol_indices — Method
sobol_indices(f, lower, upper; samples)Computes first-order (S1) and total-order (ST) Sobol sensitivity indices of f over the box lower[i] <= x[i] <= upper[i], via the Saltelli (2010)/Jansen (1999) estimators - the same estimators a SciML-ecosystem sensitivity-analysis package (GlobalSensitivity.jl) would use, reimplemented directly here rather than depending on that package: GlobalSensitivity.jl bundles many sensitivity methods together, and two of its own transitive dependencies (Copulas.jl, needed only for its unrelated Shapley-effects method, and ForwardDiff) turned out to require mutually incompatible versions of GlobalSensitivity.jl itself once resolved against this package's other, already-modern dependencies - not something fixable by adjusting a compat bound on our end. Kept as a standalone, generically useful function (not folded into sensitivity_analysis below) specifically so a real SciML package could be swapped in later as a pure implementation detail, without sensitivity_analysis's own signature or return shape needing to change, if a working dependency path for one ever exists.
Draws two independent samples x length(lower) matrices A/B uniformly from the box, plus one A with column i swapped in from B per parameter i - samples * (length(lower) + 2) evaluations of f in total. S1/ST are validated in this package's tests against the analytically known Sobol indices of a simple linear function (which has no parameter interactions, so S1 == ST exactly in the infinite-sample limit) rather than only smoke-tested, since there is no local Julia available in this project's usual workflow to cross-check the estimator against a reference implementation interactively.