~/tech-with-ugur

Plan three years of manufacturing cash with NumPy and PySCIPOpt

2026-09-12 maths

Run the companion lab

A factory’s COO has three choices: hire researchers to make production cheaper, expand the building to produce more, or keep the existing operation. Each choice needs money now. Its value depends on how many units the factory can sell later.

That dependency is the point of this lab. Research does not earn revenue by itself, and extra floor space cannot manufacture anything without workers. We will model one product, one factory, and three years, then let PySCIPOpt choose a coordinated plan. NumPy gives the annual inputs and variable arrays a consistent shape.

The companion lab contains the scenario, equations, independent result checks, and a persistent Docker workflow. Every number is a synthetic teaching assumption.

Assumptions first, decisions second

The selling price stays at $90 per unit. Demand caps sales at 4,000 units in year 1 and 6,000 in each subsequent year. The existing factory can make 4,000 units annually. A $50,000 expansion adds 2,000 units of annual capacity, starting the year after payment.

One manufacturing worker can support 1,000 units per year and costs $20,000 annually. We may employ at most six. Researchers cost $10,000 annually, with at most three employed in a year. Production initially costs $50 per unit for materials and electricity; salaries enter separately. Research can lower that cost, subject to a $25 floor.

These are inputs in scenario.yaml. The solver chooses annual manufacturing headcounts, research headcounts, expansion starts, and production volume. It also represents unit cost as a variable whose value is fixed by prior research equations.

QuantityTypeMeaning
WorkersInteger, 0–6Same-year manufacturing capacity
ResearchersInteger, 0–3Savings beginning next year
Expansion startBinaryOne payment, at most once, in year 1 or 2
ProductionContinuous, boundedSame-year units produced and sold
Unit costContinuous, $25–$50Initial cost minus accumulated research savings

Continuous production is an annual planning approximation. Staffing is chosen independently each year, without hiring or firing costs. Everything produced is sold immediately; there is no inventory.

The objective is cumulative net cash over the three years: revenue minus production spending, both salaries, and expansion payments. An expansion is paid in full when it starts. There is no depreciation, tax, discounting, financing, terminal value, or cash-balance constraint. Calling this profit planning does not make it an accounting-profit model.

Give research a timeline

The first researcher creates $4 of future saving per unit; the second adds $3; the third adds $2. The team therefore creates $4, $7, or $9 of new saving. Diminishing returns describe contributions within one year’s team.

For a team of size r, the arithmetic-series sum is 4r − r(r − 1)/2 dollars per future unit. The important equation then carries the current cost forward: next year’s unit cost equals this year’s unit cost minus this year’s new saving.

Consider an explanatory plan with two researchers in year 1, one in year 2, and none in year 3:

YearResearchersNew saving for later yearsCost used that year
12$7/unit$50/unit
21$4/unit$43/unit
30$0/unit$39/unit

The year-1 work saves $7 in both later years. Reducing research staffing does not erase knowledge. The year-2 work adds another $4 for year 3. This illustrates timing; it is not the default optimal plan.

Here is the actual recurrence:

From labs/lab-manufacturing-profit-pyscipopt/src/app/model.py:

        for year in range(_YEARS - 1):
            researchers = variables.researchers[year]
            new_saving = (
                scenario.first_researcher_saving_usd_per_unit * researchers
                - scenario.saving_drop_per_additional_researcher
                * researchers
                * (researchers - 1)
                / 2
            )
            add_constraint(
                model,
                variables.unit_cost[year + 1] == variables.unit_cost[year] - new_saving,
                name=f"research_cost_update_year_{year + 2}",
                log=operation_log,
            )

Python index 0 is year 1. The loop updates only years 2 and 3, so year-3 research has no modeled payoff. Because salaries are positive, employing researchers then only reduces cash.

The cost floor is a variable lower bound. Savings are not silently clipped to $25: research decisions must satisfy the recurrence while keeping every modeled year’s cost above that floor.

Capacity needs both workers and a building

Production cannot exceed annual demand, workers multiplied by their output, or available physical capacity. A six-person manufacturing team still cannot make 6,000 units in a 4,000-unit building.

An expansion that starts in year 1 has availability [0, 1, 1]. A year-2 start has availability [0, 0, 1]. The code sums strictly earlier starts:

From labs/lab-manufacturing-profit-pyscipopt/src/app/model.py:

        for year in range(_YEARS):
            expansion_available = quicksum(variables.expansion_start[:year])
            add_constraint(
                model,
                variables.units_produced[year]
                <= scenario.capacity_units_per_year
                + scenario.extra_capacity_units_per_year * expansion_available,
                name=f"factory_capacity_year_{year + 1}",
                log=operation_log,
            )

The payment-year capacity stays unchanged. A separate constraint permits at most one start, and the year-3 start has an upper bound of zero because it cannot help within the horizon.

Research and expansion interact through volume: a $1 unit-cost saving is worth $4,000 in a 4,000-unit year and $6,000 in a 6,000-unit year. That is why choosing each investment in isolation misses the business dependency.

Use NumPy for bounds and the Matrix API for decisions

All annual arrays have shape (3,), ordered year 1 through year 3. Numerical preprocessing computes production’s finite upper bounds:

From labs/lab-manufacturing-profit-pyscipopt/src/app/model.py:

    upper = np.minimum(scenario.demand_units, np.minimum(staffing, expanded_capacity))
    if not np.all(np.isfinite(upper)):
        raise ScenarioError("Derived bound production_upper is not finite")
    return upper.astype(np.float64, copy=True)

This takes the smallest of demand, maximum labor output, and expanded physical capacity. It is a safe domain bound; the timed capacity constraints still prevent using expansion early.

The helper creates each symbolic annual array through PySCIPOpt’s Matrix API:

From labs/lab-manufacturing-profit-pyscipopt/src/app/model.py:

    value = model.addMatrixVar(
        shape=(_YEARS,), name=name, vtype=kind, lb=lower, ub=upper
    )

Worker and researcher arrays use integer type I; expansion uses binary type B; production and cost use continuous type C. Regular annual variable families fit arrays neatly, while small named loops make the timing constraints easy to inspect. NumPy preprocessing produces numbers; the SCIP variables and expressions represent decisions and equations.

This remains a mixed-integer nonlinear model. Research contains a quadratic staffing term, and production cost multiplies two decision variables: unit cost and production volume. Array notation does not make those expressions convex or promise a fast global solve.

Put nonlinear cash beneath a linear objective

PySCIPOpt’s objective interface is linear. The model therefore introduces one bounded scalar, cash_auxiliary, and constrains it below the nonlinear three-year cash expression:

From labs/lab-manufacturing-profit-pyscipopt/src/app/model.py:

        net_cash = quicksum(
            (scenario.selling_price_usd_per_unit - variables.unit_cost[year])
            * variables.units_produced[year]
            - scenario.worker_salary_usd_per_year * variables.workers[year]
            - scenario.researcher_salary_usd_per_year * variables.researchers[year]
            - scenario.expansion_cost_usd * variables.expansion_start[year]
            for year in range(_YEARS)
        )
        add_constraint(
            model,
            variables.cash_auxiliary <= net_cash,
            name="cumulative_net_cash",
            log=operation_log,
        )
        model.setObjective(variables.cash_auxiliary, "maximize")

Maximizing the auxiliary gives the solver an incentive to raise it to attainable cash. Its conservative finite lower bound covers maximum production spending, maximum salaries across the horizon, and one expansion. Its upper bound is maximum revenue before costs.

The reports independently recompute cash from extracted decisions. They preserve the auxiliary objective separately because numerical tolerances—or an early stop—can leave a difference between that scalar and the business calculation.

Read the default answer

The measured default run reports optimal, a zero relative gap, and $372,000 of cumulative net cash, rounded to cents. Independent finite enumeration of this default also reproduces the same plan. Small raw floating-point residuals remain in CSV and JSON.

YearWorkersResearchersExpansion startsUnitsUnit costNet cash
143Yes4,000$50$0
263No6,000$41$144,000
360No6,000$32$228,000

Year 1 earns $360,000 and spends $200,000 on production, $80,000 on workers, $30,000 on research, and $50,000 on expansion. It ends at zero cash because it pays for later capacity and efficiency.

Year 2 uses the expansion and the first $9 saving. Revenue is $540,000; production costs $246,000; worker salaries cost $120,000; research costs $30,000. Year 3 retains both rounds of research saving, giving a $32 unit cost, and pays no research salary.

With the future production path held fixed, the third year-1 researcher contributes an additional $2 saving across 12,000 later units: $24,000 against a $10,000 salary. The third year-2 researcher contributes $12,000 of year-3 saving against the same salary. These conditional calculations explain the selected staffing; they do not assign a unique return to each employee across alternative plans.

optimal means SCIP proved optimality within numerical tolerances. gaplimit means it met the configured stopping gap; timelimit means it exhausted the solve-time allowance. A feasible incumbent is a usable plan, while the objective bound indicates how much improvement might remain. Always read status, bound, and gap together.

Before writing results, a separate NumPy verifier checks integrality, bounds, production limits, expansion timing, research recurrence, the cost floor, and cash arithmetic. No incumbent or failed verification produces replacement decision files.

Change one assumption and rerun

Run the commands copied from the lab README:

From labs/lab-manufacturing-profit-pyscipopt/README.md:

git clone https://github.com/utr1903/tech-with-ugur-labs.git
cd tech-with-ugur-labs/labs/lab-manufacturing-profit-pyscipopt
docker compose up -d --build
docker compose exec lab sh
python -m app

The container keeps running with the lab directory mounted. Edit scenario.yaml in your local editor, then run python -m app again in the container shell. Source edits also take effect on the next run; dependency changes require rebuilding.

Try raising expansion.cost_usd to 1,000,000. In the recorded comparison, a gap-zero solve chooses no expansion and generates $290,000: workers [4, 4, 4], researchers [3, 2, 0], and unit costs [$50, $41, $34]. Production remains 4,000 units annually. The third year-2 researcher would save only $8,000 in year 3 while costing $10,000, so that position no longer pays.

The configured 1% comparison actually stopped at gaplimit with a $288,000 incumbent and approximately 0.694% gap. It retained three year-2 researchers. To request the gap-zero comparison yourself, change solver.relative_gap to 0 before rerunning. A stopping tolerance can affect which near-optimal plan you see; it does not change the business objective.

Each successful run writes output/annual_plan.csv and output/solution.json. The JSON keeps the input hash, effective solver settings, raw decisions, objective, bound, gap, solve time, verification tolerances, and recomputed cash. Preserve runs in separate output directories when comparing assumptions.

What the three-year answer leaves out

Zero final-year research follows directly from the horizon: no modeled customer benefits from that work. A longer horizon or terminal value could change the decision. Independent annual staffing similarly assumes people can be added or removed without transition costs.

Fixed prices and demand caps remove pricing strategy and uncertainty. Immediate receipts and authorization-free spending omit liquidity constraints. The synthetic salaries and research coefficients define this example rather than describing a real factory.

The useful lesson is how to trace an answer back to its assumptions: who supplies capacity, when investment becomes available, how research persists, which costs are paid once, and what the solver has proved. Change an assumption, rerun the same equations, and check both the resulting plan and its gap.