Forecast of Bad — Computation Reference¶
"Bad" refers to unserviceable / defective units that flow back through the reverse-logistics pipeline (return → repair → re-entry into good stock). The forecast of how many "bad" units will arrive each week is computed at multiple stages and consumed by the netting engine, the supply MRP engine, and MEIO safety-stock optimizer.
1. Data Model — Core Tables & Columns¶
1.1 master.route (PostgreSQL)¶
The route table is the single source of all reverse-logistics parameters. Routes are typed by master.route_type.planning_type:
| planning_type | Role | Key columns |
|---|---|---|
RETURN |
Physical transit of defective units from customer/field back to repair facility | return_rate, lead_time + transit_time + pick_pack_time (= return lead time) |
REPAIR |
In-shop repair process at the facility | repair_yield, lead_time + transit_time + pick_pack_time (= repair TAT), repair_tat_cv, wip_qty |
BUY / MAKE / TRANSFER |
Forward replenishment | lead_time, unit_cost, min_qty, mult_qty, max_qty, order_cost |
Resolution rule: when multiple routes of the same planning_type exist for the same (item_id, site_id), the one with the lowest priority value wins (DISTINCT ON … ORDER BY priority ASC NULLS LAST).
Extended columns (added by migration, not in the original CREATE TABLE):
return_rate DOUBLE PRECISION DEFAULT 0— fraction of demand that physically returns (0–1).repair_yield DOUBLE PRECISION DEFAULT 1— fraction of returned units that are successfully repaired and re-enter good stock (0–1). 0 = not repairable.repair_tat_mean DOUBLE PRECISION DEFAULT 0— repair shop turnaround time (weeks) — computed, not stored directly on route.repair_tat_cv DOUBLE PRECISION DEFAULT 0— coefficient of variation of repair TAT.lead_time_cv DOUBLE PRECISION DEFAULT 0— CV of forward lead time.wip_qty DOUBLE PRECISION DEFAULT 0— work-in-progress units currently in the repair shop.
1.2 master.customer_site (PostgreSQL) — DROPPED (Sept 2026)¶
| Column | Type | Description |
|---|---|---|
customer_id |
BIGINT (FK → master_customer) | |
site_id |
BIGINT (FK → master_site) | The repair / stocking facility |
wash_rate |
DOUBLE PRECISION DEFAULT 0 | Fraction of returned units lost during the return transit (0–1). Schema comment describes intended use; see note below on actual consumption. |
lead_time |
DOUBLE PRECISION DEFAULT 0 | Days for the return transit from customer to site. Superseded by master.route lead time when a RETURN route exists. |
Note: wash_rate is currently a dormant parameter. It is stored on master.customer_site (loaded via files/etl/import_etl.py:294-306) but no code reads it. The demand ETL inserts ABS(da.qty) verbatim into master.demand_actuals with no wash adjustment. To model wash losses in the planning horizon today, a planner must fold the loss into the RETURN route's return_rate directly (i.e. set return_rate = nominal_rate × (1 - wash_rate)). The schema comment (schema.sql:183) describes intended use, not implemented behaviour.
1.3 master.on_hand + master.on_hand_type¶
| Column | Description |
|---|---|
master_on_hand.qty |
Quantity on hand |
master_on_hand_type.planning_type |
'bad', 'new', or other |
oh_bad_qty = SUM(qty) FILTER (WHERE planning_type = 'bad')— current unserviceable stock sitting at the facility, available to feed the repair shop.oh_new_qty = SUM(qty) FILTER (WHERE planning_type = 'new')— newly received stock not yet mixed into the general pool.
1.4 master_sales_return (PostgreSQL)¶
Confirmed/anticipated customer return orders:
| Column | Description |
|---|---|
item_id, site_id |
Series identifier |
return_date |
Expected arrival date of the returned unit |
qty |
Quantity |
status |
'confirmed', 'anticipated', 'expected' |
scenario_id |
FK to supply scenario |
1.5 ClickHouse PIPE tables (output tables)¶
| Table | Written by | Key columns for "bad" |
|---|---|---|
PIPE_forecast_netting |
Netting step | return_forecast_qty, return_netted_qty, firm_return_qty, return_consumed_qty |
PIPE_causal_forecast |
Causal runner | qty — unscheduled demand from asset failures |
PIPE_maintenance_forecast |
Maintenance step | qty — scheduled maintenance demand |
PIPE_indirect_demand |
Indirect demand step | qty — network demand = downstream_qty × route.repair_yield (default 1.0); kit/make demand = parent_qty × Π(child_qty/item_qty × attach_rate × (1−scrap)) |
PIPE_supply_orders |
Supply runner | order_type = 'REPAIR' rows |
PIPE_supply_inventory_projection |
Supply runner | oh_bad[] — weekly bad-stock projection |
2. Pipeline Step Order¶
The "bad" forecast is computed incrementally across the pipeline steps:
Step 1: ETL (demand_actuals)
Step 1a: Demand Coverage Reassignment (optional)
Step 2: Outlier Detection
Step 3: Characterization
Step 4: Forecast (statistical)
Step 5: Evaluation / Best Method
Step 6: Maintenance Forecast ─→ PIPE_maintenance_forecast
Step 7: Causal Forecast ─→ PIPE_causal_forecast
Step 7b: Indirect Demand ─→ PIPE_indirect_demand
Step 8: MEIO (safety stock)
Step 9: Netting ─→ PIPE_forecast_netting (includes return_forecast_qty)
Step 10: Supply ─→ PIPE_supply_orders (REPAIR orders), bad-stock projection
3. Forecast of Bad — Computation Details¶
3.1 Netting Step — Projected Return Forecast¶
File: files/netting/forecast_netting.py:730-746
The netting step computes return_forecast_qty per (item_id, site_id, forecast_week):
Where:
- total_demand[t] = stat_forecast[t] + maintenance_forecast[t] + causal_forecast[t] (when in add mode; in net mode, maint/causal are treated as firm)
- return_lt_weeks = lead_time + transit_time + pick_pack_time from the RETURN route (_load_return_route_params)
- return_rate = from the RETURN route's return_rate column
Key constraint: only RETURN routes with return_rate > 0 are considered. SKUs without a RETURN route get return_forecast_qty = 0.
The projected return is then netted against confirmed return orders (firm_return_qty from master_sales_return) using the same forward/backward fence consumption algorithm as demand netting:
return_consumed = amount of projected return consumed by firm return orders
return_netted = max(0, projected_return - return_consumed)
The final return_forecast_qty and return_netted_qty are stored in PIPE_forecast_netting.
3.2 Supply Runner — Repair Returns (Statistical Auto-fill)¶
File: files/supply_runner.py:1640-1683 (SQL) and files/supply/src/preprocess.rs:127-178 (Rust)
The supply runner loads the same route data and computes a 52-week repair_returns[] array for each SkuPlan. When the plan's repair_returns array is all-zero (no confirmed returns), the Rust preprocess pass auto-fills:
Where:
- demand[w] = the post-consumption demand array (after apply_forecast_consumption in Rust)
- repair_turnaround = return_lt_weeks + repair_tat_weeks
- return_lt_weeks = from RETURN route: lead_time + transit_time + pick_pack_time
- repair_tat_weeks = from REPAIR route: lead_time + transit_time + pick_pack_time
- Combined via FULL OUTER JOIN of RETURN and REPAIR routes (return_route CTE at line 1609-1625)
- return_rate = from RETURN route
- repair_yield = from REPAIR route (clamped to [0, 1])
Multiple repair routes: when a SKU has multiple REPAIR routes with positive return_rate, the auto-fill takes the maximum over all routes per week:
for route in repair_routes {
let qty = plan.demand[src] * rr * ry;
if qty > filled[w] {
filled[w] = qty;
}
}
Already-populated check: if repair_returns already contains any non-zero values (from confirmed returns in master_sales_return), the auto-fill is skipped entirely (Rust: preprocess.rs:129-131). The supply_runner Python code also loads confirmed returns first (_load_confirmed_returns at line 2507-2544) and populates the repair_returns array with them.
3.3 Supply Runner — failure_rate Array¶
File: files/supply_runner.py:1784-1788
The supply runner also emits a failure_rate[] array (52 elements, all identical):
This is a flat scalar repeated across the horizon. It is not used by the Rust engine's auto-fill (which reads route.return_rate directly from RouteOption). The failure_rate array exists for the Pyomo optimization model and the allocation engine.
3.4 Supply Runner — bad_returns Array¶
File: files/supply_runner.py:1870-1879
The bad_returns[] array is the raw bad-unit arrival schedule fed to the bad-pool projection:
bad_ret_arr = [0.0] * 52
for wk, qty in confirmed_returns_for_sku.items():
if 0 <= wk < 52:
repair_arr[wk] = float(qty)
bad_ret_arr[wk] = float(qty)
d["repair_returns"] = repair_arr
d["bad_returns"] = bad_ret_arr
Currently, bad_returns is populated from the same confirmed return orders as repair_returns. It is not auto-filled statistically — the statistical auto-fill in Rust populates repair_returns but does not touch bad_returns. This means:
- When confirmed returns exist →
bad_returns[w] = confirmed_qty[w] - When no confirmed returns →
bad_returns[w] = 0(even thoughrepair_returnsgets auto-filled)
3.5 Bad-Stock Projection (Rust Engine)¶
File: files/supply/src/engine.rs:816-823
The Rust supply engine projects the weekly bad-stock balance:
bad_carry = initial_oh_bad
for w in 0..51:
oh_bad[w] = bad_carry
bad_carry = max(0, bad_carry + bad_returns[w] - repair_returns[w])
Where:
- initial_oh_bad = current bad on-hand from master_on_hand (planning_type = 'bad')
- bad_returns[w] = bad units arriving this week (currently = confirmed returns only)
- repair_returns[w] = units leaving the bad pool for repair (auto-filled statistically or confirmed)
This is a simple bucket balance: bad stock grows by returns, shrinks by repairs.
3.6 Bad-Stock Projection (Python Pyomo Model)¶
File: files/supply_solver/model.py:238-257
The Pyomo LP model has explicit constraints when repair or bad stock is present:
# Bad stock balance constraint:
bad[si, t] == prev_bad + bad_ret - Y[si, t] - scrap[si, t] - repair_returns[t]
# Repair availability constraint:
Y[si, t] <= prev_bad + bad_ret
Where:
- Y[si, t] = units entering repair at time t (decision variable)
- scrap[si, t] = units scrapped (decision variable)
- repair_returns[t] = units exiting repair at turnaround lag (parameter from route)
The repair flow in the LP model uses the turnaround lag explicitly:
ta = int(rr.get("repair_turnaround", 0) or 0)
yw = t - ta
if yw >= 0:
ry = float(rr.get("repair_yield", 1.0) or 1.0)
repair_in_t = m.Y[si, yw] * ry
This means: units sent to repair at week yw come out as good stock at week t = yw + ta, multiplied by repair_yield.
4. MEIO (Safety Stock) — Repair Parameters¶
File: files/meio_runner.py:1880-1985 (_load_repair_params_from_routes)
The MEIO optimizer reads repair parameters from route rows to compute safety stock for repairable items:
Where:
- return_lt = transit time from RETURN route: transit_time + lead_time + pick_pack_time
- repair_tat_shop = in-shop repair time from REPAIR route: transit_time + lead_time + pick_pack_time
- repair_tat_cv = from REPAIR route's lead_time_cv (or MEIO default default_repair_tat_cv)
- repair_yield = from REPAIR route (or MEIO default default_repair_yield)
- return_rate = from RETURN route (or MEIO default default_return_rate)
- wip_qty = from REPAIR route (or MEIO default default_wip_qty)
The MEIO defaults come from config_meio_parameter_set.params JSONB:
- default_return_rate (default: 0.0)
- default_repair_yield (default: 1.0)
- default_repair_tat_mean (default: 4.0 weeks)
- default_repair_tat_cv (default: 0.3)
- default_wip_qty (default: 0.0)
For the ASL (Absolute Service Level) Phase-1 pass, the repair pool is modelled with an M/M/c queue:
And the effective repair turnaround accounts for yield losses:
5. Causal / Maintenance Forecast — Indirect "Bad" Generation¶
5.1 Causal (Unscheduled Failure) Demand¶
File: files/causal/demand_generator.py:35-263
The causal engine generates demand from asset failures:
D(item, customer, period) = Σ_asset_types [
effective_qty(asset_type, item)
× fleet_qty(asset_type, customer) [when usage_mode='per_asset']
× failure_rate(item, usage_type, customer, period)
× total_usage(customer, usage_type, period)
]
Where:
- effective_qty = bom.qty_per_asset × bom.repair_yield (from build_effective_bom)
- failure_rate = from master_causal_failure_rate (resolved via specificity waterfall: position > item > item-type > global)
- fleet_qty = from fleet plan (asset count per customer/period)
- total_usage = sum of factor_qty from scen_causal_asset_usage
The repair_yield in the BOM reduces the effective quantity of replacement parts needed (if 85% of units are repaired, only 15% need new parts).
5.2 Maintenance (Scheduled) Demand¶
File: files/causal/maintenance.py:32-101
Scheduled maintenance demand is deterministic:
scheduled_demand(item, period) = Σ_events_in_period [
msed.qty × mb.qty × COALESCE(mb.likelihood_of_removal, 1.0) × coverage_pct / 100
]
Where:
- msed.qty = number of maintenance events on that date
- mb.qty = BOM quantity per event
- likelihood_of_removal = probability the part is removed/replaced
- coverage_pct = from maintenance_event_coverage (site-level distribution)
5.3 Indirect Demand (Network + BOM)¶
The indirect-demand step produces two separate row sets, both written to PIPE_indirect_demand with a demand_type discriminator ('network' / 'kit_make'), scenario_id = 0, archive_date = planning_monday(pipeline_id).
5.3a Network indirect — Transfer routes (compute_network_indirect)¶
File: files/indirect/indirect_demand.py:100-242
Propagates demand upstream along Transfer routes. A retail store's demand pushes up to its supplying warehouse, which pushes up to the central warehouse, and so on (multi-level iterative propagation, MAX_DEPTH = 10).
Route selection (indirect_demand.py:140-156): best-priority Transfer route per destination (item_id, site_id) that has a source_item_id / source_site_id:
SELECT DISTINCT ON (r.item_id, r.site_id)
r.item_id, r.site_id,
r.source_item_id, r.source_site_id,
COALESCE(r.repair_yield, 1.0) AS yield_factor
FROM master.route r
WHERE r.type_id = ANY(%s) -- Transfer route type IDs
AND r.source_item_id IS NOT NULL
AND r.source_site_id IS NOT NULL
AND (r.end_date IS NULL OR r.end_date >= CURRENT_DATE)
ORDER BY r.item_id, r.site_id, r.priority ASC NULLS LAST
Demand seeds (indirect_demand.py:166-197):
- Direct demand from
master.demand_actuals(historical actuals) - When
pipeline_idis provided, causal forecast rows fromPIPE_causal_forecastare unioned in (_load_causal_forecast_rows) so causal-only items (no history) still generate upstream indirect demand
Propagation (indirect_demand.py:186-223) — note this is a multiply, not a divide:
where yield_factor = COALESCE(r.repair_yield, 1.0). So a Transfer route with repair_yield = 0.9 sends 90% of demand upstream (source supplies less than destination needs). For a genuinely lossy transfer you would expect the inverse (qty / 0.9), so this is either (a) repair_yield repurposed as a sourcing fraction rather than a loss fraction, or (b) a latent bug. Most Transfer routes leave repair_yield NULL → defaults to 1.0 → bit-identical pass-through, so it rarely manifests.
The iterative loop accumulates per-source output rows (one per (src, dest, date) — not aggregated across sources) and pushes the aggregated upstream demand to the next pass until no node has a Transfer route further upstream.
5.3b Kit/Make indirect — recursive BOM (compute_kit_make_indirect)¶
File: files/indirect/indirect_demand.py:247-335
Recursive CTE over master.bill_of_material. Does not use repair_yield; uses BOM scrap instead. The cumulative ratio along a BOM path is the product of:
child_qty / item_qty(how many child units per parent)attach_rate(probability the child is actually present — for optional components)(1 - scrap)(yield after manufacturing scrap — note: this is BOMscrap, notrepair_yield)
WITH RECURSIVE bom_tree AS (
-- Anchor: direct parent → child
SELECT b.item_id AS root_item_id, b.site_id AS root_site_id,
b.child_item_id, b.child_site_id,
(COALESCE(b.child_qty,1.0) / NULLIF(COALESCE(b.item_qty,1.0),0))
* COALESCE(b.attach_rate,1.0)
* (1.0 - COALESCE(b.scrap,0.0)) AS cum_ratio,
1 AS depth
FROM master.bill_of_material b
WHERE ... -- active date range
UNION ALL
-- Recursive: one level deeper
SELECT ...,
bt.cum_ratio
* (child_qty/item_qty)
* attach_rate
* (1 - scrap),
bt.depth + 1
FROM master.bill_of_material b
JOIN bom_tree bt ON bt.child_item_id = b.item_id
AND bt.child_site_id = b.site_id
WHERE bt.depth < 20 -- cycle guard
)
SELECT bt.child_item_id AS item_id, bt.child_site_id AS site_id,
da.item_id AS source_item_id, da.site_id AS source_site_id,
da.date,
SUM(da.qty * bt.cum_ratio) AS qty
FROM bom_tree bt
JOIN master.demand_actuals da ON da.item_id = bt.root_item_id
GROUP BY child_item_id, child_site_id, da.item_id, da.site_id, da.date
A component's indirect demand = parent_demand × cum_ratio summed across all BOM paths that lead to it. Parent demand is matched across all sites (no site filter on the join) so dealer demand for a service kit drives central-WH component needs.
Persistence¶
Both row sets are merged in run_and_store (indirect_demand.py:366-454):
- Supersession filter (
indirect_demand.py:380-397): items with a global supersession edge (item_supersessionwheresite_id IS NULL) are dropped — their indirect demand is redirected to successors. - Write to ClickHouse (
indirect_demand.py:437-447): drops the existingPIPE_indirect_demandpartition for(pipeline_id, scenario_id=0)then bulk-inserts. Each row carriessource_item_id/source_site_ididentifying the downstream node whose demand generated it (kept per-source, not aggregated across sources).
6. End-to-End Flow Diagram¶
┌─────────────────────────────────────────────────────────────────────┐
│ MASTER DATA │
│ │
│ master.route (RETURN) master.route (REPAIR) │
│ ├── return_rate ├── repair_yield │
│ ├── return_lt (weeks) ├── repair_tat_shop (weeks) │
│ └── priority (route select) ├── repair_tat_cv │
│ ├── lead_time_cv │
│ master.customer_site └── wip_qty │
│ ├── wash_rate (dormant — never consumed) │
│ └── lead_time (legacy fallback) │
│ │
│ master.on_hand (type='bad') → initial_oh_bad │
│ master.on_hand (type='new') → initial_oh_new │
│ master_sales_return → confirmed return orders │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ FORECAST PIPELINE │
│ │
│ 1. ETL → demand_actuals (qty inserted verbatim; wash_rate dormant)│
│ 2. Statistical forecast → stat_forecast[1..52] │
│ 3. Causal forecast: D = qty × fleet × failure_rate × usage │
│ → PIPE_causal_forecast │
│ 4. Maintenance forecast: D = events × BOM × likelihood × cov% │
│ → PIPE_maintenance_forecast │
│ 5. Indirect demand: │
│ network = downstream_qty × route.repair_yield (Transfer) │
│ kit/make = parent_qty × Π(child_qty/item_qty × attach × (1−scrap))│
│ → PIPE_indirect_demand │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ NETTING STEP │
│ │
│ For each (item_id, site_id, week): │
│ │
│ total_demand[t] = stat_f[t] + maint_f[t] + causal_f[t] │
│ projected_return[t] = total_demand[t - return_lt] × return_rate │
│ │
│ Net projected_return against firm_return_orders │
│ → return_forecast_qty, return_netted_qty, firm_return_qty │
│ → stored in PIPE_forecast_netting │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SUPPLY MRP ENGINE │
│ │
│ Per-SKU arrays built from routes + on-hand: │
│ │
│ failure_rate[] = [return_rate] × 52 (flat, for Pyomo) │
│ bad_returns[] = confirmed_returns[0..51] (from interact) │
│ repair_returns[] = confirmed OR auto-filled: │
│ rr[w] = demand[w - turnaround] × return_rate × repair_yield │
│ where turnaround = return_lt + repair_tat_shop │
│ │
│ Bad-stock weekly projection: │
│ oh_bad[w] = bad_carry │
│ bad_carry += bad_returns[w] - repair_returns[w] │
│ │
│ Repair order generation: │
│ If repair_returns[w] > 0 and bad pool has stock │
│ → REPAIR order released, arrives after turnaround │
│ → good stock += repair_returns[w] at arrival week │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ MEIO SAFETY STOCK │
│ │
│ Repair parameters per (item, site): │
│ repair_tat_mean = return_lt + repair_tat_shop │
│ effective_tat = repair_tat_mean / max(repair_yield, 0.01) │
│ rho = demand_rate × repair_tat_mean │
│ │
│ M/M/c queue model for repair pool sizing │
│ ASL pass: min buffer = safety stock for repairable items │
│ V3 Rust optimizer: uses demand-over-lead-time distributions │
│ (lead-time from supply side, not demand_actuals.received_date) │
└─────────────────────────────────────────────────────────────────────┘
7. Key Formulas Summary¶
| Computation | Formula | Where |
|---|---|---|
| Projected return forecast | demand[t - return_lt] × return_rate |
Netting step (forecast_netting.py:744) |
| Statistical repair returns | demand[w - turnaround] × return_rate × repair_yield |
Rust auto-fill (preprocess.rs:166) |
| Total repair turnaround | return_lt + repair_tat_shop |
Supply runner CTE (supply_runner.py:1619-1621) |
| Return lead time | COALESCE(lead_time,0) + COALESCE(transit_time,0) + COALESCE(pick_pack_time,0) |
RETURN route |
| Repair TAT (shop) | COALESCE(lead_time,4) + COALESCE(transit_time,0) + COALESCE(pick_pack_time,0) |
REPAIR route |
| Bad-stock balance | bad[w] = bad[w-1] + bad_returns[w] - repair_returns[w] |
Rust engine (engine.rs:822) |
| Repair availability | Y[w] ≤ bad[w-1] + bad_returns[w] |
Pyomo model (model.py:254-255) |
| Good-stock inflow from repair | repair_in[w] = Y[w - ta] × repair_yield |
Pyomo model (model.py:216-217) |
| Effective repair TAT (MEIO) | tat_eff = repair_tat_mean / max(repair_yield, 0.01) |
MEIO runner (meio_runner.py:893) |
| Causal unscheduled demand | effective_qty × fleet_qty × failure_rate × total_usage |
Demand generator (demand_generator.py:228-230) |
| Scheduled maintenance demand | event_qty × bom_qty × likelihood × coverage_pct / 100 |
Maintenance (maintenance.py:59) |
| Network indirect demand | downstream_qty × route.repair_yield (multi-level, iterated upstream) |
indirect_demand.py:213 |
| Kit/Make indirect demand | Σ(parent_demand × Π(child_qty/item_qty × attach_rate × (1−scrap))) |
indirect_demand.py:270-305 |
8. Observations & Potential Gaps¶
-
bad_returnsnot statistically filled: Thebad_returns[]array in the supply runner is populated only from confirmed returns (master_sales_return). The Rust auto-fill populatesrepair_returns[]statistically but does not touchbad_returns[]. This means the bad-stock projection (oh_bad[w]) can be zero when there are no confirmed returns, even though statistical returns are expected. Therepair_returns[w]then subtracts from a zero pool, which is clamped to zero — so the bad-stock projection shows zero bad stock even when repair orders are flowing through. -
wash_rateis dormant: The customer-sitewash_rateis stored but never consumed by any computation (verified: nowash_rateread outside ETL import + DDL definitions). Demand is inserted verbatim asABS(qty). If a planner needs wash losses modelled in the planning horizon, they must fold the loss into the RETURN route'sreturn_ratemanually (effective_return_rate = nominal_rate × (1 - wash_rate)). This is a candidate for a future wiring change. -
Netting return forecast ≠ supply bad_returns: The netting step computes
projected_return[t] = demand[t - return_lt] × return_rateand stores it asreturn_forecast_qtyinPIPE_forecast_netting. The supply runner computesrepair_returns[w] = demand[w - turnaround] × return_rate × repair_yieldwhereturnaround = return_lt + repair_tat. These are different lags and different scalars (netting usesreturn_rateonly; supply usesreturn_rate × repair_yield). The netting return forecast is a total return projection; the supply repair_returns is a good-stock inflow projection (after repair yield losses). -
Yield losses in the bad pool: When
repair_yield < 1, the(1 - repair_yield)fraction of units entering repair are scrapped or lost. In the Rust engine, this is implicit —repair_returns[w]already includes the yield factor, so the bad pool balancebad + bad_returns - repair_returnsover-subtracts by the scrap fraction. In the Pyomo model, scrap is explicit (scrap[si,t]variable) but not directly constrained — it absorbs the yield loss. -
Non-repairable items: Items where ALL REPAIR routes have
repair_yield = 0are flagged asnon_repin the supply runner CTE (supply_runner.py:1557-1566). Their RETURN/REPAIR routes are excluded from planning — they track returns but never generate REPAIR orders.