diff --git a/src/gat/__init__.py b/src/gat/__init__.py index 4e3f0ef..b23c511 100644 --- a/src/gat/__init__.py +++ b/src/gat/__init__.py @@ -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.""" diff --git a/src/gat/models/scenario.py b/src/gat/models/scenario.py index f0606f4..d8aad83 100644 --- a/src/gat/models/scenario.py +++ b/src/gat/models/scenario.py @@ -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( diff --git a/src/gat/scenariohandlers/multi.py b/src/gat/scenariohandlers/multi.py index 95ce027..54de29f 100644 --- a/src/gat/scenariohandlers/multi.py +++ b/src/gat/scenariohandlers/multi.py @@ -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) @@ -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 @@ -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 diff --git a/tests/handlers/test_multiscenario_time_alignment.py b/tests/handlers/test_multiscenario_time_alignment.py new file mode 100644 index 0000000..db4741a --- /dev/null +++ b/tests/handlers/test_multiscenario_time_alignment.py @@ -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") diff --git a/tests/models/test_data_models.py b/tests/models/test_data_models.py index a42154c..895b3d8 100644 --- a/tests/models/test_data_models.py +++ b/tests/models/test_data_models.py @@ -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" diff --git a/tests/test_logging_defaults.py b/tests/test_logging_defaults.py new file mode 100644 index 0000000..3abaffe --- /dev/null +++ b/tests/test_logging_defaults.py @@ -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