Compare commits

..

2 Commits

8 changed files with 986 additions and 226 deletions

164
.gitignore vendored Normal file
View File

@@ -0,0 +1,164 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

View File

@@ -1,7 +1,11 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>aurovite</w>
<w>callanite</w>
<w>caterium</w>
<w>larrussite</w>
<w>quickwire</w>
<w>siterite</w>
</words>
</dictionary>

8
.idea/pysatcalc.iml generated
View File

@@ -7,4 +7,12 @@
<orderEntry type="jdk" jdkName="uv (pysatcalc)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

456
main.py
View File

@@ -1,203 +1,84 @@
from __future__ import annotations
from dataclasses import dataclass
from collections import defaultdict, deque
from enum import Enum
from math import ceil
from typing import Dict, List, Tuple, Optional
from typing import Dict, List, Tuple, Optional, Any
import networkx as nx
from flask import Flask, render_template, request
from pydantic import BaseModel, Field
from plus import Items, Machines, Recipes, Recipe
from rich import print
app = Flask(__name__)
class Item(BaseModel):
name: str
# Helpers to map item names to safe query parameter keys
def _slugify(name: str) -> str:
s = ''.join(ch.lower() if ch.isalnum() else '_' for ch in name)
# collapse consecutive underscores and trim
out = []
prev_us = False
for ch in s:
if ch == '_':
if not prev_us:
out.append('_')
prev_us = True
else:
out.append(ch)
prev_us = False
return ''.join(out).strip('_')
class Machine(BaseModel):
name: str
class Machines(Enum):
Miner = Machine(name="Miner")
Smelter = Machine(name="Smelter")
Constructor = Machine(name="Constructor")
Assembler = Machine(name="Assembler")
Sorter = Machine(name="Sorter")
Crusher = Machine(name="Crusher")
Foundry = Machine(name="Foundry")
class Items(Enum):
IronIngot = Item(name="Iron Ingot")
CopperIngot = Item(name="Copper Ingot")
Limestone = Item(name="Limestone")
IronOre = Item(name="Iron Ore")
CopperOre = Item(name="Copper Ore")
IronPlate = Item(name="Iron Plate")
IronRod = Item(name="Iron Rod")
Wire = Item(name="Wire")
Cable = Item(name="Cable")
Concrete = Item(name="Concrete")
ReinforcedIronPlate = Item(name="Reinforced Iron Plate")
ModularFrame = Item(name="Modular Frame")
BronzeBeam = Item(name="Bronze Beam")
TinPlate = Item(name="Tin Plate")
TinIngot = Item(name="Tin Ingot")
CrushedTin = Item(name="Crushed Tin")
CrushedIron = Item(name="Crushed Iron")
CrushedGangue = Item(name="Crushed Gangue")
CrushedSiterite = Item(name="Crushed Siterite")
SiteriteOre = Item(name="Siterite Ore")
Screws = Item(name="Screws")
BronzeIngot = Item(name="Bronze Ingot")
CrushedCopper = Item(name="Crushed Copper")
CrushedMagnesium = Item(name="Crushed Magnesium")
CrushedCallanite = Item(name="Crushed Callanite")
CallaniteOre = Item(name="Callanite Ore")
class RawResources(Enum):
IronOre = Items.IronOre
CopperOre = Items.CopperOre
Limestone = Items.Limestone
class Production(BaseModel):
recipe: Recipe
quantity: float
# --- Domain model (minimal, extensible) ---
class Recipe(BaseModel):
name: str # Human-friendly name
building: Machines # e.g., "Smelter", "Constructor"
outputs: Dict[Items, float] # Produced item name
inputs: Dict[Items, float] = Field(default_factory=dict)
def get_recipe(item: Items, recipe_map: Dict[Items, list[Recipes]]) -> Optional[Recipe]:
return recipe_map.get(item, (None,))[0]
def compute_chain2(targets: Dict[Items, float]) -> Any:
if not targets:
return {}
recipe_map: Dict[Items, list[Recipes]] = defaultdict(list)
for r in Recipes:
for o in r.value.outputs:
recipe_map[o].append(r.value)
# print(recipe_map)
demand = defaultdict(float)
for target in targets:
demand[target] = targets[target]
production = defaultdict(float)
# add demands to production
# find a recipe for that demand
# add inputs to demand
queue = deque(targets)
g = nx.DiGraph()
production_queue = []
raw_resources = defaultdict(float)
production_chain = defaultdict(float)
while queue:
item = queue.popleft()
recipe = get_recipe(item, recipe_map)
if recipe is None:
raw_resources[item] += demand[item] - raw_resources[item]
continue
levels = []
for out, quantity in recipe.outputs.items():
if out in demand:
target_quantity = demand[out] - production[out]
if target_quantity > 0:
levels.append(target_quantity / quantity)
else:
levels.append(0)
production_level = max(levels) if max(levels) > 0 else 0
production_chain[recipe.name] = production_level
for out, quantity in recipe.outputs.items():
production[out] += production_level * quantity
for inp, quantity in recipe.inputs.items():
queue.append(inp)
demand[inp] += production_level * quantity
print(demand, production, raw_resources, production_chain)
return production
# A very small starter dataset (default, non-alternate recipes)
# Rates are per building per minute, matching Satisfactory default recipes.
class Recipes(Enum):
ModularFrame = Recipe(
name="Modular Frame",
building=Machines.Assembler,
outputs={Items.ModularFrame: 6.0},
inputs={
Items.ReinforcedIronPlate: 7.5,
Items.BronzeBeam: 7.5,
},
)
ReinforcedIronPlate = Recipe(
name="Reinforced Iron Plate",
building=Machines.Assembler,
outputs={Items.ReinforcedIronPlate: 5.0},
inputs={
Items.TinPlate: 20.0,
Items.Screws: 37.5,
},
)
TinPlate = Recipe(
name="Tin Plate",
building=Machines.Assembler,
outputs={Items.TinPlate: 40.0},
inputs={
Items.IronPlate: 20.0,
Items.TinIngot: 30.0,
},
)
TinIngot = Recipe(
name="Tin Ingot",
building=Machines.Smelter,
outputs={Items.TinIngot: 15.0},
inputs={Items.CrushedTin: 15.0},
)
CrushedIron = Recipe(
name="Crushed Iron",
building=Machines.Sorter,
outputs={
Items.CrushedIron: 90.0,
Items.CrushedTin: 60.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedSiterite: 120.0},
)
CrushedSiterite = Recipe(
name="Crushed Siterite",
building=Machines.Crusher,
outputs={
Items.CrushedSiterite: 60.0,
Items.CrushedGangue: 25.0,
},
inputs={Items.SiteriteOre: 60.0},
)
IronPlate = Recipe(
name="Iron Plate",
building=Machines.Constructor,
outputs={Items.IronPlate: 20.0},
inputs={Items.IronIngot: 30.0},
)
IronIngot = Recipe(
name="Iron Ingot",
building=Machines.Smelter,
outputs={Items.IronIngot: 30.0},
inputs={Items.CrushedIron: 30.0},
)
Screws = Recipe(
name="Screws",
building=Machines.Constructor,
outputs={Items.Screws: 50.0},
inputs={Items.IronRod: 20.0},
)
IronRod = Recipe(
name="Iron Rod",
building=Machines.Constructor,
outputs={Items.IronRod: 15.0},
inputs={Items.IronIngot: 15.0},
)
BronzeBeam = Recipe(
name="Bronze Beam",
building=Machines.Constructor,
outputs={Items.BronzeBeam: 7.5},
inputs={Items.BronzeIngot: 22.5},
)
BronzeIngot = Recipe(
name="Bronze Ingot",
building=Machines.Foundry,
outputs={Items.BronzeIngot: 45.0},
inputs={
Items.CopperIngot: 36.0,
Items.TinIngot: 15.0,
},
)
CopperIngot = Recipe(
name="Copper Ingot",
building=Machines.Smelter,
outputs={Items.CopperIngot: 48.0},
inputs={Items.CrushedCopper: 48.0},
)
CrushedCopper = Recipe(
name="Crushed Copper",
building=Machines.Sorter,
outputs={
Items.CrushedCopper: 96.0,
Items.CrushedMagnesium: 80.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedCallanite: 120.0},
)
CrushedCallanite = Recipe(
name="Crushed Callanite",
building=Machines.Sorter,
outputs={
Items.CrushedCallanite: 60.0,
Items.CrushedGangue: 25.0,
},
inputs={Items.CallaniteOre: 60.0},
)
# Items which are considered raw resources (mined); they have no crafting recipes here
RAW_RESOURCES = {
Items.IronOre,
Items.CopperOre,
Items.Limestone,
Items.SiteriteOre,
}
def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[dict], Dict[str, float], Dict[str, float]]:
def compute_chain(targets: Dict[Items, float], preferred_by_output: Optional[Dict[Items, str]] = None) -> Tuple[Dict[str, float], List[dict], Dict[str, float], Dict[str, float]]:
"""
Given desired output rates (item -> units/min), compute:
- required raw input rates (raw item -> units/min)
@@ -205,14 +86,44 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
- total production rates (echo of targets summed if multiple entries per item)
- unused byproducts (items/min) produced by multi-output recipes but not consumed or targeted
Uses the Recipes enum and Items objects.
Now supports alternate recipes: when multiple recipes produce the same output item,
a selection heuristic is used unless an explicit preference is configured.
preferred_by_output: optional mapping from output Item -> recipe name to force selection for that item.
"""
# Build a mapping from output item -> recipe that produces it (default recipe set)
output_to_recipe: Dict[Items, Recipe] = {}
# Build a mapping from output item -> list of recipes that produce it
output_to_recipes: Dict[Items, List[Recipe]] = {}
for r in Recipes:
recipe = r.value
for out_item in recipe.outputs.keys():
# prefer the first seen; can be extended to handle alternates later
output_to_recipe.setdefault(out_item, recipe)
output_to_recipes.setdefault(out_item, []).append(recipe)
# Optional explicit preferences: map output Item -> recipe name to prefer
PREFERRED_RECIPE_BY_OUTPUT: Dict[Items, str] = preferred_by_output or {}
# Heuristic to select a recipe when multiple alternatives exist
def select_recipe_for(item: Items) -> Optional[Recipe]:
candidates = output_to_recipes.get(item, [])
if not candidates:
return None
# If explicit preference exists and matches a candidate, use it
pref_name = PREFERRED_RECIPE_BY_OUTPUT.get(item)
if pref_name:
for c in candidates:
if c.name == pref_name:
return c
# Otherwise pick the candidate with the highest per-building output for this item
# Tie-breaker 1: smallest total input per unit of this output
# Tie-breaker 2: deterministic by name
def score(c: Recipe) -> Tuple[float, float, str]:
per_build_out = c.outputs.get(item, 0.0)
total_input = sum(c.inputs.values())
# Lower input per unit is better; we express as (total_input/per_build_out)
# Protect against division by zero
eff = float('inf') if per_build_out <= 0 else (total_input / per_build_out)
return (per_build_out, -eff, c.name)
return sorted(candidates, key=score, reverse=True)[0]
# Aggregate demands for each item
demand: Dict[Items, float] = {}
@@ -236,18 +147,24 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
# Expand demanded craftable items into their inputs until only raw remain
while True:
craftable_item = next(
(i for i, r in demand.items() if r > 1e-9 and i in output_to_recipe),
(i for i, r in demand.items() if r > 1e-9 and i in output_to_recipes),
None,
)
if craftable_item is None:
break
needed_rate = demand[craftable_item]
recipe = output_to_recipe[craftable_item]
recipe = select_recipe_for(craftable_item)
if recipe is None:
# Should not happen because craftable_item is in output_to_recipes,
# but guard anyway: treat as raw if selection failed.
demand[craftable_item] = 0.0
raw_requirements[craftable_item.value.name] = raw_requirements.get(craftable_item.value.name, 0.0) + needed_rate
continue
per_building_output = recipe.outputs[craftable_item]
# Buildings needed
buildings = needed_rate / per_building_output
buildings = needed_rate / per_building_output if per_building_output > 0 else 0.0
buildings_ceiled = ceil(buildings - 1e-9)
utilization = 0.0 if buildings_ceiled == 0 else buildings / buildings_ceiled
@@ -282,11 +199,7 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
for item, rate in demand.items():
if rate <= 1e-9:
continue
if item in RAW_RESOURCES or item not in output_to_recipe:
raw_requirements[item.value.name] = raw_requirements.get(item.value.name, 0.0) + rate
else:
# Shouldn't happen, but guard
raw_requirements[item.value.name] = raw_requirements.get(item.value.name, 0.0) + rate
raw_requirements[item.value.name] = raw_requirements.get(item.value.name, 0.0) + rate
# Merge steps for same item/building
merged: Dict[Tuple[str, str], dict] = {}
@@ -321,7 +234,7 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
return raw_requirements, merged_steps, total_outputs, unused_byproducts
@app.route("/", methods=["GET", "POST"])
@app.route("/", methods=["GET"])
def index():
# Build selectable items list from Items enum (display names)
item_names = sorted([i.value.name for i in Items])
@@ -332,9 +245,35 @@ def index():
selected_item = item_names[0] if item_names else ""
selected_rate = 60.0
if request.method == "POST":
item_name = request.form.get("item") or selected_item
rate_str = request.form.get("rate")
# Read from query parameters for bookmarkable URLs
item_name = request.args.get("item") or selected_item
rate_str = request.args.get("rate")
selected_recipe = request.args.get("recipe") or ""
# Parse per-item recipe overrides from query params recipe_for_<slug(item)>
# Build slug -> Items map
slug_to_item: Dict[str, Items] = { _slugify(i.value.name): i for i in Items }
overrides: Dict[Items, str] = {}
for key, value in request.args.items():
if not key.startswith("recipe_for_"):
continue
if value is None or value == "":
continue
slug = key[len("recipe_for_"):]
item_enum = slug_to_item.get(slug)
if not item_enum:
continue
# Validate that the value is a valid recipe option for this item
candidates = []
for r in Recipes:
rec = r.value
if item_enum in rec.outputs:
candidates.append(rec.name)
if value in candidates:
overrides[item_enum] = value
rate = None
if rate_str is not None and rate_str != "":
try:
rate = float(rate_str)
if rate < 0:
@@ -343,24 +282,90 @@ def index():
error = "Please enter a valid non-negative number for rate (items per minute)."
rate = None
selected_item = item_name
if rate is not None:
selected_rate = rate
selected_item = item_name
if rate is not None:
selected_rate = rate
if not error and item_name and rate is not None:
item_obj = name_to_item.get(item_name)
if item_obj is None:
error = "Unknown item selected."
else:
targets = {item_obj: rate}
raw, steps, outputs, unused = compute_chain(targets)
result = {
"targets": {item_name: rate},
"raw": raw,
"steps": steps,
"outputs": outputs,
"unused": unused,
}
# Determine candidate recipes for the selected output item
recipe_options: List[str] = []
item_obj_for_options = name_to_item.get(selected_item)
if item_obj_for_options is not None:
for r in Recipes:
recipe = r.value
if item_obj_for_options in recipe.outputs:
recipe_options.append(recipe.name)
recipe_options.sort()
# Validate selected_recipe against available options
if selected_recipe not in recipe_options:
selected_recipe = ""
else:
selected_recipe = ""
# Build preferred map merging top-level selection and overrides
preferred: Optional[Dict[Items, str]] = None
if selected_recipe or overrides:
preferred = {}
preferred.update(overrides)
if selected_recipe and item_obj_for_options is not None:
preferred[item_obj_for_options] = selected_recipe
# Compute and also prepare per-item override options based on resulting chain
overrides_ui: List[dict] = []
if not error and item_name and rate is not None:
item_obj = name_to_item.get(item_name)
if item_obj is None:
error = "Unknown item selected."
else:
targets = {item_obj: rate}
raw, steps, outputs, unused = compute_chain(targets, preferred_by_output=preferred)
result = {
"targets": {item_name: rate},
"raw": raw,
"steps": steps,
"outputs": outputs,
"unused": unused,
}
# Collect unique output items from steps
unique_items = []
seen = set()
for s in steps:
item_nm = s.get("item")
if item_nm and item_nm not in seen:
seen.add(item_nm)
unique_items.append(item_nm)
# For each item, compute candidate recipes and current selection
for item_nm in unique_items:
item_enum2 = name_to_item.get(item_nm)
if not item_enum2:
continue
candidates = []
for r in Recipes:
rec = r.value
if item_enum2 in rec.outputs:
candidates.append({
"name": rec.name,
"building": rec.building.value.name,
})
if len(candidates) <= 1:
continue # only show when alternates exist
candidates.sort(key=lambda x: (x["name"]))
sel = None
if preferred and item_enum2 in preferred:
sel = preferred[item_enum2]
slug = _slugify(item_nm)
overrides_ui.append({
"item_name": item_nm,
"slug": slug,
"options": candidates,
"selected": sel or "",
})
# Build reset query (clear overrides)
reset_query = f"?item={selected_item}&rate={selected_rate}"
if selected_recipe:
reset_query += f"&recipe={selected_recipe}"
return render_template(
"index.html",
@@ -369,6 +374,10 @@ def index():
error=error,
selected_item=selected_item,
selected_rate=selected_rate,
recipe_options=recipe_options,
selected_recipe=selected_recipe,
overrides_ui=overrides_ui,
reset_query=reset_query,
)
@@ -378,4 +387,5 @@ def create_app():
if __name__ == "__main__":
# For local dev: python main.py
app.run(host="0.0.0.0", port=5000, debug=True)
# app.run(host="0.0.0.0", port=5000, debug=True)
compute_chain2({Items.ModularFrame: 45.0})

479
plus.py Normal file
View File

@@ -0,0 +1,479 @@
from enum import Enum
from typing import Dict
from pydantic import BaseModel, Field, ConfigDict
class Item(BaseModel):
name: str
class Machine(BaseModel):
name: str
class Machines(Enum):
Miner = Machine(name="Miner")
Smelter = Machine(name="Smelter")
Constructor = Machine(name="Constructor")
Assembler = Machine(name="Assembler")
Sorter = Machine(name="Sorter")
Crusher = Machine(name="Crusher")
Foundry = Machine(name="Foundry")
class Items(Enum):
IronIngot = Item(name="Iron Ingot")
CopperIngot = Item(name="Copper Ingot")
Limestone = Item(name="Limestone")
IronOre = Item(name="Iron Ore")
CopperOre = Item(name="Copper Ore")
IronPlate = Item(name="Iron Plate")
IronRod = Item(name="Iron Rod")
Wire = Item(name="Wire")
Cable = Item(name="Cable")
Concrete = Item(name="Concrete")
ReinforcedIronPlate = Item(name="Reinforced Iron Plate")
ModularFrame = Item(name="Modular Frame")
BronzeBeam = Item(name="Bronze Beam")
TinPlate = Item(name="Tin Plate")
TinIngot = Item(name="Tin Ingot")
CrushedTin = Item(name="Crushed Tin")
CrushedIron = Item(name="Crushed Iron")
CrushedGangue = Item(name="Crushed Gangue")
CrushedSiterite = Item(name="Crushed Siterite")
SiteriteOre = Item(name="Siterite Ore")
Screws = Item(name="Screws")
BronzeIngot = Item(name="Bronze Ingot")
CrushedCopper = Item(name="Crushed Copper")
CrushedMagnesium = Item(name="Crushed Magnesium")
CrushedCallanite = Item(name="Crushed Callanite")
CallaniteOre = Item(name="Callanite Ore")
CrushedLarrussite = Item(name="Crushed Larrussite")
CrushedAurovite = Item(name="Crushed Aurovite")
AuroviteOre = Item(name="Aurovite Ore")
Sand = Item(name="Sand")
MagnesiumGranules = Item(name="Magnesium Granules")
CrushedZinc = Item(name="Crushed Zinc")
LarrussiteOre = Item(name="Larrussite Ore")
ZincIngot = Item(name="Zinc Ingot")
Glass = Item(name="Glass")
IronSheet = Item(name="Iron Sheet")
CopperRod = Item(name="Copper Rod")
CopperSheet = Item(name="Copper Sheet")
BronzePlates = Item(name="Bronze Plates")
CateriumRod = Item(name="Caterium Rod")
CateriumIngot = Item(name="Caterium Ingot")
CateriumPlate = Item(name="Caterium Plate")
TinRod = Item(name="Tin Rod")
ZincPlates = Item(name="Zinc Plates")
IronWire = Item(name="Iron Wire")
CopperBusbars = Item(name="Copper Busbars")
Quickwire = Item(name="Quickwire")
TinnedWire = Item(name="Tinned Wire")
SolarCell = Item(name="Solar Cell")
Silica = Item(name="Silica")
BronzePipes = Item(name="Bronze Pipes")
SmoothBeltDrive = Item(name="Smooth Belt Drive")
TinnedSheet = Item(name="Tinned Sheet")
Rotor = Item(name="Rotor")
BronzeFrame = Item(name="Bronze Frame")
AILimiter = Item(name="AI Limiter")
class Recipe(BaseModel):
model_config = ConfigDict(frozen=True)
name: str # Human-friendly name
building: Machines # e.g., "Smelter", "Constructor"
outputs: Dict[Items, float] # Produced item name
inputs: Dict[Items, float] = Field(default_factory=dict)
class Recipes(Enum):
# Crusher
# - Crushing Ores
CrushedSiterite = Recipe(
name="Crushed Siterite",
building=Machines.Crusher,
outputs={
Items.CrushedSiterite: 60.0,
Items.CrushedGangue: 25.0,
},
inputs={Items.SiteriteOre: 60.0},
)
CrushedLarrussite = Recipe(
name="Crushed Larrussite",
building=Machines.Crusher,
outputs={
Items.CrushedLarrussite: 40.0,
Items.CrushedGangue: 20.0,
},
inputs={Items.LarrussiteOre: 60.0},
)
CrushedCallanite = Recipe(
name="Crushed Callanite",
building=Machines.Crusher,
outputs={
Items.CrushedCallanite: 60.0,
Items.CrushedGangue: 25.0,
},
inputs={Items.CallaniteOre: 60.0},
)
CrushedAurovite = Recipe(
name="Crushed Aurovite",
building=Machines.Crusher,
outputs={
Items.CrushedAurovite: 60.0,
Items.CrushedGangue: 22.5,
},
inputs={Items.AuroviteOre: 60.0},
)
# - Crushing Powders
CoarseSand = Recipe(
name="Coarse Sand",
building=Machines.Crusher,
outputs={
Items.Sand: 30.0
},
inputs={Items.CrushedGangue: 60.0},
)
MagnesiumGranules = Recipe(
name="Magnesium Granules",
building=Machines.Crusher,
outputs={
Items.MagnesiumGranules: 72.0
},
inputs={Items.CrushedMagnesium: 80.0},
)
# Sorter
# - Simple Sorting
CrushedIron = Recipe(
name="Crushed Iron",
building=Machines.Sorter,
outputs={
Items.CrushedIron: 90.0,
Items.CrushedTin: 60.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedSiterite: 120.0},
)
CrushedCopper = Recipe(
name="Crushed Copper",
building=Machines.Sorter,
outputs={
Items.CrushedCopper: 96.0,
Items.CrushedMagnesium: 80.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedCallanite: 120.0},
)
CrushedZinc = Recipe(
name="Crushed Zinc",
building=Machines.Sorter,
outputs={
Items.CrushedZinc: 96.0,
Items.CrushedMagnesium: 48.0,
Items.CrushedGangue: 60.0,
},
inputs={Items.CrushedLarrussite: 120.0},
)
# Smelter
# - smelting
IronIngot = Recipe(
name="Iron Ingot",
building=Machines.Smelter,
outputs={Items.IronIngot: 30.0},
inputs={Items.CrushedIron: 30.0},
)
CopperIngot = Recipe(
name="Copper Ingot",
building=Machines.Smelter,
outputs={Items.CopperIngot: 48.0},
inputs={Items.CrushedCopper: 48.0},
)
TinIngot = Recipe(
name="Tin Ingot",
building=Machines.Smelter,
outputs={Items.TinIngot: 15.0},
inputs={Items.CrushedTin: 15.0},
)
# - Ingots
ImpureIronIngot = Recipe(
name="Impure Iron Ingot",
building=Machines.Smelter,
outputs={Items.IronIngot: 30.0},
inputs={Items.CrushedSiterite: 30.0},
)
ImpureCopperIngot = Recipe(
name="Impure Copper Ingot",
building=Machines.Smelter,
inputs={Items.CrushedCallanite: 24.0},
outputs={Items.CopperIngot: 24.0},
)
ImpureTinIngot = Recipe(
name="Impure Tin Ingot",
building=Machines.Smelter,
inputs={Items.CrushedSiterite: 30.0},
outputs={Items.TinIngot: 15.0},
)
ImpureCateriumIngot = Recipe(
name="Impure Caterium Ingot",
building=Machines.Smelter,
inputs={Items.CrushedAurovite: 40.0},
outputs={Items.CateriumIngot: 24.0},
)
ZincIngot = Recipe(
name="Zinc Ingot",
building=Machines.Smelter,
outputs={Items.ZincIngot: 15.0},
inputs={Items.CrushedZinc: 24.0},
)
# - Standard Parts
SloppyGlass = Recipe(
name="Sloppy Glass",
building=Machines.Smelter,
outputs={Items.Glass: 20.0},
inputs={Items.Sand: 30.0},
)
# Constructor
# - Standard Parts
IronPlate = Recipe(
name="Iron Plate",
building=Machines.Constructor,
outputs={Items.IronPlate: 20.0},
inputs={Items.IronIngot: 30.0},
)
Screws = Recipe(
name="Screws",
building=Machines.Constructor,
outputs={Items.Screws: 50.0},
inputs={Items.IronRod: 20.0},
)
IronRod = Recipe(
name="Iron Rod",
building=Machines.Constructor,
outputs={Items.IronRod: 15.0},
inputs={Items.IronIngot: 15.0},
)
IronSheet = Recipe(
name="Iron Sheet",
building=Machines.Constructor,
outputs={Items.IronSheet: 50.0},
inputs={Items.IronPlate: 40.0},
)
CopperRod = Recipe(
name="Copper Rod",
building=Machines.Constructor,
outputs={Items.CopperRod: 18.0},
inputs={Items.CopperIngot: 12.0},
)
CopperSheet = Recipe(
name="Copper Sheet",
building=Machines.Constructor,
outputs={Items.CopperSheet: 30.0},
inputs={Items.CopperIngot: 24.0},
)
BronzeBeam = Recipe(
name="Bronze Beam",
building=Machines.Constructor,
outputs={Items.BronzeBeam: 7.5},
inputs={Items.BronzeIngot: 22.5},
)
BronzePlates = Recipe(
name="Bronze Plates",
building=Machines.Constructor,
outputs={Items.BronzePlates: 24.0},
inputs={Items.BronzeIngot: 30.0},
)
CateriumRod = Recipe(
name="Caterium Rod",
building=Machines.Constructor,
outputs={Items.CateriumRod: 32.0},
inputs={Items.CateriumIngot: 16.0},
)
CateriumPlate = Recipe(
name="Caterium Plate",
building=Machines.Constructor,
outputs={Items.CateriumPlate: 36.0},
inputs={Items.CateriumIngot: 60.0},
)
TinRod = Recipe(
name="Tin Rod",
building=Machines.Constructor,
outputs={Items.TinRod: 15.0},
inputs={Items.TinIngot: 30.0},
)
ZincPlates = Recipe(
name="Zinc Plates",
building=Machines.Constructor,
outputs={Items.ZincPlates: 15.0},
inputs={Items.ZincIngot: 12.5},
)
# - Electronics
IronWire = Recipe(
name="Iron Wire",
building=Machines.Constructor,
outputs={Items.IronWire: 30.0},
inputs={Items.IronRod: 20.0},
)
Wire = Recipe(
name="Wire",
building=Machines.Constructor,
outputs={Items.Wire: 30.0},
inputs={Items.CopperRod: 18.0},
)
CopperBusbars = Recipe(
name="Copper Busbars",
building=Machines.Constructor,
outputs={Items.CopperBusbars: 15.0},
inputs={Items.CopperRod: 24.0},
)
Cable = Recipe(
name="Cable",
building=Machines.Constructor,
outputs={Items.Cable: 30.0},
inputs={Items.Wire: 60.0},
)
Quickwire = Recipe(
name="Quickwire",
building=Machines.Constructor,
outputs={Items.Quickwire: 80.0},
inputs={Items.CateriumRod: 24.0},
)
PureTinWire = Recipe(
name="Pure Tin Wire",
building=Machines.Constructor,
outputs={Items.TinnedWire: 11.25},
inputs={Items.TinRod: 15.0},
)
# - Compounds
Concrete = Recipe(
name="Concrete",
building=Machines.Constructor,
outputs={Items.Concrete: 15.0},
inputs={Items.CrushedGangue: 45.0},
)
# Foundry
# - Building Parts
SolarCell = Recipe(
name="Solar Cell",
building=Machines.Foundry,
outputs={Items.SolarCell: 11.25},
inputs={
Items.ZincPlates: 22.5,
Items.Silica: 67.5,
},
)
# - Alloys
BronzePipes = Recipe(
name="Bronze Pipes",
building=Machines.Foundry,
outputs={Items.BronzePipes: 36.0},
inputs={
Items.CopperIngot: 24.0,
Items.TinIngot: 15.0,
},
)
# - ingots
BronzeIngot = Recipe(
name="Bronze Ingot",
building=Machines.Foundry,
outputs={Items.BronzeIngot: 45.0},
inputs={
Items.CopperIngot: 36.0,
Items.TinIngot: 15.0,
},
)
# - Other
ThermalSilica = Recipe(
name="Thermal Silica",
building=Machines.Foundry,
outputs={Items.Silica: 45.0},
inputs={
Items.Sand: 30.0,
Items.MagnesiumGranules: 27.0,
},
)
# Assembler
# - Building Parts
SmoothBeltDrive = Recipe(
name="Smooth Belt Drive",
building=Machines.Assembler,
outputs={Items.SmoothBeltDrive: 18.75},
inputs={
Items.TinnedSheet: 15.0,
Items.Rotor: 3.75,
},
)
# - Standard Parts
TinPlate = Recipe(
name="Tin Plate",
building=Machines.Assembler,
outputs={Items.TinPlate: 40.0},
inputs={
Items.TinIngot: 30.0,
Items.IronPlate: 20.0,
},
)
TinnedWire = Recipe(
name="Tinned Wire",
building=Machines.Assembler,
outputs={Items.TinnedWire: 90},
inputs={
Items.Wire: 60.0,
Items.TinRod: 45.0,
},
)
TinnedSheet = Recipe(
name="Tinned Sheet",
building=Machines.Assembler,
outputs={Items.TinnedSheet: 40.0},
inputs={
Items.TinIngot: 20.0,
Items.CopperSheet: 20.0,
},
)
Rotor = Recipe(
name="Rotor",
building=Machines.Assembler,
outputs={Items.Rotor: 7.5},
inputs={
Items.CopperBusbars: 22.5,
Items.IronWire: 60.0,
},
)
ReinforcedIronPlate = Recipe(
name="Reinforced Iron Plate",
building=Machines.Assembler,
outputs={Items.ReinforcedIronPlate: 5.0},
inputs={
Items.TinPlate: 20.0,
Items.Screws: 37.5,
},
)
BronzeFrame = Recipe(
name="Bronze Frame",
building=Machines.Assembler,
outputs={Items.BronzeFrame: 5.0},
inputs={
Items.BronzeBeam: 10.0,
Items.BronzePipes: 24.0,
},
)
ModularFrame = Recipe(
name="Modular Frame",
building=Machines.Assembler,
outputs={Items.ModularFrame: 6.0},
inputs={
Items.ReinforcedIronPlate: 7.5,
Items.BronzeBeam: 7.5,
},
)
AILimiter = Recipe(
name="AI Limiter",
building=Machines.Assembler,
outputs={Items.AILimiter: 6.0},
inputs={
Items.CateriumPlate: 24.0,
Items.TinnedWire: 18.0,
},
)

View File

@@ -5,5 +5,7 @@ description = "Web-based Satisfactory production calculator in Python (Flask)"
requires-python = ">=3.13"
dependencies = [
"flask>=3.0",
"networkx>=3.5",
"pydantic>=2.12.4",
"rich>=14.2.0",
]

View File

@@ -8,7 +8,7 @@
:root { --bg: #0f172a; --card: #111827; --text: #e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --ok:#4ade80; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.container { margin: 0 auto; padding: 24px; }
h1 { font-size: 1.75rem; margin: 0 0 8px; }
p { color: var(--muted); margin-top: 0; }
.card { background: var(--card); border: 1px solid #1f2937; border-radius: 10px; padding: 16px; }
@@ -31,9 +31,9 @@
<body>
<div class="container">
<h1>Satisfactory Production Calculator</h1>
<p>Compute buildings and raw inputs for a target output rate (items per minute). Data set includes a few early-game default recipes; you can extend it in <code>main.py</code>.</p>
<p>Compute buildings and raw inputs for a target output rate (items per minute).</p>
<form class="card" method="post">
<form class="card" method="get">
<div class="row">
<div>
<label for="item">Product</label>
@@ -48,6 +48,7 @@
<input id="rate" name="rate" type="number" step="0.01" min="0" value="{{ selected_rate }}" required>
</div>
</div>
<div class="mt">
<button type="submit">Calculate</button>
</div>
@@ -78,6 +79,42 @@
<p class="pill">No raw resources required (target is a raw resource).</p>
{% endif %}
{% if overrides_ui and overrides_ui|length > 0 %}
<h3 class="mt">Recipe overrides</h3>
<form method="get" class="card" style="background: transparent; border:0; padding:0;">
<input type="hidden" name="item" value="{{ selected_item }}">
<input type="hidden" name="rate" value="{{ selected_rate }}">
{% if selected_recipe %}<input type="hidden" name="recipe" value="{{ selected_recipe }}">{% endif %}
<table>
<thead>
<tr>
<th>Item</th>
<th>Override recipe</th>
</tr>
</thead>
<tbody>
{% for ov in overrides_ui %}
<tr>
<td>{{ ov.item_name }}</td>
<td>
<select name="recipe_for_{{ ov.slug }}">
<option value="" {% if not ov.selected %}selected{% endif %}>Auto-select best</option>
{% for opt in ov.options %}
<option value="{{ opt.name }}" {% if ov.selected and opt.name==ov.selected %}selected{% endif %}>{{ opt.name }} ({{ opt.building }})</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt">
<button type="submit">Recalculate</button>
<a href="{{ reset_query }}" class="pill" style="margin-left:8px; display:inline-block; text-decoration:none;">Reset overrides</a>
</div>
</form>
{% endif %}
<h3 class="mt">Production steps</h3>
{% if result.steps %}
<table>

56
uv.lock generated
View File

@@ -79,6 +79,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -131,6 +143,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "networkx"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" },
]
[[package]]
name = "pydantic"
version = "2.12.4"
@@ -199,19 +229,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pysatcalc"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "networkx" },
{ name = "pydantic" },
{ name = "rich" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.0" },
{ name = "networkx", specifier = ">=3.5" },
{ name = "pydantic", specifier = ">=2.12.4" },
{ name = "rich", specifier = ">=14.2.0" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]