Skip to content

Endogenous Industry Heat - #1943

Open
LukasFrankenQ wants to merge 33 commits into
PyPSA:masterfrom
lukas-develops:endog
Open

LukasFrankenQ wants to merge 33 commits into
PyPSA:masterfrom
lukas-develops:endog

Conversation

@LukasFrankenQ

@LukasFrankenQ LukasFrankenQ commented Dec 11, 2025

Copy link
Copy Markdown
Collaborator

pypsa/pypsa-eur#611

Adds industry process heat demands heat100-200, heat200-500 and heat>500 by subtracting them from industry biomass and methane demands

if

config["sector"]["industry_t"]["endogen"] == True.

Temperature splits are process-aware, based on Fraunhofer ISI research Fleiter et al. (2025), Hydrogen Infrastructure in the Future CO2-Neu....

The PR does not cover steelworks. Its endogenisation is in a separate PR.

Changes to the Model

With industry heat endogenous, all existing industry-related components remain, heat demands are purely subtracted from biomass and methane industry demands, and are added as separate loads with the respective supply opportunities.

image

The changes to prepare_sector_network are largely identical to the previous PR by @lisazeyen, and the heat generation technologies added are the same. The temperature bands are chosen to match potentially interesting alternative renewable heat supply such as solar thermal and geothermal, which could be added to the model if desired.

image

The logic for exogenous heating remains untouched. There was a very sensible proposition by @koen-vg to set the exogenous heat supply mix in the config, as is done with industry production pathways. However, the volume of content this approach would add to the config was, in my estimation, too large.
Therefore, this PR introduces a new rule that builds (planning-horizons–unaware) heat-endogenous ratios and then simply infers endogenous industry demands (including the temperature bands) in build_industry_energy_demand_per_node, following the same logic as exogenous demand using ratios_intermediate.
As a result, industry_energy_demands_per_node consists essentially two concatenated dataframes. Level 0 in columns == "exogenous" contains the old dataframe and == "endogenous" is the new one.
Based on the endogenisation-switch in config, prepare_sector_network pulls the respective columns.

image

Towards Validation

Comparing exogenous and endogenous 2030 industry demands shows that the PR removes the vast majority of (industry) solid biomass and most of (industry) methane demand. Exogenous heat presumes electrification, likely explaining why the total energy demand is lower in the exogenous case.

image

The PR uses Fleiter et al. (2025) to estimate temperature band distributions for each industry process, and uses alternative process-specific sources to fill data gaps in between. Again, no steelworks in this PR.

convey_pr_content_2

These are very distinct between industries; however, they still yield largely similar temperature distributions between countries:

image

Spatial heterogeneity might only manifest in higher spatial resolutions.

Here, and in the previous iterations, we are aiming to align with Agora's estimate of industry heating demand at a total of around 1850 TWh/a:

image

Considering we are not including steelworks, we are doing quite well:

convey_pr_content_3

ToDos and Food for Thought

  • Need to ensure process emissions, HVC disaggregation, current electricity, etc. are synergising correctly with current methods.
  • After talking to @Irieo, it could be very valuable to either introduce a switch that endogenises only a subset of industries, or even represent each industry sector individually.
  • Think about synergy with @toniseibold’s steel endogenisation and the addition of individual cement production sites.
  • Other supply technologies: currently, we only consider electrified heating in heat100-200 and low-temperature heat, but in some sectors, electrification of higher-temperature demands is feasible.
  • As noted by @toniseibold, there appears to be some inconsistency in the biomass boiler's efficiency.

Happy about any feedback, and all the best,
Lukas

@lisazeyen @millingermarkus @Irieo @koen-vg @toniseibold @fneum @bobbyxng @p-glaum

PS: Are the pictures readable, or do they throw an error when clicked on?

Checklist

  • I tested my contribution locally and it works as intended.
  • Code and workflow changes are sufficiently documented.
  • [N/A] Changed dependencies are added to pixi.toml (using pixi add <dependency-name>).
  • Changes in configuration options are added in config/config.default.yaml.
  • Changes in configuration options are documented in doc/configtables/*.csv.
  • For new data sources or versions, these instructions <https://pypsa-eur.readthedocs.io/en/latest/data_sources.html>_ have been followed.
  • A release note doc/release_notes.rst is added.

LukasFrankenQ and others added 16 commits December 18, 2025 16:00
Endogenous industry sector ratios:
- Swap the Pulp/Paper PRODCOM mappings, which were crossed (pulp ->
  paper products, paper -> pulp products).
- Fix the pharmaceuticals backup share: heat>500 was 1.0, making the row
  sum to 2.0; set to 0.0 so the bands sum to 1.0.
- Override the Fleiter 2025 Mechanical pulp row (100% heat<100) with
  100% heat100-200, reflecting TMP/CTMP refiner steam at 130-170 C
  (Rehfeldt et al. 2018).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LukasFrankenQ
LukasFrankenQ marked this pull request as ready for review June 16, 2026 20:30
@LukasFrankenQ

LukasFrankenQ commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

@fneum @Irieo @toniseibold I think this is now in a quite good state, and could maybe even be merged, some notes:

  1. @Irieo By default it now applies endogenous heating to all industry sectors (except steel), but the config allows them to be switched on or off individually, so you could do an endogenous heat treatment of any subset of industry sectors.
  2. @fneum A central point we had talked about half a year ago was about the accounting of biomass emissions--that should now be fixed, biomass boilers are wired directly to spatial.biomass.
  3. @toniseibold There are definitely some conflicts with endogenous cement, but I would argue its most practical to deal with it after of our PRs is merged, if that is cool :P [ insert meme about first-PR-to-merge ]
  4. @lkstrp there is this weird deletion of a full file, please let me know if that is a concern, I am being told that its okay
image

@w-tusche w-tusche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this and pushing it further!

Upon testing the PR code for an own project I ran in to the problem that options and spatial in the new functions in prepare_sector_network.py are global. My suggestion is to pass these as arguments to the functions (as is done for options in the other functions in prepare_sector_network.py and as I suggest in PR2232 for spatial).

Some further comments on a stray pass and a minor unnecessarily complex if condition.

bus2 and bus3 are not consistent throught out the newly added links:
Sometimes it is:
bus2="co2 atmosphere",
bus3=spatial.co2.nodes,
(e.g. ["industry_t"]["heat100-200"]["biomass"])

other times:
bus2=spatial.co2.nodes,
bus3="co2 atmosphere",
(e.g., "heat200-500" biomass and methane links)

Comment thread scripts/prepare_sector_network.py Outdated
"""

logger.info("Add industry heat in the temperature band 100-200 °C.")
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function stops here, remove pass.

Comment thread scripts/prepare_sector_network.py Outdated
)


def add_t_industry100_200(n, nodes, industrial_demand, costs, must_run):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add options, spatial as parameters:
def add_t_industry100_200(n, nodes, industrial_demand, costs, must_run, options, spatial):

Comment thread scripts/prepare_sector_network.py Outdated

if (
options["industry_t"]["heat100-200"]["biomass"]
or not options["industry_t"]["endogen"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup, or not is not required, as "endogen" is required for "heat100-200".

if options["industry_t"]["heat100-200"]["biomass"]:

Comment thread scripts/prepare_sector_network.py Outdated
options["industry_t"]["heat100-200"]["biomass"]
or not options["industry_t"]["endogen"]
):
logger.info(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question

Comment thread scripts/prepare_sector_network.py Outdated
)


def add_t_industry200_500(n, nodes, industrial_demand, costs, must_run):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add options, spatial as parameters:

def add_t_industry200_500(n, nodes, industrial_demand, costs, must_run, options, spatial):

p_nom_extendable=True,
p_min_pu=must_run,
efficiency=costs.at["direct firing gas", "efficiency"],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the lifetime is missing. (lifetime=costs.at["direct firing gas", "lifetime"],)

Comment thread scripts/prepare_sector_network.py Outdated

must_run = options["industry_t"]["must_run"]

add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass arguments options and spatial add_t_industry100_200(n, nodes, industrial_demand, costs, must_run, options, spatial)

Comment thread scripts/prepare_sector_network.py Outdated
must_run = options["industry_t"]["must_run"]

add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)
add_t_industry200_500(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass arguments options and spatial add_t_industry200_500(n, nodes, industrial_demand, costs, must_run, options, spatial)

Comment thread scripts/prepare_sector_network.py Outdated

add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)
add_t_industry200_500(n, nodes, industrial_demand, costs, must_run)
add_t_industry500(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass arguments options and spatial add_t_industry500(n, nodes, industrial_demand, costs, must_run, options, spatial)

@BertoGBG

BertoGBG commented Sep 1, 2026

Copy link
Copy Markdown

I have been trying to integrate this PR in my workflow and I want to flag an issue I have encountered for the myopic runs.

add_brownfield.py fixes every non-extendable link's capacity to its inherited p_nom_opt and sets p_nom_extendable=False going forward. For the industry_t supply links (gas/hydrogen/biomass/ etc..) , p_min_pu is set to a fixed must_run fraction of that fixed inherited capacity. so once an inherited capacity becomes brownfield, its must-run floor is a fixed absolute MW value, permanently.

If the nodal industry-heat demand at that bus falls in a later horizon (e.g. demand for combustion plants due to industrial transformation), the inherited floor can end up exceeding the new demand. Since the fixed inherited plant can't ramp below its floor, and newer plant can only add supply, not subtract from an oversupplied older one. In this case the nodal balance becomes structurally infeasible.

I want to suggest a fix, that we discussed with @martavp

Links whose bus1 ends in " lowT industry"/" mediumT industry"/" highT industry", don't fix p_nom outright during brownfield carry-over. Instead, keep them extendable but capped:

  • p_nom_max = inherited p_nom_opt (shrink-only — can never exceed what was actually built)
  • p_nom_min = 0 (allow full early retirement)
  • capital_cost = 0 (sunk cost, not re-charged for whatever survives)
  • p_min_pu (the must_run fraction) is left untouched, so it stays physically meaningful for whatever capacity remains — only the quantity of surviving brownfield capacity becomes an optimization decision, not the flexibility of the equipment itself.
    This lets the solver retire part of an oversized older plant based on real fuel/CO2-price trade-offs when demand falls, instead of being forced into infeasibility. New capacity growth is unaffected, it still goes entirely through each horizon's own separately-extendable new-inherited link, so e.g. hydrogen expanding while gas contracts within the same horizon is still fully representable.

Hopefully this can be useful, otherwise please disregard this comment.

LukasFrankenQ and others added 3 commits September 3, 2026 11:29
# Conflicts:
#	doc/configuration.md
#	scripts/prepare_sector_network.py
Single-country runs drop processes the country does not have via
dropna(how='all'), so selecting them with idx[:, process] raised a
KeyError. Guard both band-splitting loops with a column mask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pass options and spatial as parameters to add_t_industry* functions
- remove stray pass and leftover review-question logging
- drop dead 'or not endogen' condition (functions only run when endogen)
- normalize CC links to bus2='co2 atmosphere', bus3=co2 stored ordering
  (efficiencies swapped alongside, no behavior change)
- add missing lifetime on heat200-500 industry hydrogen link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasFrankenQ

Copy link
Copy Markdown
Collaborator Author

Hi @w-tusche , apologies for the long silence here! Thank you for the review and suggestions, I have adjusted the content of the PR accordingly

@LukasFrankenQ

Copy link
Copy Markdown
Collaborator Author

@BertoGBG Thank you for this suggestion, very good point, this was easily reproduced! I fixed this issue in the implementation but set the capital_cost to the FOM, not 0, to capture some additional retirement pressure. Please let me know if anything else is causing trouble.

@fneum I would flag this PR now as being ready to merge (or review) :)

@w-tusche w-tusche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, we all got other things to do.
Thanks for implementing the suggested changes!

One more bug I encountered:

Comment thread config/config.default.yaml Outdated
biomass: true
methane: true
hydrogen: true
"heat>500":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I encountered an "Invalid argument" bug when running post-processing steps (creating balance maps and timeseries for the new carrieres).

OSError: [Errno 22] Invalid argument: 'results/heat-test/graphs/balances-heat>500 industry.pdf'

The ">" in the carrier name "heat>500" is not filename-safe on all OS systems. In my case the bug occured when trying to store the post-processing output for the new carriers on a NTFS-mount on a Linux system.

Suggested fix: use "+" instead of ">" so the idea would be to then have "heat500+ industry"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely fair, thank you, also adjusted :)

'>' is a reserved filename character on Windows/NTFS, so postprocessing
outputs like 'balances-heat>500 industry.pdf' fail with OSError on those
filesystems. Renamed the band key, carriers and bus names to 'heat500+'
as suggested in review; config defaults and JSON schema regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@w-tusche

w-tusche commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

I would like to bring two more things to the discussion:

1. heat100-200 industry: heat pump and electric boiler end up on different electricity buses

While testing and looking at the balance graphs I noticed, that the industrial heat pump and electric boiler are actually connected to different electricity buses, which looked odd because they seemed to be both connected the same way in add_t_industry100_200 (bus0=nodes).

Investigating I found that the change happens in insert_electricity_distribution_grid and it moves only the heat pump:

# scripts/prepare_sector_network.py:1679-1680
industrial_hps = n.links.index[n.links.carrier.str.contains("industrial heat pump")]
n.links.loc[industrial_hps, "bus0"] += " low voltage"

The band's electric boiler remains connected to the AC node. Thus, the industry HP is connected to the distribution grid and the boiler to the transmission grid. I think to be comparable they should be connected to the same grid.

minimal example (one node, dummy costs) quickly created with Claude
from types import SimpleNamespace
import pandas as pd, pypsa
from scripts.prepare_sector_network import (
    add_t_industry100_200, insert_electricity_distribution_grid,
)

n = pypsa.Network()
n.set_snapshots(["now"])
n.add("Bus", "N0", carrier="AC")
nodes = pd.Index(["N0"])

options = {
    "industry_t": {"heat100-200": {
        "biomass": False, "methane": False,
        "heat_pumps": True, "electric_boiler": True,
    }},
    "transmission_efficiency": {"enable": []},
}
costs = pd.DataFrame(0.0, columns=["efficiency", "capital_cost", "marginal_cost", "lifetime"],
    index=["industrial heat pump high temperature", "electric boiler steam",
           "electricity distribution grid", "solar-utility", "home battery storage",
           "home battery inverter", "battery storage", "battery inverter"])
costs["efficiency"] = 1.0
costs["lifetime"] = 20.0

add_t_industry100_200(n, nodes, pd.DataFrame({"heat100-200": [8760.0]}, index=nodes),
    costs, must_run=0.8, options=options, spatial=SimpleNamespace())

hp = "N0 heat100-200 industry industrial heat pump high temperature"
eb = "N0 heat100-200 industry electric boiler steam"
print(n.links.at[hp, "bus0"], "|", n.links.at[eb, "bus0"])

insert_electricity_distribution_grid(n, costs, options,
    pop_layout=pd.DataFrame(index=nodes), solar_rooftop_potentials_fn="")

print(n.links.at[hp, "bus0"], "|", n.links.at[eb, "bus0"])
before: N0             | N0
after:  N0 low voltage | N0

In my larger test runs (150 nodes, 24H and 3H resolution; however with custom additions on my side besides the PR code) this was not really relevant, as only heat pumps were built in this band, and the asymmetry favors the boiler (it skips the distribution grid). But it might matter with a lower COP, or changed heat source for the heat pump (see below), heat_pumps: false or different cost assumptions.

Suggestion: drop the two lines above and leave both on the AC node. That matches the other endogenous converters, which draw from the AC node: H2 Electrolysis (bus0=nodes), Haber-Bosch (bus0=nodes), methanolisation (bus2=nodes), and DAC (bus0=locations). Notably, electrolysis feeds its waste heat into urban central heat (if use_electrolysis_waste_heat="true") and DAC is attached to both the urban central heat and the services urban decentral heat buses, yet both take their electricity from the AC node. The low voltage bus is the buildings and transport demand side: household load, BEV chargers, rooftop PV, home batteries, and the heating-sector heat pumps and resistive heaters.

A similar split to what I suggest already exists on the gas side: insert_gas_distribution_costs adds a distribution-grid cost only to gas boiler links that are not urban central,

capital_cost = costs.at["electricity distribution grid", "capital_cost"] * f_costs
gas_b = n.links.index[n.links.carrier.str.contains("gas boiler")
                      & (~n.links.carrier.str.contains("urban central"))]
n.links.loc[gas_b, "capital_cost"] += capital_cost

and the new gas boilers with carrier heat100-200 industry gas also do not get this added as they match neither gas boiler nor urban central. Changing the name to the long form heat100-200 industry gas boiler however would make it affected.

One outlier: The industry electricity load is on LV, but I would read that as exogenous aggregate demand that includes small consumers, as I don't know the reasoning behind this.

As the new heat pump is not defined in reverse orientation as the other heat pumps are (not done because of the fixed COP?), it might be useful to add a comment to point the users to this.

2. industry heat pump sourcing from the environment rather than doing heat elevation from a lower band

The industry HP is currently connected to the environment, i.e. it draws only electricity and its source heat is free and unlimited. One might consider that the industry heat pump does heat elevation from a low-temperature heat band (urban central heat and the services urban decentral heat where there is no urban central heat bus?) into the 100-200 °C band. We did something like that in "Exploring the impact of synergistic oxygen and waste heat utilization from electrolysis in supporting a climate-neutral energy infrastructure in Germany", where the industrial heat pump draws its source heat from a dedicated electrolysis waste-heat bus (60-80 °C) and lifts it into a 100-150 °C band (COP 3.175 for 80 → 150 °C also DEA, interpolated for 2045 industrial heat pump high temperature,efficiency,3.175,per unit,"Danish Energy Agency, technology_data_for_industrial_process_heat.xlsx","302.b High temp. hp Up to 150: Total efficiency, net, annual average",2019.0).

The current DEA catalogue version (2026-01-05) puts the HP COP or net efficiency lower: in sheet 2.b High temp. hp Up to 150 the temperature lift is 65K and for the COP (net %) of 3.00 for 2050, and the annual-average row that is used in the technology data base is 2.95 for 2050 (uncertainty range 2.45-4.45).

The catalogue description of the DEA adds:

The sink temperature and source temperature levels has little influence by
themselves. The theoretical efficiency cannot be fully achieved in practice, and a typical real-world
efficiency is expected to be around 55% of the theoretical COP ["Lorenz COP"] due to various system losses.

DEA Technology report April 2026 COP over mean temperature lift plot:

image

Below I put this into the context of the 80 °C and ambient temperature (I assumed 20°C) lift to 150°C and 200°C .

$COP_{Lorenz}= T_{sink} / (T_{sink} − T_{source})$ in K. (Simplified form: the DEA defines it with logarithmic mean temperatures, which needs sink and source glides (i.e. for the source: supply temperature and return temperature) that the model does not have. The two-temperature version matches how the catalogue indexes its own lift rows ($\Delta T_{lift}$ = sink outlet − source inlet, p. 42).)

Applied to the COP used in this PR (3.2, industrial heat pump high temperature efficiency in costs_2050):

source → sink lift COP_Lorenz 55% of it
20 °C → 150 °C 130 K 3.255 1.79
20 °C → 200 °C 180 K 2.629 1.45
80 °C → 150 °C 70 K 6.045 3.32
80 °C → 200 °C 120 K 3.943 2.17
snippet for the numbers above
COP = 3.2   # industrial heat pump high temperature, efficiency, costs_2050
ETA = 0.55  # DEA: "around 55% of the theoretical COP"

def lorenz(src, snk):
    # inputs in °C simplified without glide temperatures
    return (snk + 273.15) / (snk - src)

for src, snk in [(20, 150), (20, 200), (80, 150), (80, 200)]:
    L = lorenz(src, snk)
    print(f"{src} -> {snk} | lift {snk - src} K | Lorenz {round(L,3)} | 55% {round(ETA * L,2)}")

for snk in (150, 200):  # coldest source for which COP is reachable
    T = snk + 273.15
    print(f"sink {snk} C | min source {round(T - T / COP - 273.15,1)} C (eta=100%) | {round(T - ETA * T / COP - 273.15,1)} C (eta=55%)")

For steam generation, and using the simplified formula without glides, a COP of 3.2 requires a source warmer than 17.8 °C at a 150 °C sink and warmer than 52.1 °C at a 200 °C sink before any losses at all, and above 77.3 °C and 118.7 °C, respectively, to reach it at the catalogue's 55%. So with an ambient source the value is a warm-source COP used outside its range, and above the Lorenz limit in the upper part of the band.

Since the band is aggregated over 100-200 °C and the DEA sheet is for sink <= 150 °C, not covering the top of the band may be acceptable — but in my opinion the mismatch is in the source, not only in the sink.

One thing that speaks for considering changes: the heating-sector HPs have a variable COP depending on the ambient temperature, while the industry HP has a fixed COP from the cost file. This also affects the config sector: time_dep_hp_cop — turning it on only affects the heating-sector HPs (urban central, urban decentral and rural) and not the industrial one, which might be misleading, as one would think it changes from fixed to time-dependent COP for all heat pumps. Maybe rename to to time_dep_LT_hp_cop?

All in all I think this industry heat pump implementation is rather optimistic. My investigation here is far from complete but I wanted to share the thoughts for discussion and before a quick merge.

Side note: H2 industrial combustion cost

In some of my test runs (not purely the contents of this PR) the 500+ industry heat was provided to around 40% by the industrial H2 combustion, so the "TODO: research cost of industrial H2 combustion, here set to 10x methane combustion" might be quite important.

Resolves conflicts from upstream's "Streamline workflow" (PyPSA#1838):

- rules/solve_myopic.smk: accept upstream deletion; the add_brownfield rule
  no longer exists and compose_network already receives tech_costs.
- scripts/add_brownfield.py: adopt upstream's main() entry point and pass
  the costs DataFrame through it to add_brownfield(), which needs it for the
  industry_t FOM-only costs of inherited process-heat links.
- scripts/compose_network.py: forward the already-loaded costs to
  apply_brownfield().
- rules/build_sector.smk: keep upstream's {horizon}-based
  build_industrial_energy_demand_per_node rule with the endogenous sector
  ratios as an extra input; drop the stale {planning_horizons} copy; use
  the scripts() helper and add a message for
  build_industry_sector_ratios_endogenous.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ug8xNAwre4wLU5H2h2KuQj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants