Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/source/quick_start.rst
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,17 @@ 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. 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.
Expand Down
12 changes: 12 additions & 0 deletions temoa/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion temoa/model_checking/commodity_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 8 additions & 0 deletions temoa/tutorial_assets/config_sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions tests/test_cycle_limits.py
Original file line number Diff line number Diff line change
@@ -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)