Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/relife_forecasting/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from relife_forecasting.config.logging import configure_logging
from relife_forecasting.routes import health
from relife_forecasting.utils.retry import retry_on_transient_error

# Prefer your package imports; keep a small fallback to reduce friction during refactors.
try:
Expand Down Expand Up @@ -308,11 +309,16 @@ async def simulate_building(

# 3) Weather + ISO 52016
if weather_source == "pvgis":
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(
bui_checked,
weather_source="pvgis",
sankey_graph=False,
)

@retry_on_transient_error()
def _run_pvgis_simulation():
return pybui.ISO52016.Temperature_and_Energy_needs_calculation(
bui_checked,
weather_source="pvgis",
sankey_graph=False,
)

hourly_sim, annual_results_df = _run_pvgis_simulation()

elif weather_source == "epw":
if epw_file is None:
Expand Down Expand Up @@ -987,7 +993,12 @@ def _spec_to_elements(spec: Dict[str, Any]) -> Set[str]:
# 5) Baseline (optional)
if include_baseline:
if weather_source == "pvgis":
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(base_bui, weather_source="pvgis", sankey_graph=False,)

@retry_on_transient_error()
def _run_baseline_pvgis():
return pybui.ISO52016.Temperature_and_Energy_needs_calculation(base_bui, weather_source="pvgis", sankey_graph=False,)

hourly_sim, annual_results_df = _run_baseline_pvgis()
else:
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(
base_bui, weather_source="epw", path_weather_file=epw_path, sankey_graph=False,
Expand Down Expand Up @@ -1031,7 +1042,12 @@ def _spec_to_elements(spec: Dict[str, Any]) -> Set[str]:
)

if weather_source == "pvgis":
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(bui_variant, weather_source="pvgis", sankey_graph=False,)

@retry_on_transient_error()
def _run_scenario_pvgis():
return pybui.ISO52016.Temperature_and_Energy_needs_calculation(bui_variant, weather_source="pvgis", sankey_graph=False,)

hourly_sim, annual_results_df = _run_scenario_pvgis()
else:
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(
bui_variant, weather_source="epw", path_weather_file=epw_path, sankey_graph=False,
Expand Down
15 changes: 11 additions & 4 deletions src/relife_forecasting/routes/forecasting_service_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from fastapi import HTTPException
from pydantic import BaseModel, Field

from relife_forecasting.utils.retry import retry_on_transient_error


# =============================================================================
# JSON / Data utilities
Expand Down Expand Up @@ -240,10 +242,15 @@ def simulate_building_worker(building_name: str, bui: dict, system: dict) -> Dic
}
"""
try:
hourly_sim, annual_results_df = pybui.ISO52016.Temperature_and_Energy_needs_calculation(
bui,
weather_source="pvgis",
)

@retry_on_transient_error()
def _run_worker_pvgis():
return pybui.ISO52016.Temperature_and_Energy_needs_calculation(
bui,
weather_source="pvgis",
)

hourly_sim, annual_results_df = _run_worker_pvgis()

calc = pybui.HeatingSystemCalculator(system)
calc.load_csv_data(hourly_sim)
Expand Down
65 changes: 65 additions & 0 deletions src/relife_forecasting/utils/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Retry utilities for resilient external API calls (e.g., PVGIS)."""

import logging
import time
from functools import wraps
from typing import TypeVar, Callable

logger = logging.getLogger(__name__)

T = TypeVar("T")

# Exceptions that indicate a transient network issue worth retrying.
TRANSIENT_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)

DEFAULT_MAX_RETRIES = 3
DEFAULT_INITIAL_DELAY = 1.0
DEFAULT_BACKOFF_FACTOR = 2.0


def retry_on_transient_error(
max_retries: int = DEFAULT_MAX_RETRIES,
initial_delay: float = DEFAULT_INITIAL_DELAY,
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
):
"""Decorator that retries a function on transient network errors.

Uses exponential backoff between attempts. Only retries on exceptions
that are subclasses of ``TRANSIENT_EXCEPTIONS``; all other exceptions
propagate immediately.
"""

def decorator(fn: Callable[..., T]) -> Callable[..., T]:
@wraps(fn)
def wrapper(*args, **kwargs) -> T:
delay = initial_delay
last_exc: BaseException | None = None
for attempt in range(1, max_retries + 1):
try:
return fn(*args, **kwargs)
except TRANSIENT_EXCEPTIONS as exc:
last_exc = exc
if attempt < max_retries:
logger.warning(
"Transient error in %s (attempt %d/%d): %s — retrying in %.1fs",
fn.__name__,
attempt,
max_retries,
exc,
delay,
)
time.sleep(delay)
delay *= backoff_factor
else:
logger.error(
"Transient error in %s (attempt %d/%d): %s — no retries left",
fn.__name__,
attempt,
max_retries,
exc,
)
raise last_exc # type: ignore[misc]

return wrapper

return decorator
Loading