"""Tests for data_loader module.""" from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np import pytest import xarray as xr from solar_intelligence.data_loader import ( DataLoader, NASAPowerClient, _cache_is_valid, _cache_key, generate_synthetic_solar_data, geocode_location, ) # --------------------------------------------------------------------------- # Geocoding Tests # --------------------------------------------------------------------------- class TestGeocode: """Tests for geocode_location function.""" @patch("geopy.geocoders.Nominatim") def test_geocode_valid_city(self, mock_nominatim_cls): mock_geolocator = MagicMock() mock_nominatim_cls.return_value = mock_geolocator mock_location = MagicMock() mock_location.latitude = 28.613889 mock_location.longitude = 77.209 mock_geolocator.geocode.return_value = mock_location lat, lon = geocode_location("New Delhi") assert abs(lat - 28.6139) < 0.001 assert abs(lon - 77.209) < 0.001 @patch("geopy.geocoders.Nominatim") def test_geocode_invalid_city_raises(self, mock_nominatim_cls): mock_geolocator = MagicMock() mock_nominatim_cls.return_value = mock_geolocator mock_geolocator.geocode.return_value = None with pytest.raises(ValueError, match="Could not geocode"): geocode_location("Nonexistent City 12345") # --------------------------------------------------------------------------- # Cache Tests # --------------------------------------------------------------------------- class TestCache: """Tests for caching utilities.""" def test_cache_key_deterministic(self): k1 = _cache_key(28.6, 77.2, "20230101", "20231231", "daily") k2 = _cache_key(28.6, 77.2, "20230101", "20231231", "daily") assert k1 == k2 def test_cache_key_different_for_different_inputs(self): k1 = _cache_key(28.6, 77.2, "20230101", "20231231", "daily") k2 = _cache_key(40.7, -74.0, "20230101", "20231231", "daily") assert k1 != k2 def test_cache_key_includes_temporal(self): k1 = _cache_key(28.6, 77.2, "20230101", "20231231", "daily") k2 = _cache_key(28.6, 77.2, "20230101", "20231231", "monthly") assert k1 != k2 def test_cache_validity_nonexistent_file(self, tmp_path): assert not _cache_is_valid(tmp_path / "nonexistent.nc") def test_cache_validity_fresh_file(self, tmp_path): f = tmp_path / "test.nc" f.write_text("data") assert _cache_is_valid(f, ttl_days=30) # --------------------------------------------------------------------------- # NASA POWER Client Tests # --------------------------------------------------------------------------- class TestNASAPowerClient: """Tests for NASAPowerClient.""" @patch("solar_intelligence.data_loader.requests.get") def test_fetch_daily_parses_response(self, mock_get, mock_nasa_power_response, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = mock_nasa_power_response mock_resp.raise_for_status.return_value = None mock_get.return_value = mock_resp client = NASAPowerClient(cache_dir=tmp_path) ds = client.fetch_daily(28.6139, 77.209, "20230101", "20230105") assert isinstance(ds, xr.Dataset) assert "time" in ds.dims assert "ALLSKY_SFC_SW_DWN" in ds assert len(ds.time) == 5 @patch("solar_intelligence.data_loader.requests.get") def test_fetch_daily_caches_result(self, mock_get, mock_nasa_power_response, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = mock_nasa_power_response mock_resp.raise_for_status.return_value = None mock_get.return_value = mock_resp client = NASAPowerClient(cache_dir=tmp_path) # First call — hits API client.fetch_daily(28.6139, 77.209, "20230101", "20230105") assert mock_get.call_count == 1 # Second call — uses cache client.fetch_daily(28.6139, 77.209, "20230101", "20230105") assert mock_get.call_count == 1 # No additional API call @patch("solar_intelligence.data_loader.requests.get") def test_fetch_handles_missing_values(self, mock_get, tmp_path): """NASA POWER uses -999.0 for missing data — should become NaN.""" response = { "properties": { "parameter": { "ALLSKY_SFC_SW_DWN": { "20230101": 5.0, "20230102": -999.0, "20230103": 4.5, } } } } mock_resp = MagicMock() mock_resp.json.return_value = response mock_resp.raise_for_status.return_value = None mock_get.return_value = mock_resp client = NASAPowerClient( cache_dir=tmp_path, parameters=["ALLSKY_SFC_SW_DWN"], ) ds = client.fetch_daily(28.6, 77.2, "20230101", "20230103") assert np.isnan(ds["ALLSKY_SFC_SW_DWN"].values[1]) assert ds["ALLSKY_SFC_SW_DWN"].values[0] == 5.0 # --------------------------------------------------------------------------- # DataLoader Tests # --------------------------------------------------------------------------- class TestDataLoader: """Tests for the unified DataLoader.""" def test_load_netcdf(self, tmp_netcdf): loader = DataLoader() ds = loader.load_netcdf(tmp_netcdf) assert isinstance(ds, xr.Dataset) assert "ALLSKY_SFC_SW_DWN" in ds def test_load_netcdf_file_not_found(self): loader = DataLoader() with pytest.raises(FileNotFoundError): loader.load_netcdf("/nonexistent/path.nc") def test_slice_time(self, sample_dataset): sliced = DataLoader.slice_time(sample_dataset, "2021-01-01", "2021-12-31") assert sliced.time.values[0] >= np.datetime64("2021-01-01") assert sliced.time.values[-1] <= np.datetime64("2021-12-31") def test_slice_time_no_time_dim_raises(self): ds = xr.Dataset({"x": ("a", [1, 2, 3])}) with pytest.raises(ValueError, match="no 'time' dimension"): DataLoader.slice_time(ds, "2021-01-01") @patch("solar_intelligence.data_loader.geocode_location") @patch.object(NASAPowerClient, "fetch_daily") def test_load_for_location_by_city(self, mock_fetch, mock_geocode, sample_dataset): mock_geocode.return_value = (28.6139, 77.2090) mock_fetch.return_value = sample_dataset loader = DataLoader() ds = loader.load_for_location(city="New Delhi") mock_geocode.assert_called_once_with("New Delhi") assert isinstance(ds, xr.Dataset) @patch.object(NASAPowerClient, "fetch_daily") def test_load_for_location_by_coords(self, mock_fetch, sample_dataset): mock_fetch.return_value = sample_dataset loader = DataLoader() ds = loader.load_for_location(lat=28.6139, lon=77.2090) assert isinstance(ds, xr.Dataset) def test_load_for_location_no_input_raises(self): loader = DataLoader() with pytest.raises(ValueError, match="city.*lat.*lon"): loader.load_for_location() # --------------------------------------------------------------------------- # Synthetic Data Tests # --------------------------------------------------------------------------- class TestSyntheticData: """Tests for synthetic data generator.""" def test_output_structure(self, sample_dataset): assert isinstance(sample_dataset, xr.Dataset) assert "time" in sample_dataset.dims expected_vars = [ "ALLSKY_SFC_SW_DWN", "CLRSKY_SFC_SW_DWN", "ALLSKY_SFC_SW_DNI", "ALLSKY_SFC_SW_DIFF", "ALLSKY_KT", "T2M", "WS2M", "RH2M", ] for var in expected_vars: assert var in sample_dataset, f"Missing variable: {var}" def test_ghi_range(self, sample_dataset): ghi = sample_dataset["ALLSKY_SFC_SW_DWN"].values assert np.all(ghi >= 0), "GHI should be non-negative" assert np.all(ghi <= 12), "GHI should not exceed 12 kWh/m²/day" def test_clearness_index_range(self, sample_dataset): kt = sample_dataset["ALLSKY_KT"].values assert np.all(kt >= 0.1), "Clearness index should be >= 0.1" assert np.all(kt <= 1.0), "Clearness index should be <= 1.0" def test_temperature_reasonable(self, sample_dataset): t = sample_dataset["T2M"].values assert np.all(t > -50), "Temperature should be > -50°C" assert np.all(t < 60), "Temperature should be < 60°C" def test_different_locations_differ(self): ds1 = generate_synthetic_solar_data(lat=28.6, lon=77.2) ds2 = generate_synthetic_solar_data(lat=51.5, lon=-0.1) # Higher latitude should have lower average GHI ghi1 = float(ds1["ALLSKY_SFC_SW_DWN"].mean()) ghi2 = float(ds2["ALLSKY_SFC_SW_DWN"].mean()) assert ghi1 > ghi2, "Lower latitude should have higher GHI" def test_southern_hemisphere(self, sample_dataset_sydney): """Southern hemisphere should have valid data.""" ghi = sample_dataset_sydney["ALLSKY_SFC_SW_DWN"].values assert np.all(ghi >= 0) assert float(sample_dataset_sydney["ALLSKY_SFC_SW_DWN"].mean()) > 0 def test_has_metadata_attributes(self, sample_dataset): assert "source" in sample_dataset.attrs assert "latitude" in sample_dataset.attrs assert sample_dataset["ALLSKY_SFC_SW_DWN"].attrs["units"] == "kWh/m²/day" def test_deterministic_with_same_coords(self): ds1 = generate_synthetic_solar_data(lat=28.6, lon=77.2) ds2 = generate_synthetic_solar_data(lat=28.6, lon=77.2) np.testing.assert_array_equal( ds1["ALLSKY_SFC_SW_DWN"].values, ds2["ALLSKY_SFC_SW_DWN"].values, )