v1
Signed-off-by: gwg313 <gwg313@pm.me>
This commit is contained in:
commit
e776c88219
118 changed files with 16463 additions and 0 deletions
0
src/optimizer/__init__.py
Normal file
0
src/optimizer/__init__.py
Normal file
0
src/optimizer/api/__init__.py
Normal file
0
src/optimizer/api/__init__.py
Normal file
56
src/optimizer/api/main.py
Normal file
56
src/optimizer/api/main.py
Normal 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}
|
||||
16
src/optimizer/celery_app.py
Normal file
16
src/optimizer/celery_app.py
Normal 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",
|
||||
)
|
||||
0
src/optimizer/config/__init__.py
Normal file
0
src/optimizer/config/__init__.py
Normal file
326
src/optimizer/config/static_config.py
Normal file
326
src/optimizer/config/static_config.py
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
0
src/optimizer/schemas/__init__.py
Normal file
0
src/optimizer/schemas/__init__.py
Normal file
15
src/optimizer/schemas/model_input_schema.py
Normal file
15
src/optimizer/schemas/model_input_schema.py
Normal 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'])")
|
||||
44
src/optimizer/schemas/model_output_schema.py
Normal file
44
src/optimizer/schemas/model_output_schema.py
Normal 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
|
||||
0
src/optimizer/solver/__init__.py
Normal file
0
src/optimizer/solver/__init__.py
Normal file
363
src/optimizer/solver/nf.py
Normal file
363
src/optimizer/solver/nf.py
Normal 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
36
src/optimizer/tasks.py
Normal 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)
|
||||
0
src/optimizer/utils/__init__.py
Normal file
0
src/optimizer/utils/__init__.py
Normal file
183
src/optimizer/utils/extract_json_output.py
Normal file
183
src/optimizer/utils/extract_json_output.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue