Signed-off-by: gwg313 <gwg313@pm.me>
This commit is contained in:
gwg313 2026-04-13 17:57:28 -04:00
commit e776c88219
Signed by: gwg313
GPG key ID: 60FF63B4826B7400
118 changed files with 16463 additions and 0 deletions

View file

View file

56
src/optimizer/api/main.py Normal file
View file

@ -0,0 +1,56 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi import FastAPI, HTTPException
from optimizer.schemas.model_input_schema import ModelInput
from optimizer.solver.nf import build_full_config
from optimizer.solver.nf import create_multi_market_model
from optimizer.utils.extract_json_output import extract_output_json
from pyomo.opt import SolverFactory
from optimizer.tasks import run_optimization
from celery.result import AsyncResult
from optimizer.celery_app import celery_app
from fastapi.responses import JSONResponse
from optimizer.schemas.model_output_schema import ModelOutput
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/solve")
def submit_job(input_data: ModelInput):
try:
task = run_optimization.delay(input_data.dict())
return {"task_id": task.id}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/status/{task_id}")
def check_status(task_id: str):
result = AsyncResult(task_id)
return {"task_id": task_id, "status": result.status}
@app.get("/result/{task_id}")
def get_result(task_id: str):
result = AsyncResult(task_id, app=celery_app)
if result.status == "SUCCESS":
return JSONResponse(content=result.result)
elif result.status == "FAILURE":
return JSONResponse(
status_code=500,
content={
"status": "FAILURE",
"error": repr(result.result),
"traceback": result.traceback,
},
)
else:
return {"status": result.status}

View file

@ -0,0 +1,16 @@
from celery import Celery
celery_app = Celery(
"optimizer",
broker="redis://redis:6379/0",
backend="redis://redis:6379/1",
include=["optimizer.tasks"],
)
celery_app.conf.update(
result_expires=3600,
task_track_started=True,
task_serializer="json",
accept_content=["json"],
result_serializer="json",
)

View file

View file

@ -0,0 +1,326 @@
static_config = {
"products": {
"Transport Bot": {
"value": 14,
"inputs": {
"Xenoferrite Plates": 1,
"Machinary Parts": 1,
"Electronic Components": 1,
},
"output_seconds": {"base_time": 10},
},
"Heavy Duty Transport Bot": {
"value": 39,
"inputs": {
"Steel Beams": 1,
"Advanced Machinary Parts": 1,
"Electronic Components": 1,
"Firmarlite Sheet": 1,
},
"output_seconds": {"base_time": 15},
},
"Vacuum Bot": {
"value": 14,
"inputs": {
"Xenoferrite Plates": 1,
"Machinary Parts": 1,
"Electronic Components": 1,
},
"output_seconds": {"base_time": 12},
},
"Cleaning Bot": {
"value": 55,
"inputs": {
"Xenoferrite Plates": 1,
"Polymer Board": 1,
"Machinary Parts": 1,
"Circuit Board": 1,
"Energy Cell": 1,
},
"output_seconds": {"base_time": 15},
},
"Snack Bot": {
"value": 25,
"inputs": {
"Xenoferrite Plates": 1,
"Machinary Parts": 1,
"Electronic Components": 1,
"Glass": 1,
},
"output_seconds": {"base_time": 12},
},
"Light Bot": {
"value": 34,
"inputs": {
"Steel Beams": 1,
"Machinary Parts": 1,
"Electronic Components": 1,
"Glass": 1,
},
"output_seconds": {"base_time": 12},
},
"Screen Bot": {
"value": 28,
"inputs": {
"Xenoferrite Plates": 1,
"Polymer Board": 1,
"Circuit Board": 1,
"Glass": 1,
},
"output_seconds": {"base_time": 8},
},
"Science Assistant Drone": {
"value": 75,
"inputs": {
"Polymer Board": 1,
"Machinary Parts": 1,
"Circuit Board": 1,
"Hover Engine": 1,
"Energy Cell": 1,
},
"output_seconds": {"base_time": 10},
},
"Planter Drone": {
"value": 44,
"inputs": {
"Firmarlite Sheet": 1,
"Machinary Parts": 2,
"Electronic Components": 2,
"Hover Engine": 1,
},
"output_seconds": {"base_time": 8},
},
"Mining Drone": {
"value": 66,
"inputs": {
"Steel Beams": 1,
"Advanced Machinary Parts": 1,
"Circuit Board": 1,
"Hover Engine": 1,
},
"output_seconds": {"base_time": 20},
},
"Maintenance Drone": {
"value": 79,
"inputs": {
"Firmarlite Sheet": 1,
"Advanced Machinary Parts": 1,
"Circuit Board": 1,
"Hover Engine": 1,
"Energy Cell": 1,
},
"output_seconds": {"base_time": 30},
},
"Combat Drone": {
"value": 75,
"inputs": {
"Firmarlite Sheet": 1,
"Advanced Machinary Parts": 1,
"Circuit Board": 1,
"Hover Engine": 1,
"Weapon Components": 1,
},
"output_seconds": {"base_time": 15},
},
# Components
"Wire Coil": {
"value": 0,
"inputs": {
"Technum Rods": 1,
},
"output_seconds": {"base_time": 1.5},
},
"Machinary Parts": {
"value": 0,
"inputs": {
"Xenoferrite Plates": 1,
},
"output_seconds": {"base_time": 1.5},
},
"Advanced Machinary Parts": {
"value": 0,
"inputs": {
"Machinary Parts": 2,
"Steel Beams": 1,
},
"output_seconds": {"base_time": 1.5},
},
"Electronic Components": {
"value": 0,
"inputs": {
"Technum Rods": 1,
"Wire Coil": 2,
},
"output_seconds": {"base_time": 3},
},
"Polymer Board": {
"value": 0,
"inputs": {
"Liquid Polymer": 5,
},
"output_seconds": {"base_time": 2},
"allowed_assemblers": ["A1"],
},
"Circuit Board": {
"value": 0,
"inputs": {
"Polymer Board": 1,
"Electronic Components": 2,
},
"output_seconds": {"base_time": 5},
},
"Energy Cell": {
"value": 0,
"inputs": {
"Xenoferrite Plates": 3,
"Electronic Components": 6,
"Glass": 6,
"Olumic Acid": 60,
},
"output_seconds": {"base_time": 30},
"output_qty": 6,
"allowed_assemblers": ["A1"],
},
"Explosives": {
"value": 0,
"inputs": {
"Ignium Powder": 4,
"Low Density Olumite": 50,
},
"output_seconds": {"base_time": 5},
"allowed_assemblers": ["A1"],
},
"Weapon Components": {
"value": 0,
"inputs": {
"Firmarlite Sheet": 1,
"Machinary Parts": 1,
"Explosives": 1,
},
"output_seconds": {"base_time": 12},
},
"Hover Engine": {
"value": 0,
"inputs": {
"Xenoferrite Plates": 1,
"Electronic Components": 1,
"Machinary Parts": 1,
},
"output_seconds": {"base_time": 12},
},
},
"markets": {
"galactic": {
"type": "fixed",
"prices": {
"Transport Bot": 20,
"Heavy Duty Transport Bot": 56,
"Vacuum Bot": 20,
"Cleaning Bot": 79,
"Snack Bot": 0,
"Light Bot": 49,
"Screen Bot": 41,
"Science Assistant Drone": 0,
"Planter Drone": 63,
"Mining Drone": 95,
"Maintenance Drone": 0,
"Combat Drone": 108,
},
},
"local": {
"type": "tiered",
"tiers": [
{
"label": "minus_50",
"multiplier": 0.5, # 50%
"demands": {
"Transport Bot": 6400,
"Heavy Duty Transport Bot": 4000,
"Vacuum Bot": 5200,
"Cleaning Bot": 4000,
"Snack Bot": 5200,
"Light Bot": 5200,
"Screen Bot": 8000,
"Science Assistant Drone": 6000,
"Planter Drone": 8000,
"Mining Drone": 3200,
"Maintenance Drone": 2400,
"Combat Drone": 4000,
},
},
{
"label": "minus_25",
"multiplier": 0.75, # 25%
"demands": {
"Transport Bot": 3200,
"Heavy Duty Transport Bot": 2000,
"Vacuum Bot": 2600,
"Cleaning Bot": 2000,
"Snack Bot": 2600,
"Light Bot": 2600,
"Screen Bot": 4000,
"Science Assistant Drone": 3000,
"Planter Drone": 4000,
"Mining Drone": 1600,
"Maintenance Drone": 1200,
"Combat Drone": 2000,
},
},
{
"label": "regular",
"multiplier": 1.0, # base price
"demands": {
"Transport Bot": 1600,
"Heavy Duty Transport Bot": 1000,
"Vacuum Bot": 1300,
"Cleaning Bot": 1000,
"Snack Bot": 1300,
"Light Bot": 1300,
"Screen Bot": 2000,
"Science Assistant Drone": 1500,
"Planter Drone": 2000,
"Mining Drone": 800,
"Maintenance Drone": 600,
"Combat Drone": 1000,
},
},
{
"label": "plus_25",
"multiplier": 1.25, # +25%
"demands": {
"Transport Bot": 852,
"Heavy Duty Transport Bot": 532,
"Vacuum Bot": 692,
"Cleaning Bot": 532,
"Snack Bot": 692,
"Light Bot": 692,
"Screen Bot": 1066,
"Science Assistant Drone": 798,
"Planter Drone": 1066,
"Mining Drone": 426,
"Maintenance Drone": 318,
"Combat Drone": 532,
},
},
{
"label": "plus_50",
"multiplier": 1.5, # +50%
"demands": {
"Transport Bot": 532,
"Heavy Duty Transport Bot": 332,
"Vacuum Bot": 432,
"Cleaning Bot": 332,
"Snack Bot": 432,
"Light Bot": 432,
"Screen Bot": 666,
"Science Assistant Drone": 498,
"Planter Drone": 666,
"Mining Drone": 266,
"Maintenance Drone": 198,
"Combat Drone": 332,
},
},
],
},
},
}

View file

View file

@ -0,0 +1,15 @@
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
AssemblerTier = Literal['A1', 'A2', 'A3']
class ProductInput(BaseModel):
price_bonus: float = Field(0.0, description="Percentage bonus to apply to base prices (e.g. 0.1 for +10%)")
galactic_demand_per_hr: float = Field(..., description="Maximum number of units sellable per hour on the galactic market")
class ModelInput(BaseModel):
tax_rate: float = Field(..., ge=0.0, le=1.0, description="Tax rate to apply to revenue, as a decimal (e.g. 0.15 for 15%)")
input_rates_per_min: Dict[str, float] = Field(..., description="Resource input limits per minute (e.g. {'xeno': 1000})")
products: Dict[str, ProductInput] = Field(..., description="Map of product name to its galactic demand and price bonus")
unlocked_products: List[str] = Field(..., description="List of products the user can manufacture")
unlocked_assemblers: List[AssemblerTier] = Field(..., description="Assembler tiers available to the user (e.g. ['A1', 'A2'])")

View file

@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Dict, Optional
class MarketStats(BaseModel):
units_per_min: float
units_per_hr: float
demand_per_hr: float
percent_satisfied: float
gross_revenue: float
tax: float
net_revenue: float
price_per_unit: float
tier: Optional[str] = None # Only for local
class ProductOutput(BaseModel):
units_per_min: float
inputs_per_min: Dict[str, float]
assemblers: Dict[str, int]
galactic: MarketStats
local: MarketStats
total_sold: Dict[str, float] # keys: units_per_hr, demand_per_hr, percent_satisfied
class IntermediateStats(BaseModel):
built_per_min: float
used_per_min: float
input_from_supply_per_min: float
net_produced_per_min: float
class RevenueBlock(BaseModel):
galactic: float
local: float
total: float
class SummaryOutput(BaseModel):
gross_revenue: RevenueBlock
tax: RevenueBlock
net_revenue: RevenueBlock
units_sold: Dict[str, float] # keys: total_units_per_hr, total_demand_per_hr, percent_satisfied
assemblers_used: Dict[str, int]
intermediates: Dict[str, IntermediateStats]
class ModelOutput(BaseModel):
product_outputs: Dict[str, ProductOutput]
summary: SummaryOutput

View file

363
src/optimizer/solver/nf.py Normal file
View file

@ -0,0 +1,363 @@
import pyomo.environ as pyo
from pyomo.opt import SolverFactory
from optimizer.utils.extract_json_output import extract_output_json
from optimizer.config.static_config import static_config
from optimizer.schemas.model_input_schema import ModelInput
# ============================================================
# BUILD CONFIG
# ============================================================
def build_full_config(user_input: ModelInput) -> dict:
config = {
"tax_rate": user_input.tax_rate,
"input_rates": user_input.input_rates_per_min,
"products": {},
"markets": {
"galactic": {
"type": "fixed",
"prices": static_config["markets"]["galactic"]["prices"],
"demands": {},
},
"local": static_config["markets"]["local"],
},
}
for product in user_input.unlocked_products:
if product not in static_config["products"]:
raise ValueError(f"Unknown product: {product}")
if product not in user_input.products:
raise ValueError(f"Missing user input for product: {product}")
static_def = static_config["products"][product]
user_def = user_input.products[product]
config["products"][product] = {
"value": static_def["value"],
"inputs": static_def["inputs"],
"output_seconds": static_def["output_seconds"],
"output_qty": static_def.get("output_qty", 1),
"allowed_assemblers": static_def.get("allowed_assemblers"),
"price_bonus": user_def.price_bonus,
}
config["markets"]["galactic"]["demands"][
product
] = user_def.galactic_demand_per_hr
return config
def parse_config(raw_config):
for product in raw_config["products"].values():
product.setdefault("price_bonus", 0.0)
product.setdefault("output_qty", 1)
return raw_config
# ============================================================
# SETS
# ============================================================
def define_sets(model, config):
model.products = pyo.Set(initialize=list(config["products"].keys()), ordered=True)
model.assemblers = pyo.Set(initialize=["A1", "A2", "A3"], ordered=True)
model.resources = pyo.Set(
initialize=list(config["input_rates"].keys()), ordered=True
)
input_refs = {k for p in config["products"].values() for k in p.get("inputs", {})}
intermediates = sorted(input_refs & set(config["products"].keys()))
model.intermediates = pyo.Set(initialize=intermediates, ordered=True)
model.all_inputs = pyo.Set(
initialize=list(set(model.resources) | set(intermediates)), ordered=True
)
tier_labels = [t["label"] for t in config["markets"]["local"]["tiers"]]
model.tiers = pyo.Set(initialize=tier_labels, ordered=True)
# ============================================================
# PARAMETERS
# ============================================================
def define_parameters(model, config):
assembler_speeds = {"A1": 1.0, "A2": 1.5, "A3": 2.0}
model.input_rates = config["input_rates"]
model.value = pyo.Param(
model.products,
initialize=lambda m, p: config["products"][p]["value"],
)
model.price_bonus = pyo.Param(
model.products,
initialize=lambda m, p: config["products"][p].get("price_bonus", 0.0),
default=0.0,
)
def output_per_min_init(m, p, a):
prod = config["products"][p]
allowed = prod.get("allowed_assemblers")
if allowed is not None and a not in allowed:
return 0.0 # disallowed combo produces nothing
seconds = prod["output_seconds"]["base_time"]
qty = prod.get("output_qty", 1)
crafts_per_min = 60 / seconds
return crafts_per_min * qty * assembler_speeds[a]
model.output_per_min = pyo.Param(
model.products, model.assemblers, initialize=output_per_min_init
)
def recipe_init(m, p, r):
qty = config["products"][p].get("output_qty", 1)
per_craft = config["products"][p].get("inputs", {}).get(r, 0)
return per_craft / qty
model.recipe = pyo.Param(
model.products,
model.all_inputs,
initialize=recipe_init,
default=0,
)
model.market = pyo.Block()
model.market.galactic = pyo.Block()
model.market.local = pyo.Block()
# galactic
model.market.galactic.max_demand = pyo.Param(
model.products,
initialize=lambda m, p: config["markets"]["galactic"]["demands"].get(p, 0) / 60,
default=0,
)
model.market.galactic.price = pyo.Param(
model.products,
initialize=lambda m, p: config["markets"]["galactic"]["prices"].get(p, 0),
default=0,
)
# local tiers
def tier_demand_init(m, p, t):
tier = next(x for x in config["markets"]["local"]["tiers"] if x["label"] == t)
return tier["demands"].get(p, 0) / 60
def tier_multiplier_init(m, t):
tier = next(x for x in config["markets"]["local"]["tiers"] if x["label"] == t)
return tier["multiplier"]
model.market.local.demand = pyo.Param(
model.products, model.tiers, initialize=tier_demand_init, default=0
)
model.market.local.multiplier = pyo.Param(
model.tiers, initialize=tier_multiplier_init, default=0
)
# ============================================================
# VARIABLES
# ============================================================
def define_variables(model):
# continuous bots for speed
model.bots = pyo.Var(model.products, model.assemblers, within=pyo.NonNegativeReals)
model.market.galactic.sold = pyo.Var(model.products, within=pyo.NonNegativeReals)
# local sold per tier (linear)
model.market.local.sold = pyo.Var(
model.products, model.tiers, within=pyo.NonNegativeReals
)
# ============================================================
# CONSTRAINTS
# ============================================================
def define_constraints(model, config):
# -------------------------
# RAW RESOURCE CONSTRAINTS
# -------------------------
model.resource_constraints = pyo.ConstraintList()
for res in model.resources - model.intermediates:
model.resource_constraints.add(
sum(
model.bots[p, a] * model.output_per_min[p, a] * model.recipe[p, res]
for p in model.products
for a in model.assemblers
)
<= model.input_rates[res]
)
# -------------------------
# INTERMEDIATE BALANCE
# -------------------------
model.intermediate_built = pyo.Expression(
model.intermediates,
rule=lambda m, ip: sum(
m.bots[ip, a] * m.output_per_min[ip, a] for a in m.assemblers
),
)
model.intermediate_used = pyo.Expression(
model.intermediates,
rule=lambda m, ip: sum(
m.bots[p, a] * m.output_per_min[p, a] * m.recipe[p, ip]
for p in m.products
for a in m.assemblers
if m.recipe[p, ip] > 0
),
)
def intermediate_link_rule(m, ip):
provided = m.input_rates.get(ip, 0)
return m.intermediate_built[ip] + provided >= m.intermediate_used[ip]
model.intermediate_link = pyo.Constraint(
model.intermediates, rule=intermediate_link_rule
)
# -------------------------
# LOCAL DEMAND LIMITS
# -------------------------
def local_tier_bound_rule(m, p, t):
return m.market.local.sold[p, t] <= m.market.local.demand[p, t]
model.local_tier_bounds = pyo.Constraint(
model.products, model.tiers, rule=local_tier_bound_rule
)
# -------------------------
# GALACTIC DEMAND LIMIT
# -------------------------
def galactic_bound_rule(m, p):
return m.market.galactic.sold[p] <= m.market.galactic.max_demand[p]
model.galactic_demand_bound = pyo.Constraint(
model.products, rule=galactic_bound_rule
)
# -------------------------
# CANNOT SELL MORE THAN BUILT
# -------------------------
def sold_vs_built_rule(m, p):
total_sold = m.market.galactic.sold[p] + sum(
m.market.local.sold[p, t] for t in m.tiers
)
total_built = sum(m.bots[p, a] * m.output_per_min[p, a] for a in m.assemblers)
return total_built >= total_sold
model.total_sold_bounded = pyo.Constraint(model.products, rule=sold_vs_built_rule)
# -------------------------
# SOS1: ONLY ONE LOCAL TIER PER PRODUCT
# (Correct block scoping!)
# -------------------------
def tier_sos_rule(block, p):
root = block.model() # <-- root model
return [root.market.local.sold[p, t] for t in root.tiers]
model.market.local.tier_sos = pyo.SOSConstraint(
model.products, rule=tier_sos_rule, sos=1
)
model.disallowed_assembler = pyo.ConstraintList()
for p in model.products:
allowed = config["products"][p].get("allowed_assemblers")
if allowed is None:
continue
for a in model.assemblers:
if a not in allowed:
model.disallowed_assembler.add(model.bots[p, a] == 0)
# ============================================================
# OBJECTIVE
# ============================================================
def define_objective(model):
# keep this linear
assembler_bonus = {"A1": 0.0, "A2": 1e-5, "A3": 2e-5}
def obj_rule(m):
galactic = sum(
m.market.galactic.sold[p]
* m.market.galactic.price[p]
* (1 + m.price_bonus[p])
for p in m.products
)
local = sum(
m.market.local.sold[p, t]
* m.value[p]
* m.market.local.multiplier[t]
* (1 + m.price_bonus[p])
for p in m.products
for t in m.tiers
)
bias = sum(
m.bots[p, a] * assembler_bonus[a] for p in m.products for a in m.assemblers
)
return galactic + local + bias
model.obj = pyo.Objective(rule=obj_rule, sense=pyo.maximize)
model.total_output = pyo.Expression(
model.products,
rule=lambda m, p: sum(
m.bots[p, a] * m.output_per_min[p, a] for a in m.assemblers
),
)
model.total_assemblers = pyo.Expression(
model.assemblers,
rule=lambda m, a: sum(m.bots[p, a] for p in m.products),
)
# ============================================================
# MODEL BUILDER
# ============================================================
def create_multi_market_model(config):
config = parse_config(config)
model = pyo.ConcreteModel(name="MultiMarketProductionModel")
define_sets(model, config)
define_parameters(model, config)
define_variables(model)
define_constraints(model, config)
define_objective(model)
return model
# ============================================================
# RUN
# ============================================================
if __name__ == "__main__":
# full_config = build_full_config(test_input)
model = create_multi_market_model(full_config)
# IMPORTANT: make sure your celery task uses "highs" too
solver = SolverFactory("highs")
solver.solve(model, tee=False)
extract_output_json(model, full_config)

36
src/optimizer/tasks.py Normal file
View file

@ -0,0 +1,36 @@
from optimizer.celery_app import celery_app
from optimizer.schemas.model_input_schema import ModelInput
from optimizer.solver.nf import build_full_config
from optimizer.solver.nf import create_multi_market_model
from optimizer.utils.extract_json_output import extract_output_json
from pyomo.opt import SolverFactory
from pydantic import ValidationError
@celery_app.task(bind=True)
def run_optimization(self, input_dict: dict) -> dict:
try:
# Validate and parse input
user_input = ModelInput(**input_dict)
# Build full config
config = build_full_config(user_input)
# Solve model
model = create_multi_market_model(config)
solver = SolverFactory("cbc")
result = solver.solve(model, tee=False)
if (result.solver.status != "ok") or (
result.solver.termination_condition != "optimal"
):
raise RuntimeError(f"Solver failed: {result.solver}")
# Extract and return results as dict
output = extract_output_json(model, config)
return output.dict()
except ValidationError as e:
raise self.retry(exc=e, countdown=5, max_retries=1)
except Exception as e:
raise self.retry(exc=e, countdown=5, max_retries=1)

View file

View file

@ -0,0 +1,183 @@
from optimizer.schemas.model_output_schema import (
ModelOutput,
ProductOutput,
MarketStats,
RevenueBlock,
SummaryOutput,
IntermediateStats,
)
import pyomo.environ as pyo
import json
def extract_output_json(model, config) -> ModelOutput:
tax_rate = config.get("tax_rate", 0.0)
product_outputs = {}
total_galactic_rev = 0.0
total_local_rev = 0.0
total_units_sold = 0.0
total_demand = 0.0
for p in model.products:
out_per_min = pyo.value(model.total_output[p])
inputs_per_min = {
r: sum(
pyo.value(model.bots[p, a])
* pyo.value(model.output_per_min[p, a])
* pyo.value(model.recipe[p, r])
for a in model.assemblers
)
for r in model.resources
if pyo.value(model.recipe[p, r]) > 0
}
assemblers = {
a: int(pyo.value(model.bots[p, a]))
for a in model.assemblers
if pyo.value(model.bots[p, a]) > 0
}
price_bonus = pyo.value(model.price_bonus[p])
gal_units_min = pyo.value(model.market.galactic.sold[p])
gal_units_hr = gal_units_min * 60
gal_demand_hr = pyo.value(model.market.galactic.max_demand[p]) * 60
gal_price = pyo.value(model.market.galactic.price[p]) * (1 + price_bonus)
gal_gross = gal_units_hr * gal_price
gal_tax = gal_gross * tax_rate
gal_net = gal_gross - gal_tax
total_galactic_rev += gal_gross
total_units_sold += gal_units_hr
total_demand += gal_demand_hr
local_units_hr = 0.0
local_demand_hr = 0.0
local_gross = 0.0
selected_tier = None
local_price = 0.0
# SOS1 means at most one tier will have nonzero sold; pick the max to be safe.
best_tier = None
best_sold_min = 0.0
for t in model.tiers:
sold_min = float(pyo.value(model.market.local.sold[p, t]) or 0.0)
if sold_min > best_sold_min + 1e-12:
best_sold_min = sold_min
best_tier = t
if best_tier is not None and best_sold_min > 0:
selected_tier = best_tier
sold_hr = best_sold_min * 60
demand_hr = (
float(pyo.value(model.market.local.demand[p, best_tier]) or 0.0) * 60
)
local_price = (
float(pyo.value(model.value[p]) or 0.0)
* float(pyo.value(model.market.local.multiplier[best_tier]) or 0.0)
* (1 + price_bonus)
)
local_units_hr = sold_hr
local_demand_hr = demand_hr
local_gross = sold_hr * local_price
local_tax = local_gross * tax_rate
local_net = local_gross - local_tax
total_local_rev += local_gross
total_units_sold += local_units_hr
total_demand += local_demand_hr
product_outputs[p] = ProductOutput(
units_per_min=out_per_min,
inputs_per_min=inputs_per_min,
assemblers=assemblers,
galactic=MarketStats(
units_per_min=gal_units_min,
units_per_hr=gal_units_hr,
demand_per_hr=gal_demand_hr,
percent_satisfied=(
(gal_units_hr / gal_demand_hr * 100) if gal_demand_hr > 0 else 0
),
gross_revenue=gal_gross,
tax=gal_tax,
net_revenue=gal_net,
price_per_unit=gal_price,
),
local=MarketStats(
units_per_min=local_units_hr / 60,
units_per_hr=local_units_hr,
demand_per_hr=local_demand_hr,
percent_satisfied=(
(local_units_hr / local_demand_hr * 100)
if local_demand_hr > 0
else 0
),
gross_revenue=local_gross,
tax=local_tax,
net_revenue=local_net,
price_per_unit=local_price,
tier=selected_tier,
),
total_sold={
"units_per_hr": gal_units_hr + local_units_hr,
"demand_per_hr": gal_demand_hr + local_demand_hr,
"percent_satisfied": (
(
(gal_units_hr + local_units_hr)
/ (gal_demand_hr + local_demand_hr)
* 100
)
if (gal_demand_hr + local_demand_hr) > 0
else 0
),
},
)
assembler_usage = {
a: int(pyo.value(model.total_assemblers[a])) for a in model.assemblers
}
intermediates = {}
for ip in model.intermediates:
built = pyo.value(model.intermediate_built[ip])
used = pyo.value(model.intermediate_used[ip])
supplied = config["input_rates"].get(ip, 0)
intermediates[ip] = IntermediateStats(
built_per_min=built,
used_per_min=used,
input_from_supply_per_min=supplied,
net_produced_per_min=built + supplied - used,
)
summary = SummaryOutput(
gross_revenue=RevenueBlock(
galactic=total_galactic_rev,
local=total_local_rev,
total=total_galactic_rev + total_local_rev,
),
tax=RevenueBlock(
galactic=total_galactic_rev * tax_rate,
local=total_local_rev * tax_rate,
total=(total_galactic_rev + total_local_rev) * tax_rate,
),
net_revenue=RevenueBlock(
galactic=total_galactic_rev * (1 - tax_rate),
local=total_local_rev * (1 - tax_rate),
total=(total_galactic_rev + total_local_rev) * (1 - tax_rate),
),
units_sold={
"total_units_per_hr": total_units_sold,
"total_demand_per_hr": total_demand,
"percent_satisfied": (
(total_units_sold / total_demand * 100) if total_demand > 0 else 0
),
},
assemblers_used=assembler_usage,
intermediates=intermediates,
)
result = ModelOutput(product_outputs=product_outputs, summary=summary)
print(json.dumps(result.dict(), indent=2))
return result