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/.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..99b1bef --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/README.md @@ -0,0 +1,25 @@ +# 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 + +## 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..7559e26 --- /dev/null +++ b/src/private/app/factorio-cycle-calculator/app.py @@ -0,0 +1,1467 @@ +"""Streamlit example for the Factorio oil-processing chain.""" + +from __future__ import annotations + +import io +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, Self + +import streamlit as st +from ortools.linear_solver import pywraplp +from PIL import Image + +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" +) +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 +MIN_LIST_ENTRY_LEN = 2 + + +@dataclass(frozen=True) +class Machine: + """Describe a crafting machine and its capabilities.""" + + key: str + label: str + crafting_speed: float + allow_productivity: bool + crafting_categories: tuple[str, ...] + module_slots: int + allowed_effects: frozenset[str] + + +@dataclass(frozen=True) +class Recipe: + """Describe a recipe and its inputs/outputs.""" + + key: str + label: str + category: str + energy_required: float + ingredients: Mapping[tuple[str, str], float] + results: Mapping[tuple[str, str], float] + allow_productivity: bool + + +@dataclass(frozen=True) +class EffectSettings: + """Hold module and beacon bonuses.""" + + speed_bonus: float + productivity_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[tuple[str, str], float] + consumption: dict[tuple[str, 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[tuple[str, str], float] + objective_value: float | None + + +@dataclass(frozen=True) +class IconSpec: + """Hold a resolved icon path and its intended size.""" + + path: Path + 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.""" + + 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[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] + recipe_order: tuple[str, str, str] + + +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 + + +@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, +) -> 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 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]: + """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_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], +) -> 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 + + +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) + + +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]: + """Build the icon catalog for recipes, machines, and flows.""" + data_dir = Path(data_dir_path) + if not data_dir.exists(): + return {} + + 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)) + + 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: + continue + resolved = resolve_icon_path(icon_path, data_dir) + if resolved.exists(): + catalog[(proto_type, name)] = IconSpec( + path=resolved, + size=icon_size, + ) + 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, ...], + *, + 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[tuple[str, str], float], dict[tuple[str, str], float]]: + """Accumulate total per-second production and consumption.""" + 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 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[tuple[str, str], float], + consumption: Mapping[tuple[str, 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[tuple[str, str], float] = {} + keys = set(production) | set(consumption) + 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 flow_key, value in sorted(net.items()): + scaled = value * unit_multiplier + if scaled > FLOW_EPSILON: + if flow_key == ("fluid", "petroleum-gas"): + products.append((flow_key[0], flow_key[1], scaled)) + else: + byproducts.append((flow_key[0], flow_key[1], scaled)) + elif scaled < -FLOW_EPSILON: + ingredients.append((flow_key[0], flow_key[1], abs(scaled))) + + return products, byproducts, ingredients + + +def build_recipe_rows( + 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 = { + flow_key: rate * count * unit_multiplier + for flow_key, rate in rates.production.items() + } + consumption = { + flow_key: rate * count * unit_multiplier + for flow_key, rate in rates.consumption.items() + } + + 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 flow_key, value in production.items(): + if primary and flow_key != primary: + byproducts.append((flow_key[0], flow_key[1], value)) + else: + products.append((flow_key[0], flow_key[1], value)) + + ingredients = [ + (flow_key[0], flow_key[1], value) + for flow_key, 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_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) + 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) + + +@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( + context: ProductionContext, +) -> 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[ContainerSlot, ContainerSlot, ContainerSlot], + ] = {} + count_slots: dict[str, CaptionSlot] = {} + + for recipe_key in context.recipe_order: + recipe = context.recipes[recipe_key] + eligible = [ + key + for key, machine in context.machines.items() + if recipe.category in machine.crafting_categories + ] + if not eligible: + 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( + context.icon_catalog, + ("recipe",), + name=recipe.key, + ) + render_icon_label(recipe_icon, recipe.label) + + with cols[1]: + machine = render_machine_selector( + recipe, + context.machines, + eligible, + ) + machine_icon = find_icon( + context.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, + machine=machine, + modules=context.modules, + beacon=context.beacon, + ) + + 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(("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}" + ) + 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 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, + 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 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.""" + return machine.crafting_speed * (1.0 + effects.speed_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, + 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) + 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, heavy_key, light_key = recipe_order + + 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( + ("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( + ("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] + >= demand_pg_per_s + ) + + objective_terms = [] + for recipe_key in recipes: + 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] + ) + solver.Minimize(solver.Sum(objective_terms)) + 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 = { + ("fluid", "heavy-oil"): heavy_prod * machine_counts[advanced_key] + - heavy_cons * machine_counts[heavy_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], + ("fluid", "petroleum-gas"): pg_prod_advanced + * machine_counts[advanced_key] + + pg_prod_from_light * machine_counts[light_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, + *, + machine: Machine, + modules: Mapping[str, ModuleSpec], + beacon: BeaconSpec | None, +) -> EffectSettings: + """Render module and beacon controls for a recipe.""" + module_column, beacon_column = st.columns(2) + + 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=module_speed + beacon_speed, + productivity_bonus=module_productivity + beacon_productivity, + ) + + +def render_machine_selector( + recipe: Recipe, + machines: Mapping[str, Machine], + machine_keys: list[str], +) -> Machine: + """Render the machine selector for a recipe.""" + options = [machines[key] for key in machine_keys] + return st.selectbox( + "Machine", + options=options, + format_func=machine_label, + key=f"{recipe.key}-machine", + label_visibility="collapsed", + ) + + +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 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() + + 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 + + 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.") + 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." + ) + + products_slot, byproducts_slot, ingredients_slot = ( + render_summary_placeholders() + ) + status_slot = st.empty() + + 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( + production_context + ) + + demand_pg_per_s = demand_pg_per_min / 60.0 + result = solve_chain( + demand_pg_per_s, + recipes, + config_map, + force_integer=force_integer, + recipe_order=recipe_order, + ) + + if result is None: + return + + 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, + recipe_order=recipe_order, + ) + 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 new file mode 100644 index 0000000..a1cefb9 --- /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", "pillow>=10.3.0", "streamlit>=1.42.0"] diff --git a/uv.lock b/uv.lock index c0b2c0a..69b8675 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,23 @@ 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 = "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" }, +] + [[package]] name = "fastavro" version = "1.12.1" @@ -1063,6 +1090,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 +1969,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 +2220,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]]