Skip to content
Merged
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
29 changes: 29 additions & 0 deletions src/gat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@
__version__ = "unknown"


def _configure_default_logging() -> None:
"""Mirror the CLI's quiet-by-default logging (see logging_config.py's
setup_cli_logging) for plain ``import gat`` usage -- notebooks and
scripts otherwise inherit loguru's untouched default sink, which
shows every DEBUG-level call across the codebase, not just the
handful that matter interactively.

Only acts if loguru is still at its pristine, single-default-handler
state, so a caller's own logger.add(...)/logger.remove() (before or
instead of importing gat) is never clobbered. warnings.warn(...) --
the channel GAT uses for user-actionable notices like unmapped
technologies -- is untouched either way.
"""
try:
from loguru import logger

handlers = logger._core.handlers
if len(handlers) == 1 and 0 in handlers:
import sys

logger.remove()
logger.add(sys.stderr, level="WARNING")
except Exception:
pass


_configure_default_logging()


# Lazy imports - these are loaded only when accessed
def __getattr__(name):
"""Lazy import mechanism for heavy modules."""
Expand Down
5 changes: 4 additions & 1 deletion src/gat/models/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ def new(cls, model_technology: str):
display_order = 0 # Default to bottom
curtailable = model_technology in gc.curtailable_tech
warnings.warn(
f"Technology '{model_technology}' not found in standard mappings. Assigning random color."
f"Technology '{model_technology}' not found in standard mappings "
f"and no fuzzy match was found either. Assigning a random color -- "
f"define an explicit mapping via config technology_mappings to set "
f"a stable display name/color for it."
)

return cls(
Expand Down
28 changes: 28 additions & 0 deletions src/gat/scenariohandlers/multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def _concat_gat_df(self, method_name):
Combined DataFrame with scenario as an additional index level
"""
frames = []
frame_names = []
for display_name, sobj in self.scenarios.items():
# Get the method from the scenario object
method = getattr(sobj, method_name)
Expand Down Expand Up @@ -138,6 +139,7 @@ def _concat_gat_df(self, method_name):
result_df.columns = new_columns

frames.append(result_df)
frame_names.append(display_name)
else:
import warnings

Expand All @@ -158,6 +160,32 @@ def _concat_gat_df(self, method_name):
# If conversion fails, keep original index
pass

# pd.concat(axis=1) unions each scenario's index, silently
# introducing NaN rows for any scenario missing a timestamp
# the others have -- e.g. scenarios covering different date
# ranges or resolutions. Flag it rather than let it show up
# only as unexplained NaNs downstream.
misaligned = [
(name, df.index.min(), df.index.max(), len(df))
for name, df in zip(frame_names, frames)
if len(df) != len(result)
]
if misaligned:
import warnings

details = "; ".join(
f"'{name}' has {n} timestamps ({start} to {end})"
for name, start, end, n in misaligned
)
warnings.warn(
f"Scenarios have misaligned time ranges for "
f"'{method_name}' -- {details}; the combined result "
f"has {len(result)} timestamps, so scenarios that "
f"don't cover the full range will have NaN there. "
f"Check that scenarios represent the same period/"
f"resolution before comparing."
)

return result
else:
return NotImplemented
Expand Down
69 changes: 69 additions & 0 deletions tests/handlers/test_multiscenario_time_alignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Regression tests for MultiScenario._concat_gat_df's misaligned-time-range
warning (issue #22) -- pd.concat(axis=1) silently unions differing scenario
indexes, introducing NaN rows for whichever scenario doesn't cover a given
timestamp. These tests use lightweight fake scenario objects rather than
real fixtures, since only the alignment logic itself is under test.
"""

import warnings

import pandas as pd
import pytest

from gat.scenariohandlers.multi import MultiScenario


class _FakeScenario:
def __init__(self, df: pd.DataFrame):
self._df = df

def get_load(self):
return self._df

def get_generation_capacity(self):
return self._df


def _hourly_frame(start: str, periods: int) -> pd.DataFrame:
idx = pd.date_range(start, periods=periods, freq="h")
return pd.DataFrame({"Load": range(periods)}, index=idx)


class TestMultiScenarioTimeAlignment:
def test_warns_when_scenario_date_ranges_dont_overlap(self):
ms = MultiScenario(
{
"A": _FakeScenario(_hourly_frame("2030-01-01", 24)),
"B": _FakeScenario(_hourly_frame("2030-01-02", 24)),
}
)
with pytest.warns(UserWarning, match="misaligned time ranges"):
result = ms._concat_gat_df("get_load")
assert len(result) == 48

def test_no_warning_when_scenarios_share_the_same_index(self):
ms = MultiScenario(
{
"A": _FakeScenario(_hourly_frame("2030-01-01", 24)),
"B": _FakeScenario(_hourly_frame("2030-01-01", 24)),
}
)
with warnings.catch_warnings():
warnings.simplefilter("error")
result = ms._concat_gat_df("get_load")
assert len(result) == 24

def test_no_alignment_check_for_generation_capacity(self):
# get_generation_capacity isn't a timeseries -- indexed by Area, not
# Timestamp -- so differing indexes there are expected, not a bug.
ms = MultiScenario(
{
"A": _FakeScenario(pd.DataFrame({"Coal": [1]}, index=["Area1"])),
"B": _FakeScenario(
pd.DataFrame({"Coal": [1, 2]}, index=["Area1", "Area2"])
),
}
)
with warnings.catch_warnings():
warnings.simplefilter("error")
ms._concat_gat_df("get_generation_capacity")
2 changes: 1 addition & 1 deletion tests/models/test_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_no_shared_tokens_falls_back_to_random_color(self):
"""A technology with zero token overlap against every standard
display group must fall through to the original random-color
behavior, not a spurious fuzzy match."""
with pytest.warns(UserWarning, match="Assigning random color"):
with pytest.warns(UserWarning, match="Assigning a random color"):
tech_map = TechnologyMapping.new("MUNICIPAL_WASTE_OT")
assert tech_map.display_group == "MUNICIPAL_WASTE_OT"

Expand Down
47 changes: 47 additions & 0 deletions tests/test_logging_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Regression tests for gat's default logging setup (issue #22, part 3):
a plain `import gat` should be quiet by default (no DEBUG-level spam from
e.g. plot-function registration) without ever clobbering a sink the caller
already configured. Run each case in a subprocess -- the behavior under
test only happens on a package's *first* import in a fresh interpreter,
and this test suite has already imported gat long before this file runs.
"""

import subprocess
import sys


def _run(code: str) -> str:
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
return result.stdout + result.stderr


def test_plain_import_suppresses_debug_level_noise():
output = _run("import gat\n" "import gat.quickplots.dispatch\n")
assert "DEBUG" not in output


def test_plain_import_still_shows_warning_level():
output = _run(
"import gat\n"
"from loguru import logger\n"
"logger.warning('should be visible')\n"
)
assert "should be visible" in output


def test_preexisting_sink_survives_gat_import():
output = _run(
"from loguru import logger\n"
"logger.remove()\n"
"logger.add(lambda msg: print('CUSTOM:' + msg, end=''), level='INFO')\n"
"import gat\n"
"logger.info('should reach the custom sink')\n"
)
assert "CUSTOM:" in output
assert "should reach the custom sink" in output
Loading