Skip to content

Optimization

Mathematical Programming is the foundation of analyzing and optimizing Victoria 3's economy. The vic3_analysis package provides optimizers that choose building levels, which maximize or minimize the objective, for an Economy according to a scenario's constraints. Start with an economy model:

from vic3_analysis import Economy

economy = Economy()

See the economy guide for model construction and direct solving, and the economy-report dictionary for returned state columns, formulas, and units.

Scenario definition

Objective and constraints are defined in an immutable Scenario dataclass. Create a new scenario to change its assumptions.

  • Use game script keys when setting attributes of Scenario, such as "steel" or "building_steel_mills", rather than localized display names.
  • Fields containing pairs use tuples such as (("steel", 100.0),)
  • Fields containing only keys use tuples such as ("pm_diesel_engines",).
  • An empty tuple () adds no entries.
  • Optional caps default to None, which leaves that constraint disabled.
  • The decision variables are non-negative, continuous (Fractional levels are allowed) building levels for each production configuration.
  • Use weekly flows when setting goods quantities
  • Employment is the workforce headcount, excluding dependents.

Scenario name

Attribute Default Meaning
name None Optional label for the experiment; it does not affect the solution. display_name() returns this label, otherwise joins the unique goods_export good keys in first-occurrence order with +, or returns "unnamed" when there are no exports.

Objectives

The objective attribute defaults to "automation". Its supported values are:

Objective Direction Meaning Optimizer
gdp maximize Model GDP: net goods output valued at base prices with NominalOptimizer, or endogenous market prices with MarketOptimizer and EquilibriumOptimizer. All three
gdp_per_capita maximize Market-price GDP divided by workers plus dependents. Market and equilibrium
average_wealth maximize Population-weighted household wealth tier, equal to employment-weighted wealth under the uniform workforce ratio. Equilibrium only
employment maximize Total employed population. Nominal only
automation minimize Employment required to satisfy the constraints. Nominal only
construction_cost minimize Total construction cost required to satisfy the constraints. Nominal only

Constraints

Goods balance and market orders

Attribute Default Meaning
min_goods_balance 0.0 Minimum weekly balance for every good: building output + imports − building inputs − exports. Household consumption is excluded. Zero prevents deficits, negative values permit bounded deficits, and None disables the constraint. Numeric floors must be finite.
goods_import () Fixed weekly non-negative (good, amount) pairs adding sell orders. Imports allow domestic production deficits under the balance floor and affect market prices.
goods_export () Fixed weekly non-negative (good, amount) pairs adding buy orders. With a zero balance floor, production plus imports must cover building inputs and exports.
workforce_ratio 1.0 Uniform workers / total population in (0, 1], retained in returned states. Employment caps count workers. Market GDP per capita and its gradient scale by this ratio; equilibrium also uses it for dependent consumption and earnings.

goods_import and goods_export require known good keys and finite, non-negative amounts. Repeated entries for the same good are summed within each input. A good may have both import and export orders, which are kept separate when calculating prices. Orders are fixed, rather than optimized. They enter the balance constraints in every optimizer when the floor is enabled. Market and equilibrium optimizers also use them to calculate prices. Household demand affects equilibrium prices but is excluded from the balance floor.

For example, with min_goods_balance=0.0, goods_import=(("steel", 20.0),) permits domestic steel production to fall 20 units below building consumption each week. Conversely, goods_export=(("steel", 100.0),) requires at least 100 units of net domestic steel output when no steel is imported. With both orders, the net domestic requirement is 80 units. A floor of 5 raises that requirement to 85 units.

Available configurations and throughput

Attribute Default Meaning
banned_pms () Production-method keys to exclude. Every production configuration containing any listed method is forced to zero levels.
banned_building_groups () Building-group keys to exclude. Configurations whose building_group exactly matches a listed key are forced to zero; parent-group descendants are not matched automatically.
era_cap None Highest permitted technology era. Configurations whose production-table era exceeds this value are forced to zero levels. Requires an era-enriched production table.
throughput_multipliers () (building, multiplier) pairs applied to every configuration of that building. 1.5 means 150% throughput (+50%). Gross goods inputs and outputs both scale; employment and construction cost per level do not. Repeated entries for one building multiply together.

All optimizers disable level-dependent economy-of-scale bonuses. Use fixed throughput_multipliers to represent throughput assumptions in the scenario.

Capacity and relationship constraints

Attribute Default Meaning
building_level_caps () (building, maximum_levels) pairs capping the sum of levels across all configurations of that building. A zero limit bans the building. State-region resource helpers produce limits in this form.
construction_cost_cap None Maximum total construction points: the sum of building levels multiplied by each configuration's construction_cost.
employment_cap None Maximum total employed population: the sum of building levels multiplied by per-level employment.
arable_land_cap None Maximum total arable-land use. Each level in the model's agricultural, plantation, ranching, or subsistence land-use groups consumes one unit; other configurations consume zero.
min_net_infrastructure None Minimum total infrastructure generation minus consumption, weighted by building levels. Net infrastructure is the negative of the production-table infrastructure_usage_per_level total; missing values are treated as zero. 0.0 prevents deficits, positive values require spare capacity, and negative values permit a bounded deficit. None leaves infrastructure unconstrained.
urbanization_per_center None Urbanization points required per level of building_urban_center. Enforces an equality: total building urbanization equals this value multiplied by total urban-center levels. For example, 100.0 ties each center level to 100 urbanization points.

Example scenario

from vic3_analysis import NominalOptimizer, Scenario

scenario = Scenario(
    goods_export=(("automobiles", 100.0),),
    objective="automation",
    min_goods_balance=0.0,
    era_cap=5,
    banned_pms=("pm_diesel_engines",),
)
state = NominalOptimizer(economy).solve(scenario)

Problem inspection and solving

Use optimizer.compile(scenario) to inspect the resulting compiled problem before solving it, or optimizer.solve_problem(problem) to solve an already compiled problem. Shared context and constraints are compiled by BaseOptimizer; Scenario remains configuration-only.

After solving, call optimizer.summary() for a fresh one-row DataFrame of scenario settings, economy metrics, and search diagnostics:

optimizer = NominalOptimizer(economy)
state = optimizer.solve(scenario)
summary = optimizer.summary()

The report includes weekly and annual GDP, annual GDP per capita, worker employment, total population including dependents, average wealth tier, construction cost, arable-land consumption, and net infrastructure usage (consumption minus generation). throughput_multiplier contains the common compiled multiplier, or NaN when configuration multipliers differ. Level-dependent economy-of-scale bonuses remain disabled.

Equilibrium optimizers add household convergence diagnostics and evaluation counts; the supply-chain optimizer also reports chain and building configuration counts. A budget-limited search that returns a verified equilibrium can be summarized with search_success=False. Reporting uses the retained solution without rerunning the solver. Calling summary() before a solution is returned, after binding a new problem, or after solving or verifying that bound problem fails raises ValueError. Each report is independent and can be extended with experiment metadata using summary.assign(...).

For endogenous national prices, use MarketOptimizer. It supports gdp and gdp_per_capita and accepts numeric min_goods_balance floors or None. Fixed goods_import and goods_export orders affect prices and enter enabled goods-balance constraints. Its SLSQP solve is local and requires a bounded scenario. The model excludes shortages, MAPI and local prices, wealth feedback, endogenous demand, and economy-of-scale bonuses.

from vic3_analysis import MarketOptimizer, Scenario

scenario = Scenario(
    goods_export=(("automobiles", 100.0),),
    objective="gdp_per_capita",
    min_goods_balance=0.0,
    employment_cap=1_000_000.0,
)
state = MarketOptimizer(economy).solve(scenario)

Equilibrium optimization

For a runnable example and saved building levels, see the million-worker wealth search.

EquilibriumOptimizer chooses building levels while the existing household equilibrium solver determines wealth, consumption, prices, and finances. It supports gdp, gdp_per_capita, and average_wealth. Population varies with employment; an employment cap does not require the optimizer to employ everyone.

from vic3_analysis import EquilibriumOptimizer, Scenario

scenario = Scenario(
    objective="average_wealth",
    min_goods_balance=None,
    workforce_ratio=0.25,
    building_level_caps=tuple(
        (str(building), 100.0)
        for building in economy.df_production["building"].unique()
    ),
)
optimizer = EquilibriumOptimizer(economy)
problem = optimizer.compile(scenario, max_iterations=1000)
state = optimizer.solve_problem(problem, options={"maxiter": 20_000})
print(state.average_wealth, state.gdp_per_capita(annual=True))
print(optimizer.result.success, optimizer.result.message)
print(optimizer.result.equilibrium_failures)

The linear scenario region must be feasible and bounded. Capping every building is one way to ensure boundedness. Shared constraints apply to each candidate's building levels, with fixed trade and throughput assumptions. EquilibriumOptimizer permits any min_goods_balance setting.

Equilibrium optimization funds operating losses with a proportional tax on all household income, including dependent earnings, by default. Pass subsidize_operating_losses=False to compile() or solve() to disable it. Compiled problems retain the policy for every evaluation and final verification. Both EquilibriumOptimizer and SupplyChainEquilibriumOptimizer support it. Trials whose operating losses exceed all household income are rejected and counted as insufficient_income failures. These trials receive a finite penalty based on the unfunded fraction of their operating losses. Smaller shortfalls score better, guiding search toward a fundable mix even when the starting point fails. Every such penalty remains worse than every converged objective score. Failed snapshots are never eligible as returned solutions, and trial budgets and final verification still apply. The tax lowers disposable income used for wealth and demand; objective formulas are unchanged. There is no guaranteed payroll. See tax-funded operating losses.

Household consumption is calculated endogenously, while goods_import and goods_export specify fixed external orders.

EquilibriumProblem exposes read-only matrices, named constraint slices, a nominal GDP warm_start LP, evaluate(levels) returning a verified state, and objective(levels) returning its negative, unscaled objective value. Direct evaluation validates physical levels but does not enforce scenario constraints. solve() compiles and solves in one call with the same controls.

Each evaluation rebuilds supply-dependent consumption baskets and starts from the same initial_wealth matrix, defaulting to profession starting tiers. This prevents evaluation order from selecting different wealth histories. An explicit seed must match configurations × professions; it is defensively copied. max_iterations=1000, rtol=1e-9, and atol=1e-8 control the inner equilibrium solver and are frozen in the compiled problem. Empty-population GDP per capita and average wealth remain zero.

The outer search uses deterministic COBYLA local search without derivatives. Wealth tiers can cause jumps and plateaus, so search termination does not certify a global optimum or smooth-objective stationarity. A supplied x0 must satisfy the scenario; otherwise the nominal GDP LP supplies a start. Fixed-zero configurations are removed from search coordinates, and every returned array still follows the full economy index order. Free coordinates are scaled by max(1, abs(starting_level)). Constraint rows are normalized by 1 + abs(rhs); feasibility is independently checked in original units with the existing 1e-7 * (1 + abs(rhs)) tolerance.

Solver options default to maxiter=20_000, rhobeg=1, tol=1e-4, catol=1e-7, and disp=0. maxiter is a hard trial-evaluation budget, including initialization and physically rejected trials. An immediately repeated point reuses the preceding score. Fresh verification of the winner does not consume this budget. Full vanilla searches can take tens of minutes or longer; use a smaller budget for exploration. f_target, when supplied, uses the internal minimizing score: arctan(-metric / scale), where scale is at least one and is fixed from the first converged trial's metric magnitude. The bounded transform preserves metric ordering and gives rejected trials a strictly worse finite penalty. problem.objective() and result.fun retain unscaled units.

EquilibriumError trial outcomes are rejected and counted by status while search continues. Invalid model inputs and unexpected calculation errors propagate. Stationary states with loss_buildings or unaffordable_floor_cells remain valid results under the existing accounting policy; no sustainability constraint, subsidy, or financing model is added.

The optimizer retains the best feasible converged state and independently reevaluates it before returning. Budget exhaustion returns that state with optimizer.result.success == False and an explicit message. Always inspect search status separately from state.equilibrium_diagnostics.status, which must be "converged" on a returned state. Other ordinary COBYLA termination statuses also remain visible on the result.

result.x and result.fun match the returned state. result.nfev counts trial evaluations; result.equilibrium_evaluations counts inner attempts; result.equilibrium_failures maps failure statuses to counts; and result.last_equilibrium_error retains the last failed inner snapshot. result.solver_result retains raw COBYLA output, or None when COBYLA was skipped or interrupted by the hard budget. Working memory retains only the best state, the last failure, and the preceding score. If no feasible converged state is found, solving raises ValueError with these diagnostics still available on .result. A failure during final verification propagates and marks the retained result unsuccessful.

Live progress logging

The library uses standard Python module loggers and leaves handler configuration to the caller. Calls remain quiet under Python's default logging levels. Enable INFO summaries and choose a positive trial interval with log_every on either solve() or solve_problem():

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
state = optimizer.solve_problem(
    problem, log_every=100, options={"maxiter": 20_000}
)

The logger vic3_analysis.optimize.equilibrium reports start, completed preflight and warm start, initialization, periodic progress, search termination, verification, and completion. Periodic summaries appear every log_every uncached trials; lifecycle and terminal messages appear regardless of that interval. COBYLA initialization can consume many trials before its first trust-region iteration. phase=initialization distinguishes this stage from phase=search, which begins when COBYLA reports a trust-region step.

Progress messages contain these scalar fields:

Field Meaning
phase, status, message Current stage, run/termination status, and explanatory text. The search stop is logged before fresh verification.
trials, elapsed_s Uncached trials used versus the hard budget, and elapsed monotonic seconds since optimizer start. Time includes preflight and verification; trial counts exclude final verification.
objective, units, current, best Actual maximizing metric for the latest converged trial and best feasible converged trial. Units are GBP/week, GBP/person/week, or wealth_tier. n/a means no corresponding value is available. These values are unscaled and do not use the internal minimizing transform.
current_feasible Whether the latest converged trial satisfies scenario constraints. Physical rejections report False; unverified equilibrium failures report None. A current metric can exceed the best when the current trial is infeasible.
feasible_trials, infeasible_trials Counts of converged trial evaluations satisfying or violating the scenario, respectively. Repeated points do not count again.
physical_rejections, equilibrium_evaluations Physically invalid trials rejected before an inner solve, and inner solve attempts.
failures Counts of skipped equilibrium errors by solver status. Each skipped error remains separate from converged infeasible points.
last_equilibrium, last_inner_iterations Latest trial's household outcome and completed wealth-transition count, or None when unavailable. This inner count is separate from optimizer trials.

DEBUG adds individual uncached trial outcomes and new-incumbent messages. The solver logger vic3_analysis.analysis.economy.equilibrium emits DEBUG start, completion, and failure summaries, including completed transitions, cycle information on failure, and financial-flag counts on success. It also emits an iteration summary after initialization (inner_iterations=0) and each completed wealth transition, with counts of cells eligible to increase or decrease wealth, unaffordable floor cells, and loss-making building configurations. Increase/decrease counts describe changes permitted from the current assignment, rather than changes already applied. Verification-started and verification-completed events bracket successful final verification; verification errors appear in the solver failure summary. All of these events remain at DEBUG inside optimizer trials, and household arrays are not logged. Enable that logger independently when using Economy.solve(..., method="equilibrium") directly:

logging.getLogger("vic3_analysis.analysis.economy.equilibrium").setLevel(logging.DEBUG)

The final optimizer message distinguishes status=budget_exhausted from household_equilibrium=converged. If verification or another computation fails, it logs the failing phase with status=failed and preserves the original exception. No progress history is retained. Reporting controls are runtime arguments rather than scenario or compiled-problem data; they do not affect the numerical stopping rules, returned metrics, or evaluation budget. Native COBYLA options["disp"] remains available independently.

To save package logs, configure a file handler or pass filename and encoding="utf-8" to logging.basicConfig() in the calling application.

State-region limits

from vic3_analysis import (
    StateRegionsParser,
    state_region_arable_land_limit,
    state_region_resource_limits,
)

regions = StateRegionsParser().to_dataframe()
keys = ("STATE_KANTO", "STATE_KANSAI")
limits = state_region_resource_limits(regions, keys)
land = state_region_arable_land_limit(regions, keys)

scenario = Scenario(
    objective="gdp",
    min_goods_balance=0.0,
    building_level_caps=tuple(limits.items()),
    arable_land_cap=land,
)

See the optimization API and resource-limited optimization showcase for a complete regional experiment.

Equilibrium optimization over supply chains

SupplyChainEquilibriumOptimizer searches nonnegative chain levels z using fixed recipes supplied as a DataFrame C with building configurations as rows and chains as columns. Every trial computes x = C @ z and passes those building levels to the existing equilibrium solver.

import numpy as np
from vic3_analysis import Scenario, SupplyChainEquilibriumOptimizer, sweep_supply_chains

sweep = sweep_supply_chains(
    economy,
    Scenario(objective="construction_cost"),
    goods=["clothes", "furniture"],
)
optimizer = SupplyChainEquilibriumOptimizer(economy, sweep.df_building_levels)
problem = optimizer.compile(scenario)
state = optimizer.solve_problem(problem, x0=np.ones(len(optimizer.chain_index)))
chain_levels = optimizer.result.x
building_levels = optimizer.to_building_levels(chain_levels)

DataFrame rows identify building configurations and columns identify chains. Rows are aligned by label to economy.building_index; the set of configurations must match exactly. Columns retain their order, except that entirely NaN columns from failed sweep solves are skipped with a UserWarning naming them. No usable columns, partially missing values, negative or nonfinite coefficients, and all-zero recipes raise ValueError. Chain vectors follow the surviving optimizer.chain_index.

The optimizer snapshots read-only coefficients and unique, nonempty string labels. SupplyChainEquilibriumOptimizer.validate_building_levels(frame, building_index=economy.building_index) returns an aligned, independent DataFrame using the same validation. optimizer.df_building_levels returns a defensive copy of the accepted recipes. Each compiled problem retains its own read-only chain_matrix and immutable chain_index, and provides problem.to_building_levels(z) for mapping chain coordinates. Later changes to the source DataFrame do not affect the optimizer or compiled constraints. No recipes are normalized. Shared configurations receive the sum of contributions. problem.evaluate(z) returns an ordinary building-based EconomyState, and problem.objective(z) returns the negative selected equilibrium metric.

Scenario constraints are transformed into chain coordinates: A @ C @ z <= b and A_eq @ C @ z == b_eq. Building bounds remain enforced; any chain using a prohibited configuration is fixed to zero. Feasibility, boundedness, and the nominal GDP warm-start LP are evaluated in this restricted chain space. Production matrices, workforce, wealth seeds, and returned state arrays retain the economy's original indexes. Final verification checks the selected chain vector, its physical building levels, and the original building constraints.

GDP, GDP per capita, average wealth, equilibrium tolerances, COBYLA options, hard budgets, and failure diagnostics follow EquilibriumOptimizer. x0 and result.x use chain coordinates; result.building_levels and state.building_levels use building coordinates. The raw solver_result retains COBYLA's internal reduced/scaled coordinates. Budget exhaustion can return the best verified equilibrium with result.success=False.

Fixed recipes restrict the feasible production space. They coordinate upstream and downstream changes but do not guarantee balanced household demand or profitable individual buildings. Financial flags remain diagnostics. See the era-5 chain example for a matrix loaded from the comparison example's building_levels.csv and reproducible output artifacts.