diff --git a/murgtools/getdata/getOutsideData.py b/murgtools/getdata/getOutsideData.py index 016351d..aaf7672 100755 --- a/murgtools/getdata/getOutsideData.py +++ b/murgtools/getdata/getOutsideData.py @@ -397,33 +397,34 @@ def getSatelliteImagery(corners, filename=None, collection='sentinel-2-l2a', # Extract actual image extent from GeoTIFF tags and convert to lat/lon # STAC bbox is not reliable for pixel coordinate calculation - with tifffile.TiffFile(tmp_path) as tif: - tags = tif.pages[0].tags - tiepoint = tags[33922].value # (i, j, k, x, y, z) - UTM coordinates - scale = tags[33550].value # (scaleX, scaleY, scaleZ) - img_h, img_w = tif.pages[0].shape[:2] - - # UTM bounds - utm_left = tiepoint[3] - utm_right = tiepoint[3] + img_w * scale[0] - utm_top = tiepoint[4] - utm_bottom = tiepoint[4] - img_h * scale[1] - - # Determine UTM zone from GeoKey (tag 34737 contains projection info) - proj_str = tags.get(34737, None) - if proj_str: - proj_str = proj_str.value - # Parse UTM zone from string like "WGS 84 / UTM zone 18N" - match = re.search(r'UTM zone (\d+)([NS])', str(proj_str)) - if match: - zone = int(match.group(1)) - hemisphere = match.group(2) - epsg = 32600 + zone if hemisphere == 'N' else 32700 + zone - else: - epsg = 32618 - - # Convert UTM corners to lat/lon - transformer = Transformer.from_crs(f'EPSG:{epsg}', 'EPSG:4326', always_xy=True) + try: + with tifffile.TiffFile(tmp_path) as tif: + tags = tif.pages[0].tags + tiepoint = tags[33922].value # (i, j, k, x, y, z) - UTM coordinates + scale = tags[33550].value # (scaleX, scaleY, scaleZ) + img_h, img_w = tif.pages[0].shape[:2] + + # UTM bounds + utm_left = tiepoint[3] + utm_right = tiepoint[3] + img_w * scale[0] + utm_top = tiepoint[4] + utm_bottom = tiepoint[4] - img_h * scale[1] + + # Determine UTM zone from GeoKey (tag 34737 contains projection info) + proj_str = tags.get(34737, None) + if proj_str: + proj_str = proj_str.value + # Parse UTM zone from string like "WGS 84 / UTM zone 18N" + match = re.search(r'UTM zone (\d+)([NS])', str(proj_str)) + if match: + zone = int(match.group(1)) + hemisphere = match.group(2) + epsg = 32600 + zone if hemisphere == 'N' else 32700 + zone + else: + epsg = 32618 + + # Convert UTM corners to lat/lon + transformer = Transformer.from_crs(f'EPSG:{epsg}', 'EPSG:4326', always_xy=True) tl_lon, tl_lat = transformer.transform(utm_left, utm_top) br_lon, br_lat = transformer.transform(utm_right, utm_bottom) diff --git a/murgtools/utils/sblib.py b/murgtools/utils/sblib.py index 8e15674..82ebe27 100644 --- a/murgtools/utils/sblib.py +++ b/murgtools/utils/sblib.py @@ -13,6 +13,7 @@ import netCDF4 as nc import math, warnings, os from dateutil.relativedelta import relativedelta +from murgtools.exceptions import InvalidParameterError ######################################## # following functions deal with general things @@ -505,6 +506,9 @@ def roundDatetimeToInterval(dt, interval_minutes=30, method='nearest'): Returns: datetime: Rounded datetime with seconds and microseconds zeroed + Raises: + InvalidParameterError: If method is not one of 'nearest', 'floor', or 'ceil' + Examples: >>> import datetime as DT >>> dt = DT.datetime(2024, 6, 15, 14, 23, 45) @@ -513,6 +517,15 @@ def roundDatetimeToInterval(dt, interval_minutes=30, method='nearest'): >>> roundDatetimeToInterval(dt, 30, method='floor') datetime.datetime(2024, 6, 15, 14, 0, 0) """ + valid_methods = ['nearest', 'floor', 'ceil'] + if method not in valid_methods: + raise InvalidParameterError( + f"Invalid rounding method: '{method}'", + parameter_name='method', + value=method, + expected=f"One of {valid_methods}" + ) + total_minutes = dt.hour * 60 + dt.minute if method == 'floor': diff --git a/tests/test_sblib.py b/tests/test_sblib.py new file mode 100644 index 0000000..c49b1d9 --- /dev/null +++ b/tests/test_sblib.py @@ -0,0 +1,81 @@ +"""Tests for murgtools.utils.sblib utility functions.""" + +import pytest +import datetime as DT + +from murgtools.utils.sblib import roundDatetimeToInterval +from murgtools.exceptions import InvalidParameterError + + +class TestRoundDatetimeToInterval: + """Test roundDatetimeToInterval function.""" + + def test_round_to_nearest_30min(self): + """Test rounding to nearest 30-minute interval.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + result = roundDatetimeToInterval(dt, 30, method='nearest') + expected = DT.datetime(2024, 6, 15, 14, 30, 0) + assert result == expected + + def test_round_to_floor_30min(self): + """Test rounding down (floor) to 30-minute interval.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + result = roundDatetimeToInterval(dt, 30, method='floor') + expected = DT.datetime(2024, 6, 15, 14, 0, 0) + assert result == expected + + def test_round_to_ceil_30min(self): + """Test rounding up (ceil) to 30-minute interval.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + result = roundDatetimeToInterval(dt, 30, method='ceil') + expected = DT.datetime(2024, 6, 15, 14, 30, 0) + assert result == expected + + def test_default_method_is_nearest(self): + """Test that default method is 'nearest'.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + result = roundDatetimeToInterval(dt, 30) + expected = DT.datetime(2024, 6, 15, 14, 30, 0) + assert result == expected + + def test_invalid_method_raises_error(self): + """Test that invalid method raises InvalidParameterError.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + with pytest.raises(InvalidParameterError) as exc_info: + roundDatetimeToInterval(dt, 30, method='round') + + # Check error message details + assert "Invalid rounding method: 'round'" in str(exc_info.value) + assert exc_info.value.parameter_name == 'method' + assert exc_info.value.value == 'round' + assert "['nearest', 'floor', 'ceil']" in str(exc_info.value.expected) + + def test_invalid_method_average_raises_error(self): + """Test that 'average' method raises InvalidParameterError.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + with pytest.raises(InvalidParameterError) as exc_info: + roundDatetimeToInterval(dt, 30, method='average') + + assert "Invalid rounding method: 'average'" in str(exc_info.value) + + def test_invalid_method_empty_string_raises_error(self): + """Test that empty string method raises InvalidParameterError.""" + dt = DT.datetime(2024, 6, 15, 14, 23, 45) + with pytest.raises(InvalidParameterError) as exc_info: + roundDatetimeToInterval(dt, 30, method='') + + assert "Invalid rounding method: ''" in str(exc_info.value) + + def test_seconds_and_microseconds_zeroed(self): + """Test that seconds and microseconds are always zeroed.""" + dt = DT.datetime(2024, 6, 15, 14, 30, 45, 123456) + result = roundDatetimeToInterval(dt, 30, method='nearest') + assert result.second == 0 + assert result.microsecond == 0 + + def test_midnight_overflow(self): + """Test handling of overflow past midnight.""" + dt = DT.datetime(2024, 6, 15, 23, 50, 0) + result = roundDatetimeToInterval(dt, 30, method='ceil') + expected = DT.datetime(2024, 6, 16, 0, 0, 0) + assert result == expected