From e8b0789d2d82910f2b88ccf6fb4ae8a8c4cf21fe Mon Sep 17 00:00:00 2001 From: Shuai Zhang Date: Wed, 11 Feb 2026 16:15:22 -0800 Subject: [PATCH 1/5] docs(factorio-cycle-calculator): Introduce Initial Investigation Documentation --- .../.AGENT/factorio-data-analysis.md | 106 ++++++ .../factorio-recipe-data-analysis.addendum.md | 90 +++++ .../.AGENT/factorio-recipe-data-analysis.md | 266 +++++++++++++ .../.AGENT/scripts/check_icons_and_locale.py | 352 ++++++++++++++++++ .../app/factorio-cycle-calculator/README.md | 15 + 5 files changed, 829 insertions(+) create mode 100644 src/private/app/factorio-cycle-calculator/.AGENT/factorio-data-analysis.md create mode 100644 src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.addendum.md create mode 100644 src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.md create mode 100644 src/private/app/factorio-cycle-calculator/.AGENT/scripts/check_icons_and_locale.py create mode 100644 src/private/app/factorio-cycle-calculator/README.md diff --git a/src/private/app/factorio-cycle-calculator/.AGENT/factorio-data-analysis.md b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-data-analysis.md new file mode 100644 index 0000000..4cf2d29 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-data-analysis.md @@ -0,0 +1,106 @@ +# Factorio data-raw-dump.json analysis + +Date: 2026-02-11 + +## Scope and sources + +- Analyzed file: `/mnt/c/Users/zhang/AppData/Roaming/Factorio/script-output/data-raw-dump.json` +- File size: ~27.8 MB +- Reference docs: + - Factorio data.raw schema project (README): https://github.com/jacquev6/factorio-data-raw-json-schema + - Factorio Lua API (Data::raw / AnyPrototype / Data lifecycle): https://lua-api.factorio.com/latest/types/Data.html#raw + - Factorio modding overview and data.raw listing: https://wiki.factorio.com/Modding + - data.raw listing of built-in prototypes: https://wiki.factorio.com/Data.raw + +Note: The raw full JSON schema file is available at the URL below, but the fetch attempt could not extract content in this environment (likely due to size). Use it if you want schema validation. + +- https://raw.githubusercontent.com/jacquev6/factorio-data-raw-json-schema/refs/heads/main/factorio-data-raw-json-schema.full.json + +## High-level structure (matches Lua API documentation) + +The dump is the JSON serialization of `data.raw`, which is documented as: + +```lua +raw :: dictionary[string -> dictionary[string -> AnyPrototype]] +``` + +In practice, the file is a dictionary whose keys are prototype type names (e.g., `item`, `recipe`, `technology`). Each value is a dictionary from prototype name to prototype object. + +### Structural checks + +- Top-level categories: **251** +- Total prototype objects: **5137** +- All top-level values are JSON objects. +- Every prototype has both `name` and `type` fields. +- The `name` field matches its dictionary key. +- The `type` field matches its category key. + +This consistency indicates a clean dump and makes it safe to treat `(category, name)` as a unique identifier. + +## Distribution of prototypes + +Top 20 categories by number of prototypes: + +| Rank | Prototype type | Count | +| ---: | ---------------------- | ----: | +| 1 | optimized-particle | 845 | +| 2 | recipe | 659 | +| 3 | noise-expression | 504 | +| 4 | technology | 275 | +| 5 | item | 241 | +| 6 | explosion | 225 | +| 7 | corpse | 177 | +| 8 | optimized-decorative | 160 | +| 9 | virtual-signal | 155 | +| 10 | tile | 150 | +| 11 | item-subgroup | 136 | +| 12 | smoke-with-trigger | 101 | +| 13 | delayed-active-trigger | 100 | +| 14 | ambient-sound | 95 | +| 15 | tips-and-tricks-item | 81 | +| 16 | trivial-smoke | 67 | +| 17 | segment | 60 | +| 18 | noise-function | 48 | +| 19 | sprite | 44 | +| 20 | simple-entity | 41 | + +### Singleton categories + +There are **119** categories with exactly one prototype. Examples include: +`accumulator`, `achievement`, `beacon`, `character`, `character-corpse`, `map-settings`, `map-gen-presets`, `rocket-silo`, `space-platform-hub`, `surface`, `utility-constants`. + +This is normal for “global” or “singleton” systems (map settings, GUI style, utility constants, etc.). + +## Example prototype names (samples) + +A few representative names by category: + +- `item`: accumulator, active-provider-chest, advanced-circuit, agricultural-tower, assembling-machine-1, assembling-machine-2 +- `recipe`: accumulator, accumulator-recycling, acid-neutralisation, advanced-circuit, advanced-oil-processing +- `technology`: advanced-asteroid-processing, advanced-circuit, advanced-material-processing, agriculture, artillery +- `fluid`: ammonia, ammoniacal-solution, crude-oil, fluoroketone-cold, heavy-oil +- `tile`: acid-refined-concrete, ammoniacal-ocean, artificial-jellynut-soil, brash-ice, concrete + +## Observations and domain notes + +1. **Space Age content is present.** Categories and names such as `space-platform-hub`, `space-connection`, `planet`, and `quality` indicate the Space Age mod is active in this dump (consistent with the wiki’s data.raw listing for 2.0.65 + Space Age). + +2. **Very large “content” categories.** Particles, recipes, noise expressions, and technologies dominate the size. Tools should expect these to be the biggest memory/time drivers. + +3. **Type system is stable but dynamic.** The schema project notes that `data-raw-dump.json` is large, uses dynamic typing, and includes quirks such as empty arrays serialized as `{}`. It also recommends lenient number handling (integers can be floats in practice) and allowing additional properties for forward compatibility. + +4. **data.raw is data-stage only.** The `data` table is populated during the prototype stage (data.lua, data-updates.lua, data-final-fixes.lua) and then frozen. This dump is a snapshot after the data stage has completed. + +## Practical implications for tooling + +- **Treat `(type, name)` as a stable key.** It is consistent in this dump. +- **Plan for scale.** Thousands of entries and deep nested objects are normal. +- **Be lenient with numeric types.** Many fields documented as integers appear as floats in practice. +- **Allow unknown properties.** The schema project explicitly allows additional properties for compatibility. +- **Handle array/object quirks.** Some arrays may appear as `{}` in JSON output; tooling should normalize these to empty arrays when needed. + +## Follow-up ideas + +- Validate against the full JSON schema or generate a partial schema for specific domains (e.g., items/recipes only) to simplify downstream typing. +- Build a per-category “field histogram” (top properties and type variability) to identify dynamic fields. +- Normalize known quirks (empty arrays as `{}`) before processing. diff --git a/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.addendum.md b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.addendum.md new file mode 100644 index 0000000..a8ff76e --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.addendum.md @@ -0,0 +1,90 @@ +# Addendum: machine speed, 900 petroleum gas/min example, and test script + +Date: 2026-02-11 + +## Machine speed impact (same recipe, different machines) + +Given a recipe with `energy_required` and a machine with `crafting_speed`, the per-machine throughput is: + +- `effective_crafting_speed = crafting_speed * (1 + speed_bonus)` +- `cycle_seconds = (energy_required or 0.5) / effective_crafting_speed` +- `output_rate = result_amount / cycle_seconds` +- `input_rate = ingredient_amount / cycle_seconds` + +Productivity applies only when: + +- the recipe allows it (`allow_productivity == true`), +- the machine allows it (`allowed_effects` includes `productivity`), and +- the result is not marked `ignored_by_productivity`. + +This is why the same recipe can yield different rates across different machines: the difference is strictly due to `crafting_speed` and module/base effects. + +## Example: 900 petroleum gas per minute + +Target: 900 petroleum gas/min = 15 petroleum gas/s. + +Using only the advanced oil processing chain (no coal liquefaction), with the dump values: + +- `advanced-oil-processing` (oil refinery): 5 s → heavy-oil 25, light-oil 45, petroleum-gas 55 + - per refinery: 5 HO/s, 9 LO/s, 11 PG/s +- `heavy-oil-cracking` (chemical plant/biochamber): 2 s → light-oil 30 + - per plant: consumes 20 HO/s, produces 15 LO/s +- `light-oil-cracking` (chemical plant/biochamber): 2 s → petroleum-gas 20 + - per plant: consumes 15 LO/s, produces 10 PG/s + +Let A = refineries, H = heavy cracking, L = light cracking. + +Steady-state with no leftover fluids: + +- HO balance: `5A - 20H = 0` → `H = 0.25A` +- LO balance: `9A + 15H - 15L = 0` → `L = 0.6A + H = 0.85A` +- PG rate: `PG = 11A + 10L = 19.5A` + +To reach 15 PG/s: + +- `A = 15 / 19.5 = 0.76923` +- `H = 0.19231` +- `L = 0.65385` + +This is the fractional solution. In an integer program, you can: + +1. keep the balance constraints as inequalities (allow leftovers), or +2. enforce exact balance and allow overproduction with a penalty, or +3. relax integer constraints for early planning, then round and re-optimize. + +Example integer reference: + +- `A = 1` (no cracking) gives 11 PG/s = 660 PG/min (short of target). +- `A = 2` (no cracking) gives 22 PG/s = 1320 PG/min (over target). + +If you enforce zero leftovers and integer counts, you must scale the ratio 20:5:17 (from the wiki) or accept overproduction with a penalty term. + +## Icon + localization test script + +Script path: + +- `src/private/app/factorio-cycle-calculator/.AGENT/scripts/check_icons_and_locale.py` + +It validates: + +- icon paths for selected recipes, fluids, items, and machines +- PNG dimensions (if available) +- localization strings from `data//locale//*.cfg` + +Usage (example): + +- `python check_icons_and_locale.py --data-dir /mnt/c/Program\ Files/Factorio/data` +- `python check_icons_and_locale.py --data-raw /mnt/c/Users/zhang/AppData/Roaming/Factorio/script-output/data-raw-dump.json --data-dir /mnt/c/Program\ Files/Factorio/data --locale en` + +## Notes on missing item/entity localization and subgroup icons + +The missing locale entries reported by the script are expected and consistent +with how Factorio data is organized: + +- Placeable buildings often only have `entity-name` localization entries. The + corresponding `item-name` can be missing (for example, `oil-refinery`, + `chemical-plant`, `biochamber`). For UI labels, prefer `item-name` and fall + back to `entity-name` when the item key is not present. +- `item-subgroup` entries are internal categorization metadata. They frequently + have no localization entry and no icon. If you need a label or icon, prefer + the parent `item-group` or fall back to the raw subgroup name. diff --git a/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.md b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.md new file mode 100644 index 0000000..d67258b --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/.AGENT/factorio-recipe-data-analysis.md @@ -0,0 +1,266 @@ +# Factorio recipe data analysis for an IP-based calculator + +Date: 2026-02-11 + +## Goal + +Build a Factorio recipe calculator using integer programming (IP) to find machine counts that meet production targets while minimizing: + +1. total raw materials consumed and +2. total machine count. + +The app should allow users to choose which machine(s) can produce each recipe and must include UI icon data for items, fluids, recipes, and machine choices. + +## Primary data sources + +- `data-raw-dump.json` (from `factorio --dump-data-raw`) +- Factorio Lua API docs for `data.raw` structure +- Factorio Wiki for cross-checking recipes and machine roles + +Relevant pages used for verification (oil processing chain example): + +- https://wiki.factorio.com/Oil_processing#Overview +- https://wiki.factorio.com/Oil_refinery +- https://wiki.factorio.com/Chemical_plant +- https://wiki.factorio.com/Biochamber +- https://wiki.factorio.com/Crude_oil +- https://wiki.factorio.com/Heavy_oil +- https://wiki.factorio.com/Light_oil +- https://wiki.factorio.com/Petroleum_gas +- https://wiki.factorio.com/Oil_processing_(research) +- https://wiki.factorio.com/Advanced_oil_processing_(research) + +## Data model needed by the calculator + +### 1) Recipes + +Required fields (from `recipe` prototypes): + +- `name`, `category`, `energy_required` +- `ingredients[]` (see key set below) +- `results[]` (see key set below) +- `enabled` (early access before tech) +- `allow_productivity`, `allow_quality` +- `subgroup`, `order` (UI grouping) +- `surface_conditions` (Space Age constraints) + +Observed ingredient keys across all recipes: + +- `amount`, `name`, `type`, `fluidbox_index`, `fluidbox_multiplier`, `ignored_by_stats` + +Observed result keys across all recipes: + +- `amount`, `name`, `type`, `probability`, `extra_count_fraction`, `temperature`, `percent_spoiled`, + `fluidbox_index`, `ignored_by_productivity`, `ignored_by_stats`, `show_details_in_recipe_tooltip` + +Notes: + +- 63 recipes in this dump have `energy_required == null`. Use the Factorio default of 0.5 seconds when missing. This is recipe time (the wiki shows it as the "Time" column), not power usage; energy consumption comes from the machine `energy_usage` field. +- Results with `probability` should be handled via expected value (or explicit stochastic modeling). `ignored_by_productivity` disables productivity scaling for that result. +- `surface_conditions` exist for 36 recipes and should be used to constrain availability by planet/surface. + +### 2) Machines (crafting entities) + +Main production buildings live under `assembling-machine` prototypes (also includes oil refinery, chemical plant, biochamber, etc.). + +Required fields: + +- `name`, `crafting_categories[]`, `crafting_speed` +- `energy_usage`, `energy_source.type` (electric/burner/heat/void) +- `module_slots`, `allowed_effects` +- `effect_receiver.base_effect` (for built-in productivity bonuses) +- `ingredient_count` (hard limit if present) +- `fixed_recipe` (if present, restricts to one recipe) + +Notes: + +- `allowed_effects` must include an effect before a module can apply it. +- `effect_receiver.base_effect.productivity` exists on `biochamber` (0.5 in this dump). +- Some machines are burner-powered (e.g., biochamber uses fuel category `nutrients`). If modeling fuel usage, read `energy_source.fuel_categories`. + +Throughput impact of machine speed: + +- Effective craft time per cycle: `cycle_seconds = (energy_required or 0.5) / effective_crafting_speed` +- Effective speed: `effective_crafting_speed = crafting_speed * (1 + speed_bonus)` +- Per-second rates: `rate = amount / cycle_seconds` +- Productivity applies only when both the recipe and the machine allow it (and to results not marked `ignored_by_productivity`). + +### 3) Technologies (unlock gating) + +From `technology` prototypes: + +- `effects[]` with `{type: "unlock-recipe", recipe: "..."}` +- `prerequisites` and `unit` or `research_trigger` + +Notes: + +- `oil-processing` in this dump is triggered by mining crude oil (`research_trigger`), not a science pack unit. +- Use tech effects to filter which recipes are available in a given tech state. + +### 4) Items and fluids + +Items (`item`) and fluids (`fluid`) are used to: + +- resolve ingredient/result identities +- display UI icons +- apply stack size / energy / fuel logic if needed + +Useful fields: + +- `item`: `stack_size`, `fuel_value`, `fuel_category`, `place_result`, `subgroup`, `order`, `icon`/`icons` +- `fluid`: `default_temperature`, `max_temperature`, `heat_capacity`, `base_color`, `flow_color`, `icon`/`icons` + +### 5) Modules + +From `module` prototypes: + +- `category`, `tier`, `effect` (speed/productivity/consumption/pollution/quality) +- optional `limitation` / `limitation_blacklist` (none in this dump) + +## UI data requirements + +### Icons + +Sources: + +- `icon` or `icons[]` (IconData) fields on `item`, `recipe`, `fluid`, `item-group`, `item-subgroup`, and some entities. + +Findings from this dump: + +- Many `item` and `fluid` entries have `icon` but **no `icon_size`**. +- A large portion of recipes use `icons[]` (layered icons). IconData entries also often lack `icon_size`. + +Recommendation: + +- When `icon_size` is missing, read the PNG image dimensions directly. +- Support layered icons: apply `tint`, `scale`, `shift` if present in IconData. + +Path resolution: + +- Icon paths use mod tokens, e.g., `__base__/graphics/icons/...` or `__space-age__/...`. +- Resolve to the Factorio install data directory: `data//...`. + +### Localization + +`localised_name` is `null` for many prototypes in this dump. + +Recommendation: + +- Load locale files from mods: `data//locale//...` to map internal names to display strings. +- Use internal names as fallback if locale resolution is missing. + +### Grouping and ordering + +Use `item-group` and `item-subgroup` prototypes: + +- `item-subgroup.group` → `item-group.name` +- `order` fields for sorted display + +Example: + +- Recipe subgroup `fluid-recipes` belongs to item group `intermediate-products`. + +## How to extract the data (examples) + +All extraction should be done against `data-raw-dump.json` without loading the full file into memory at once. + +Examples using jq (safe for large files): + +- Recipe fields: `jq '.recipe["advanced-oil-processing"]' data-raw-dump.json` +- Machine summary: `jq '."assembling-machine"["chemical-plant"] | {crafting_categories, crafting_speed, module_slots, allowed_effects}'` +- Category-to-machine mapping: filter `assembling-machine` by `crafting_categories`. +- Ingredient/result schema: aggregate unique keys across all recipes. + +If using Python: + +- Stream parse with `ijson` or incremental JSON readers. +- Extract only the sections needed (recipes, machines, items, fluids, technologies, modules). + +## Test case: advanced oil processing → cracking → petroleum gas + +This chain is sufficient to test production math and UI rendering. + +### Recipes (from this dump) + +1. `advanced-oil-processing` + - Category: `oil-processing` + - Time: 5 s + - Ingredients: water 50, crude-oil 100 + - Results: heavy-oil 25, light-oil 45, petroleum-gas 55 + - `allow_productivity`: true, `allow_quality`: false + +2. `heavy-oil-cracking` + - Category: `organic-or-chemistry` + - Time: 2 s + - Ingredients: water 30, heavy-oil 40 + - Results: light-oil 30 + - `allow_productivity`: true, `allow_quality`: false + +3. `light-oil-cracking` + - Category: `organic-or-chemistry` + - Time: 2 s + - Ingredients: water 30, light-oil 30 + - Results: petroleum-gas 20 + - `allow_productivity`: true, `allow_quality`: false + +### Machines for those categories + +- `oil-processing` → `oil-refinery` only +- `organic-or-chemistry` → `chemical-plant`, `biochamber` + +Machine summaries (from this dump): + +- `oil-refinery`: crafting_speed 1, module_slots 3, allowed_effects [consumption, speed, productivity, pollution] +- `chemical-plant`: crafting_speed 1, module_slots 3, allowed_effects [consumption, speed, productivity, pollution, quality] +- `biochamber`: crafting_speed 2, module_slots 4, allowed_effects [consumption, speed, productivity, pollution, quality], + base productivity via `effect_receiver.base_effect.productivity = 0.5`, fuel category `nutrients` + +### Tech gating + +From technology prototypes: + +- `oil-processing` unlocks: `oil-refinery`, `chemical-plant`, `basic-oil-processing`, `solid-fuel-from-petroleum-gas` +- `advanced-oil-processing` unlocks: `advanced-oil-processing`, `heavy-oil-cracking`, `light-oil-cracking`, + `solid-fuel-from-heavy-oil`, `solid-fuel-from-light-oil` + +### Feasibility for IP model + +All required data is present in the dump: + +- exact IO amounts +- craft times +- machine speeds +- category-to-machine mapping +- module effects and base productivity +- tech gating if needed +- UI icon paths for recipes/items/fluids + +This is sufficient to model the chain and verify that the calculator can: + +1. match a target petroleum-gas output, and +2. minimize raw inputs (crude oil + water) and machine counts. + +## Recommended data pipeline + +1. Extract and cache: + - `recipes`, `machines`, `items`, `fluids`, `technologies`, `modules`, `item-group`, `item-subgroup` +2. Build lookup indexes: + - recipe → ingredients/results + - recipe → category + - category → machines + - item/fluid → icon path + colors + - tech → unlocked recipes +3. For UI: + - icon path resolution to mod data directory + - locale string resolution from mod locale files +4. For the solver: + - convert each recipe/machine option into a linear production rate + - apply module/base effects where allowed + - treat probabilistic results as expected value unless a more complex model is desired + +## Known gaps and handling + +- Missing `icon_size` on most items/fluids/recipes: read PNG dimensions at runtime. +- Localization mostly absent in the dump: use locale files. +- Some recipes have `surface_conditions`: enforce planet constraints. +- Recipes with `energy_required == null`: use default 0.5 s. diff --git a/src/private/app/factorio-cycle-calculator/.AGENT/scripts/check_icons_and_locale.py b/src/private/app/factorio-cycle-calculator/.AGENT/scripts/check_icons_and_locale.py new file mode 100644 index 0000000..e8b65a5 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/.AGENT/scripts/check_icons_and_locale.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Check icon paths and localization entries for selected prototypes. + +This script intentionally avoids loading the full data-raw-dump.json into +memory. It uses jq to extract only the needed prototypes. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +DEFAULT_DATA_RAW = ( + "/mnt/c/Users/zhang/AppData/Roaming/Factorio/" + "script-output/data-raw-dump.json" +) + + +@dataclass(frozen=True) +class Target: + """Describe a prototype to verify.""" + + proto_type: str + name: str + locale_sections: tuple[str, ...] + + +@dataclass(frozen=True) +class CheckContext: + """Hold shared context for checks.""" + + data_raw: Path + data_dir: Path + locale_map: dict[str, dict[str, str]] + + +TARGETS = [ + Target("recipe", "advanced-oil-processing", ("recipe-name",)), + Target("recipe", "heavy-oil-cracking", ("recipe-name",)), + Target("recipe", "light-oil-cracking", ("recipe-name",)), + Target("fluid", "crude-oil", ("fluid-name",)), + Target("fluid", "heavy-oil", ("fluid-name",)), + Target("fluid", "light-oil", ("fluid-name",)), + Target("fluid", "petroleum-gas", ("fluid-name",)), + Target("item", "oil-refinery", ("item-name",)), + Target("item", "chemical-plant", ("item-name",)), + Target("item", "biochamber", ("item-name",)), + Target("assembling-machine", "oil-refinery", ("entity-name",)), + Target("assembling-machine", "chemical-plant", ("entity-name",)), + Target("assembling-machine", "biochamber", ("entity-name",)), + Target("item-group", "intermediate-products", ("item-group-name",)), + Target("item-subgroup", "fluid-recipes", ("item-subgroup-name",)), +] + +ICON_TOKEN_RE = re.compile(r"__([^/]+)__/(.+)") + + +def run_jq(data_raw: Path, proto_type: str, name: str) -> dict | None: + """Run jq to extract a minimal prototype payload.""" + jq = shutil.which("jq") + if not jq: + print("ERROR: jq not found in PATH.", file=sys.stderr) + return None + + jq_filter = ".[ $t ][ $n ] | {name, type, icon, icon_size, icons}" + cmd = [ + jq, + "-c", + "--arg", + "t", + proto_type, + "--arg", + "n", + name, + jq_filter, + str(data_raw), + ] + try: + result = subprocess.run( # noqa: S603 + cmd, check=True, capture_output=True, text=True + ) + except subprocess.CalledProcessError as exc: + print( + f"ERROR: jq failed for {proto_type}/{name}: {exc.stderr}", + file=sys.stderr, + ) + return None + + output = result.stdout.strip() + if not output or output == "null": + return None + + try: + return json.loads(output) + except json.JSONDecodeError: + print( + f"ERROR: failed to parse jq output for {proto_type}/{name}", + file=sys.stderr, + ) + return None + + +def resolve_icon_path(icon_path: str, data_dir: Path) -> Path: + """Resolve Factorio icon paths with mod token expansion.""" + match = ICON_TOKEN_RE.match(icon_path) + if match: + mod_name = match.group(1) + rel_path = match.group(2) + return data_dir / mod_name / rel_path + return data_dir / icon_path.lstrip("/") + + +def read_png_size(path: Path) -> tuple[int, int] | None: + """Read PNG dimensions from the file header.""" + if path.suffix.lower() != ".png": + return None + try: + with path.open("rb") as handle: + signature = handle.read(8) + if signature != b"\x89PNG\r\n\x1a\n": + return None + _length = int.from_bytes(handle.read(4), "big") + chunk_type = handle.read(4) + if chunk_type != b"IHDR": + return None + width = int.from_bytes(handle.read(4), "big") + height = int.from_bytes(handle.read(4), "big") + return width, height + except OSError: + return None + + +def parse_locale_file(path: Path) -> dict[str, dict[str, str]]: + """Parse a locale .cfg file into a nested section map.""" + data: dict[str, dict[str, str]] = {} + section: str | None = None + try: + for raw_line in path.read_text( + encoding="utf-8", errors="ignore" + ).splitlines(): + line = raw_line.strip() + if not line or line.startswith((";", "#")): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + data.setdefault(section, {}) + continue + if section and "=" in line: + key, value = line.split("=", 1) + data[section][key.strip()] = value.strip() + except OSError: + return {} + return data + + +def load_locale(data_dir: Path, language: str) -> dict[str, dict[str, str]]: + """Load locale entries from all mods for the requested language.""" + merged: dict[str, dict[str, str]] = {} + if not data_dir.exists(): + return merged + + for mod_dir in sorted(data_dir.iterdir()): + if not mod_dir.is_dir(): + continue + locale_dir = mod_dir / "locale" / language + if not locale_dir.is_dir(): + continue + for cfg in sorted(locale_dir.glob("*.cfg")): + parsed = parse_locale_file(cfg) + for section, entries in parsed.items(): + merged.setdefault(section, {}) + merged[section].update(entries) + return merged + + +def extract_icon_paths(payload: dict) -> list[str]: + """Collect icon paths from icon and icons fields.""" + icons: list[str] = [] + if payload.get("icon"): + icons.append(payload["icon"]) + if payload.get("icons"): + for entry in payload["icons"]: + if isinstance(entry, dict) and entry.get("icon"): + icons.append(entry["icon"]) + return icons + + +def find_locale( + locale_map: dict[str, dict[str, str]], + sections: Iterable[str], + key: str, +) -> tuple[str, str] | None: + """Find a localized name in the first matching section.""" + for section in sections: + value = locale_map.get(section, {}).get(key) + if value: + return section, value + return None + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description=( + "Check icon paths and locale entries for selected prototypes." + ) + ) + parser.add_argument( + "--data-raw", + default=DEFAULT_DATA_RAW, + help="Path to data-raw-dump.json", + ) + parser.add_argument( + "--data-dir", + default=os.environ.get("FACTORIO_DATA_DIR"), + help="Factorio data directory (contains base/, space-age/, etc.)", + ) + parser.add_argument( + "--locale", default="en", help="Locale to load (default: en)" + ) + return parser.parse_args() + + +def ensure_data_raw(data_raw: Path) -> Path | None: + """Validate the data-raw-dump.json path.""" + if not data_raw.exists(): + print( + f"ERROR: data-raw-dump.json not found: {data_raw}", + file=sys.stderr, + ) + return None + return data_raw + + +def ensure_data_dir(data_dir: str | None) -> Path | None: + """Validate the Factorio data directory path.""" + if not data_dir: + print( + "ERROR: --data-dir is required (or set FACTORIO_DATA_DIR).", + file=sys.stderr, + ) + return None + resolved = Path(data_dir) + if not resolved.exists(): + print(f"ERROR: data directory not found: {resolved}", file=sys.stderr) + return None + return resolved + + +def check_target(context: CheckContext, target: Target) -> tuple[int, int]: + """Check icon and locale entries for a single prototype.""" + payload = run_jq(context.data_raw, target.proto_type, target.name) + if not payload: + print( + f"SKIP: {target.proto_type}/{target.name} " + "not found in data-raw-dump.json" + ) + return 0, 0 + + icon_failures = 0 + locale_failures = 0 + + icon_paths = extract_icon_paths(payload) + if not icon_paths: + print(f"ICON: {target.proto_type}/{target.name}: NO ICON") + else: + for icon in icon_paths: + resolved = resolve_icon_path(icon, context.data_dir) + if resolved.exists(): + size = read_png_size(resolved) + size_text = f" ({size[0]}x{size[1]})" if size else "" + print( + f"ICON: {target.proto_type}/{target.name}: OK " + f"{resolved}{size_text}" + ) + else: + icon_failures += 1 + print( + f"ICON: {target.proto_type}/{target.name}: " + f"MISSING {resolved}" + ) + + locale_hit = find_locale( + context.locale_map, target.locale_sections, target.name + ) + if locale_hit: + section, value = locale_hit + print(f"LOCALE: {section}/{target.name}: OK {value}") + else: + locale_failures += 1 + sections = ",".join(target.locale_sections) + print(f"LOCALE: {sections}/{target.name}: MISSING") + + return icon_failures, locale_failures + + +def summarize_failures(icon_failures: int, locale_failures: int) -> int: + """Print the summary line and return an exit code.""" + if icon_failures or locale_failures: + print( + "DONE: icon failures=" + f"{icon_failures}, locale failures={locale_failures}" + ) + return 1 + print("DONE: all icons and locales resolved") + return 0 + + +def main() -> int: + """Run the icon and locale verification workflow.""" + args = parse_args() + data_raw = ensure_data_raw(Path(args.data_raw)) + if not data_raw: + return 2 + + data_dir = ensure_data_dir(args.data_dir) + if not data_dir: + return 2 + + locale_map = load_locale(data_dir, args.locale) + if not locale_map: + print(f"WARNING: no locale data found for language: {args.locale}") + + context = CheckContext( + data_raw=data_raw, + data_dir=data_dir, + locale_map=locale_map, + ) + icon_failures = 0 + locale_failures = 0 + + for target in TARGETS: + icon_delta, locale_delta = check_target(context, target) + icon_failures += icon_delta + locale_failures += locale_delta + + return summarize_failures(icon_failures, locale_failures) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/private/app/factorio-cycle-calculator/README.md b/src/private/app/factorio-cycle-calculator/README.md new file mode 100644 index 0000000..ff336f9 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/README.md @@ -0,0 +1,15 @@ +# Factorio Cycle Calculator + +_Factorio Cycle Calculator_ is a steady-state calculator for production chains that don’t form a simple tree—especially **recycling loops** and **multi-output bottlenecks**. + +In _Factorio: Space Age_, the _Recycler_ can convert most items back into the ingredients of their crafting recipe, but it is lossy (you lose 75% of the ingredients on average). That means “craft → recycle → craft” creates real feedback loops that a normal “expand the recipe tree” calculator can’t balance cleanly. + +Cycles become even more important on _Fulgora_, where scrap is a primary resource and recycling it yields a probabilistic mix of many different items. The scrap recycling outputs add up to 60% on average, so a full belt of scrap becomes ~60% belt of products, and many of those products are then recycled again to reach basic ingredients. + +The base game also has classic “cycle-like” balancing problems, such as advanced oil processing producing multiple fluids (heavy/light/petroleum) where production can stall if any output backs up, and cracking is used to keep the system flowing. + +This project models your factory as a flow network and solves the balance equations for a stable throughput, so you can reason about: + +1. recycling loops (lossy reverse crafting via _Recycler_) +2. scrap recycling chains and downstream recycling decisions on _Fulgora_ +3. multi-output systems like oil processing that can deadlock when outputs fill From ace8b3aed48fa431a3fe0cd987e38f54fcfb2c5d Mon Sep 17 00:00:00 2001 From: Shuai Zhang Date: Wed, 11 Feb 2026 20:34:07 -0800 Subject: [PATCH 2/5] feat(factorio-cycle-calculator): Introduce Example App --- pyproject.toml | 2 + .../app/factorio-cycle-calculator/README.md | 10 + .../app/factorio-cycle-calculator/app.py | 423 ++++++++++++++++++ .../factorio-cycle-calculator/pyproject.toml | 10 + uv.lock | 80 +++- 5 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 src/private/app/factorio-cycle-calculator/app.py create mode 100644 src/private/app/factorio-cycle-calculator/pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index 7481815..b498b66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dev = ["pyrefly>=0.25.0", "pytest>=8.3.4", "ruff>=0.11.8"] [tool.uv.workspace] members = [ "src/lab/azure-document-intelligence-lab", + "src/private/app/factorio-cycle-calculator", "src/private/app/git-commit-heatmap", "src/private/app/html-sm-processor", "src/private/app/llm-text-splitter", @@ -33,6 +34,7 @@ nbgv-python = { workspace = true } [tool.pyrefly] project-includes = [ "src/lab/azure-document-intelligence-lab", + "src/private/app/factorio-cycle-calculator", "src/private/app/git-commit-heatmap", "src/private/app/html-sm-processor", "src/private/app/llm-text-splitter", diff --git a/src/private/app/factorio-cycle-calculator/README.md b/src/private/app/factorio-cycle-calculator/README.md index ff336f9..99b1bef 100644 --- a/src/private/app/factorio-cycle-calculator/README.md +++ b/src/private/app/factorio-cycle-calculator/README.md @@ -13,3 +13,13 @@ This project models your factory as a flow network and solves the balance equati 1. recycling loops (lossy reverse crafting via _Recycler_) 2. scrap recycling chains and downstream recycling decisions on _Fulgora_ 3. multi-output systems like oil processing that can deadlock when outputs fill + +## Example app (Streamlit) + +An initial Streamlit prototype is available in `app.py`. It focuses on the +advanced oil processing chain and uses Google OR-Tools to compute machine +counts given a petroleum gas demand and the chosen machine/effect settings. + +Run the app with: + +- `uv run --project src/private/app/factorio-cycle-calculator streamlit run app.py` diff --git a/src/private/app/factorio-cycle-calculator/app.py b/src/private/app/factorio-cycle-calculator/app.py new file mode 100644 index 0000000..5c3ef54 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/app.py @@ -0,0 +1,423 @@ +"""Streamlit example for the Factorio oil-processing chain.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import streamlit as st +from ortools.linear_solver import pywraplp + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass(frozen=True) +class Machine: + """Describe a crafting machine and its capabilities.""" + + key: str + label: str + crafting_speed: float + allow_productivity: bool + + +@dataclass(frozen=True) +class Recipe: + """Describe a recipe and its inputs/outputs.""" + + key: str + label: str + energy_required: float + ingredients: Mapping[str, float] + results: Mapping[str, float] + allow_productivity: bool + + +@dataclass(frozen=True) +class EffectSettings: + """Hold module and beacon bonuses.""" + + speed_bonus: float + productivity_bonus: float + beacon_speed_bonus: float + + +@dataclass(frozen=True) +class RecipeConfig: + """Store a recipe's chosen machine and effects.""" + + machine: Machine + effects: EffectSettings + + +@dataclass(frozen=True) +class FlowRates: + """Store per-second production and consumption rates.""" + + production: dict[str, float] + consumption: dict[str, float] + + +@dataclass(frozen=True) +class SolveResult: + """Capture the solver output for display.""" + + status: str + machine_counts: dict[str, float] + net_flows_per_s: dict[str, float] + objective_value: float | None + + +def build_machines() -> dict[str, Machine]: + """Create the machine catalog for the example.""" + return { + "oil-refinery": Machine( + key="oil-refinery", + label="Oil refinery", + crafting_speed=1.0, + allow_productivity=True, + ), + "chemical-plant": Machine( + key="chemical-plant", + label="Chemical plant", + crafting_speed=1.0, + allow_productivity=True, + ), + "biochamber": Machine( + key="biochamber", + label="Biochamber", + crafting_speed=2.0, + allow_productivity=True, + ), + } + + +def build_recipes() -> dict[str, Recipe]: + """Create the oil-processing recipes used in the example.""" + return { + "advanced-oil-processing": Recipe( + key="advanced-oil-processing", + label="Advanced oil processing", + energy_required=5.0, + ingredients={"crude-oil": 100.0, "water": 50.0}, + results={ + "heavy-oil": 25.0, + "light-oil": 45.0, + "petroleum-gas": 55.0, + }, + allow_productivity=True, + ), + "heavy-oil-cracking": Recipe( + key="heavy-oil-cracking", + label="Heavy oil cracking", + energy_required=2.0, + ingredients={"heavy-oil": 40.0, "water": 30.0}, + results={"light-oil": 30.0}, + allow_productivity=True, + ), + "light-oil-cracking": Recipe( + key="light-oil-cracking", + label="Light oil cracking", + energy_required=2.0, + ingredients={"light-oil": 30.0, "water": 30.0}, + results={"petroleum-gas": 20.0}, + allow_productivity=True, + ), + } + + +def machine_label(machine: Machine) -> str: + """Format machine labels for UI controls.""" + return f"{machine.label} (speed {machine.crafting_speed:g})" + + +def compute_effective_speed(machine: Machine, effects: EffectSettings) -> float: + """Compute the effective crafting speed after bonuses.""" + bonus = effects.speed_bonus + effects.beacon_speed_bonus + return machine.crafting_speed * (1.0 + bonus) + + +def per_machine_rates(recipe: Recipe, config: RecipeConfig) -> FlowRates: + """Compute per-second production and consumption for one machine.""" + effective_speed = compute_effective_speed(config.machine, config.effects) + cycle_seconds = recipe.energy_required / effective_speed + productivity = 0.0 + if recipe.allow_productivity and config.machine.allow_productivity: + productivity = config.effects.productivity_bonus + multiplier = 1.0 + productivity + + production = { + key: amount * multiplier / cycle_seconds + for key, amount in recipe.results.items() + } + consumption = { + key: amount / cycle_seconds + for key, amount in recipe.ingredients.items() + } + return FlowRates(production=production, consumption=consumption) + + +def build_solver(*, force_integer: bool) -> pywraplp.Solver | None: + """Create the OR-Tools solver instance.""" + solver_name = "CBC_MIXED_INTEGER_PROGRAMMING" if force_integer else "GLOP" + return pywraplp.Solver.CreateSolver(solver_name) + + +def solve_chain( + demand_pg_per_s: float, + recipes: Mapping[str, Recipe], + configs: Mapping[str, RecipeConfig], + *, + force_integer: bool, +) -> SolveResult | None: + """Solve the oil-processing chain to meet petroleum gas demand.""" + solver = build_solver(force_integer=force_integer) + if solver is None: + st.error("OR-Tools solver is not available in this environment.") + return None + + for recipe_key, recipe in recipes.items(): + config = configs[recipe_key] + effective_speed = compute_effective_speed( + config.machine, config.effects + ) + if effective_speed <= 0.0: + st.error( + "Effective crafting speed must be positive. " + "Check module and beacon bonuses." + ) + return None + if recipe.energy_required <= 0.0: + st.error("Recipe energy_required must be positive.") + return None + + rates = { + recipe_key: per_machine_rates(recipe, configs[recipe_key]) + for recipe_key, recipe in recipes.items() + } + + variables: dict[str, pywraplp.Variable] = {} + for recipe_key in recipes: + if force_integer: + variables[recipe_key] = solver.IntVar( + 0.0, solver.infinity(), recipe_key + ) + else: + variables[recipe_key] = solver.NumVar( + 0.0, solver.infinity(), recipe_key + ) + + advanced_key = "advanced-oil-processing" + heavy_key = "heavy-oil-cracking" + light_key = "light-oil-cracking" + + heavy_prod = rates[advanced_key].production.get("heavy-oil", 0.0) + heavy_cons = rates[heavy_key].consumption.get("heavy-oil", 0.0) + solver.Add( + heavy_prod * variables[advanced_key] # type: ignore[operator] + == heavy_cons * variables[heavy_key] # type: ignore[operator] + ) + + light_prod = rates[advanced_key].production.get("light-oil", 0.0) + rates[ + heavy_key + ].production.get("light-oil", 0.0) + light_cons = rates[light_key].consumption.get("light-oil", 0.0) + solver.Add( + light_prod * variables[advanced_key] # type: ignore[operator] + == light_cons * variables[light_key] # type: ignore[operator] + ) + + pg_prod = rates[advanced_key].production.get("petroleum-gas", 0.0) + rates[ + light_key + ].production.get("petroleum-gas", 0.0) + solver.Add(pg_prod * variables[advanced_key] >= demand_pg_per_s) # type: ignore[operator] + + solver.Minimize(sum(variables.values())) # type: ignore[operator] + status_code = solver.Solve() + + status_map = { + pywraplp.Solver.OPTIMAL: "Optimal", + pywraplp.Solver.FEASIBLE: "Feasible", + pywraplp.Solver.INFEASIBLE: "Infeasible", + pywraplp.Solver.UNBOUNDED: "Unbounded", + pywraplp.Solver.ABNORMAL: "Abnormal", + pywraplp.Solver.NOT_SOLVED: "Not solved", + } + status = status_map.get(status_code, "Unknown") + + machine_counts = { + recipe_key: variables[recipe_key].solution_value() + for recipe_key in recipes + } + net_flows = { + "heavy-oil": heavy_prod * machine_counts[advanced_key] + - heavy_cons * machine_counts[heavy_key], + "light-oil": light_prod * machine_counts[advanced_key] + - light_cons * machine_counts[light_key], + "petroleum-gas": pg_prod * machine_counts[advanced_key], + } + + objective_value: float | None + if status_code in {pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE}: + objective_value = solver.Objective().Value() + else: + objective_value = None + + return SolveResult( + status=status, + machine_counts=machine_counts, + net_flows_per_s=net_flows, + objective_value=objective_value, + ) + + +def render_effect_controls(recipe: Recipe) -> EffectSettings: + """Render module and beacon controls for a recipe.""" + use_modules = st.checkbox( + "Use modules", + key=f"{recipe.key}-modules", + help="Provide total speed/productivity bonuses from modules.", + ) + speed_bonus = 0.0 + productivity_bonus = 0.0 + if use_modules: + speed_bonus = st.number_input( + "Module speed bonus (%)", + min_value=0.0, + value=0.0, + step=5.0, + key=f"{recipe.key}-module-speed", + ) + productivity_bonus = st.number_input( + "Module productivity bonus (%)", + min_value=0.0, + value=0.0, + step=1.0, + key=f"{recipe.key}-module-prod", + ) + + use_beacons = st.checkbox( + "Use beacons", + key=f"{recipe.key}-beacons", + help="Provide a total speed bonus from beacons.", + ) + beacon_speed_bonus = 0.0 + if use_beacons: + beacon_speed_bonus = st.number_input( + "Beacon speed bonus (%)", + min_value=0.0, + value=0.0, + step=5.0, + key=f"{recipe.key}-beacon-speed", + ) + + return EffectSettings( + speed_bonus=speed_bonus / 100.0, + productivity_bonus=productivity_bonus / 100.0, + beacon_speed_bonus=beacon_speed_bonus / 100.0, + ) + + +def render_recipe_config( + recipe: Recipe, + machines: Mapping[str, Machine], + machine_keys: list[str], +) -> RecipeConfig: + """Render the machine and effect selection for one recipe.""" + options = [machines[key] for key in machine_keys] + machine = st.selectbox( + "Machine", + options=options, + format_func=machine_label, + key=f"{recipe.key}-machine", + ) + effects = render_effect_controls(recipe) + return RecipeConfig(machine=machine, effects=effects) + + +def main() -> None: + """Run the Streamlit UI for the oil-processing example.""" + st.set_page_config(page_title="Factorio Cycle Calculator", layout="wide") + st.title("Factorio Cycle Calculator") + st.markdown( + "This example models the advanced oil processing chain using " + "Google OR-Tools. Choose machines and bonuses, then solve for the " + "machine counts that satisfy a petroleum gas demand." + ) + + machines = build_machines() + recipes = build_recipes() + + st.sidebar.header("Demand") + demand_pg_per_min = st.sidebar.number_input( + "Petroleum gas target (per minute)", + min_value=0.0, + value=900.0, + step=30.0, + ) + force_integer = st.sidebar.checkbox( + "Force integer machine counts", + value=False, + ) + + st.subheader("Recipe configuration") + + config_map: dict[str, RecipeConfig] = {} + config_map["advanced-oil-processing"] = render_recipe_config( + recipes["advanced-oil-processing"], + machines, + ["oil-refinery"], + ) + config_map["heavy-oil-cracking"] = render_recipe_config( + recipes["heavy-oil-cracking"], + machines, + ["chemical-plant", "biochamber"], + ) + config_map["light-oil-cracking"] = render_recipe_config( + recipes["light-oil-cracking"], + machines, + ["chemical-plant", "biochamber"], + ) + + demand_pg_per_s = demand_pg_per_min / 60.0 + result = solve_chain( + demand_pg_per_s, + recipes, + config_map, + force_integer=force_integer, + ) + + if result is None: + return + + st.subheader("Solution") + st.write(f"Solver status: **{result.status}**") + if result.objective_value is not None: + st.write(f"Objective (total machines): {result.objective_value:.3f}") + + table_rows = [] + for recipe_key, recipe in recipes.items(): + count = result.machine_counts.get(recipe_key, 0.0) + table_rows.append( + { + "Recipe": recipe.label, + "Machine": config_map[recipe_key].machine.label, + "Count": round(count, 4), + } + ) + st.table(table_rows) + + flow_rows = [] + for fluid, rate in result.net_flows_per_s.items(): + flow_rows.append( + { + "Fluid": fluid, + "Net rate (per s)": round(rate, 4), + "Net rate (per min)": round(rate * 60.0, 2), + } + ) + st.table(flow_rows) + + +main() diff --git a/src/private/app/factorio-cycle-calculator/pyproject.toml b/src/private/app/factorio-cycle-calculator/pyproject.toml new file mode 100644 index 0000000..8cba3a5 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "factorio-cycle-calculator" +version = "0.0.0-dev" +description = "Web calculator for Factorio production cycles." +readme = "README.md" +authors = [{ name = "Shuai Zhang", email = "zhangshuai.ustc@gmail.com" }] +requires-python = ">=3.12" +license = "GPL-3.0-or-later" +classifiers = ["Private :: Do Not Upload"] +dependencies = ["ortools>=9.11.4210", "streamlit>=1.42.0"] diff --git a/uv.lock b/uv.lock index c0b2c0a..56eca6f 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,7 @@ requires-python = ">=3.13" [manifest] members = [ "azure-document-intelligence-lab", + "factorio-cycle-calculator", "git-commit-heatmap", "hcoona-one-python", "html-sm-processor", @@ -17,6 +18,15 @@ members = [ "transcribe", ] +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -687,6 +697,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, ] +[[package]] +name = "factorio-cycle-calculator" +version = "0.0.0.dev0" +source = { virtual = "src/private/app/factorio-cycle-calculator" } +dependencies = [ + { name = "ortools" }, + { name = "streamlit" }, +] + +[package.metadata] +requires-dist = [ + { name = "ortools", specifier = ">=9.11.4210" }, + { name = "streamlit", specifier = ">=1.42.0" }, +] + [[package]] name = "fastavro" version = "1.12.1" @@ -1063,6 +1088,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "immutabledict" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/d5/99764e9a1fa555d0c0be0ea03ed42f32a6948ee00fd135b436e7ea35153c/immutabledict-4.3.0.tar.gz", hash = "sha256:9a6ea13c6baacebdcecb3aed3f1fb571bc3d600777d87518f1d176cc8b207e9c", size = 6863, upload-time = "2026-02-10T18:15:01.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/17/6c49e68e6b1824ba03483fd1adcdebad7f8a261bb332ae8d4f0aae9d448a/immutabledict-4.3.0-py3-none-any.whl", hash = "sha256:998d43d1dbc3a0e733ce4135c1c948aa7866c256761d3fa63d8627366502ca00", size = 4923, upload-time = "2026-02-10T18:15:00.378Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1933,6 +1967,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[package]] +name = "ortools" +version = "9.15.6755" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "immutabledict" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/53/e21c54ff10002cc2e2b9748012ffc324ec32ea4acdcc85e190a920ab2766/ortools-9.15.6755-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:27a10474e62c9dceed37cfa0e4845c5ffaf792138ebf5b61483771b96f1290b6", size = 23927705, upload-time = "2026-01-14T15:39:07.29Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e6/f7019048ffdf41f8a1bff6815b2203cf7b9117ba9e26bf46c4585421d1c4/ortools-9.15.6755-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:076565b803c85c4f87863e0616f537dd37f99c03e6f092e4068404f7b425d2b0", size = 21914246, upload-time = "2026-01-14T15:39:10.584Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ad/aaacd340918b03e22c42f6ae4a9c72aac09810b4b398e99a7eeee58d9c42/ortools-9.15.6755-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b85bd20259b146abce5e0721ce1bfd8fd273efc904216aa3be178c31b6d34057", size = 27646600, upload-time = "2026-01-14T15:38:04.79Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/28d5efb832190b6edfccc5a703e88e64779c1eda34a42ea96d03307236c0/ortools-9.15.6755-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebd5aea00374e3aad7a78de59058aca5e871a26a3c385cd0860ef1d685d03c9a", size = 29838741, upload-time = "2026-01-14T15:38:07.945Z" }, + { url = "https://files.pythonhosted.org/packages/be/22/ab894b6f846b4b1a89795c1ba966834e56cac394c4cf2b72433909739982/ortools-9.15.6755-cp313-cp313-win_amd64.whl", hash = "sha256:caac1d48b967adb877da2abcaf82c28f0f908a7cc208a6a1bbe01bc69590816c", size = 23908100, upload-time = "2026-01-14T15:39:48.398Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/ada4146ae491d7798c6eb045d93135158c0b66030853c7cd9607768dda59/ortools-9.15.6755-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82b4a8e6e4f9380b453ab5fa4382ea7ee91e628f9b8be89d9ad760b33fca3323", size = 27681510, upload-time = "2026-01-14T15:38:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/e6/239e96912fc8c4e0e917e72ec413983bc042cd9a0b20c3c6a7e43fc3002b/ortools-9.15.6755-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d1f2fb2088e8953ccb902e68ffd06032cce0c7dcf7268b6135f3b6c553ca52b", size = 29850935, upload-time = "2026-01-14T15:38:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/53/ef/53a172ad12cf0d762b9a5af681b1f13f1b4105b38bf65c2b383d530ed97f/ortools-9.15.6755-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:acdf06a167933307608e7eba23a9490255933504df44c8de5f62c48656c29688", size = 23916963, upload-time = "2026-01-14T15:39:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/54/ed73ec00369fb6d6c71049d62e4b7c87c918b61f86ddd55a11c20ada395e/ortools-9.15.6755-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a0677270b0cd317a6b8dae42514264eaf5da5756c5bc7215eeea409424577df", size = 21923649, upload-time = "2026-01-14T15:39:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e0/ac57dd43eaadd73748bb542b30912e16c7dbf3a75f393f69efb8a1a2f032/ortools-9.15.6755-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:899b92afe3f775ab5867b9a8aa2850f81f2d95232db9b4ceec3456d69e6b8528", size = 27657273, upload-time = "2026-01-14T15:38:18.375Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e0/11144feb4ddadc491dc9d833d3a2080e6556245f912bebe2c0c7e174f2a1/ortools-9.15.6755-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7181183cdcafe2b0d83ca5505b65048c7953dc7b5ad479361dded607964cc1b3", size = 29843939, upload-time = "2026-01-14T15:38:21.457Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/771515ba3a05da3903b7da55a190d9f88f36a08c4bf848852e0ea4e3a731/ortools-9.15.6755-cp314-cp314-win_amd64.whl", hash = "sha256:afabb869e5fabeb704bd8147b22bf8139dee042e55fabd0d447a996428009e0c", size = 24673633, upload-time = "2026-01-14T15:39:51.212Z" }, + { url = "https://files.pythonhosted.org/packages/46/99/0932d6d7d6ad326adf68f4ce9063ef07db7e9859859dddbcd200102aedff/ortools-9.15.6755-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9d07cddca201e25e2e219006a9d6cda10c7e9ee2c712c50d19d508f9ed8a888", size = 27682088, upload-time = "2026-01-14T15:38:25.174Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4d/bd75961e2c82db69bb41dd2c4a82131ca580e997485be2d5f59f8d26f31e/ortools-9.15.6755-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:990838ad66a052e72a50e69da500878710e3420e91717fe88bf3071995caba9e", size = 29851493, upload-time = "2026-01-14T15:38:28.168Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -2155,16 +2218,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.30.2" +version = "6.33.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/8c/cf2ac658216eebe49eaedf1e06bc06cbf6a143469236294a1171a51357c3/protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048", size = 429315, upload-time = "2025-03-26T19:12:57.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/85/cd53abe6a6cbf2e0029243d6ae5fb4335da2996f6c177bb2ce685068e43d/protobuf-6.30.2-cp310-abi3-win32.whl", hash = "sha256:b12ef7df7b9329886e66404bef5e9ce6a26b54069d7f7436a0853ccdeb91c103", size = 419148, upload-time = "2025-03-26T19:12:41.359Z" }, - { url = "https://files.pythonhosted.org/packages/97/e9/7b9f1b259d509aef2b833c29a1f3c39185e2bf21c9c1be1cd11c22cb2149/protobuf-6.30.2-cp310-abi3-win_amd64.whl", hash = "sha256:7653c99774f73fe6b9301b87da52af0e69783a2e371e8b599b3e9cb4da4b12b9", size = 431003, upload-time = "2025-03-26T19:12:44.156Z" }, - { url = "https://files.pythonhosted.org/packages/8e/66/7f3b121f59097c93267e7f497f10e52ced7161b38295137a12a266b6c149/protobuf-6.30.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:0eb523c550a66a09a0c20f86dd554afbf4d32b02af34ae53d93268c1f73bc65b", size = 417579, upload-time = "2025-03-26T19:12:45.447Z" }, - { url = "https://files.pythonhosted.org/packages/d0/89/bbb1bff09600e662ad5b384420ad92de61cab2ed0f12ace1fd081fd4c295/protobuf-6.30.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:50f32cc9fd9cb09c783ebc275611b4f19dfdfb68d1ee55d2f0c7fa040df96815", size = 317319, upload-time = "2025-03-26T19:12:46.999Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/1925de813499546bc8ab3ae857e3ec84efe7d2f19b34529d0c7c3d02d11d/protobuf-6.30.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4f6c687ae8efae6cf6093389a596548214467778146b7245e886f35e1485315d", size = 316212, upload-time = "2025-03-26T19:12:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a1/93c2acf4ade3c5b557d02d500b06798f4ed2c176fa03e3c34973ca92df7f/protobuf-6.30.2-py3-none-any.whl", hash = "sha256:ae86b030e69a98e08c77beab574cbcb9fff6d031d57209f574a5aea1445f4b51", size = 167062, upload-time = "2025-03-26T19:12:55.892Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] [[package]] From ded3a1af25ea9c6612c0f6bdf7f1a20ac0a298b1 Mon Sep 17 00:00:00 2001 From: Shuai Zhang Date: Wed, 11 Feb 2026 22:34:04 -0800 Subject: [PATCH 3/5] feat(factorio-cycle-calculator): Refine Example App to Display Icons --- .../app/factorio-cycle-calculator/app.py | 822 ++++++++++++++++-- .../factorio-cycle-calculator/pyproject.toml | 2 +- uv.lock | 2 + 3 files changed, 747 insertions(+), 79 deletions(-) diff --git a/src/private/app/factorio-cycle-calculator/app.py b/src/private/app/factorio-cycle-calculator/app.py index 5c3ef54..de2b06e 100644 --- a/src/private/app/factorio-cycle-calculator/app.py +++ b/src/private/app/factorio-cycle-calculator/app.py @@ -2,15 +2,38 @@ from __future__ import annotations +import io +import json +import os +import re +import shutil +import subprocess from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING import streamlit as st from ortools.linear_solver import pywraplp +from PIL import Image if TYPE_CHECKING: from collections.abc import Mapping +DEFAULT_DATA_DIR = ( + "/mnt/c/Program Files (x86)/Steam/steamapps/common/Factorio/data" +) +DEFAULT_DATA_RAW = ( + "/mnt/c/Users/zhang/AppData/Roaming/Factorio/" + "script-output/data-raw-dump.json" +) + +ICON_TOKEN_RE = re.compile(r"__([^/]+)__/(.+)") + +FORMAT_MILLION = 1_000_000.0 +FORMAT_THOUSAND = 1_000.0 +FORMAT_TEN = 10.0 +FLOW_EPSILON = 1e-6 + @dataclass(frozen=True) class Machine: @@ -69,6 +92,652 @@ class SolveResult: objective_value: float | None +@dataclass(frozen=True) +class IconTarget: + """Describe an icon to load for the UI.""" + + proto_type: str + name: str + fallback: str | None + + +@dataclass(frozen=True) +class IconSpec: + """Hold a resolved icon path and its intended size.""" + + path: Path + size: int | None + + +@dataclass(frozen=True) +class RenderState: + """Bundle UI state for rendering outputs.""" + + recipes: Mapping[str, Recipe] + config_map: Mapping[str, RecipeConfig] + row_slots: Mapping[str, tuple] + count_slots: Mapping[str, object] + products_slot: object + byproducts_slot: object + ingredients_slot: object + status_slot: object + unit_multiplier: float + unit_label: str + icon_catalog: Mapping[tuple[str, str], IconSpec] + + +ICON_TARGETS = [ + IconTarget( + "recipe", + "advanced-oil-processing", + "__base__/graphics/icons/fluid/advanced-oil-processing.png", + ), + IconTarget( + "recipe", + "heavy-oil-cracking", + "__base__/graphics/icons/fluid/heavy-oil-cracking.png", + ), + IconTarget( + "recipe", + "light-oil-cracking", + "__base__/graphics/icons/fluid/light-oil-cracking.png", + ), + IconTarget( + "fluid", + "crude-oil", + "__base__/graphics/icons/fluid/crude-oil.png", + ), + IconTarget( + "fluid", + "heavy-oil", + "__base__/graphics/icons/fluid/heavy-oil.png", + ), + IconTarget( + "fluid", + "light-oil", + "__base__/graphics/icons/fluid/light-oil.png", + ), + IconTarget( + "fluid", + "water", + "__base__/graphics/icons/fluid/water.png", + ), + IconTarget( + "fluid", + "petroleum-gas", + "__base__/graphics/icons/fluid/petroleum-gas.png", + ), + IconTarget( + "item", + "oil-refinery", + "__base__/graphics/icons/oil-refinery.png", + ), + IconTarget( + "item", + "chemical-plant", + "__base__/graphics/icons/chemical-plant.png", + ), + IconTarget( + "item", + "biochamber", + "__space-age__/graphics/icons/biochamber.png", + ), + IconTarget( + "assembling-machine", + "oil-refinery", + "__base__/graphics/icons/oil-refinery.png", + ), + IconTarget( + "assembling-machine", + "chemical-plant", + "__base__/graphics/icons/chemical-plant.png", + ), + IconTarget( + "assembling-machine", + "biochamber", + "__space-age__/graphics/icons/biochamber.png", + ), +] + +PRIMARY_OUTPUTS = { + "advanced-oil-processing": "petroleum-gas", + "heavy-oil-cracking": "light-oil", + "light-oil-cracking": "petroleum-gas", +} + +RECIPE_ORDER = ( + "advanced-oil-processing", + "heavy-oil-cracking", + "light-oil-cracking", +) + + +def resolve_icon_path(icon_path: str, data_dir: Path) -> Path: + """Resolve a Factorio icon path against the data directory.""" + match = ICON_TOKEN_RE.match(icon_path) + if match: + mod_name = match.group(1) + rel_path = match.group(2) + return data_dir / mod_name / rel_path + return data_dir / icon_path.lstrip("/") + + +def extract_icon_from_payload(payload: dict) -> tuple[str | None, int | None]: + """Extract a single icon path and size from a prototype payload.""" + icon_path = payload.get("icon") + icon_size = payload.get("icon_size") + if isinstance(icon_size, float): + icon_size = int(icon_size) + if not isinstance(icon_size, int): + icon_size = None + if isinstance(icon_path, str): + return icon_path, icon_size + icons = payload.get("icons") + if isinstance(icons, list): + for entry in icons: + if isinstance(entry, dict) and entry.get("icon"): + entry_size = entry.get("icon_size") + if isinstance(entry_size, float): + entry_size = int(entry_size) + if not isinstance(entry_size, int): + entry_size = icon_size + return str(entry["icon"]), entry_size + return None, icon_size + + +def query_icon_from_data_raw( + data_raw: Path, + *, + proto_type: str, + name: str, +) -> tuple[str | None, int | None]: + """Query a prototype icon from data-raw-dump.json using jq.""" + jq = shutil.which("jq") + if not jq: + return None, None + + jq_filter = ".[ $t ][ $n ] | {icon, icons}" + cmd = [ + jq, + "-c", + "--arg", + "t", + proto_type, + "--arg", + "n", + name, + jq_filter, + str(data_raw), + ] + result = None + try: + result = subprocess.run( # noqa: S603 + cmd, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + result = None + + icon_path: str | None = None + icon_size: int | None = None + if result: + output = result.stdout.strip() + if output and output != "null": + try: + payload = json.loads(output) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + icon_path, icon_size = extract_icon_from_payload(payload) + + return icon_path, icon_size + + +@st.cache_data(show_spinner=False) +def load_icon_catalog( + data_raw_path: str, + data_dir_path: str, +) -> dict[tuple[str, str], IconSpec]: + """Load a catalog of resolved icon paths.""" + if not data_dir_path: + return {} + + data_dir = Path(data_dir_path) + if not data_dir.exists(): + return {} + + data_raw = Path(data_raw_path) if data_raw_path else None + catalog: dict[tuple[str, str], IconSpec] = {} + + for target in ICON_TARGETS: + icon_path: str | None = None + icon_size: int | None = None + if data_raw and data_raw.exists(): + icon_path, icon_size = query_icon_from_data_raw( + data_raw, + proto_type=target.proto_type, + name=target.name, + ) + if not icon_path: + icon_path = target.fallback + if icon_path: + resolved = resolve_icon_path(icon_path, data_dir) + if resolved.exists(): + catalog[(target.proto_type, target.name)] = IconSpec( + path=resolved, + size=icon_size, + ) + + return catalog + + +def find_icon( + catalog: Mapping[tuple[str, str], IconSpec], + proto_types: tuple[str, ...], + *, + name: str, +) -> IconSpec | None: + """Find the first matching icon in the catalog.""" + for proto_type in proto_types: + icon = catalog.get((proto_type, name)) + if icon: + return icon + return None + + +@st.cache_data(show_spinner=False) +def load_icon_image(path: str, size: int | None) -> bytes | None: + """Load and crop an icon image to a single square.""" + try: + image = Image.open(path) + except OSError: + return None + + image = image.convert("RGBA") + width, height = image.size + target_size = size + if target_size is None and width != height: + target_size = min(width, height) + if target_size: + target_size = min(target_size, width, height) + image = image.crop((0, 0, target_size, target_size)) + + with io.BytesIO() as buffer: + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def format_amount(value: float) -> str: + """Format a number using compact k/M suffixes.""" + abs_value = abs(value) + if abs_value >= FORMAT_MILLION: + return f"{value / FORMAT_MILLION:.2f}M" + if abs_value >= FORMAT_THOUSAND: + return f"{value / FORMAT_THOUSAND:.1f}k" + if abs_value >= FORMAT_TEN: + return f"{value:.1f}" + return f"{value:.2f}" + + +def render_icon_label(icon_spec: IconSpec | None, label: str) -> None: + """Render an icon with a label beneath it.""" + if icon_spec: + image_data = load_icon_image(str(icon_spec.path), icon_spec.size) + if image_data: + st.image(image_data, width=32) + st.caption(label) + + +def render_icon_amount_list( + items: list[tuple[str, str, float]], + icon_catalog: Mapping[tuple[str, str], IconSpec], + *, + unit_label: str, +) -> None: + """Render a horizontal list of icon + amount pairs.""" + if not items: + st.caption("—") + return + columns = st.columns(len(items)) + for col, (proto_type, name, amount) in zip( + columns, + items, + strict=False, + ): + with col: + icon_path = find_icon( + icon_catalog, + (proto_type,), + name=name, + ) + if icon_path: + image_data = load_icon_image( + str(icon_path.path), + icon_path.size, + ) + if image_data: + st.image(image_data, width=28) + st.caption(f"{format_amount(amount)} {unit_label}") + st.caption(name.replace("-", " ").title()) + + +def render_summary_panel( + title: str, + items: list[tuple[str, str, float]], + icon_catalog: Mapping[tuple[str, str], IconSpec], + *, + unit_label: str, +) -> None: + """Render one of the summary panels (products/byproducts/ingredients).""" + st.markdown(f"**{title}**") + render_icon_amount_list(items, icon_catalog, unit_label=unit_label) + + +def accumulate_flows( + recipes: Mapping[str, Recipe], + configs: Mapping[str, RecipeConfig], + counts: Mapping[str, float], +) -> tuple[dict[str, float], dict[str, float]]: + """Accumulate total per-second production and consumption.""" + production: dict[str, float] = {} + consumption: dict[str, float] = {} + for recipe_key, recipe in recipes.items(): + rates = per_machine_rates(recipe, configs[recipe_key]) + count = counts.get(recipe_key, 0.0) + for fluid, rate in rates.production.items(): + production[fluid] = production.get(fluid, 0.0) + rate * count + for fluid, rate in rates.consumption.items(): + consumption[fluid] = consumption.get(fluid, 0.0) + rate * count + return production, consumption + + +def build_summary_items( + production: Mapping[str, float], + consumption: Mapping[str, float], + *, + unit_multiplier: float, +) -> tuple[ + list[tuple[str, str, float]], + list[tuple[str, str, float]], + list[tuple[str, str, float]], +]: + """Split net flows into product, byproduct, and ingredient lists.""" + net: dict[str, float] = {} + keys = set(production) | set(consumption) + for fluid in keys: + net[fluid] = production.get(fluid, 0.0) - consumption.get(fluid, 0.0) + + products: list[tuple[str, str, float]] = [] + byproducts: list[tuple[str, str, float]] = [] + ingredients: list[tuple[str, str, float]] = [] + + for fluid, value in sorted(net.items()): + scaled = value * unit_multiplier + if scaled > FLOW_EPSILON: + if fluid == "petroleum-gas": + products.append(("fluid", fluid, scaled)) + else: + byproducts.append(("fluid", fluid, scaled)) + elif scaled < -FLOW_EPSILON: + ingredients.append(("fluid", fluid, abs(scaled))) + + return products, byproducts, ingredients + + +def build_recipe_rows( + recipe_key: str, + recipe: Recipe, + config: RecipeConfig, + *, + count: float, + unit_multiplier: float, +) -> tuple[ + list[tuple[str, str, float]], + list[tuple[str, str, float]], + list[tuple[str, str, float]], +]: + """Build per-recipe product, byproduct, and ingredient lists.""" + rates = per_machine_rates(recipe, config) + production = { + fluid: rate * count * unit_multiplier + for fluid, rate in rates.production.items() + } + consumption = { + fluid: rate * count * unit_multiplier + for fluid, rate in rates.consumption.items() + } + + primary = PRIMARY_OUTPUTS.get(recipe_key) + products: list[tuple[str, str, float]] = [] + byproducts: list[tuple[str, str, float]] = [] + + for fluid, value in production.items(): + if primary and fluid != primary: + byproducts.append(("fluid", fluid, value)) + else: + products.append(("fluid", fluid, value)) + + ingredients = [ + ("fluid", fluid, value) for fluid, value in consumption.items() + ] + + return products, byproducts, ingredients + + +def render_sidebar_controls() -> tuple[str, str, float, bool, float, str]: + """Render sidebar controls and return selected values.""" + st.sidebar.header("Assets") + data_dir_path = st.sidebar.text_input( + "Factorio data directory", + value=os.environ.get("FACTORIO_DATA_DIR", DEFAULT_DATA_DIR), + ) + data_raw_path = st.sidebar.text_input( + "data-raw-dump.json path", + value=os.environ.get("FACTORIO_DATA_RAW", DEFAULT_DATA_RAW), + ) + + st.sidebar.header("Demand") + demand_pg_per_min = st.sidebar.number_input( + "Petroleum gas target (per minute)", + min_value=0.0, + value=900.0, + step=30.0, + ) + force_integer = st.sidebar.checkbox( + "Force integer machine counts", + value=False, + ) + rate_unit = st.sidebar.radio( + "Rate unit", + options=["per minute", "per second"], + index=0, + ) + unit_multiplier = 60.0 if rate_unit == "per minute" else 1.0 + unit_label = "per min" if rate_unit == "per minute" else "per s" + + return ( + data_dir_path, + data_raw_path, + demand_pg_per_min, + force_integer, + unit_multiplier, + unit_label, + ) + + +def render_summary_placeholders() -> tuple[object, object, object]: + """Render the summary placeholder panels and return their slots.""" + summary_container = st.container() + summary_cols = summary_container.columns(3) + return ( + summary_cols[0].empty(), + summary_cols[1].empty(), + summary_cols[2].empty(), + ) + + +def render_production_header() -> None: + """Render the production table header row.""" + header_cols = st.columns([1.4, 1.8, 1.6, 0.9, 2.4, 2.4, 2.4]) + header_labels = [ + "Recipe", + "Machine", + "Modules / Beacons", + "Power", + "Products", + "Byproducts", + "Ingredients", + ] + for col, label in zip(header_cols, header_labels, strict=False): + col.caption(label) + + +def render_production_rows( + recipes: Mapping[str, Recipe], + machines: Mapping[str, Machine], + icon_catalog: Mapping[tuple[str, str], IconSpec], +) -> tuple[dict[str, RecipeConfig], dict[str, tuple], dict[str, object]]: + """Render production rows and return configs and row placeholders.""" + config_map: dict[str, RecipeConfig] = {} + row_slots: dict[str, tuple] = {} + count_slots: dict[str, object] = {} + + machine_choices = { + "advanced-oil-processing": ["oil-refinery"], + "heavy-oil-cracking": ["chemical-plant", "biochamber"], + "light-oil-cracking": ["chemical-plant", "biochamber"], + } + + for recipe_key in RECIPE_ORDER: + recipe = recipes[recipe_key] + cols = st.columns([1.4, 1.8, 1.6, 0.9, 2.4, 2.4, 2.4]) + + with cols[0]: + recipe_icon = find_icon( + icon_catalog, + ("recipe",), + name=recipe.key, + ) + render_icon_label(recipe_icon, recipe.label) + + with cols[1]: + machine = render_machine_selector( + recipe, + machines, + machine_choices[recipe_key], + ) + machine_icon = find_icon( + icon_catalog, + ("item", "assembling-machine"), + name=machine.key, + ) + render_icon_label(machine_icon, machine.label) + count_slots[recipe_key] = st.empty() + + with cols[2]: + effects = render_effect_controls(recipe) + + with cols[3]: + st.caption("—") + + with cols[4]: + products_cell = st.empty() + + with cols[5]: + byproducts_cell = st.empty() + + with cols[6]: + ingredients_cell = st.empty() + + config_map[recipe_key] = RecipeConfig(machine=machine, effects=effects) + row_slots[recipe_key] = ( + products_cell, + byproducts_cell, + ingredients_cell, + ) + + return config_map, row_slots, count_slots + + +def render_solution(result: SolveResult, state: RenderState) -> None: + """Render the solved output and update placeholders.""" + production, consumption = accumulate_flows( + state.recipes, + state.config_map, + result.machine_counts, + ) + crude_input = consumption.get("crude-oil", 0.0) * state.unit_multiplier + status_line = ( + f"Solver status: **{result.status}** • " + f"Crude input: {format_amount(crude_input)} {state.unit_label}" + ) + state.status_slot.markdown(status_line) + products, byproducts, ingredients = build_summary_items( + production, + consumption, + unit_multiplier=state.unit_multiplier, + ) + + with state.products_slot.container(): + render_summary_panel( + "Products", + products, + state.icon_catalog, + unit_label=state.unit_label, + ) + with state.byproducts_slot.container(): + render_summary_panel( + "Byproducts", + byproducts, + state.icon_catalog, + unit_label=state.unit_label, + ) + with state.ingredients_slot.container(): + render_summary_panel( + "Ingredients", + ingredients, + state.icon_catalog, + unit_label=state.unit_label, + ) + + for recipe_key in RECIPE_ORDER: + recipe = state.recipes[recipe_key] + count = result.machine_counts.get(recipe_key, 0.0) + state.count_slots[recipe_key].caption(f"Count: {format_amount(count)}") + products, byproducts, ingredients = build_recipe_rows( + recipe_key, + recipe, + state.config_map[recipe_key], + count=count, + unit_multiplier=state.unit_multiplier, + ) + ( + products_cell, + byproducts_cell, + ingredients_cell, + ) = state.row_slots[recipe_key] + with products_cell.container(): + render_icon_amount_list( + products, + state.icon_catalog, + unit_label=state.unit_label, + ) + with byproducts_cell.container(): + render_icon_amount_list( + byproducts, + state.icon_catalog, + unit_label=state.unit_label, + ) + with ingredients_cell.container(): + render_icon_amount_list( + ingredients, + state.icon_catalog, + unit_label=state.unit_label, + ) + + def build_machines() -> dict[str, Machine]: """Create the machine catalog for the example.""" return { @@ -219,21 +888,31 @@ def solve_chain( == heavy_cons * variables[heavy_key] # type: ignore[operator] ) - light_prod = rates[advanced_key].production.get("light-oil", 0.0) + rates[ - heavy_key - ].production.get("light-oil", 0.0) + light_prod_advanced = rates[advanced_key].production.get("light-oil", 0.0) + light_prod_from_heavy = rates[heavy_key].production.get("light-oil", 0.0) light_cons = rates[light_key].consumption.get("light-oil", 0.0) solver.Add( - light_prod * variables[advanced_key] # type: ignore[operator] + light_prod_advanced * variables[advanced_key] # type: ignore[operator] + + light_prod_from_heavy * variables[heavy_key] # type: ignore[operator] == light_cons * variables[light_key] # type: ignore[operator] ) - pg_prod = rates[advanced_key].production.get("petroleum-gas", 0.0) + rates[ - light_key - ].production.get("petroleum-gas", 0.0) - solver.Add(pg_prod * variables[advanced_key] >= demand_pg_per_s) # type: ignore[operator] + pg_prod_advanced = rates[advanced_key].production.get("petroleum-gas", 0.0) + pg_prod_from_light = rates[light_key].production.get("petroleum-gas", 0.0) + solver.Add( + pg_prod_advanced * variables[advanced_key] # type: ignore[operator] + + pg_prod_from_light * variables[light_key] # type: ignore[operator] + >= demand_pg_per_s + ) - solver.Minimize(sum(variables.values())) # type: ignore[operator] + objective_terms = [] + for recipe_key in recipes: + crude_rate = rates[recipe_key].consumption.get("crude-oil", 0.0) + if crude_rate > 0.0: + objective_terms.append( + crude_rate * variables[recipe_key] # type: ignore[operator] + ) + solver.Minimize(solver.Sum(objective_terms)) status_code = solver.Solve() status_map = { @@ -253,9 +932,11 @@ def solve_chain( net_flows = { "heavy-oil": heavy_prod * machine_counts[advanced_key] - heavy_cons * machine_counts[heavy_key], - "light-oil": light_prod * machine_counts[advanced_key] + "light-oil": light_prod_advanced * machine_counts[advanced_key] + + light_prod_from_heavy * machine_counts[heavy_key] - light_cons * machine_counts[light_key], - "petroleum-gas": pg_prod * machine_counts[advanced_key], + "petroleum-gas": pg_prod_advanced * machine_counts[advanced_key] + + pg_prod_from_light * machine_counts[light_key], } objective_value: float | None @@ -275,22 +956,23 @@ def solve_chain( def render_effect_controls(recipe: Recipe) -> EffectSettings: """Render module and beacon controls for a recipe.""" use_modules = st.checkbox( - "Use modules", + "Modules", key=f"{recipe.key}-modules", help="Provide total speed/productivity bonuses from modules.", ) speed_bonus = 0.0 productivity_bonus = 0.0 if use_modules: - speed_bonus = st.number_input( - "Module speed bonus (%)", + module_cols = st.columns(2) + speed_bonus = module_cols[0].number_input( + "Speed %", min_value=0.0, value=0.0, step=5.0, key=f"{recipe.key}-module-speed", ) - productivity_bonus = st.number_input( - "Module productivity bonus (%)", + productivity_bonus = module_cols[1].number_input( + "Productivity %", min_value=0.0, value=0.0, step=1.0, @@ -298,14 +980,14 @@ def render_effect_controls(recipe: Recipe) -> EffectSettings: ) use_beacons = st.checkbox( - "Use beacons", + "Beacons", key=f"{recipe.key}-beacons", help="Provide a total speed bonus from beacons.", ) beacon_speed_bonus = 0.0 if use_beacons: beacon_speed_bonus = st.number_input( - "Beacon speed bonus (%)", + "Beacon speed %", min_value=0.0, value=0.0, step=5.0, @@ -319,21 +1001,20 @@ def render_effect_controls(recipe: Recipe) -> EffectSettings: ) -def render_recipe_config( +def render_machine_selector( recipe: Recipe, machines: Mapping[str, Machine], machine_keys: list[str], -) -> RecipeConfig: - """Render the machine and effect selection for one recipe.""" +) -> Machine: + """Render the machine selector for a recipe.""" options = [machines[key] for key in machine_keys] - machine = st.selectbox( + return st.selectbox( "Machine", options=options, format_func=machine_label, key=f"{recipe.key}-machine", + label_visibility="collapsed", ) - effects = render_effect_controls(recipe) - return RecipeConfig(machine=machine, effects=effects) def main() -> None: @@ -343,41 +1024,39 @@ def main() -> None: st.markdown( "This example models the advanced oil processing chain using " "Google OR-Tools. Choose machines and bonuses, then solve for the " - "machine counts that satisfy a petroleum gas demand." + "machine counts that satisfy a petroleum gas demand while minimizing " + "crude oil input (water is treated as a free input)." ) + ( + data_dir_path, + data_raw_path, + demand_pg_per_min, + force_integer, + unit_multiplier, + unit_label, + ) = render_sidebar_controls() + + icon_catalog = load_icon_catalog(data_raw_path, data_dir_path) + if not icon_catalog: + st.sidebar.warning( + "Icons were not resolved. Check your data directory paths." + ) + machines = build_machines() recipes = build_recipes() - st.sidebar.header("Demand") - demand_pg_per_min = st.sidebar.number_input( - "Petroleum gas target (per minute)", - min_value=0.0, - value=900.0, - step=30.0, - ) - force_integer = st.sidebar.checkbox( - "Force integer machine counts", - value=False, + products_slot, byproducts_slot, ingredients_slot = ( + render_summary_placeholders() ) + status_slot = st.empty() - st.subheader("Recipe configuration") - - config_map: dict[str, RecipeConfig] = {} - config_map["advanced-oil-processing"] = render_recipe_config( - recipes["advanced-oil-processing"], - machines, - ["oil-refinery"], - ) - config_map["heavy-oil-cracking"] = render_recipe_config( - recipes["heavy-oil-cracking"], - machines, - ["chemical-plant", "biochamber"], - ) - config_map["light-oil-cracking"] = render_recipe_config( - recipes["light-oil-cracking"], + st.subheader("Production") + render_production_header() + config_map, row_slots, count_slots = render_production_rows( + recipes, machines, - ["chemical-plant", "biochamber"], + icon_catalog, ) demand_pg_per_s = demand_pg_per_min / 60.0 @@ -391,33 +1070,20 @@ def main() -> None: if result is None: return - st.subheader("Solution") - st.write(f"Solver status: **{result.status}**") - if result.objective_value is not None: - st.write(f"Objective (total machines): {result.objective_value:.3f}") - - table_rows = [] - for recipe_key, recipe in recipes.items(): - count = result.machine_counts.get(recipe_key, 0.0) - table_rows.append( - { - "Recipe": recipe.label, - "Machine": config_map[recipe_key].machine.label, - "Count": round(count, 4), - } - ) - st.table(table_rows) - - flow_rows = [] - for fluid, rate in result.net_flows_per_s.items(): - flow_rows.append( - { - "Fluid": fluid, - "Net rate (per s)": round(rate, 4), - "Net rate (per min)": round(rate * 60.0, 2), - } - ) - st.table(flow_rows) + state = RenderState( + recipes=recipes, + config_map=config_map, + row_slots=row_slots, + count_slots=count_slots, + products_slot=products_slot, + byproducts_slot=byproducts_slot, + ingredients_slot=ingredients_slot, + status_slot=status_slot, + unit_multiplier=unit_multiplier, + unit_label=unit_label, + icon_catalog=icon_catalog, + ) + render_solution(result, state) main() diff --git a/src/private/app/factorio-cycle-calculator/pyproject.toml b/src/private/app/factorio-cycle-calculator/pyproject.toml index 8cba3a5..a1cefb9 100644 --- a/src/private/app/factorio-cycle-calculator/pyproject.toml +++ b/src/private/app/factorio-cycle-calculator/pyproject.toml @@ -7,4 +7,4 @@ authors = [{ name = "Shuai Zhang", email = "zhangshuai.ustc@gmail.com" }] requires-python = ">=3.12" license = "GPL-3.0-or-later" classifiers = ["Private :: Do Not Upload"] -dependencies = ["ortools>=9.11.4210", "streamlit>=1.42.0"] +dependencies = ["ortools>=9.11.4210", "pillow>=10.3.0", "streamlit>=1.42.0"] diff --git a/uv.lock b/uv.lock index 56eca6f..69b8675 100644 --- a/uv.lock +++ b/uv.lock @@ -703,12 +703,14 @@ version = "0.0.0.dev0" source = { virtual = "src/private/app/factorio-cycle-calculator" } dependencies = [ { name = "ortools" }, + { name = "pillow" }, { name = "streamlit" }, ] [package.metadata] requires-dist = [ { name = "ortools", specifier = ">=9.11.4210" }, + { name = "pillow", specifier = ">=10.3.0" }, { name = "streamlit", specifier = ">=1.42.0" }, ] From 0e23c8b62cf80c27f63d4c5fa67119de821af8d7 Mon Sep 17 00:00:00 2001 From: Shuai Zhang Date: Wed, 11 Feb 2026 23:10:17 -0800 Subject: [PATCH 4/5] chore(factorio-cycle-calculator): Refactor Example App to Remove Hardcoded Stuffs --- .../app/factorio-cycle-calculator/app.py | 696 ++++++++++-------- 1 file changed, 398 insertions(+), 298 deletions(-) diff --git a/src/private/app/factorio-cycle-calculator/app.py b/src/private/app/factorio-cycle-calculator/app.py index de2b06e..c1fa21f 100644 --- a/src/private/app/factorio-cycle-calculator/app.py +++ b/src/private/app/factorio-cycle-calculator/app.py @@ -6,11 +6,9 @@ import json import os import re -import shutil -import subprocess from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, Self import streamlit as st from ortools.linear_solver import pywraplp @@ -18,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from types import TracebackType DEFAULT_DATA_DIR = ( "/mnt/c/Program Files (x86)/Steam/steamapps/common/Factorio/data" @@ -33,6 +32,7 @@ FORMAT_THOUSAND = 1_000.0 FORMAT_TEN = 10.0 FLOW_EPSILON = 1e-6 +MIN_LIST_ENTRY_LEN = 2 @dataclass(frozen=True) @@ -43,6 +43,7 @@ class Machine: label: str crafting_speed: float allow_productivity: bool + crafting_categories: tuple[str, ...] @dataclass(frozen=True) @@ -51,9 +52,10 @@ class Recipe: key: str label: str + category: str energy_required: float - ingredients: Mapping[str, float] - results: Mapping[str, float] + ingredients: Mapping[tuple[str, str], float] + results: Mapping[tuple[str, str], float] allow_productivity: bool @@ -78,8 +80,8 @@ class RecipeConfig: class FlowRates: """Store per-second production and consumption rates.""" - production: dict[str, float] - consumption: dict[str, float] + production: dict[tuple[str, str], float] + consumption: dict[tuple[str, str], float] @dataclass(frozen=True) @@ -88,19 +90,10 @@ class SolveResult: status: str machine_counts: dict[str, float] - net_flows_per_s: dict[str, float] + net_flows_per_s: dict[tuple[str, str], float] objective_value: float | None -@dataclass(frozen=True) -class IconTarget: - """Describe an icon to load for the UI.""" - - proto_type: str - name: str - fallback: str | None - - @dataclass(frozen=True) class IconSpec: """Hold a resolved icon path and its intended size.""" @@ -109,107 +102,59 @@ class IconSpec: size: int | None +class ContainerSlot(Protocol): + """Define the container slot API used by the UI.""" + + def container(self) -> ContainerSlot: + """Return a context manager for nested rendering.""" + ... + + def __enter__(self) -> Self: + """Enter the container context.""" + ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + """Exit the container context.""" + ... + + +class CaptionSlot(Protocol): + """Define the caption API used by the UI.""" + + def caption(self, body: str) -> None: + """Render a caption string.""" + ... + + +class MarkdownSlot(Protocol): + """Define the markdown API used by the UI.""" + + def markdown(self, body: str) -> None: + """Render a markdown string.""" + ... + + @dataclass(frozen=True) class RenderState: """Bundle UI state for rendering outputs.""" recipes: Mapping[str, Recipe] config_map: Mapping[str, RecipeConfig] - row_slots: Mapping[str, tuple] - count_slots: Mapping[str, object] - products_slot: object - byproducts_slot: object - ingredients_slot: object - status_slot: object + row_slots: Mapping[str, tuple[ContainerSlot, ContainerSlot, ContainerSlot]] + count_slots: Mapping[str, CaptionSlot] + products_slot: ContainerSlot + byproducts_slot: ContainerSlot + ingredients_slot: ContainerSlot + status_slot: MarkdownSlot unit_multiplier: float unit_label: str icon_catalog: Mapping[tuple[str, str], IconSpec] - - -ICON_TARGETS = [ - IconTarget( - "recipe", - "advanced-oil-processing", - "__base__/graphics/icons/fluid/advanced-oil-processing.png", - ), - IconTarget( - "recipe", - "heavy-oil-cracking", - "__base__/graphics/icons/fluid/heavy-oil-cracking.png", - ), - IconTarget( - "recipe", - "light-oil-cracking", - "__base__/graphics/icons/fluid/light-oil-cracking.png", - ), - IconTarget( - "fluid", - "crude-oil", - "__base__/graphics/icons/fluid/crude-oil.png", - ), - IconTarget( - "fluid", - "heavy-oil", - "__base__/graphics/icons/fluid/heavy-oil.png", - ), - IconTarget( - "fluid", - "light-oil", - "__base__/graphics/icons/fluid/light-oil.png", - ), - IconTarget( - "fluid", - "water", - "__base__/graphics/icons/fluid/water.png", - ), - IconTarget( - "fluid", - "petroleum-gas", - "__base__/graphics/icons/fluid/petroleum-gas.png", - ), - IconTarget( - "item", - "oil-refinery", - "__base__/graphics/icons/oil-refinery.png", - ), - IconTarget( - "item", - "chemical-plant", - "__base__/graphics/icons/chemical-plant.png", - ), - IconTarget( - "item", - "biochamber", - "__space-age__/graphics/icons/biochamber.png", - ), - IconTarget( - "assembling-machine", - "oil-refinery", - "__base__/graphics/icons/oil-refinery.png", - ), - IconTarget( - "assembling-machine", - "chemical-plant", - "__base__/graphics/icons/chemical-plant.png", - ), - IconTarget( - "assembling-machine", - "biochamber", - "__space-age__/graphics/icons/biochamber.png", - ), -] - -PRIMARY_OUTPUTS = { - "advanced-oil-processing": "petroleum-gas", - "heavy-oil-cracking": "light-oil", - "light-oil-cracking": "petroleum-gas", -} - -RECIPE_ORDER = ( - "advanced-oil-processing", - "heavy-oil-cracking", - "light-oil-cracking", -) + recipe_order: tuple[str, str, str] def resolve_icon_path(icon_path: str, data_dir: Path) -> Path: @@ -245,91 +190,207 @@ def extract_icon_from_payload(payload: dict) -> tuple[str | None, int | None]: return None, icon_size -def query_icon_from_data_raw( - data_raw: Path, - *, +@st.cache_data(show_spinner=False) +def load_data_raw(data_raw_path: str) -> dict: + """Load data-raw-dump.json into memory.""" + try: + with Path(data_raw_path).open("r", encoding="utf-8") as handle: + return json.load(handle) + except OSError: + return {} + + +def get_prototype( + data_raw: Mapping[str, Mapping[str, dict]], proto_type: str, name: str, -) -> tuple[str | None, int | None]: - """Query a prototype icon from data-raw-dump.json using jq.""" - jq = shutil.which("jq") - if not jq: - return None, None - - jq_filter = ".[ $t ][ $n ] | {icon, icons}" - cmd = [ - jq, - "-c", - "--arg", - "t", - proto_type, - "--arg", - "n", - name, - jq_filter, - str(data_raw), - ] - result = None - try: - result = subprocess.run( # noqa: S603 - cmd, - check=True, - capture_output=True, - text=True, +) -> dict | None: + """Fetch a prototype from data-raw by type and name.""" + return data_raw.get(proto_type, {}).get(name) + + +def parse_amount(entry: dict) -> float: + """Parse an amount from a recipe ingredient/result entry.""" + if "amount" in entry: + return float(entry["amount"]) + amount_min = entry.get("amount_min") + amount_max = entry.get("amount_max") + if amount_min is not None and amount_max is not None: + return (float(amount_min) + float(amount_max)) / 2.0 + if amount_min is not None: + return float(amount_min) + if amount_max is not None: + return float(amount_max) + return 0.0 + + +def parse_ingredient_list( + entries: list[object], +) -> dict[tuple[str, str], float]: + """Parse a list of ingredient-like entries into a typed map.""" + parsed: dict[tuple[str, str], float] = {} + for entry in entries: + if isinstance(entry, list) and len(entry) >= MIN_LIST_ENTRY_LEN: + name = entry[0] + amount = float(entry[1]) + proto_type = "item" + elif isinstance(entry, dict): + name = entry.get("name") + amount = parse_amount(entry) + proto_type = entry.get("type", "item") + else: + continue + if not isinstance(name, str): + continue + key = (proto_type, name) + parsed[key] = parsed.get(key, 0.0) + amount + return parsed + + +def parse_results(proto: dict) -> dict[tuple[str, str], float]: + """Parse recipe results into a typed map.""" + results: dict[tuple[str, str], float] = {} + if "results" in proto and isinstance(proto["results"], list): + for entry in proto["results"]: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str): + continue + amount = parse_amount(entry) + probability = entry.get("probability", 1.0) + amount *= float(probability) + proto_type = entry.get("type", "item") + key = (proto_type, name) + results[key] = results.get(key, 0.0) + amount + return results + + result_name = proto.get("result") + if isinstance(result_name, str): + amount = float(proto.get("result_count", 1.0)) + results[("item", result_name)] = amount + return results + + +def build_recipe_from_proto(name: str, proto: dict) -> Recipe: + """Build a Recipe instance from a data-raw recipe prototype.""" + energy_required = proto.get("energy_required") + if energy_required is None: + energy_required = 0.5 + category = proto.get("category", "crafting") + ingredients = parse_ingredient_list(proto.get("ingredients", [])) + results = parse_results(proto) + allow_productivity = bool(proto.get("allow_productivity", False)) + return Recipe( + key=name, + label=name.replace("-", " ").title(), + category=str(category), + energy_required=float(energy_required), + ingredients=ingredients, + results=results, + allow_productivity=allow_productivity, + ) + + +def build_machine_catalog( + data_raw: Mapping[str, Mapping[str, dict]], +) -> dict[str, Machine]: + """Build a machine catalog from data-raw assembling machines.""" + catalog: dict[str, Machine] = {} + for name, proto in data_raw.get("assembling-machine", {}).items(): + crafting_speed = float(proto.get("crafting_speed", 1.0)) + crafting_categories = tuple(proto.get("crafting_categories", [])) + allowed_effects = proto.get("allowed_effects", []) + allow_productivity = "productivity" in allowed_effects + if not allow_productivity: + base_effect = (proto.get("effect_receiver") or {}).get( + "base_effect", {} + ) + allow_productivity = bool(base_effect.get("productivity", 0)) + catalog[name] = Machine( + key=name, + label=name.replace("-", " ").title(), + crafting_speed=crafting_speed, + allow_productivity=allow_productivity, + crafting_categories=crafting_categories, ) - except subprocess.CalledProcessError: - result = None + return catalog + - icon_path: str | None = None - icon_size: int | None = None - if result: - output = result.stdout.strip() - if output and output != "null": - try: - payload = json.loads(output) - except json.JSONDecodeError: - payload = None - if isinstance(payload, dict): - icon_path, icon_size = extract_icon_from_payload(payload) +def build_recipe_catalog( + data_raw: Mapping[str, Mapping[str, dict]], + recipe_keys: tuple[str, str, str], +) -> dict[str, Recipe]: + """Build recipe definitions for the selected chain.""" + catalog: dict[str, Recipe] = {} + for recipe_key in recipe_keys: + proto = get_prototype(data_raw, "recipe", recipe_key) + if not proto: + continue + catalog[recipe_key] = build_recipe_from_proto(recipe_key, proto) + return catalog - return icon_path, icon_size +def list_recipe_names_by_category( + data_raw: Mapping[str, Mapping[str, dict]], + category: str, +) -> list[str]: + """List recipe names matching a category.""" + matches = [] + for name, proto in data_raw.get("recipe", {}).items(): + if proto.get("category", "crafting") == category: + matches.append(name) + return sorted(matches) -@st.cache_data(show_spinner=False) -def load_icon_catalog( - data_raw_path: str, + +def select_recipe_option( + label: str, + options: list[str], + *, + default_name: str, +) -> str: + """Select a recipe name from options with a preferred default.""" + if not options: + st.sidebar.warning(f"No recipes found for {label}.") + return "" + index = options.index(default_name) if default_name in options else 0 + return st.sidebar.selectbox(label, options=options, index=index) + + +def build_icon_catalog( + data_raw: Mapping[str, Mapping[str, dict]], data_dir_path: str, + recipes: Mapping[str, Recipe], + machines: Mapping[str, Machine], ) -> dict[tuple[str, str], IconSpec]: - """Load a catalog of resolved icon paths.""" - if not data_dir_path: - return {} - + """Build the icon catalog for recipes, machines, and flows.""" data_dir = Path(data_dir_path) if not data_dir.exists(): return {} - data_raw = Path(data_raw_path) if data_raw_path else None - catalog: dict[tuple[str, str], IconSpec] = {} + icon_keys: set[tuple[str, str]] = set() + for recipe in recipes.values(): + icon_keys.add(("recipe", recipe.key)) + icon_keys.update(recipe.ingredients.keys()) + icon_keys.update(recipe.results.keys()) + for machine in machines.values(): + icon_keys.add(("assembling-machine", machine.key)) + icon_keys.add(("item", machine.key)) - for target in ICON_TARGETS: - icon_path: str | None = None - icon_size: int | None = None - if data_raw and data_raw.exists(): - icon_path, icon_size = query_icon_from_data_raw( - data_raw, - proto_type=target.proto_type, - name=target.name, - ) + catalog: dict[tuple[str, str], IconSpec] = {} + for proto_type, name in icon_keys: + proto = get_prototype(data_raw, proto_type, name) + if not proto: + continue + icon_path, icon_size = extract_icon_from_payload(proto) if not icon_path: - icon_path = target.fallback - if icon_path: - resolved = resolve_icon_path(icon_path, data_dir) - if resolved.exists(): - catalog[(target.proto_type, target.name)] = IconSpec( - path=resolved, - size=icon_size, - ) - + continue + resolved = resolve_icon_path(icon_path, data_dir) + if resolved.exists(): + catalog[(proto_type, name)] = IconSpec( + path=resolved, + size=icon_size, + ) return catalog @@ -439,23 +500,25 @@ def accumulate_flows( recipes: Mapping[str, Recipe], configs: Mapping[str, RecipeConfig], counts: Mapping[str, float], -) -> tuple[dict[str, float], dict[str, float]]: +) -> tuple[dict[tuple[str, str], float], dict[tuple[str, str], float]]: """Accumulate total per-second production and consumption.""" - production: dict[str, float] = {} - consumption: dict[str, float] = {} + production: dict[tuple[str, str], float] = {} + consumption: dict[tuple[str, str], float] = {} for recipe_key, recipe in recipes.items(): rates = per_machine_rates(recipe, configs[recipe_key]) count = counts.get(recipe_key, 0.0) - for fluid, rate in rates.production.items(): - production[fluid] = production.get(fluid, 0.0) + rate * count - for fluid, rate in rates.consumption.items(): - consumption[fluid] = consumption.get(fluid, 0.0) + rate * count + for flow_key, rate in rates.production.items(): + production[flow_key] = production.get(flow_key, 0.0) + rate * count + for flow_key, rate in rates.consumption.items(): + consumption[flow_key] = ( + consumption.get(flow_key, 0.0) + rate * count + ) return production, consumption def build_summary_items( - production: Mapping[str, float], - consumption: Mapping[str, float], + production: Mapping[tuple[str, str], float], + consumption: Mapping[tuple[str, str], float], *, unit_multiplier: float, ) -> tuple[ @@ -464,30 +527,31 @@ def build_summary_items( list[tuple[str, str, float]], ]: """Split net flows into product, byproduct, and ingredient lists.""" - net: dict[str, float] = {} + net: dict[tuple[str, str], float] = {} keys = set(production) | set(consumption) - for fluid in keys: - net[fluid] = production.get(fluid, 0.0) - consumption.get(fluid, 0.0) + for flow_key in keys: + net[flow_key] = production.get(flow_key, 0.0) - consumption.get( + flow_key, 0.0 + ) products: list[tuple[str, str, float]] = [] byproducts: list[tuple[str, str, float]] = [] ingredients: list[tuple[str, str, float]] = [] - for fluid, value in sorted(net.items()): + for flow_key, value in sorted(net.items()): scaled = value * unit_multiplier if scaled > FLOW_EPSILON: - if fluid == "petroleum-gas": - products.append(("fluid", fluid, scaled)) + if flow_key == ("fluid", "petroleum-gas"): + products.append((flow_key[0], flow_key[1], scaled)) else: - byproducts.append(("fluid", fluid, scaled)) + byproducts.append((flow_key[0], flow_key[1], scaled)) elif scaled < -FLOW_EPSILON: - ingredients.append(("fluid", fluid, abs(scaled))) + ingredients.append((flow_key[0], flow_key[1], abs(scaled))) return products, byproducts, ingredients def build_recipe_rows( - recipe_key: str, recipe: Recipe, config: RecipeConfig, *, @@ -501,26 +565,31 @@ def build_recipe_rows( """Build per-recipe product, byproduct, and ingredient lists.""" rates = per_machine_rates(recipe, config) production = { - fluid: rate * count * unit_multiplier - for fluid, rate in rates.production.items() + flow_key: rate * count * unit_multiplier + for flow_key, rate in rates.production.items() } consumption = { - fluid: rate * count * unit_multiplier - for fluid, rate in rates.consumption.items() + flow_key: rate * count * unit_multiplier + for flow_key, rate in rates.consumption.items() } - primary = PRIMARY_OUTPUTS.get(recipe_key) + primary: tuple[str, str] | None = None + if ("fluid", "petroleum-gas") in recipe.results: + primary = ("fluid", "petroleum-gas") + elif len(recipe.results) == 1: + primary = next(iter(recipe.results.keys())) products: list[tuple[str, str, float]] = [] byproducts: list[tuple[str, str, float]] = [] - for fluid, value in production.items(): - if primary and fluid != primary: - byproducts.append(("fluid", fluid, value)) + for flow_key, value in production.items(): + if primary and flow_key != primary: + byproducts.append((flow_key[0], flow_key[1], value)) else: - products.append(("fluid", fluid, value)) + products.append((flow_key[0], flow_key[1], value)) ingredients = [ - ("fluid", fluid, value) for fluid, value in consumption.items() + (flow_key[0], flow_key[1], value) + for flow_key, value in consumption.items() ] return products, byproducts, ingredients @@ -567,7 +636,36 @@ def render_sidebar_controls() -> tuple[str, str, float, bool, float, str]: ) -def render_summary_placeholders() -> tuple[object, object, object]: +def render_recipe_selection( + data_raw: Mapping[str, Mapping[str, dict]], +) -> tuple[str, str, str] | None: + """Render recipe selectors for the oil chain.""" + st.sidebar.header("Recipes") + oil_processing = list_recipe_names_by_category(data_raw, "oil-processing") + chemistry = list_recipe_names_by_category(data_raw, "organic-or-chemistry") + advanced_key = select_recipe_option( + "Oil processing recipe", + oil_processing, + default_name="advanced-oil-processing", + ) + heavy_key = select_recipe_option( + "Heavy oil cracking recipe", + chemistry, + default_name="heavy-oil-cracking", + ) + light_key = select_recipe_option( + "Light oil cracking recipe", + chemistry, + default_name="light-oil-cracking", + ) + if not advanced_key or not heavy_key or not light_key: + return None + return advanced_key, heavy_key, light_key + + +def render_summary_placeholders() -> tuple[ + ContainerSlot, ContainerSlot, ContainerSlot +]: """Render the summary placeholder panels and return their slots.""" summary_container = st.container() summary_cols = summary_container.columns(3) @@ -598,20 +696,29 @@ def render_production_rows( recipes: Mapping[str, Recipe], machines: Mapping[str, Machine], icon_catalog: Mapping[tuple[str, str], IconSpec], -) -> tuple[dict[str, RecipeConfig], dict[str, tuple], dict[str, object]]: + recipe_order: tuple[str, str, str], +) -> tuple[ + dict[str, RecipeConfig], + dict[str, tuple[ContainerSlot, ContainerSlot, ContainerSlot]], + dict[str, CaptionSlot], +]: """Render production rows and return configs and row placeholders.""" config_map: dict[str, RecipeConfig] = {} - row_slots: dict[str, tuple] = {} - count_slots: dict[str, object] = {} + row_slots: dict[ + str, + tuple[ContainerSlot, ContainerSlot, ContainerSlot], + ] = {} + count_slots: dict[str, CaptionSlot] = {} - machine_choices = { - "advanced-oil-processing": ["oil-refinery"], - "heavy-oil-cracking": ["chemical-plant", "biochamber"], - "light-oil-cracking": ["chemical-plant", "biochamber"], - } - - for recipe_key in RECIPE_ORDER: + for recipe_key in recipe_order: recipe = recipes[recipe_key] + eligible = [ + key + for key, machine in machines.items() + if recipe.category in machine.crafting_categories + ] + if not eligible: + eligible = list(machines.keys()) cols = st.columns([1.4, 1.8, 1.6, 0.9, 2.4, 2.4, 2.4]) with cols[0]: @@ -626,7 +733,7 @@ def render_production_rows( machine = render_machine_selector( recipe, machines, - machine_choices[recipe_key], + eligible, ) machine_icon = find_icon( icon_catalog, @@ -668,7 +775,9 @@ def render_solution(result: SolveResult, state: RenderState) -> None: state.config_map, result.machine_counts, ) - crude_input = consumption.get("crude-oil", 0.0) * state.unit_multiplier + crude_input = ( + consumption.get(("fluid", "crude-oil"), 0.0) * state.unit_multiplier + ) status_line = ( f"Solver status: **{result.status}** • " f"Crude input: {format_amount(crude_input)} {state.unit_label}" @@ -702,12 +811,11 @@ def render_solution(result: SolveResult, state: RenderState) -> None: unit_label=state.unit_label, ) - for recipe_key in RECIPE_ORDER: + for recipe_key in state.recipe_order: recipe = state.recipes[recipe_key] count = result.machine_counts.get(recipe_key, 0.0) state.count_slots[recipe_key].caption(f"Count: {format_amount(count)}") products, byproducts, ingredients = build_recipe_rows( - recipe_key, recipe, state.config_map[recipe_key], count=count, @@ -738,64 +846,6 @@ def render_solution(result: SolveResult, state: RenderState) -> None: ) -def build_machines() -> dict[str, Machine]: - """Create the machine catalog for the example.""" - return { - "oil-refinery": Machine( - key="oil-refinery", - label="Oil refinery", - crafting_speed=1.0, - allow_productivity=True, - ), - "chemical-plant": Machine( - key="chemical-plant", - label="Chemical plant", - crafting_speed=1.0, - allow_productivity=True, - ), - "biochamber": Machine( - key="biochamber", - label="Biochamber", - crafting_speed=2.0, - allow_productivity=True, - ), - } - - -def build_recipes() -> dict[str, Recipe]: - """Create the oil-processing recipes used in the example.""" - return { - "advanced-oil-processing": Recipe( - key="advanced-oil-processing", - label="Advanced oil processing", - energy_required=5.0, - ingredients={"crude-oil": 100.0, "water": 50.0}, - results={ - "heavy-oil": 25.0, - "light-oil": 45.0, - "petroleum-gas": 55.0, - }, - allow_productivity=True, - ), - "heavy-oil-cracking": Recipe( - key="heavy-oil-cracking", - label="Heavy oil cracking", - energy_required=2.0, - ingredients={"heavy-oil": 40.0, "water": 30.0}, - results={"light-oil": 30.0}, - allow_productivity=True, - ), - "light-oil-cracking": Recipe( - key="light-oil-cracking", - label="Light oil cracking", - energy_required=2.0, - ingredients={"light-oil": 30.0, "water": 30.0}, - results={"petroleum-gas": 20.0}, - allow_productivity=True, - ), - } - - def machine_label(machine: Machine) -> str: """Format machine labels for UI controls.""" return f"{machine.label} (speed {machine.crafting_speed:g})" @@ -839,6 +889,7 @@ def solve_chain( configs: Mapping[str, RecipeConfig], *, force_integer: bool, + recipe_order: tuple[str, str, str], ) -> SolveResult | None: """Solve the oil-processing chain to meet petroleum gas demand.""" solver = build_solver(force_integer=force_integer) @@ -877,28 +928,47 @@ def solve_chain( 0.0, solver.infinity(), recipe_key ) - advanced_key = "advanced-oil-processing" - heavy_key = "heavy-oil-cracking" - light_key = "light-oil-cracking" + advanced_key, heavy_key, light_key = recipe_order - heavy_prod = rates[advanced_key].production.get("heavy-oil", 0.0) - heavy_cons = rates[heavy_key].consumption.get("heavy-oil", 0.0) + heavy_prod = rates[advanced_key].production.get( + ("fluid", "heavy-oil"), + 0.0, + ) + heavy_cons = rates[heavy_key].consumption.get( + ("fluid", "heavy-oil"), + 0.0, + ) solver.Add( heavy_prod * variables[advanced_key] # type: ignore[operator] == heavy_cons * variables[heavy_key] # type: ignore[operator] ) - light_prod_advanced = rates[advanced_key].production.get("light-oil", 0.0) - light_prod_from_heavy = rates[heavy_key].production.get("light-oil", 0.0) - light_cons = rates[light_key].consumption.get("light-oil", 0.0) + light_prod_advanced = rates[advanced_key].production.get( + ("fluid", "light-oil"), + 0.0, + ) + light_prod_from_heavy = rates[heavy_key].production.get( + ("fluid", "light-oil"), + 0.0, + ) + light_cons = rates[light_key].consumption.get( + ("fluid", "light-oil"), + 0.0, + ) solver.Add( light_prod_advanced * variables[advanced_key] # type: ignore[operator] + light_prod_from_heavy * variables[heavy_key] # type: ignore[operator] == light_cons * variables[light_key] # type: ignore[operator] ) - pg_prod_advanced = rates[advanced_key].production.get("petroleum-gas", 0.0) - pg_prod_from_light = rates[light_key].production.get("petroleum-gas", 0.0) + pg_prod_advanced = rates[advanced_key].production.get( + ("fluid", "petroleum-gas"), + 0.0, + ) + pg_prod_from_light = rates[light_key].production.get( + ("fluid", "petroleum-gas"), + 0.0, + ) solver.Add( pg_prod_advanced * variables[advanced_key] # type: ignore[operator] + pg_prod_from_light * variables[light_key] # type: ignore[operator] @@ -907,7 +977,10 @@ def solve_chain( objective_terms = [] for recipe_key in recipes: - crude_rate = rates[recipe_key].consumption.get("crude-oil", 0.0) + crude_rate = rates[recipe_key].consumption.get( + ("fluid", "crude-oil"), + 0.0, + ) if crude_rate > 0.0: objective_terms.append( crude_rate * variables[recipe_key] # type: ignore[operator] @@ -930,12 +1003,14 @@ def solve_chain( for recipe_key in recipes } net_flows = { - "heavy-oil": heavy_prod * machine_counts[advanced_key] + ("fluid", "heavy-oil"): heavy_prod * machine_counts[advanced_key] - heavy_cons * machine_counts[heavy_key], - "light-oil": light_prod_advanced * machine_counts[advanced_key] + ("fluid", "light-oil"): light_prod_advanced + * machine_counts[advanced_key] + light_prod_from_heavy * machine_counts[heavy_key] - light_cons * machine_counts[light_key], - "petroleum-gas": pg_prod_advanced * machine_counts[advanced_key] + ("fluid", "petroleum-gas"): pg_prod_advanced + * machine_counts[advanced_key] + pg_prod_from_light * machine_counts[light_key], } @@ -1037,15 +1112,37 @@ def main() -> None: unit_label, ) = render_sidebar_controls() - icon_catalog = load_icon_catalog(data_raw_path, data_dir_path) + data_raw = load_data_raw(data_raw_path) + if not data_raw: + st.error("Failed to load data-raw-dump.json.") + return + + recipe_order = render_recipe_selection(data_raw) + if recipe_order is None: + st.error("Recipe selection is incomplete.") + return + + machines = build_machine_catalog(data_raw) + if not machines: + st.error("No assembling machines found in data-raw.") + return + + recipes = build_recipe_catalog(data_raw, recipe_order) + if len(recipes) != len(recipe_order): + st.error("Some selected recipes were not found in data-raw.") + return + + icon_catalog = build_icon_catalog( + data_raw, + data_dir_path, + recipes, + machines, + ) if not icon_catalog: st.sidebar.warning( "Icons were not resolved. Check your data directory paths." ) - machines = build_machines() - recipes = build_recipes() - products_slot, byproducts_slot, ingredients_slot = ( render_summary_placeholders() ) @@ -1057,6 +1154,7 @@ def main() -> None: recipes, machines, icon_catalog, + recipe_order, ) demand_pg_per_s = demand_pg_per_min / 60.0 @@ -1065,6 +1163,7 @@ def main() -> None: recipes, config_map, force_integer=force_integer, + recipe_order=recipe_order, ) if result is None: @@ -1082,6 +1181,7 @@ def main() -> None: unit_multiplier=unit_multiplier, unit_label=unit_label, icon_catalog=icon_catalog, + recipe_order=recipe_order, ) render_solution(result, state) From 9861174c464b4ecd369e0c6604f1209bb39221e0 Mon Sep 17 00:00:00 2001 From: Shuai Zhang Date: Wed, 11 Feb 2026 23:40:35 -0800 Subject: [PATCH 5/5] feat(factorio-cycle-calculator): Example Support Modules & Beacon --- .../app/factorio-cycle-calculator/app.py | 400 +++++++++++++++--- 1 file changed, 339 insertions(+), 61 deletions(-) diff --git a/src/private/app/factorio-cycle-calculator/app.py b/src/private/app/factorio-cycle-calculator/app.py index c1fa21f..7559e26 100644 --- a/src/private/app/factorio-cycle-calculator/app.py +++ b/src/private/app/factorio-cycle-calculator/app.py @@ -44,6 +44,8 @@ class Machine: crafting_speed: float allow_productivity: bool crafting_categories: tuple[str, ...] + module_slots: int + allowed_effects: frozenset[str] @dataclass(frozen=True) @@ -65,7 +67,6 @@ class EffectSettings: speed_bonus: float productivity_bonus: float - beacon_speed_bonus: float @dataclass(frozen=True) @@ -102,6 +103,29 @@ class IconSpec: size: int | None +@dataclass(frozen=True) +class ModuleSpec: + """Describe a module item and its effects.""" + + key: str + label: str + speed_bonus: float + productivity_bonus: float + limitation: frozenset[str] + limitation_blacklist: frozenset[str] + + +@dataclass(frozen=True) +class BeaconSpec: + """Describe a beacon entity and its effects.""" + + key: str + label: str + module_slots: int + distribution_effectivity: float + allowed_effects: frozenset[str] + + class ContainerSlot(Protocol): """Define the container slot API used by the UI.""" @@ -292,6 +316,32 @@ def build_recipe_from_proto(name: str, proto: dict) -> Recipe: ) +def normalize_allowed_effects(raw: object) -> frozenset[str]: + """Normalize allowed effects lists to a usable set.""" + if isinstance(raw, list): + allowed = {str(value) for value in raw if isinstance(value, str)} + else: + allowed = set() + if not allowed: + allowed = {"speed", "productivity"} + return frozenset(allowed) + + +def parse_effect_bonus(effect: object) -> float: + """Parse a module effect bonus payload.""" + bonus: object + if isinstance(effect, dict): + bonus = effect.get("bonus", 0.0) + elif isinstance(effect, (float, int)): + bonus = effect + else: + bonus = 0.0 + try: + return float(bonus) + except (TypeError, ValueError): + return 0.0 + + def build_machine_catalog( data_raw: Mapping[str, Mapping[str, dict]], ) -> dict[str, Machine]: @@ -300,23 +350,85 @@ def build_machine_catalog( for name, proto in data_raw.get("assembling-machine", {}).items(): crafting_speed = float(proto.get("crafting_speed", 1.0)) crafting_categories = tuple(proto.get("crafting_categories", [])) - allowed_effects = proto.get("allowed_effects", []) + allowed_effects_raw = proto.get("allowed_effects", []) + allowed_effects = normalize_allowed_effects(allowed_effects_raw) allow_productivity = "productivity" in allowed_effects if not allow_productivity: base_effect = (proto.get("effect_receiver") or {}).get( "base_effect", {} ) allow_productivity = bool(base_effect.get("productivity", 0)) + module_slots = int(proto.get("module_slots", 0)) catalog[name] = Machine( key=name, label=name.replace("-", " ").title(), crafting_speed=crafting_speed, allow_productivity=allow_productivity, crafting_categories=crafting_categories, + module_slots=module_slots, + allowed_effects=allowed_effects, + ) + return catalog + + +def build_module_catalog( + data_raw: Mapping[str, Mapping[str, dict]], +) -> dict[str, ModuleSpec]: + """Build a module catalog from data-raw module items.""" + catalog: dict[str, ModuleSpec] = {} + for name, proto in data_raw.get("module", {}).items(): + effects = proto.get("effect", {}) + if not isinstance(effects, dict): + effects = {} + speed_bonus = parse_effect_bonus(effects.get("speed")) + productivity_bonus = parse_effect_bonus(effects.get("productivity")) + limitation = frozenset(proto.get("limitation", []) or []) + limitation_blacklist = frozenset( + proto.get("limitation_blacklist", []) or [] + ) + catalog[name] = ModuleSpec( + key=name, + label=name.replace("-", " ").title(), + speed_bonus=speed_bonus, + productivity_bonus=productivity_bonus, + limitation=limitation, + limitation_blacklist=limitation_blacklist, ) return catalog +def build_beacon_catalog( + data_raw: Mapping[str, Mapping[str, dict]], +) -> dict[str, BeaconSpec]: + """Build a beacon catalog from data-raw beacon entities.""" + catalog: dict[str, BeaconSpec] = {} + for name, proto in data_raw.get("beacon", {}).items(): + module_slots = int(proto.get("module_slots", 0)) + effectivity = float(proto.get("distribution_effectivity", 1.0)) + allowed_effects = normalize_allowed_effects( + proto.get("allowed_effects", []) + ) + catalog[name] = BeaconSpec( + key=name, + label=name.replace("-", " ").title(), + module_slots=module_slots, + distribution_effectivity=effectivity, + allowed_effects=allowed_effects, + ) + return catalog + + +def select_default_beacon( + beacons: Mapping[str, BeaconSpec], +) -> BeaconSpec | None: + """Select the default beacon spec (prefer base beacon).""" + if not beacons: + return None + if "beacon" in beacons: + return beacons["beacon"] + return beacons[sorted(beacons.keys())[0]] + + def build_recipe_catalog( data_raw: Mapping[str, Mapping[str, dict]], recipe_keys: tuple[str, str, str], @@ -394,6 +506,92 @@ def build_icon_catalog( return catalog +def module_matches_recipe( + module: ModuleSpec, + recipe: Recipe, +) -> bool: + """Check module limitation lists against a recipe.""" + if module.limitation and recipe.key not in module.limitation: + return False + return not ( + module.limitation_blacklist + and recipe.key in module.limitation_blacklist + ) + + +def filter_modules_for_machine( + modules: Mapping[str, ModuleSpec], + *, + recipe: Recipe, + machine: Machine, + allowed_effects: frozenset[str], +) -> list[ModuleSpec]: + """Filter modules based on recipe, machine, and allowed effects.""" + filtered: list[ModuleSpec] = [] + for module in modules.values(): + if module.speed_bonus == 0.0 and module.productivity_bonus == 0.0: + continue + if module.productivity_bonus != 0.0 and ( + not recipe.allow_productivity or not machine.allow_productivity + ): + continue + if module.speed_bonus != 0.0 and "speed" not in allowed_effects: + continue + if ( + module.productivity_bonus != 0.0 + and "productivity" not in allowed_effects + ): + continue + if not module_matches_recipe(module, recipe): + continue + filtered.append(module) + return sorted(filtered, key=lambda item: item.label) + + +def module_label(module: ModuleSpec | None) -> str: + """Format module labels for selection widgets.""" + if module is None: + return "None" + return module.label + + +def beacon_label(beacon: BeaconSpec | None) -> str: + """Format beacon labels for selection widgets.""" + if beacon is None: + return "None" + return beacon.label + + +def compute_module_effects( + module: ModuleSpec | None, + *, + count: int, +) -> tuple[float, float]: + """Compute total module effects for a machine.""" + if module is None or count <= 0: + return 0.0, 0.0 + return module.speed_bonus * count, module.productivity_bonus * count + + +def compute_beacon_effects( + module: ModuleSpec | None, + *, + module_count: int, + beacon_count: int, + effectivity: float, +) -> tuple[float, float]: + """Compute total beacon effects applied to a machine.""" + if ( + module is None + or module_count <= 0 + or beacon_count <= 0 + or effectivity <= 0.0 + ): + return 0.0, 0.0 + factor = module_count * beacon_count * effectivity + return module.speed_bonus * factor, module.productivity_bonus * factor + + def find_icon( catalog: Mapping[tuple[str, str], IconSpec], proto_types: tuple[str, ...], @@ -692,11 +890,20 @@ def render_production_header() -> None: col.caption(label) +@dataclass(frozen=True) +class ProductionContext: + """Hold the data needed to render production rows.""" + + recipes: Mapping[str, Recipe] + machines: Mapping[str, Machine] + modules: Mapping[str, ModuleSpec] + beacon: BeaconSpec | None + icon_catalog: Mapping[tuple[str, str], IconSpec] + recipe_order: tuple[str, str, str] + + def render_production_rows( - recipes: Mapping[str, Recipe], - machines: Mapping[str, Machine], - icon_catalog: Mapping[tuple[str, str], IconSpec], - recipe_order: tuple[str, str, str], + context: ProductionContext, ) -> tuple[ dict[str, RecipeConfig], dict[str, tuple[ContainerSlot, ContainerSlot, ContainerSlot]], @@ -710,20 +917,20 @@ def render_production_rows( ] = {} count_slots: dict[str, CaptionSlot] = {} - for recipe_key in recipe_order: - recipe = recipes[recipe_key] + for recipe_key in context.recipe_order: + recipe = context.recipes[recipe_key] eligible = [ key - for key, machine in machines.items() + for key, machine in context.machines.items() if recipe.category in machine.crafting_categories ] if not eligible: - eligible = list(machines.keys()) + eligible = list(context.machines.keys()) cols = st.columns([1.4, 1.8, 1.6, 0.9, 2.4, 2.4, 2.4]) with cols[0]: recipe_icon = find_icon( - icon_catalog, + context.icon_catalog, ("recipe",), name=recipe.key, ) @@ -732,11 +939,11 @@ def render_production_rows( with cols[1]: machine = render_machine_selector( recipe, - machines, + context.machines, eligible, ) machine_icon = find_icon( - icon_catalog, + context.icon_catalog, ("item", "assembling-machine"), name=machine.key, ) @@ -744,7 +951,12 @@ def render_production_rows( count_slots[recipe_key] = st.empty() with cols[2]: - effects = render_effect_controls(recipe) + effects = render_effect_controls( + recipe, + machine=machine, + modules=context.modules, + beacon=context.beacon, + ) with cols[3]: st.caption("—") @@ -853,8 +1065,7 @@ def machine_label(machine: Machine) -> str: def compute_effective_speed(machine: Machine, effects: EffectSettings) -> float: """Compute the effective crafting speed after bonuses.""" - bonus = effects.speed_bonus + effects.beacon_speed_bonus - return machine.crafting_speed * (1.0 + bonus) + return machine.crafting_speed * (1.0 + effects.speed_bonus) def per_machine_rates(recipe: Recipe, config: RecipeConfig) -> FlowRates: @@ -1028,51 +1239,104 @@ def solve_chain( ) -def render_effect_controls(recipe: Recipe) -> EffectSettings: +def render_effect_controls( + recipe: Recipe, + *, + machine: Machine, + modules: Mapping[str, ModuleSpec], + beacon: BeaconSpec | None, +) -> EffectSettings: """Render module and beacon controls for a recipe.""" - use_modules = st.checkbox( - "Modules", - key=f"{recipe.key}-modules", - help="Provide total speed/productivity bonuses from modules.", - ) - speed_bonus = 0.0 - productivity_bonus = 0.0 - if use_modules: - module_cols = st.columns(2) - speed_bonus = module_cols[0].number_input( - "Speed %", - min_value=0.0, - value=0.0, - step=5.0, - key=f"{recipe.key}-module-speed", - ) - productivity_bonus = module_cols[1].number_input( - "Productivity %", - min_value=0.0, - value=0.0, - step=1.0, - key=f"{recipe.key}-module-prod", - ) + module_column, beacon_column = st.columns(2) - use_beacons = st.checkbox( - "Beacons", - key=f"{recipe.key}-beacons", - help="Provide a total speed bonus from beacons.", - ) - beacon_speed_bonus = 0.0 - if use_beacons: - beacon_speed_bonus = st.number_input( - "Beacon speed %", - min_value=0.0, - value=0.0, - step=5.0, - key=f"{recipe.key}-beacon-speed", - ) + module_speed = 0.0 + module_productivity = 0.0 + + with module_column: + st.caption("Modules") + if machine.module_slots <= 0: + st.caption("No module slots") + else: + allowed_modules = filter_modules_for_machine( + modules, + recipe=recipe, + machine=machine, + allowed_effects=machine.allowed_effects, + ) + module_options: list[ModuleSpec | None] = [None] + module_options.extend(allowed_modules) + selected_module = st.selectbox( + "Module", + options=module_options, + format_func=module_label, + key=f"{recipe.key}-module", + label_visibility="collapsed", + ) + count_options = list(range(machine.module_slots + 1)) + module_count = st.selectbox( + "Module count", + options=count_options, + index=machine.module_slots, + key=f"{recipe.key}-module-count", + label_visibility="collapsed", + ) + module_speed, module_productivity = compute_module_effects( + selected_module, + count=module_count, + ) + + beacon_speed = 0.0 + beacon_productivity = 0.0 + + with beacon_column: + st.caption("Beacons") + if beacon is None: + st.caption("No beacon data") + else: + effectivity = beacon.distribution_effectivity + st.caption(f"{beacon.label} (effectivity {effectivity:g})") + beacon_allowed_effects = ( + beacon.allowed_effects & machine.allowed_effects + ) + beacon_modules = filter_modules_for_machine( + modules, + recipe=recipe, + machine=machine, + allowed_effects=beacon_allowed_effects, + ) + beacon_module_options: list[ModuleSpec | None] = [None] + beacon_module_options.extend(beacon_modules) + selected_beacon_module = st.selectbox( + "Beacon module", + options=beacon_module_options, + format_func=module_label, + key=f"{recipe.key}-beacon-module", + label_visibility="collapsed", + ) + beacon_module_count = st.selectbox( + "Beacon module count", + options=list(range(beacon.module_slots + 1)), + index=beacon.module_slots, + key=f"{recipe.key}-beacon-module-count", + label_visibility="collapsed", + ) + beacon_count = st.selectbox( + "Beacon count", + options=list(range(13)), + index=0, + key=f"{recipe.key}-beacon-count", + label_visibility="collapsed", + ) + beacon_speed, beacon_productivity = compute_beacon_effects( + selected_beacon_module, + module_count=beacon_module_count, + beacon_count=beacon_count, + effectivity=beacon.distribution_effectivity, + ) return EffectSettings( - speed_bonus=speed_bonus / 100.0, - productivity_bonus=productivity_bonus / 100.0, - beacon_speed_bonus=beacon_speed_bonus / 100.0, + speed_bonus=module_speed + beacon_speed, + productivity_bonus=module_productivity + beacon_productivity, ) @@ -1127,6 +1391,15 @@ def main() -> None: st.error("No assembling machines found in data-raw.") return + modules = build_module_catalog(data_raw) + if not modules: + st.warning("No modules found in data-raw.") + + beacons = build_beacon_catalog(data_raw) + beacon_spec = select_default_beacon(beacons) + if beacon_spec is None: + st.warning("No beacons found in data-raw.") + recipes = build_recipe_catalog(data_raw, recipe_order) if len(recipes) != len(recipe_order): st.error("Some selected recipes were not found in data-raw.") @@ -1150,11 +1423,16 @@ def main() -> None: st.subheader("Production") render_production_header() + production_context = ProductionContext( + recipes=recipes, + machines=machines, + modules=modules, + beacon=beacon_spec, + icon_catalog=icon_catalog, + recipe_order=recipe_order, + ) config_map, row_slots, count_slots = render_production_rows( - recipes, - machines, - icon_catalog, - recipe_order, + production_context ) demand_pg_per_s = demand_pg_per_min / 60.0