From e2e81150841d97195db8a0203471f58a04110a1a Mon Sep 17 00:00:00 2001 From: ParticularlyPythonicBS Date: Thu, 26 Feb 2026 11:10:28 -0500 Subject: [PATCH 1/2] feat: implementing configurability for cycle detection in commodity graphing --- docs/source/quick_start.rst | 11 +- temoa/core/config.py | 12 ++ temoa/model_checking/commodity_graph.py | 15 +- temoa/tutorial_assets/config_sample.toml | 8 + tests/test_cycle_limits.py | 194 +++++++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 tests/test_cycle_limits.py diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 9f725076..a0908b59 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -299,7 +299,16 @@ data and analyzes it for "orphans" which likely represent gaps in the network th to erroneous output data. The operation is enabled by tagging foundational commodities for which there are no predecessors as "source" commodities in the `Commodity` database table with an `s` tag. Orphans (or chains of orphans) on either the demand or supply side are reported and *suppressed* in -the data to prevent network corruption. +the data to prevent network corruption. Additionally, Temoa performs cycle detection on the commodity +network to identify circular dependencies that could lead to non-convergence or erroneous results. +Users can configure the cycle detection behavior using the following settings: + +* **cycle_count_limit**: Limits the number of cycles reported in the log. A value of `-1` allows + unbounded detection, `0` causes the system to log an error on the first detected cycle and then + suppresses further cycle reports for the remainder of the run (without terminating execution), + and a positive integer sets a specific limit. Default is 100. +* **cycle_length_limit**: Minimum length of cycles to report. This can be used to filter out small, + expected circularities if necessary. Default is 1. Note that the myopic mode *requires* the use of Source Tracing to ensure accuracy as some orphans may be produced by endogenous decisions in myopic runs. diff --git a/temoa/core/config.py b/temoa/core/config.py index 6441fb5c..d61ee04e 100644 --- a/temoa/core/config.py +++ b/temoa/core/config.py @@ -63,6 +63,8 @@ def __init__( check_units: bool = False, plot_commodity_network: bool = False, graphviz_output: bool = False, + cycle_count_limit: int = 100, + cycle_length_limit: int = 1, ): if '-' in scenario: raise ValueError( @@ -147,6 +149,14 @@ def __init__( self.graphviz_output = graphviz_output self.stochastic_config = stochastic_config + # Cycle detection limits + if not isinstance(cycle_count_limit, int) or cycle_count_limit < -1: + raise ValueError('cycle_count_limit must be an integer >= -1') + if not isinstance(cycle_length_limit, int) or cycle_length_limit < 1: + raise ValueError('cycle_length_limit must be an integer >= 1') + self.cycle_count_limit = cycle_count_limit + self.cycle_length_limit = cycle_length_limit + # warn if output db != input db if self.input_database.suffix == self.output_database.suffix: # they are both .db/.sqlite if self.input_database != self.output_database: # they are not the same db @@ -270,6 +280,8 @@ def __repr__(self) -> str: msg += '{:>{}s}: {}\n'.format('Unit checking', width, self.check_units) msg += '{:>{}s}: {}\n'.format('Commodity network plots', width, self.plot_commodity_network) msg += '{:>{}s}: {}\n'.format('Graphviz output', width, self.graphviz_output) + msg += '{:>{}s}: {}\n'.format('Cycle count limit', width, self.cycle_count_limit) + msg += '{:>{}s}: {}\n'.format('Cycle length limit', width, self.cycle_length_limit) msg += spacer msg += '{:>{}s}: {}\n'.format('Selected solver', width, self.solver_name) diff --git a/temoa/model_checking/commodity_graph.py b/temoa/model_checking/commodity_graph.py index 0a2e30a0..8275e0e1 100644 --- a/temoa/model_checking/commodity_graph.py +++ b/temoa/model_checking/commodity_graph.py @@ -305,10 +305,23 @@ def visualize_graph( # 8. Perform cycle detection on the commodity graph try: + count = 0 + limit = config.cycle_count_limit + length_limit = config.cycle_length_limit + for cycle in nx.simple_cycles(G=commodity_graph): - if len(cycle) < 2: + if limit != -1 and count >= limit: + if limit > 0: + logger.warning('Cycle detection reached limit of %d cycles. Stopping.', limit) + else: + logger.error('Cycles detected but cycle_count_limit is 0. Stopping.') + break + + if len(cycle) < length_limit: continue + cycle_str = ' -> '.join(cycle) + f' -> {cycle[0]}' logger.info('Cycle detected: %s', cycle_str) + count += 1 except nx.NetworkXError as e: logger.warning('NetworkXError during cycle detection: %s', e, exc_info=True) diff --git a/temoa/tutorial_assets/config_sample.toml b/temoa/tutorial_assets/config_sample.toml index daa88be7..4cf8d767 100644 --- a/temoa/tutorial_assets/config_sample.toml +++ b/temoa/tutorial_assets/config_sample.toml @@ -51,6 +51,14 @@ plot_commodity_network = true # Recommended for production runs after units are populated in the database check_units = true +# Limit the number of cycles detected in the commodity graph +# -1 = unbounded (INFO), 0 = strictly disallow (ERROR), positive integer = limit +cycle_count_limit = 100 + +# Minimum cycle length to report (default: 1) +# Use this to filter out very small cycles if needed +cycle_length_limit = 1 + # ------------------------------------ # SOLVER # Solver Selection diff --git a/tests/test_cycle_limits.py b/tests/test_cycle_limits.py new file mode 100644 index 00000000..80895e4a --- /dev/null +++ b/tests/test_cycle_limits.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock + +import networkx as nx +import pytest + +from temoa.core.config import TemoaConfig +from temoa.model_checking.commodity_graph import visualize_graph +from temoa.types.core_types import Period, Region + + +@pytest.fixture +def mock_config() -> MagicMock: + config = MagicMock(spec=TemoaConfig) + config.plot_commodity_network = True + config.output_path = Path('./') + config.cycle_count_limit = 100 + config.cycle_length_limit = 1 + return config + + +@pytest.fixture +def cycle_graph() -> nx.MultiDiGraph[str]: + dg: nx.MultiDiGraph[str] = nx.MultiDiGraph() + # Create two cycles: (A->B->A) length 2, (C->D->E->C) length 3 + dg.add_edge('A', 'B') + dg.add_edge('B', 'A') + dg.add_edge('C', 'D') + dg.add_edge('D', 'E') + dg.add_edge('E', 'C') + # Add some other nodes/edges to make it look like a real graph + dg.add_node('A', layer=1, sector='S1') + dg.add_node('B', layer=2, sector='S1') + dg.add_node('C', layer=1, sector='S2') + dg.add_node('D', layer=2, sector='S2') + dg.add_node('E', layer=3, sector='S2') + return dg + + +def test_cycle_limits_logging( + mock_config: MagicMock, + cycle_graph: nx.MultiDiGraph[str], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that cycles are logged according to length limit.""" + mock_config.cycle_length_limit = 3 + + with caplog.at_level(logging.INFO): + # We need to mock generate_commodity_graph to return our controlled graph + import temoa.model_checking.commodity_graph as cg + + monkeypatch.setattr( + cg, 'generate_commodity_graph', MagicMock(return_value=(cycle_graph, {})) + ) + monkeypatch.setattr(cg, 'generate_technology_graph', MagicMock()) + monkeypatch.setattr(cg, 'nx_to_vis', MagicMock(return_value='path')) + + visualize_graph( + region=Region('R1'), + period=Period(2020), + network_data=MagicMock(), + demand_orphans=[], + other_orphans=[], + driven_techs=[], + config=mock_config, + ) + + # Should only log cycles of length >= 3 + # (C->D->E->C) is length 3 + # (A->B->A) is length 2, should be skipped + assert any( + 'Cycle detected' in record.message + and 'C' in record.message + and 'D' in record.message + and 'E' in record.message + for record in caplog.records + ) + assert not any( + 'Cycle detected' in record.message and 'A' in record.message and 'B' in record.message + for record in caplog.records + ) + + +def test_cycle_count_limit( + mock_config: MagicMock, + cycle_graph: nx.MultiDiGraph[str], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that cycle detection stops after cycle_count_limit.""" + mock_config.cycle_count_limit = 1 + mock_config.cycle_length_limit = 1 + + with caplog.at_level(logging.INFO): + import temoa.model_checking.commodity_graph as cg + + monkeypatch.setattr( + cg, 'generate_commodity_graph', MagicMock(return_value=(cycle_graph, {})) + ) + monkeypatch.setattr(cg, 'generate_technology_graph', MagicMock()) + monkeypatch.setattr(cg, 'nx_to_vis', MagicMock(return_value='path')) + + visualize_graph( + region=Region('R1'), + period=Period(2020), + network_data=MagicMock(), + demand_orphans=[], + other_orphans=[], + driven_techs=[], + config=mock_config, + ) + + # Should only log 1 cycle and then the warning + # nx.simple_cycles might return them in different order depending on version/impl + # but it should only log ONE of them. + cycle_logs = [record.message for record in caplog.records if 'Cycle detected' in record.message] + assert len(cycle_logs) == 1 + assert 'Cycle detection reached limit of 1 cycles. Stopping.' in caplog.text + + +def test_cycle_count_limit_zero( + mock_config: MagicMock, + cycle_graph: nx.MultiDiGraph[str], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that cycle_count_limit=0 logs an error and stops immediately.""" + mock_config.cycle_count_limit = 0 + + with caplog.at_level(logging.INFO): + import temoa.model_checking.commodity_graph as cg + + monkeypatch.setattr( + cg, 'generate_commodity_graph', MagicMock(return_value=(cycle_graph, {})) + ) + monkeypatch.setattr(cg, 'generate_technology_graph', MagicMock()) + monkeypatch.setattr(cg, 'nx_to_vis', MagicMock(return_value='path')) + + visualize_graph( + region=Region('R1'), + period=Period(2020), + network_data=MagicMock(), + demand_orphans=[], + other_orphans=[], + driven_techs=[], + config=mock_config, + ) + + assert any( + 'Cycles detected but cycle_count_limit is 0. Stopping.' in record.message + for record in caplog.records + ) + assert not any('Cycle detected:' in record.message for record in caplog.records) + + +def test_cycle_unbounded( + mock_config: MagicMock, + cycle_graph: nx.MultiDiGraph[str], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that cycle_count_limit=-1 allows all cycles.""" + mock_config.cycle_count_limit = -1 + + with caplog.at_level(logging.INFO): + import temoa.model_checking.commodity_graph as cg + + monkeypatch.setattr( + cg, 'generate_commodity_graph', MagicMock(return_value=(cycle_graph, {})) + ) + monkeypatch.setattr(cg, 'generate_technology_graph', MagicMock()) + monkeypatch.setattr(cg, 'nx_to_vis', MagicMock(return_value='path')) + + visualize_graph( + region=Region('R1'), + period=Period(2020), + network_data=MagicMock(), + demand_orphans=[], + other_orphans=[], + driven_techs=[], + config=mock_config, + ) + + assert any( + 'Cycle detected' in record.message and 'C' in record.message for record in caplog.records + ) + assert any( + 'Cycle detected' in record.message and 'A' in record.message for record in caplog.records + ) + assert not any('Stopping' in record.message for record in caplog.records) From e3dc8370331b32590ec9ecd8844ec3d4dd6b54fc Mon Sep 17 00:00:00 2001 From: ParticularlyPythonicBS Date: Sat, 28 Feb 2026 14:47:19 -0500 Subject: [PATCH 2/2] docs: adding cycle config info to source tracing docs --- docs/source/quick_start.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index a0908b59..80172084 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -308,7 +308,8 @@ Users can configure the cycle detection behavior using the following settings: suppresses further cycle reports for the remainder of the run (without terminating execution), and a positive integer sets a specific limit. Default is 100. * **cycle_length_limit**: Minimum length of cycles to report. This can be used to filter out small, - expected circularities if necessary. Default is 1. + expected circularities if necessary. Default is 1. The length limit is inclusive, so a cycle of + length 1 is a self-loop, and a cycle of length `n` has `n` unique nodes. Note that the myopic mode *requires* the use of Source Tracing to ensure accuracy as some orphans may be produced by endogenous decisions in myopic runs.