Repository: bcgov/wps
Pull request #4510: Herbie/ECMWF job logic
Author: login: brettedw
Created: 2025-05-15T20:02:18Z | Merged: 2025-05-15T23:11:14Z

- Will check if a url has already been processed, so it doesn't try to process it again
- If a url isn't found for a model run, we'll just stop processing the model run and try again later
- Changes logic for determining if a model run was completely processed
# Test Links:
[Landing Page](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/)
[MoreCast](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/morecast)
[Percentile Calculator](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/percentile-calculator)
[C-Haines](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/c-haines)
[FireCalc](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/fire-behaviour-calculator)
[FireCalc bookmark](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/fire-behaviour-calculator?s=266&f=c5&c=NaN&w=20,s=286&f=c7&c=NaN&w=16,s=1055&f=c7&c=NaN&w=NaN,s=305&f=c7&c=NaN&w=NaN,s=344&f=c5&c=NaN&w=NaN,s=346&f=c7&c=NaN&w=NaN,s=328&f=c7&c=NaN&w=NaN,s=1399&f=c7&c=NaN&w=NaN,s=334&f=c7&c=NaN&w=NaN,s=1082&f=c3&c=NaN&w=NaN,s=388&f=c7&c=NaN&w=NaN,s=309&f=c7&c=NaN&w=16,s=306&f=c7&c=NaN&w=NaN,s=1029&f=c7&c=NaN&w=NaN,s=298&f=c7&c=NaN&w=NaN,s=836&f=c7&c=NaN&w=NaN,s=9999&f=c7&c=NaN&w=NaN)
[Auto Spatial Advisory (ASA)](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/auto-spatial-advisory)
[HFI Calculator](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/hfi-calculator)
[SFMS Insights](https://wps-pr-4510-e1e498-dev.apps.silver.devops.gov.bc.ca/insights)



Diff:
diff --git a/wps_jobs/tests/weather_models/test_ecmwf.py b/wps_jobs/tests/weather_models/test_ecmwf.py
index a5420441e1..4421189a25 100644
--- a/wps_jobs/tests/weather_models/test_ecmwf.py
+++ b/wps_jobs/tests/weather_models/test_ecmwf.py
@@ -1,13 +1,11 @@
-from datetime import datetime
 import os
-import sys
 import pytest
 from aiohttp import ClientSession
 from pytest_mock import MockerFixture
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, PropertyMock
 from wps_shared.tests.common import default_mock_client_get
 import wps_jobs.weather_model_jobs.ecmwf
-from wps_jobs.weather_model_jobs import ModelEnum, ProjectionEnum
+from wps_jobs.weather_model_jobs import ModelEnum
 from wps_shared.schemas.stations import WeatherStation
 from wps_shared.db.crud.model_run_repository import ModelRunRepository
 from wps_shared.db.models.weather_models import PredictionModelRunTimestamp
@@ -18,9 +16,11 @@
     get_stations_dataframe,
     ECMWF,
 )
-from wps_jobs.weather_model_jobs.ecmwf_model_processor import TEMP, ECMWFModelProcessor
+from wps_jobs.weather_model_jobs.ecmwf_model_processor import ECMWFModelProcessor
 from wps_jobs.weather_model_jobs.utils.process_grib import PredictionModelNotFound
 
+num_forecast_hours = len(list(get_ecmwf_forecast_hours()))
+
 
 class MockModelRunRepository(ModelRunRepository):
     def __init__(self):
@@ -40,6 +40,9 @@ def get_or_create_prediction_run(self, prediction_model, prediction_run_timestam
             self.prediction_runs[key] = run
         return self.prediction_runs[key]
 
+    def get_processed_url(self, url):
+        return None
+
 
 @pytest.fixture
 def mock_herbie_instance(mocker: MockerFixture):
@@ -53,7 +56,6 @@ def mock_herbie_instance(mocker: MockerFixture):
     yield mock_herbie_instance
 
 
-
 def test_get_model_run_hours():
     hours = list(get_model_run_hours(ModelEnum.ECMWF))
     assert hours == [0, 12]
@@ -77,6 +79,7 @@ def test_get_stations_dataframe():
     assert df.iloc[0]["latitude"] == 11.0
     assert df.iloc[0]["longitude"] == 21.0
 
+
 def test_ecmwf_process_model_run_no_url(mock_herbie_instance):
     mock_herbie_instance.grib = None
     stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
@@ -86,37 +89,23 @@ def test_ecmwf_process_model_run_no_url(mock_herbie_instance):
     assert ecmwf.files_downloaded == 0
     assert ecmwf.files_processed == 0
 
+
 @pytest.mark.parametrize(
-    "complete, expected_processed, expected_complete_check, expected_complete",
-    [
-        (False, 65, 1, 1),
-        (True, 0, 0, 0)
-    ],
+    "complete, expected_processed, expected_complete",
+    [(False, num_forecast_hours, 1), (True, 0, 0)],
 )
-def test_ecmwf_process_model_complete(complete, expected_processed, expected_complete_check, expected_complete, mock_herbie_instance, mocker: MockerFixture):
-    mocker.patch(
-        "wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer",
-        return_value=MagicMock()
-    )
-    mocker.patch(
-        "wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe",
-        return_value=MagicMock()
-    )
-    mocker.patch.object(
-        ECMWFModelProcessor,
-        "process_grib_data",
-        return_value=MagicMock()
-    )
+def test_ecmwf_process_model_complete(complete, expected_processed, expected_complete, mock_herbie_instance, mocker: MockerFixture):
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer", return_value=MagicMock())
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe", return_value=MagicMock())
+    mocker.patch.object(ECMWFModelProcessor, "process_grib_data", return_value=MagicMock())
     # Mock ECMWF.store_processed_result to do nothing
-    mocker.patch.object(
-        ECMWF,
-        "store_processed_result",
-        return_value=None
-    )
+    mocker.patch.object(ECMWF, "store_processed_result", return_value=None)
     stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
     mock_repository = MagicMock(spec=ModelRunRepository)
-    mock_repository.get_or_create_prediction_run = MagicMock(return_value=MagicMock(complete=complete))
-    mock_repository.check_if_model_run_complete = MagicMock(return_value=True)
+    mock_repository.get_or_create_prediction_run.return_value.complete = complete
+    mock_repository.check_if_model_run_complete.return_value = complete
+    mock_repository.get_processed_url.return_value = None
+
     ecmwf = ECMWF("/tmp", stations, mock_repository)
     ecmwf.process_model_run(0)
 
@@ -124,10 +113,58 @@ def test_ecmwf_process_model_complete(complete, expected_processed, expected_com
     assert ecmwf.files_downloaded == expected_processed
     assert ecmwf.files_processed == expected_processed
     assert mock_repository.mark_url_as_processed.call_count == expected_processed
-    assert mock_repository.check_if_model_run_complete.call_count == expected_complete_check
     assert mock_repository.mark_prediction_model_run_processed.call_count == expected_complete
 
 
+def test_ecmwf_process_model_previously_partial_processed(mock_herbie_instance, mocker: MockerFixture):
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer", return_value=MagicMock())
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe", return_value=MagicMock())
+    mocker.patch.object(ECMWFModelProcessor, "process_grib_data", return_value=MagicMock())
+    # Mock ECMWF.store_processed_result to do nothing
+    mocker.patch.object(ECMWF, "store_processed_result", return_value=None)
+    stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
+    mock_repository = MagicMock(spec=ModelRunRepository)
+    mock_repository.get_or_create_prediction_run.return_value.complete = False
+    mock_repository.check_if_model_run_complete.return_value = False
+
+    processed_urls = ["url0", "url1", "url2"]
+    unprocessed_urls = [None] * (num_forecast_hours - len(processed_urls))
+    mock_get_processed_urls_return = processed_urls + unprocessed_urls
+
+    mock_repository.get_processed_url.side_effect = mock_get_processed_urls_return
+
+    ecmwf = ECMWF("/tmp", stations, mock_repository)
+    ecmwf.process_model_run(0)
+
+    assert ecmwf.files_processed == len(mock_get_processed_urls_return)
+    assert mock_repository.mark_url_as_processed.call_count == num_forecast_hours - len(processed_urls)
+
+
+def test_ecmwf_process_model_partial_process(mock_herbie_instance, mocker: MockerFixture):
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer", return_value=MagicMock())
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe", return_value=MagicMock())
+    mocker.patch.object(ECMWFModelProcessor, "process_grib_data", return_value=MagicMock())
+    # Mock ECMWF.store_processed_result to do nothing
+    mocker.patch.object(ECMWF, "store_processed_result", return_value=None)
+    stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
+    mock_repository = MagicMock(spec=ModelRunRepository)
+    mock_repository.get_or_create_prediction_run.return_value.complete = False
+    mock_repository.check_if_model_run_complete.return_value = False
+    mock_repository.get_processed_url.return_value = None
+
+    urls = ["url0", "url1", "url2"]
+    unprocessed_urls = [None] * (num_forecast_hours - len(urls))
+    grib_values = urls + unprocessed_urls
+
+    type(mock_herbie_instance).grib = PropertyMock(side_effect=grib_values)
+
+    ecmwf = ECMWF("/tmp", stations, mock_repository)
+    ecmwf.process_model_run(0)
+
+    assert ecmwf.files_processed == len(urls)
+    assert mock_repository.mark_url_as_processed.call_count == len(urls)
+    assert mock_repository.mark_prediction_model_run_processed.call_count == 0
+
 
 def test_ecmwf_process(mock_herbie_instance):
     mock_repo = MockModelRunRepository()
@@ -137,11 +174,11 @@ def test_ecmwf_process(mock_herbie_instance):
 
     assert mock_repo.get_or_create_prediction_run_calls == 2  # For hours 0 and 12
     # For each hour (0 and 12) get all the forecast hours (2 * (len(range(0, 145, 3)) + len(range(150, 241, 6)))
-    assert ecmwf.files_downloaded == 130
-    assert ecmwf.files_processed == 130
+    assert ecmwf.files_downloaded == num_forecast_hours * 2
+    assert ecmwf.files_processed == num_forecast_hours * 2
 
 
-def test_ecmwf_process_model_run_prediction_not_found(mock_herbie_instance, monkeypatch):
+def test_ecmwf_process_model_run_exception(mock_herbie_instance, monkeypatch):
     def mock_get_transformer(_, __):
         raise Exception()
 
@@ -151,15 +188,13 @@ def mock_get_transformer(_, __):
     ecmwf = ECMWF("/tmp", stations, MockModelRunRepository())
 
     ecmwf.process()
-    # For each hour (0 and 12) get all the forecast hours (2 * (len(range(0, 145, 3)) + len(range(150, 241, 6)))
-    assert ecmwf.exception_count == 130
+    # 1 exception for each forecast hour + 1 for the model run
+    assert ecmwf.exception_count == (num_forecast_hours * 2) + 1
+
 
 @pytest.mark.parametrize(
     "prediction_exists",
-    [
-        (True),
-        (False)
-    ],
+    [(True), (False)],
 )
 def test_store_processed_result(prediction_exists):
     mock_repo = MagicMock(spec=ModelRunRepository)
@@ -194,31 +229,34 @@ def test_ecmwf_process_handles_exceptions(monkeypatch):
     stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
     monkeypatch.setattr(wps_jobs.weather_model_jobs.ecmwf.ECMWF, "process_model_run", lambda: Exception("Mocked exception"))
 
-
     ecmwf = ECMWF("/tmp", stations, mock_repo)
     ecmwf.process()
 
     assert ecmwf.exception_count > 0
 
+
 def test_main_success(mocker: MockerFixture, monkeypatch):
     """Test the main function when it runs successfully."""
+
     async def mock_process_models():
         pass
+
     monkeypatch.setattr(ClientSession, "get", default_mock_client_get)
     monkeypatch.setattr(wps_jobs.weather_model_jobs.ecmwf, "process_models", mock_process_models)
     rocket_chat_spy = mocker.spy(wps_jobs.weather_model_jobs.ecmwf, "send_rocketchat_notification")
 
     with pytest.raises(SystemExit) as excinfo:
         wps_jobs.weather_model_jobs.ecmwf.main()
-    
-     # Assert that we exited with an error code.
+
+    # Assert that we exited with an error code.
     assert excinfo.value.code == os.EX_OK
     # Assert that rocket chat was called.
     assert rocket_chat_spy.call_count == 0
 
 
 def test_main_fail(mocker: MockerFixture, monkeypatch):
-    """ Run the main method, check that message is sent to rocket chat, and exit code is EX_SOFTWARE """
+    """Run the main method, check that message is sent to rocket chat, and exit code is EX_SOFTWARE"""
+
     def mock_process_models():
         raise Exception()
 
@@ -233,6 +271,7 @@ def mock_process_models():
     # Assert that rocket chat was called.
     assert rocket_chat_spy.call_count == 1
 
+
 @pytest.mark.anyio
 async def test_process_models_success(mocker: MockerFixture):
     """Test process_models when it runs successfully."""
@@ -253,7 +292,6 @@ async def test_process_models_success(mocker: MockerFixture):
     mock_ecmwf_instance = mock_ecmwf.return_value
     mock_ecmwf_instance.files_processed = 10
 
-
     mock_temp_dir.return_value.__enter__.return_value = "/mock/temp/dir"
     mock_session_scope.return_value.__enter__.return_value = mock_repo
 
@@ -261,6 +299,7 @@ async def test_process_models_success(mocker: MockerFixture):
 
     mock_ecmwf_instance.process.assert_called_once()
 
+
 def test_process_model_run_prediction_model_not_found(mock_herbie_instance):
     """Test process_model_run when prediction model is not found."""
     stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
@@ -275,12 +314,10 @@ def test_process_model_run_prediction_model_not_found(mock_herbie_instance):
     assert ecmwf.files_processed == 0
     assert ecmwf.exception_count == 0
 
+
 @pytest.mark.parametrize(
     "complete",
-    [
-        (True),
-        (False)
-    ],
+    [(True), (False)],
 )
 def test_process_model_run_prediction_model_complete(mock_herbie_instance, complete):
     """Test process_model_run when prediction model is not found."""
@@ -297,26 +334,3 @@ def test_process_model_run_prediction_model_complete(mock_herbie_instance, compl
     assert ecmwf.files_downloaded == 0
     assert ecmwf.files_processed == 0
     assert ecmwf.exception_count == 0
-
-def test_get_model_run_urls(mock_herbie_instance):
-    """Test get_model_run_urls to ensure it returns the correct URLs."""
-    mock_herbie_instance.grib = "/mock/path/to/file.grib"
-
-    urls = wps_jobs.weather_model_jobs.ecmwf.get_model_run_urls(datetime(2023, 1, 1, 0, 0))
-
-    # Ensure the number of URLs matches the forecast hours
-    expected_forecast_hours = len(list(get_ecmwf_forecast_hours()))
-    assert len(urls) == expected_forecast_hours
-
-    # Ensure all URLs are the mocked grib file path
-    assert all(url == "/mock/path/to/file.grib" for url in urls)
-
-
-def test_get_model_run_urls_no_grib(mock_herbie_instance):
-    """Test get_model_run_urls when no grib files are found."""
-    mock_herbie_instance.grib = None  # Simulate no grib file found
-
-    urls = wps_jobs.weather_model_jobs.ecmwf.get_model_run_urls(datetime(2023, 1, 1, 0, 0))
-
-    # Ensure no URLs are returned
-    assert len(urls) == 0
diff --git a/wps_jobs/wps_jobs/weather_model_jobs/ecmwf.py b/wps_jobs/wps_jobs/weather_model_jobs/ecmwf.py
index d52fe6aaf5..6ac78aaadd 100644
--- a/wps_jobs/wps_jobs/weather_model_jobs/ecmwf.py
+++ b/wps_jobs/wps_jobs/weather_model_jobs/ecmwf.py
@@ -48,20 +48,6 @@ def get_ecmwf_forecast_hours():
     for h in range(150, 241, 6):
         yield h
 
-def get_model_run_urls(model_datetime: datetime) -> List[str]:
-    urls = []
-    for fxx in get_ecmwf_forecast_hours():
-        H = Herbie(
-            model_datetime.strftime("%Y-%m-%d %H"),
-            model="ifs",
-            product="oper",
-            priority="aws",
-            fxx=fxx,
-            verbose=False
-        )
-        if H.grib:
-            urls.append(H.grib)
-    return urls
 
 def get_stations_dataframe(transformer: Transformer, stations: List[WeatherStation]) -> pd.DataFrame:
     stations_df = pd.DataFrame([station.model_dump(include={"code", "name", "lat", "long"}) for station in stations]).rename(columns={"lat": "latitude", "long": "longitude"})
@@ -105,7 +91,6 @@ def process_model_run(self, model_run_hour):
         logger.info("Processing {} model run {:02d}".format(self.model_type, model_run_hour))
 
         model_datetime = self.now.replace(hour=model_run_hour, minute=0, second=0, microsecond=0)
-        all_model_run_urls = get_model_run_urls(model_datetime)
 
         self.prediction_model = self.model_run_repository.get_prediction_model(self.model_type, self.projection)
         if not self.prediction_model:
@@ -123,15 +108,32 @@ def process_model_run(self, model_run_hour):
                 projection=self.projection,
             )
 
-            with tempfile.TemporaryDirectory() as temp_dir:
-                H = Herbie(model_datetime.strftime("%Y-%m-%d %H"), model="ifs", product="oper", priority="aws", fxx=prediction_hour, save_dir=temp_dir, verbose=False)
+        with tempfile.TemporaryDirectory() as temp_dir:
+            forecast_hours = list(get_ecmwf_forecast_hours())
+
+            for prediction_hour in forecast_hours:
+                H = Herbie(
+                    model_datetime.strftime("%Y-%m-%d %H"),
+                    model="ifs",
+                    product="oper",
+                    priority="aws",
+                    fxx=prediction_hour,
+                    save_dir=temp_dir,
+                    verbose=False,
+                )
+
                 url = H.grib
                 if not url:
-                    logger.info(f"grib file not found for {model_info.prediction_timestamp} ")
-                    continue
+                    logger.info(f"GRIB file not found for {model_datetime} - hour {prediction_hour}. Exiting model run. ")
+                    return
 
                 self.files_downloaded += 1
 
+                if self.model_run_repository.get_processed_url(url):
+                    logger.info(f"file already processed -- {url}")
+                    self.files_processed += 1
+                    continue
+
                 try:
                     transformer = get_ecmwf_transformer(self.working_directory, H)
                     stations_df = get_stations_dataframe(transformer, self.stations)
@@ -139,14 +141,15 @@ def process_model_run(self, model_run_hour):
                     self.store_processed_result(self.stations, prediction_run, process_result)
                 except Exception as exception:
                     self.exception_count += 1
-                    # We intentionally catch a broad exception, as we want to try and download as much
-                    # as we can.
                     logger.error("unexpected exception processing %s", url, exc_info=exception)
+
                 self.files_processed += 1
                 self.model_run_repository.mark_url_as_processed(url)
-        if self.model_run_repository.check_if_model_run_complete(all_model_run_urls):
-            logger.info("{} model run {:02d}:00 completed with SUCCESS".format(self.model_type, model_run_hour))
-            self.model_run_repository.mark_prediction_model_run_processed(self.model_type, self.projection, model_datetime)
+
+            # files_processed is incremented whether the file was processed previously or on this run, so we can use it to check if all files were processed.
+            if len(forecast_hours) == self.files_processed:
+                logger.info(f"{self.model_type} model run {model_run_hour:02d}:00 completed with SUCCESS")
+                self.model_run_repository.mark_prediction_model_run_processed(self.model_type, self.projection, model_datetime)
 
     def process(self):
         for hour in get_model_run_hours(self.model_type):
@@ -208,7 +211,7 @@ def main():
     except Exception as exception:
         # Exit non 0 - failure.
         logger.error("An error occurred while processing ECMWF model.", exc_info=exception)
-        rc_message = ':poop: Encountered error retrieving model data from ECMWF'
+        rc_message = ":poop: Encountered error retrieving model data from ECMWF"
         send_rocketchat_notification(rc_message, exception)
         sys.exit(os.EX_SOFTWARE)
 


Reviews:
user: login: conbrad
body: Way nicer solution!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T21:45:23Z
user: login: conbrad
body: 🙌🙌🙌
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:13:37Z
user: login: dgboss
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:42:13Z

Review comments:
diff_hunk: @@ -86,49 +84,33 @@ def test_ecmwf_process_model_run_no_url(mock_herbie_instance):
     assert ecmwf.files_downloaded == 0
     assert ecmwf.files_processed == 0
 
+
 @pytest.mark.parametrize(
     "complete, expected_processed, expected_complete_check, expected_complete",
-    [
-        (False, 65, 1, 1),
-        (True, 0, 0, 0)
-    ],
+    [(False, 65, 1, 1), (True, 0, 0, 0)],
 )
 def test_ecmwf_process_model_complete(complete, expected_processed, expected_complete_check, expected_complete, mock_herbie_instance, mocker: MockerFixture):
-    mocker.patch(
-        "wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer",
-        return_value=MagicMock()
-    )
-    mocker.patch(
-        "wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe",
-        return_value=MagicMock()
-    )
-    mocker.patch.object(
-        ECMWFModelProcessor,
-        "process_grib_data",
-        return_value=MagicMock()
-    )
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_ecmwf_transformer", return_value=MagicMock())
+    mocker.patch("wps_jobs.weather_model_jobs.ecmwf.get_stations_dataframe", return_value=MagicMock())
+    mocker.patch.object(ECMWFModelProcessor, "process_grib_data", return_value=MagicMock())
     # Mock ECMWF.store_processed_result to do nothing
-    mocker.patch.object(
-        ECMWF,
-        "store_processed_result",
-        return_value=None
-    )
+    mocker.patch.object(ECMWF, "store_processed_result", return_value=None)
     stations = [WeatherStation(code="001", name="Station1", lat=10.0, long=20.0)]
     mock_repository = MagicMock(spec=ModelRunRepository)
-    mock_repository.get_or_create_prediction_run = MagicMock(return_value=MagicMock(complete=complete))
-    mock_repository.check_if_model_run_complete = MagicMock(return_value=True)
+    mock_repository.get_or_create_prediction_run.return_value.complete = complete
+    mock_repository.check_if_model_run_complete.return_value = complete
+    mock_repository.get_processed_url.return_value = None
+
     ecmwf = ECMWF("/tmp", stations, mock_repository)
     ecmwf.process_model_run(0)
 
     # For a single hour (0) get all the forecast hours (len(range(0, 145, 3)) + len(range(150, 241, 6))
     assert ecmwf.files_downloaded == expected_processed
     assert ecmwf.files_processed == expected_processed
     assert mock_repository.mark_url_as_processed.call_count == expected_processed
-    assert mock_repository.check_if_model_run_complete.call_count == expected_complete_check
path: wps_jobs/tests/weather_models/test_ecmwf.py
user: login: conbrad
body: I think `expected_complete_check` is unused now.
created_at: 2025-05-15T21:39:40Z
author_association: COLLABORATOR
diff_hunk: @@ -141,25 +123,9 @@ def test_ecmwf_process(mock_herbie_instance):
     assert ecmwf.files_processed == 130
 
 
-def test_ecmwf_process_model_run_prediction_not_found(mock_herbie_instance, monkeypatch):
path: wps_jobs/tests/weather_models/test_ecmwf.py
user: login: conbrad
body: We may want to keep this test and adjust it to check that `exception_count` is incremented.
created_at: 2025-05-15T21:45:07Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-15T20:08:26Z
body: ## [Codecov](https://app.codecov.io/gh/bcgov/wps/pull/4510?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bcgov) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 69.29%. Comparing base [(`cfeac85`)](https://app.codecov.io/gh/bcgov/wps/commit/cfeac8576fac2d99fa7b46726ed8cfc3e3a6c52b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bcgov) to head [(`106bdf8`)](https://app.codecov.io/gh/bcgov/wps/commit/106bdf82a530884c7f1572ce945da5e82fab13b1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bcgov).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #4510      +/-   ##
==========================================
- Coverage   69.29%   69.29%   -0.01%     
==========================================
  Files         362      362              
  Lines       13055    13053       -2     
  Branches     1549     1549              
==========================================
- Hits         9047     9045       -2     
  Misses       3546     3546              
  Partials      462      462              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bcgov/wps/pull/4510?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bcgov).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bcgov).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-15T22:24:07Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=bcgov_wps&pullRequest=4510) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=bcgov_wps&pullRequest=4510&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=bcgov_wps&pullRequest=4510&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=bcgov_wps&pullRequest=4510&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=bcgov_wps&pullRequest=4510&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=bcgov_wps&pullRequest=4510&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=bcgov_wps&pullRequest=4510)


author_association: NONE