Skip to content

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):

projected_return[t] = total_demand[t - return_lt_weeks] × return_rate

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:

repair_returns[w] = demand[w - repair_turnaround] × return_rate × repair_yield

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):

ret_rate = float(d.get("return_rate", 0.0) or 0.0)
d["failure_rate"] = [ret_rate] * 52

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 though repair_returns gets 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:

repair_tat_mean = return_lt + repair_tat_shop

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:

rho = demand_rate × repair_tat_mean  (server utilization)

And the effective repair turnaround accounts for yield losses:

effective_tat = repair_tat_mean / max(repair_yield, 0.01)

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_id is provided, causal forecast rows from PIPE_causal_forecast are 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:

contribution = downstream_qty × route.yield_factor

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 BOM scrap, not repair_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):

  1. Supersession filter (indirect_demand.py:380-397): items with a global supersession edge (item_supersession where site_id IS NULL) are dropped — their indirect demand is redirected to successors.
  2. Write to ClickHouse (indirect_demand.py:437-447): drops the existing PIPE_indirect_demand partition for (pipeline_id, scenario_id=0) then bulk-inserts. Each row carries source_item_id / source_site_id identifying 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

  1. bad_returns not statistically filled: The bad_returns[] array in the supply runner is populated only from confirmed returns (master_sales_return). The Rust auto-fill populates repair_returns[] statistically but does not touch bad_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. The repair_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.

  2. wash_rate is dormant: The customer-site wash_rate is stored but never consumed by any computation (verified: no wash_rate read outside ETL import + DDL definitions). Demand is inserted verbatim as ABS(qty). If a planner needs wash losses modelled in the planning horizon, they must fold the loss into the RETURN route's return_rate manually (effective_return_rate = nominal_rate × (1 - wash_rate)). This is a candidate for a future wiring change.

  3. Netting return forecast ≠ supply bad_returns: The netting step computes projected_return[t] = demand[t - return_lt] × return_rate and stores it as return_forecast_qty in PIPE_forecast_netting. The supply runner computes repair_returns[w] = demand[w - turnaround] × return_rate × repair_yield where turnaround = return_lt + repair_tat. These are different lags and different scalars (netting uses return_rate only; supply uses return_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).

  4. 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 balance bad + bad_returns - repair_returns over-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.

  5. Non-repairable items: Items where ALL REPAIR routes have repair_yield = 0 are flagged as non_rep in 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.