Switch to 15-min autorefresh, drop manual button, sync all cycle archives
Browse files- app.py: AUTO_REFRESH_SECONDS = 15 min, CACHE_TTL = 14 min so the next
autorefresh always misses the cache.
- app.py: remove the 'Refresh forecast' button and replace with a status
line. Dropdowns still trigger re-render (cheap — they hit the cache).
- app.py: each autorefresh tick now also calls sync.sync_cycle for every
cycle_type (5min / 30min / 4hour) so the all-channel Ecowitt archive
in data/ecowitt.db stays current. Rate-limit aware.
- src/persist.py: push_all / pull_all / push_all_async — multi-file
commit that ships forecasts.db + ecowitt.db together to the HF
Dataset. push_db_async on user-triggered refresh stays as before
for snappier round-trips; the autorefresh thread does the full push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +37 -8
- src/persist.py +86 -0
|
@@ -17,7 +17,7 @@ from datetime import datetime, timedelta, timezone
|
|
| 17 |
import gradio as gr
|
| 18 |
import pandas as pd
|
| 19 |
|
| 20 |
-
from src import ecowitt, forecast_log, nws, persist
|
| 21 |
from src.forecast import forecast_series
|
| 22 |
from src.weather_ui import (
|
| 23 |
aligned_comparison_markdown,
|
|
@@ -26,8 +26,8 @@ from src.weather_ui import (
|
|
| 26 |
hero_markdown,
|
| 27 |
)
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
| 32 |
PLACE_NAME = os.environ.get("PLACE_NAME", "Yaphank, NY")
|
| 33 |
|
|
@@ -158,8 +158,9 @@ def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
|
|
| 158 |
strip = emoji_strip_markdown(nws_df_raw, DISPLAY_TZ, n=12)
|
| 159 |
scoreboard = render_scoreboard(log_conn)
|
| 160 |
|
| 161 |
-
# Backup
|
| 162 |
persist.push_db_async()
|
|
|
|
| 163 |
|
| 164 |
return hero, comparison_md, strip, fig, scoreboard
|
| 165 |
|
|
@@ -198,10 +199,37 @@ def render_scoreboard(conn) -> str:
|
|
| 198 |
|
| 199 |
|
| 200 |
# --- auto-refresh background thread --------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
def _autorefresh_loop():
|
| 202 |
while True:
|
| 203 |
try:
|
| 204 |
-
refresh()
|
|
|
|
|
|
|
| 205 |
except Exception: # noqa: BLE001
|
| 206 |
print("[autorefresh] error during refresh:")
|
| 207 |
traceback.print_exc()
|
|
@@ -243,7 +271,9 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
|
|
| 243 |
choices=list(HORIZON_CONFIG.keys()), value="24 h",
|
| 244 |
label="Forecast horizon", scale=1,
|
| 245 |
)
|
| 246 |
-
|
|
|
|
|
|
|
| 247 |
|
| 248 |
scoreboard_md = gr.Markdown()
|
| 249 |
plot = gr.Plot(label="Forecast")
|
|
@@ -251,12 +281,11 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
|
|
| 251 |
outputs = [hero_md, comparison_md, strip_md, plot, scoreboard_md]
|
| 252 |
inputs = [cycle_dd, horizon_dd]
|
| 253 |
demo.load(refresh, inputs=inputs, outputs=outputs)
|
| 254 |
-
refresh_btn.click(refresh, inputs=inputs, outputs=outputs)
|
| 255 |
cycle_dd.change(refresh, inputs=inputs, outputs=outputs)
|
| 256 |
horizon_dd.change(refresh, inputs=inputs, outputs=outputs)
|
| 257 |
|
| 258 |
|
| 259 |
if __name__ == "__main__":
|
| 260 |
-
persist.
|
| 261 |
_start_autorefresh()
|
| 262 |
demo.launch()
|
|
|
|
| 17 |
import gradio as gr
|
| 18 |
import pandas as pd
|
| 19 |
|
| 20 |
+
from src import ecowitt, forecast_log, nws, persist, storage, sync
|
| 21 |
from src.forecast import forecast_series
|
| 22 |
from src.weather_ui import (
|
| 23 |
aligned_comparison_markdown,
|
|
|
|
| 26 |
hero_markdown,
|
| 27 |
)
|
| 28 |
|
| 29 |
+
AUTO_REFRESH_SECONDS = 15 * 60 # background tick + archive sync
|
| 30 |
+
CACHE_TTL_SECONDS = AUTO_REFRESH_SECONDS - 60 # so autorefresh always refetches
|
| 31 |
DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
| 32 |
PLACE_NAME = os.environ.get("PLACE_NAME", "Yaphank, NY")
|
| 33 |
|
|
|
|
| 158 |
strip = emoji_strip_markdown(nws_df_raw, DISPLAY_TZ, n=12)
|
| 159 |
scoreboard = render_scoreboard(log_conn)
|
| 160 |
|
| 161 |
+
# Backup forecast log to HF Dataset (non-blocking).
|
| 162 |
persist.push_db_async()
|
| 163 |
+
# The full archive sync + push happens in the autorefresh thread.
|
| 164 |
|
| 165 |
return hero, comparison_md, strip, fig, scoreboard
|
| 166 |
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
# --- auto-refresh background thread --------------------------------------
|
| 202 |
+
ECOWITT_ARCHIVE_DB = "data/ecowitt.db"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _sync_archive_all_cycles() -> None:
|
| 206 |
+
"""Refresh the SQLite archive (data/ecowitt.db) for every cycle_type
|
| 207 |
+
so the local mirror of Ecowitt's storage stays current."""
|
| 208 |
+
try:
|
| 209 |
+
cfg = ecowitt.EcowittConfig.from_env()
|
| 210 |
+
except RuntimeError:
|
| 211 |
+
return
|
| 212 |
+
conn = storage.connect(ECOWITT_ARCHIVE_DB)
|
| 213 |
+
try:
|
| 214 |
+
for cycle in sync.CYCLES:
|
| 215 |
+
try:
|
| 216 |
+
sync.sync_cycle(cfg, conn, cycle, verbose=False)
|
| 217 |
+
except ecowitt.EcowittRateLimitError as err:
|
| 218 |
+
print(f"[autorefresh] rate-limited on {cycle.name}: {err} — skipping rest")
|
| 219 |
+
break
|
| 220 |
+
except Exception: # noqa: BLE001
|
| 221 |
+
print(f"[autorefresh] sync error on {cycle.name}:")
|
| 222 |
+
traceback.print_exc()
|
| 223 |
+
finally:
|
| 224 |
+
conn.close()
|
| 225 |
+
|
| 226 |
+
|
| 227 |
def _autorefresh_loop():
|
| 228 |
while True:
|
| 229 |
try:
|
| 230 |
+
refresh() # live forecast + forecasts.db log
|
| 231 |
+
_sync_archive_all_cycles() # 5min/30min/4hour raw archive
|
| 232 |
+
persist.push_all_async() # back up both DBs to HF Dataset
|
| 233 |
except Exception: # noqa: BLE001
|
| 234 |
print("[autorefresh] error during refresh:")
|
| 235 |
traceback.print_exc()
|
|
|
|
| 271 |
choices=list(HORIZON_CONFIG.keys()), value="24 h",
|
| 272 |
label="Forecast horizon", scale=1,
|
| 273 |
)
|
| 274 |
+
gr.Markdown(
|
| 275 |
+
"<span style='opacity:0.55'>🔄 Live data + forecast auto-refresh every 15 minutes.</span>"
|
| 276 |
+
)
|
| 277 |
|
| 278 |
scoreboard_md = gr.Markdown()
|
| 279 |
plot = gr.Plot(label="Forecast")
|
|
|
|
| 281 |
outputs = [hero_md, comparison_md, strip_md, plot, scoreboard_md]
|
| 282 |
inputs = [cycle_dd, horizon_dd]
|
| 283 |
demo.load(refresh, inputs=inputs, outputs=outputs)
|
|
|
|
| 284 |
cycle_dd.change(refresh, inputs=inputs, outputs=outputs)
|
| 285 |
horizon_dd.change(refresh, inputs=inputs, outputs=outputs)
|
| 286 |
|
| 287 |
|
| 288 |
if __name__ == "__main__":
|
| 289 |
+
persist.pull_all() # bootstrap forecast log + archive from the HF Dataset
|
| 290 |
_start_autorefresh()
|
| 291 |
demo.launch()
|
|
@@ -101,3 +101,89 @@ def push_db_async(local_path: str = DEFAULT_LOCAL) -> None:
|
|
| 101 |
threading.Thread(
|
| 102 |
target=push_db, args=(local_path,), daemon=True, name="persist-push"
|
| 103 |
).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
threading.Thread(
|
| 102 |
target=push_db, args=(local_path,), daemon=True, name="persist-push"
|
| 103 |
).start()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# --- multi-file push (forecast log + Ecowitt archive in one commit) ------
|
| 107 |
+
ARCHIVE_LOCAL = "data/ecowitt.db"
|
| 108 |
+
ARCHIVE_PATH_IN_REPO = "ecowitt.db"
|
| 109 |
+
_multi_lock = threading.Lock()
|
| 110 |
+
_multi_last = 0.0
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def push_all(
|
| 114 |
+
forecast_local: str = DEFAULT_LOCAL,
|
| 115 |
+
archive_local: str = ARCHIVE_LOCAL,
|
| 116 |
+
) -> bool:
|
| 117 |
+
"""Upload both DBs in a single dataset commit."""
|
| 118 |
+
global _multi_last
|
| 119 |
+
tok = _token()
|
| 120 |
+
if not tok:
|
| 121 |
+
return False
|
| 122 |
+
if time.time() - _multi_last < PUSH_MIN_INTERVAL:
|
| 123 |
+
return False
|
| 124 |
+
if not _multi_lock.acquire(blocking=False):
|
| 125 |
+
return False
|
| 126 |
+
try:
|
| 127 |
+
from huggingface_hub import CommitOperationAdd, HfApi # noqa: PLC0415
|
| 128 |
+
api = HfApi(token=tok)
|
| 129 |
+
ops = []
|
| 130 |
+
for local, in_repo in (
|
| 131 |
+
(forecast_local, PATH_IN_REPO),
|
| 132 |
+
(archive_local, ARCHIVE_PATH_IN_REPO),
|
| 133 |
+
):
|
| 134 |
+
if os.path.exists(local):
|
| 135 |
+
ops.append(CommitOperationAdd(path_in_repo=in_repo, path_or_fileobj=local))
|
| 136 |
+
if not ops:
|
| 137 |
+
return False
|
| 138 |
+
api.create_commit(
|
| 139 |
+
repo_id=_repo_id(),
|
| 140 |
+
repo_type="dataset",
|
| 141 |
+
operations=ops,
|
| 142 |
+
commit_message="forecast log + archive update",
|
| 143 |
+
)
|
| 144 |
+
_multi_last = time.time()
|
| 145 |
+
sizes = ", ".join(f"{op.path_in_repo}={os.path.getsize(forecast_local if op.path_in_repo==PATH_IN_REPO else archive_local)}B" for op in ops)
|
| 146 |
+
print(f"[persist] pushed multi to {_repo_id()} ({sizes})")
|
| 147 |
+
return True
|
| 148 |
+
except Exception: # noqa: BLE001
|
| 149 |
+
print("[persist] push_all failed:")
|
| 150 |
+
traceback.print_exc()
|
| 151 |
+
return False
|
| 152 |
+
finally:
|
| 153 |
+
_multi_lock.release()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def push_all_async(
|
| 157 |
+
forecast_local: str = DEFAULT_LOCAL,
|
| 158 |
+
archive_local: str = ARCHIVE_LOCAL,
|
| 159 |
+
) -> None:
|
| 160 |
+
threading.Thread(
|
| 161 |
+
target=push_all, args=(forecast_local, archive_local),
|
| 162 |
+
daemon=True, name="persist-push-all",
|
| 163 |
+
).start()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def pull_all(
|
| 167 |
+
forecast_local: str = DEFAULT_LOCAL,
|
| 168 |
+
archive_local: str = ARCHIVE_LOCAL,
|
| 169 |
+
) -> None:
|
| 170 |
+
"""Pull both DBs from the dataset on startup. Each missing file is silently skipped."""
|
| 171 |
+
pull_db(forecast_local)
|
| 172 |
+
# Pull the archive too if it exists.
|
| 173 |
+
tok = _token()
|
| 174 |
+
if not tok:
|
| 175 |
+
return
|
| 176 |
+
try:
|
| 177 |
+
from huggingface_hub import hf_hub_download # noqa: PLC0415
|
| 178 |
+
downloaded = hf_hub_download(
|
| 179 |
+
repo_id=_repo_id(),
|
| 180 |
+
repo_type="dataset",
|
| 181 |
+
filename=ARCHIVE_PATH_IN_REPO,
|
| 182 |
+
token=tok,
|
| 183 |
+
)
|
| 184 |
+
os.makedirs(os.path.dirname(archive_local) or ".", exist_ok=True)
|
| 185 |
+
shutil.copyfile(downloaded, archive_local)
|
| 186 |
+
print(f"[persist] pulled archive ({os.path.getsize(archive_local)} bytes)")
|
| 187 |
+
except Exception:
|
| 188 |
+
# 404 on first run is expected.
|
| 189 |
+
pass
|