1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Graphviz + NetworkXで複雑なレシピをグラフで可視化

Posted at

下の図のように、グラフでレシピを可視化するのが目的です。
gv.png
gv.png

はじめに

Satisfactoryというゲームをご存じでしょうか?
このゲームでは工場を作って、大量の部品を生産していきます。しかし、そのレシピ(製造フロー)が非常に複雑で、何をどのくらい作ればいいのかを考えるのが大変です。

グラフで可視化すれば解決できるのでは?

そこで、レシピをグラフ(ネットワーク)で可視化すること解決を図りました。グラフはノード(頂点)とエッジ(辺)で構成されるデータ構造です。ネットワークとも言います。グラフを可視化することで、複雑なデータの関係性を直感的に理解しやすくなります。今回は、ノードをアイテム、エッジを「材料かどうか」の関係とし、レシピをツリー状に可視化しました。
実際に作成したグラフが、冒頭の図です。階層型の構造にすることで、どのアイテムがどの材料から作られるのかが一目で分かります。また、エッジの上には「1分間に何個生産すればよいか」を表示しました。こうすることでレシピと必要生産量が簡単に確認できます。

実装

レシピのデータを取得する

まず、グラフの元となるレシピデータを用意する必要があります。
手作業でレシピを入力するのは大変なので、攻略Wikiからデータを抽出します。
https://satisfactory.wiki.gg/wiki/Recipes
このページには、ゲーム内のすべてのレシピが表形式で掲載されています。
この表を取得し、プログラムで扱いやすいデータに変換していきます。

ページの取得には requests、HTML解析には BeautifulSoup を使用します。

pip install requests beautifulsoup4

まずは、WikiのページのHTMLを取得します。

import requests

url = "https://satisfactory.wiki.gg/wiki/Recipes"
response = requests.get(url)

html_content = response.content

次に、HTMLを解析して、レシピの表を取り出します。

from bs4 import BeautifulSoup

# BeautifulSoupでHTMLを解析
soup = BeautifulSoup(html_content, "html.parser")

# 最初の<table>タグを取得
table = soup.find("table")

# 行データを取り出す
rows = table.find_all("tr")
data = []

# 各行のセル(thやtd)を取得
for row in rows:
    cells = row.find_all(["th", "td"])  # th: ヘッダー, td: データセル
    data.append(cells)

この時点で、HTMLの表をプログラムで扱えるリストの形に変換できました。
しかし、このままではHTMLのタグ情報が混ざっているため、次にデータを整理していきます。

import re
from bs4 import Tag

def extract_number(text: str) -> float:
    # 文字列から数字部分だけを抽出

    # 正規表現で数字とカンマを抽出
    number_str = ''.join(re.findall(r'[0-9,]+', text))
    
    # カンマを取り除き、floatに変換
    return float(number_str.replace(',', ''))

def extract_materials_from_tag(tag: Tag) -> list:
    # HTMLタグから材料情報をリストとして取得
    
    recipe_items = tag.find_all('div', class_='recipe-item')

    materials = []
    for item in recipe_items:
        # 数量を取得
        quantity_text = item.find('span', class_='item-amount').text.strip()
        quantity = extract_number(quantity_text)
        
        # アイテム名を取得
        name = item.find('span', class_='item-name').text.strip()
        
        # 速度を取得 (数値部分のみ)
        speed_text = item.find('span', class_='item-minute').text.strip()
        speed = float(speed_text.split(' ')[0].replace(',', ''))  # "1,500 / min" -> "1500" -> 1500.0
        
        materials.append({
            "name": name,
            "quantity": quantity,
            "speed": speed # 分速
        })
    
    return materials


def extract_produces_in(tag: Tag):
    # 生産設備情報を取得
    
    machine, crafting_time = list(tag.stripped_strings)[:2] # 一番最初のものだけ
    
    crafting_time = extract_number(crafting_time)

    produced_in = {
        "name": machine,
        "crafting_time": crafting_time # 秒
    }

    return produced_in
    

def parse_row(row: list[Tag]):
    # 1つのレシピを解析し、辞書形式で返す
    
    recipe, ingredients, produced_in, products, unlocked_by = row

    return {
        "recipe": list(recipe.stripped_strings)[0],
        "ingredients": extract_materials_from_tag(ingredients),
        "produced_in": extract_produces_in(produced_in),
        "products": extract_materials_from_tag(products),
        "unlocked_by": unlocked_by.get_text(separator=" ")
    }
recipes_tag = data[1:] # 見出し行を除外
recipes = [parse_row(recipe_tag) for recipe_tag in recipes_tag]

# 確認
print(recipes[0])

出力例(見やすいように整形しています):

{
    "recipe": "AI Expansion Server",
    "ingredients": [
        {"name": "Magnetic Field Generator", "quantity": 1.0, "speed": 4.0},
        {"name": "Neural-Quantum Processor", "quantity": 1.0, "speed": 4.0},
        {"name": "Superposition Oscillator", "quantity": 1.0, "speed": 4.0},
        {"name": "Excited Photonic Matter", "quantity": 25.0, "speed": 100.0}
    ],
    "produced_in": {"name": "Quantum Encoder", "crafting_time": 15.0},
    "products": [
        {"name": "AI Expansion Server", "quantity": 1.0, "speed": 4.0},
        {"name": "Dark Matter Residue", "quantity": 25.0, "speed": 100.0}
    ],
    "unlocked_by": "Tier 9 - Quantum Encoding\n"
}

レシピデータを辞書型に整理し、JSONに保存します。
アイテム名をキーにして辞書に変換すると、検索や処理がしやすくなります。

# "recipe"をキーとして、残りの情報を値にする辞書を作成
recipes_dict = {recipe["recipe"]: {key: recipe[key] for key in recipe if key != "recipe"} for recipe in recipes}

JSONファイルとして保存。

import json

with open("recipes.json", 'w', encoding='utf-8') as f:
    json.dump(recipes_dict, f, ensure_ascii=False, indent=4)

次は、これをもとにグラフを生成していきます。

Networkx,Graphvizのインストール

グラフの作成にはNetworkXというライブラリを使用します。

pip install networkx

また、グラフの描画にはGraphvizを使います。NetworkXでも描画は可能ですが、階層型の描画にはGraphvizが必要です。
GraphvizはPythonライブラリではなく、システムにインストールする必要があります。

  1. Graphvizの公式サイト からインストーラーをダウンロード
  2. インストール時に 「Add Graphviz to the system PATH for current user」 にチェックを入れる
    スクリーンショット 2025-03-04 124713.png

インストールが完了したら、Python用のGraphvizライブラリをインストールします。

pip install graphviz

グラフの作成・描画

保存しておいたレシピデータを読み込みます。

import json

# JSONファイルを読み込んで辞書に変換
with open("recipes.json", "r", encoding="utf-8") as f:
    recipes = json.load(f)

グラフを作成する関数です。

import graphviz
import networkx as nx

def get_ingredients_rate_tree(G, target, target_rate):
    """
    指定したアイテムを1分間にtarget_rate個作るために必要な材料の量を計算し、
    networkxのグラフに追加していく。

    引数:
        G (networkx.Graph): 作成するグラフオブジェクト
        target (str): 作成したいアイテム名
        target_rate (float): 目標の生産速度(1分間に何個作るか)
    """
    # レシピに target が存在しない場合は処理を終了
    if target not in recipes:
        return
    
    # 作成倍率を計算
    mul = target_rate / recipes[target]["products"][0]["speed"]
    
    # 必要な材料ごとに処理
    for ingredient in recipes[target]["ingredients"]:
        # 必要な材料の量を計算
        weight = ingredient["speed"] * mul
        ingredient_name = ingredient["name"]
        
        if G.has_edge(ingredient_name, target):
            # すでに同じエッジが存在する場合、重みを加算
            G[ingredient_name][target]['weight'] += weight
        else:
            # 新しいエッジとして追加(材料 -> 完成品 の流れ)
            G.add_weighted_edges_from([[ingredient_name, target, weight]])
        
        # 材料を再帰的に処理し、ツリー構造を作成
        get_ingredients_rate_tree(G, ingredient_name, weight)

グラフを描画する関数です。

def draw_graph(G, filename="gv"):
    # グラフをGraphvizを使って描画
    
    dot = graphviz.Digraph(format="svg", filename=filename) # svgの方が文字が見やすい
    dot.attr(rankdir="LR") # ツリーを横向き(左から右)に描画

    # ノードを追加
    for node in G.nodes():
        dot.node(node, shape="box")

    # エッジを追加(重みをラベルとして表示)
    for u, v, data in G.edges(data=True):
        weight = f"{data['weight']:.2f}"  # エッジの重みを取得(小数点2桁に整形)
        dot.edge(u, v, label=weight)  # エッジのラベルに重みを設定
        # ちなみにパラメータは文字列で指定する必要がある

    dot.view()

実際にグラフを作成・描画

例:Adaptive Control Unitを1分間に1個作るためのグラフを描画。

G = nx.DiGraph()
get_ingredients_rate_tree(G, "Adaptive Control Unit", 1)
draw_graph(G)

結果はこうなります。
gv.png

辺の上にある数字が1分間に必要な量です。
このように、レシピと必要な材料の関係が可視化されます。また、各材料の1分間あたりの必要量も表示されます。

ちなみに、ExecutableNotFound: failed to execute WindowsPath('dot'), make sure the Graphviz executables are on your systems' PATHというエラーが出たら、エディタを再起動すると直るかもしれません。

終わりに

グラフで可視化することで、複雑なレシピを把握しやすくできました。これで計画が立てやすくなります。

ちなみに、作った後に調べてみたらもうすでに同じことをやっているWebアプリがありました。
https://www.satisfactorytools.com/1.0/production
車輪の再発明...

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?