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