imergpy 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imergpy-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lakshitha S. Senavirathna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ include imergpy/templates/*.html
2
+ include imergpy/static/*
3
+ include LICENSE
4
+ include README.md
5
+ recursive-include examples *.py
6
+ recursive-include tests *.py
7
+ global-exclude __pycache__/*
8
+ global-exclude *.py[cod]
9
+ global-exclude *.xlsx
10
+ global-exclude *.nc
11
+ global-exclude *.nc4
12
+ global-exclude .env
imergpy-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: imergpy
3
+ Version: 1.1.0
4
+ Summary: Download, extract, average, plot, and analyze NASA GPM IMERG precipitation data from Python or a local web UI.
5
+ Author-email: "Lakshitha S. Senavirathna" <lakshithasrimal256@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/LakshithaSenavirathna/imergpy
8
+ Keywords: IMERG,GPM,NASA,precipitation,rainfall,Earthdata,GES DISC
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
19
+ Classifier: Topic :: Scientific/Engineering :: GIS
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests
24
+ Requires-Dist: xarray
25
+ Requires-Dist: netCDF4
26
+ Requires-Dist: pandas
27
+ Requires-Dist: openpyxl
28
+ Requires-Dist: matplotlib
29
+ Requires-Dist: flask
30
+ Requires-Dist: python-dateutil
31
+ Provides-Extra: dev
32
+ Requires-Dist: build; extra == "dev"
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: ruff; extra == "dev"
35
+ Requires-Dist: twine; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # imergpy
39
+
40
+ `imergpy` is a Python package and local web interface for downloading NASA GPM IMERG precipitation data through NASA Earthdata/GES DISC. It can extract point rainfall time series and compute grid-cell average rainfall for selected countries or square areas.
41
+
42
+ ## Features
43
+
44
+ - Local web UI launched with the `imergpy` command
45
+ - Python API for scripted workflows
46
+ - Point, country, and square-area selection in the web map
47
+ - Grid-cell average precipitation for country and square-area selections
48
+ - Half-hourly, daily, and monthly IMERG products
49
+ - Early, Late, and Final IMERG run types where available
50
+ - Excel export with separate `Start Time` and `End Time` columns
51
+ - Basic rainfall plotting and statistics utilities
52
+
53
+ ## Installation
54
+
55
+ Users only need two commands:
56
+
57
+ ```bash
58
+ pip install imergpy
59
+ imergpy
60
+ ```
61
+
62
+ For local development:
63
+
64
+ ```bash
65
+ pip install -e ".[dev]"
66
+ ```
67
+
68
+ The browser should open automatically. If it does not, copy the local URL printed in the terminal.
69
+
70
+ ## Start The Web UI Manually
71
+
72
+ ```bash
73
+ imergpy
74
+ ```
75
+
76
+ If the command is not available on Windows:
77
+
78
+ ```bash
79
+ python -m imergpy.cli
80
+ ```
81
+
82
+ If port `5000` is busy:
83
+
84
+ ```powershell
85
+ $env:IMERGPY_PORT = "5001"
86
+ python -m imergpy.cli
87
+ ```
88
+
89
+ ## Python API Example
90
+
91
+ ```python
92
+ import os
93
+ import imergpy
94
+
95
+ excel_path, records = imergpy.get_precipitation(
96
+ lat=6.9271,
97
+ lon=79.8612,
98
+ start_datetime="2025-01",
99
+ end_datetime="2025-01",
100
+ username=os.environ["EARTHDATA_USERNAME"],
101
+ password=os.environ["EARTHDATA_PASSWORD"],
102
+ run_type="final",
103
+ freq="monthly",
104
+ interp_method="nearest",
105
+ )
106
+
107
+ print(excel_path)
108
+ ```
109
+
110
+ Accepted date formats:
111
+
112
+ - `YYYY-MM`
113
+ - `YYYY-MM-DD`
114
+ - `YYYY-MM-DD HH:MM`
115
+
116
+ ## NASA Earthdata Credentials
117
+
118
+ You need a free NASA Earthdata account. After creating the account, authorize GES DISC under Earthdata authorized applications.
119
+
120
+ Do not write credentials into scripts. Use environment variables:
121
+
122
+ ```powershell
123
+ $env:EARTHDATA_USERNAME = "your_username"
124
+ $env:EARTHDATA_PASSWORD = "your_password"
125
+ ```
126
+
127
+ ## Legal And Data Use Notice
128
+
129
+ `imergpy` is an independent open-source tool. It is not developed, endorsed, or certified by NASA, GES DISC, or the GPM mission team.
130
+
131
+ Users are responsible for:
132
+
133
+ - creating and using their own NASA Earthdata account,
134
+ - accepting and following NASA/GES DISC data access terms,
135
+ - citing NASA GPM IMERG data correctly in reports, papers, and products,
136
+ - checking data quality, latency, and suitability before operational or scientific use,
137
+ - keeping Earthdata usernames, passwords, and tokens private.
138
+
139
+ This software is provided under the MIT License without warranty.
140
+
141
+ ## Development
142
+
143
+ Run tests:
144
+
145
+ ```bash
146
+ pytest
147
+ ```
148
+
149
+ Build package files:
150
+
151
+ ```bash
152
+ python -m build
153
+ ```
154
+
155
+ Upload to TestPyPI first:
156
+
157
+ ```bash
158
+ twine upload --repository testpypi dist/*
159
+ ```
160
+
161
+ Upload to PyPI:
162
+
163
+ ```bash
164
+ twine upload dist/*
165
+ ```
166
+
167
+ ## License
168
+
169
+ MIT License. Developed by Lakshitha S. Senavirathna.
@@ -0,0 +1,132 @@
1
+ # imergpy
2
+
3
+ `imergpy` is a Python package and local web interface for downloading NASA GPM IMERG precipitation data through NASA Earthdata/GES DISC. It can extract point rainfall time series and compute grid-cell average rainfall for selected countries or square areas.
4
+
5
+ ## Features
6
+
7
+ - Local web UI launched with the `imergpy` command
8
+ - Python API for scripted workflows
9
+ - Point, country, and square-area selection in the web map
10
+ - Grid-cell average precipitation for country and square-area selections
11
+ - Half-hourly, daily, and monthly IMERG products
12
+ - Early, Late, and Final IMERG run types where available
13
+ - Excel export with separate `Start Time` and `End Time` columns
14
+ - Basic rainfall plotting and statistics utilities
15
+
16
+ ## Installation
17
+
18
+ Users only need two commands:
19
+
20
+ ```bash
21
+ pip install imergpy
22
+ imergpy
23
+ ```
24
+
25
+ For local development:
26
+
27
+ ```bash
28
+ pip install -e ".[dev]"
29
+ ```
30
+
31
+ The browser should open automatically. If it does not, copy the local URL printed in the terminal.
32
+
33
+ ## Start The Web UI Manually
34
+
35
+ ```bash
36
+ imergpy
37
+ ```
38
+
39
+ If the command is not available on Windows:
40
+
41
+ ```bash
42
+ python -m imergpy.cli
43
+ ```
44
+
45
+ If port `5000` is busy:
46
+
47
+ ```powershell
48
+ $env:IMERGPY_PORT = "5001"
49
+ python -m imergpy.cli
50
+ ```
51
+
52
+ ## Python API Example
53
+
54
+ ```python
55
+ import os
56
+ import imergpy
57
+
58
+ excel_path, records = imergpy.get_precipitation(
59
+ lat=6.9271,
60
+ lon=79.8612,
61
+ start_datetime="2025-01",
62
+ end_datetime="2025-01",
63
+ username=os.environ["EARTHDATA_USERNAME"],
64
+ password=os.environ["EARTHDATA_PASSWORD"],
65
+ run_type="final",
66
+ freq="monthly",
67
+ interp_method="nearest",
68
+ )
69
+
70
+ print(excel_path)
71
+ ```
72
+
73
+ Accepted date formats:
74
+
75
+ - `YYYY-MM`
76
+ - `YYYY-MM-DD`
77
+ - `YYYY-MM-DD HH:MM`
78
+
79
+ ## NASA Earthdata Credentials
80
+
81
+ You need a free NASA Earthdata account. After creating the account, authorize GES DISC under Earthdata authorized applications.
82
+
83
+ Do not write credentials into scripts. Use environment variables:
84
+
85
+ ```powershell
86
+ $env:EARTHDATA_USERNAME = "your_username"
87
+ $env:EARTHDATA_PASSWORD = "your_password"
88
+ ```
89
+
90
+ ## Legal And Data Use Notice
91
+
92
+ `imergpy` is an independent open-source tool. It is not developed, endorsed, or certified by NASA, GES DISC, or the GPM mission team.
93
+
94
+ Users are responsible for:
95
+
96
+ - creating and using their own NASA Earthdata account,
97
+ - accepting and following NASA/GES DISC data access terms,
98
+ - citing NASA GPM IMERG data correctly in reports, papers, and products,
99
+ - checking data quality, latency, and suitability before operational or scientific use,
100
+ - keeping Earthdata usernames, passwords, and tokens private.
101
+
102
+ This software is provided under the MIT License without warranty.
103
+
104
+ ## Development
105
+
106
+ Run tests:
107
+
108
+ ```bash
109
+ pytest
110
+ ```
111
+
112
+ Build package files:
113
+
114
+ ```bash
115
+ python -m build
116
+ ```
117
+
118
+ Upload to TestPyPI first:
119
+
120
+ ```bash
121
+ twine upload --repository testpypi dist/*
122
+ ```
123
+
124
+ Upload to PyPI:
125
+
126
+ ```bash
127
+ twine upload dist/*
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT License. Developed by Lakshitha S. Senavirathna.
@@ -0,0 +1,18 @@
1
+ import os
2
+
3
+ import imergpy
4
+
5
+
6
+ excel_path, records = imergpy.get_precipitation(
7
+ lat=6.9271,
8
+ lon=79.8612,
9
+ start_datetime="2024-05-20 14:30",
10
+ end_datetime="2024-05-20 15:00",
11
+ username=os.environ["EARTHDATA_USERNAME"],
12
+ password=os.environ["EARTHDATA_PASSWORD"],
13
+ run_type="final",
14
+ freq="hhr",
15
+ )
16
+
17
+ print(excel_path)
18
+ print(records[:1])
@@ -0,0 +1,12 @@
1
+ # imergpy package
2
+ from .core import get_precipitation
3
+ from .plotter import plot_from_excel
4
+ from .analyzer import add_accumulation, resample_data, calculate_statistics
5
+
6
+ __all__ = [
7
+ "get_precipitation",
8
+ "plot_from_excel",
9
+ "add_accumulation",
10
+ "resample_data",
11
+ "calculate_statistics"
12
+ ]
@@ -0,0 +1,87 @@
1
+ import pandas as pd
2
+
3
+
4
+ PRECIP_COLUMNS = [
5
+ "Precipitation_mm_per_half_hour",
6
+ "Precipitation_mm_per_day",
7
+ "Precipitation_mm_per_month",
8
+ "Precipitation_mm",
9
+ "Precipitation_mm_hr",
10
+ ]
11
+
12
+
13
+ def _find_column(df, candidates, label):
14
+ for column in candidates:
15
+ if column in df.columns:
16
+ return column
17
+ raise ValueError(f"Could not find {label}. Expected one of: {', '.join(candidates)}")
18
+
19
+
20
+ def _time_column(df):
21
+ return _find_column(df, ["Start_Time", "Time"], "time column")
22
+
23
+
24
+ def _precip_column(df):
25
+ return _find_column(df, PRECIP_COLUMNS, "precipitation column")
26
+
27
+ def add_accumulation(df):
28
+ """
29
+ Takes a DataFrame with half-hourly IMERG data and adds:
30
+ 1. 'Absolute_Precip_mm': Total mm fallen in that 30 min interval (Rate * 0.5)
31
+ 2. 'Cumulative_Precip_mm': Running total of rainfall over the period.
32
+ """
33
+ df = df.copy()
34
+ precip_col = _precip_column(df)
35
+
36
+ if precip_col == "Precipitation_mm_hr":
37
+ df['Absolute_Precip_mm'] = df[precip_col] * 0.5
38
+ else:
39
+ df['Absolute_Precip_mm'] = df[precip_col]
40
+ df['Cumulative_Precip_mm'] = df['Absolute_Precip_mm'].cumsum()
41
+ return df
42
+
43
+ def resample_data(df, freq='D'):
44
+ """
45
+ Resamples the half-hourly data to Daily ('D') or Monthly ('M') totals.
46
+ Args:
47
+ df: Pandas DataFrame from IMERG excel
48
+ freq: 'D' for Daily, 'M' for Monthly
49
+ Returns:
50
+ Resampled DataFrame
51
+ """
52
+ df = df.copy()
53
+ if 'Absolute_Precip_mm' not in df.columns:
54
+ df = add_accumulation(df)
55
+
56
+ time_col = _time_column(df)
57
+ df[time_col] = pd.to_datetime(df[time_col])
58
+ df.set_index(time_col, inplace=True)
59
+
60
+ # Resample and sum the absolute precipitation
61
+ resampled = df[['Absolute_Precip_mm']].resample(freq).sum()
62
+ resampled.rename(columns={'Absolute_Precip_mm': 'Total_Precip_mm'}, inplace=True)
63
+
64
+ return resampled.reset_index()
65
+
66
+ def calculate_statistics(df):
67
+ """
68
+ Calculates extreme event statistics and thresholds for the given data.
69
+ """
70
+ if 'Absolute_Precip_mm' not in df.columns:
71
+ df = add_accumulation(df)
72
+
73
+ # Get daily totals for threshold analysis
74
+ daily_df = resample_data(df, freq='D')
75
+
76
+ stats = {
77
+ "Total_Rainfall_mm": float(df['Absolute_Precip_mm'].sum()),
78
+ "Max_Interval_Precip_mm": float(df['Absolute_Precip_mm'].max()),
79
+ "Max_Daily_Rainfall_mm": float(daily_df['Total_Precip_mm'].max()),
80
+ "Total_Days_Analyzed": int(len(daily_df)),
81
+ "Dry_Days_(<1mm)": int(len(daily_df[daily_df['Total_Precip_mm'] < 1.0])),
82
+ "Wet_Days_(>=1mm)": int(len(daily_df[daily_df['Total_Precip_mm'] >= 1.0])),
83
+ "Heavy_Rain_Days_(>25mm)": int(len(daily_df[daily_df['Total_Precip_mm'] > 25.0])),
84
+ "Extreme_Rain_Days_(>50mm)": int(len(daily_df[daily_df['Total_Precip_mm'] > 50.0]))
85
+ }
86
+
87
+ return stats
@@ -0,0 +1,13 @@
1
+ import sys
2
+ from .server import start_server
3
+
4
+ def main():
5
+ """Entry point for the imergpy CLI."""
6
+ try:
7
+ start_server()
8
+ except KeyboardInterrupt:
9
+ print("\nShutting down imergpy interface...")
10
+ sys.exit(0)
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,27 @@
1
+ # List of exactly 48 time strings used by GES DISC OTF
2
+ IMERG_TIMES = [
3
+ "S000000-E002959.0000", "S003000-E005959.0030", "S010000-E012959.0060", "S013000-E015959.0090",
4
+ "S020000-E022959.0120", "S023000-E025959.0150", "S030000-E032959.0180", "S033000-E035959.0210",
5
+ "S040000-E042959.0240", "S043000-E045959.0270", "S050000-E052959.0300", "S053000-E055959.0330",
6
+ "S060000-E062959.0360", "S063000-E065959.0390", "S070000-E072959.0420", "S073000-E075959.0450",
7
+ "S080000-E082959.0480", "S083000-E085959.0510", "S090000-E092959.0540", "S093000-E095959.0570",
8
+ "S100000-E102959.0600", "S103000-E105959.0630", "S110000-E112959.0660", "S113000-E115959.0690",
9
+ "S120000-E122959.0720", "S123000-E125959.0750", "S130000-E132959.0780", "S133000-E135959.0810",
10
+ "S140000-E142959.0840", "S143000-E145959.0870", "S150000-E152959.0900", "S153000-E155959.0930",
11
+ "S160000-E162959.0960", "S163000-E165959.0990", "S170000-E172959.1020", "S173000-E175959.1050",
12
+ "S180000-E182959.1080", "S183000-E185959.1110", "S190000-E192959.1140", "S193000-E195959.1170",
13
+ "S200000-E202959.1200", "S203000-E205959.1230", "S210000-E212959.1260", "S213000-E215959.1290",
14
+ "S220000-E222959.1320", "S223000-E225959.1350", "S230000-E232959.1380", "S233000-E235959.1410"
15
+ ]
16
+
17
+ def get_time_string(dt_obj):
18
+ """
19
+ Given a datetime object, returns the correct IMERG time string interval.
20
+ IMERG files are 30 min increments starting at top of hour.
21
+ """
22
+ hour = dt_obj.hour
23
+ minute = dt_obj.minute
24
+
25
+ # 0 to 29 mins maps to first half hour, 30 to 59 maps to second
26
+ idx = hour * 2 + (1 if minute >= 30 else 0)
27
+ return IMERG_TIMES[idx]
@@ -0,0 +1,174 @@
1
+ import os
2
+ import tempfile
3
+ import pandas as pd
4
+ from datetime import datetime, timedelta
5
+ from dateutil.relativedelta import relativedelta
6
+ from .downloader import DownloadError, EarthdataDownloader
7
+ from .processor import extract_area_average, extract_precipitation
8
+
9
+
10
+ VALID_RUN_TYPES = {"early", "late", "final"}
11
+ VALID_FREQUENCIES = {"hhr", "daily", "monthly"}
12
+ VALID_INTERPOLATION_METHODS = {"nearest", "linear", "cubic"}
13
+
14
+
15
+ def _parse_datetime(value):
16
+ value = str(value).replace('T', ' ')
17
+ formats = {
18
+ 7: "%Y-%m",
19
+ 10: "%Y-%m-%d",
20
+ 16: "%Y-%m-%d %H:%M",
21
+ }
22
+ try:
23
+ return datetime.strptime(value, formats[len(value)])
24
+ except (KeyError, ValueError) as e:
25
+ raise ValueError(f"Invalid date format: {value}. Use YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM.") from e
26
+
27
+
28
+ def _validate_inputs(lat, lon, run_type, freq, interp_method):
29
+ if not -90 <= float(lat) <= 90:
30
+ raise ValueError("lat must be between -90 and 90.")
31
+ if not -180 <= float(lon) <= 180:
32
+ raise ValueError("lon must be between -180 and 180.")
33
+ if run_type not in VALID_RUN_TYPES:
34
+ raise ValueError("run_type must be 'early', 'late', or 'final'.")
35
+ if freq not in VALID_FREQUENCIES:
36
+ raise ValueError("freq must be 'hhr', 'daily', or 'monthly'.")
37
+ if interp_method not in VALID_INTERPOLATION_METHODS:
38
+ raise ValueError("interp_method must be 'nearest', 'linear', or 'cubic'.")
39
+ if freq == "monthly" and run_type != "final":
40
+ raise ValueError("monthly frequency only supports run_type='final'.")
41
+
42
+
43
+ def _excel_dataframe(results):
44
+ df = pd.DataFrame(results)
45
+ preferred_order = [
46
+ "Start_Time",
47
+ "End_Time",
48
+ "Requested_Lat",
49
+ "Requested_Lon",
50
+ "Actual_Lat",
51
+ "Actual_Lon",
52
+ "Interpolation",
53
+ "IMERG_Version",
54
+ "Run_Type",
55
+ "Region_Type",
56
+ "Region_Name",
57
+ "Min_Lat",
58
+ "Min_Lon",
59
+ "Max_Lat",
60
+ "Max_Lon",
61
+ "Grid_Cells_Averaged",
62
+ ]
63
+ precip_cols = [c for c in df.columns if c.startswith("Precipitation_")]
64
+ ordered_cols = [c for c in preferred_order + precip_cols if c in df.columns]
65
+ remaining_cols = [c for c in df.columns if c not in ordered_cols]
66
+ df = df[ordered_cols + remaining_cols]
67
+ return df.rename(columns={"Start_Time": "Start Time", "End_Time": "End Time"})
68
+
69
+ def get_precipitation(lat, lon, start_datetime, end_datetime, username, password,
70
+ run_type="early", freq="hhr", interp_method="nearest", out_dir=".",
71
+ progress_callback=None, selection_mode="point", bbox=None,
72
+ geometry=None, region_name=None):
73
+ """
74
+ Main function to download IMERG data for a time period and save to Excel.
75
+ Now includes dual Start_Time and End_Time columns.
76
+ """
77
+ if selection_mode == "point":
78
+ _validate_inputs(lat, lon, run_type, freq, interp_method)
79
+ else:
80
+ _validate_inputs(lat, lon, run_type, freq, "nearest")
81
+ if not bbox:
82
+ raise ValueError("bbox is required for country and square-area downloads.")
83
+ dt_start = _parse_datetime(start_datetime)
84
+ dt_end = _parse_datetime(end_datetime)
85
+
86
+ if dt_end < dt_start:
87
+ raise ValueError("end_datetime must be after start_datetime")
88
+
89
+ downloader = EarthdataDownloader(username, password)
90
+
91
+ time_stamp_start = dt_start.strftime("%Y%m%d_%H%M")
92
+ time_stamp_end = dt_end.strftime("%Y%m%d_%H%M")
93
+ region_label = region_name or f"{lat}_{lon}"
94
+ region_label = "".join(c if c.isalnum() or c in "._-" else "_" for c in str(region_label))
95
+ excel_filename = f"IMERG_{run_type}_{freq}_{selection_mode}_{region_label}_{time_stamp_start}_to_{time_stamp_end}.xlsx"
96
+ os.makedirs(out_dir, exist_ok=True)
97
+ excel_path = os.path.join(out_dir, excel_filename)
98
+
99
+ # Snap start time appropriately
100
+ if freq == "hhr":
101
+ minute = 0 if dt_start.minute < 30 else 30
102
+ current_dt = dt_start.replace(minute=minute, second=0, microsecond=0)
103
+ elif freq == "daily":
104
+ current_dt = dt_start.replace(hour=0, minute=0, second=0, microsecond=0)
105
+ dt_end = dt_end.replace(hour=23, minute=59)
106
+ elif freq == "monthly":
107
+ current_dt = dt_start.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
108
+ total_steps = 0
109
+ temp_dt = current_dt
110
+ while temp_dt <= dt_end:
111
+ total_steps += 1
112
+ if freq == "hhr": temp_dt += timedelta(minutes=30)
113
+ elif freq == "daily": temp_dt += timedelta(days=1)
114
+ elif freq == "monthly": temp_dt += relativedelta(months=1)
115
+
116
+ results = []
117
+ failures = []
118
+ step_count = 0
119
+ if progress_callback:
120
+ progress_callback(0)
121
+
122
+ while current_dt <= dt_end:
123
+ step_count += 1
124
+
125
+ fd, temp_nc_path = tempfile.mkstemp(suffix=".nc4")
126
+ os.close(fd)
127
+
128
+ try:
129
+ _, version_used = downloader.download_granule(lat, lon, current_dt, temp_nc_path, run_type, freq, bbox=bbox)
130
+ if selection_mode == "point":
131
+ data_dict = extract_precipitation(temp_nc_path, lat, lon, method=interp_method, freq=freq, current_dt=current_dt)
132
+ else:
133
+ data_dict = extract_area_average(
134
+ temp_nc_path,
135
+ bbox=bbox,
136
+ freq=freq,
137
+ current_dt=current_dt,
138
+ geometry=geometry,
139
+ region_name=region_name,
140
+ region_type=selection_mode,
141
+ )
142
+ data_dict["IMERG_Version"] = version_used
143
+ data_dict["Run_Type"] = run_type
144
+ results.append(data_dict)
145
+ except DownloadError as e:
146
+ failures.append({"datetime": current_dt.isoformat(), "error": str(e)})
147
+ print(f" -> Warning: {e}")
148
+ finally:
149
+ if os.path.exists(temp_nc_path):
150
+ os.remove(temp_nc_path)
151
+ if progress_callback:
152
+ progress_callback(int((step_count / total_steps) * 100))
153
+
154
+ if freq == "hhr": current_dt += timedelta(minutes=30)
155
+ elif freq == "daily": current_dt += timedelta(days=1)
156
+ elif freq == "monthly": current_dt += relativedelta(months=1)
157
+
158
+ if not results:
159
+ details = "; ".join(f"{f['datetime']}: {f['error']}" for f in failures[:3])
160
+ raise RuntimeError(f"No data could be successfully downloaded. {details}")
161
+
162
+ df = _excel_dataframe(results)
163
+ df.to_excel(excel_path, index=False)
164
+
165
+ # JSON-friendly results
166
+ serializable_results = []
167
+ for r in results:
168
+ entry = r.copy()
169
+ for k in ["Start_Time", "End_Time"]:
170
+ if hasattr(entry[k], 'isoformat'): entry[k] = entry[k].isoformat()
171
+ else: entry[k] = str(entry[k])
172
+ serializable_results.append(entry)
173
+
174
+ return excel_path, serializable_results