Skip to content

Commit dc8adc2

Browse files
committed
fix(streamlit): auto-fetch full geo and rent data on cloud
1 parent 9e63cbe commit dc8adc2

1 file changed

Lines changed: 97 additions & 12 deletions

File tree

streamlit_app.py

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import networkx as nx
1111
import pandas as pd
1212
import plotly.graph_objects as go
13+
import requests
1314
import streamlit as st
1415
from streamlit_folium import st_folium
1516

@@ -24,6 +25,8 @@
2425
DEFAULT_RENT_CSV,
2526
HDB_CITATION_URL,
2627
HDB_MEDIAN_RENT_CITATION_URL,
28+
HDB_MEDIAN_RENT_RESOURCE_ID,
29+
PLANNING_AREA_GEOJSON_POLL_URL,
2730
)
2831
from singapore_eda.display_table import (
2932
block_table_for_display,
@@ -36,6 +39,7 @@
3639
from singapore_eda.eip import eip_match_stats
3740
from singapore_eda.features import model_design_subset
3841
from singapore_eda.forecasting import backtest_rmse, monthly_median_price
42+
from singapore_eda.gov_http import get_gov_client
3943
from singapore_eda.graph_analytics import (
4044
correlation_community_table,
4145
correlation_edges_dataframe,
@@ -53,6 +57,7 @@
5357
)
5458
from singapore_eda.pipeline import load_enriched
5559
from singapore_eda.rent_cache import age_hours, rent_csv_is_fresh
60+
from singapore_eda.rent_ingest import download_median_rent
5661
from singapore_eda.rental_yields import gross_yield_table
5762
from singapore_eda.stats import (
5863
numeric_correlation,
@@ -159,6 +164,78 @@ def _bootstrap_resale_if_missing(default_path: str) -> str:
159164
return default_path
160165

161166

167+
def _bootstrap_rent_if_missing(default_path: str) -> str:
168+
p = Path(default_path)
169+
if p.exists():
170+
return default_path
171+
172+
auto_fetch = _default_bool_env(
173+
"SINGAPORE_EDA_AUTO_DOWNLOAD_RENT_ON_MISSING",
174+
cloud_default=True,
175+
local_default=False,
176+
)
177+
if auto_fetch:
178+
rid = (_setting("HDB_MEDIAN_RENT_RESOURCE_ID") or HDB_MEDIAN_RENT_RESOURCE_ID).strip()
179+
max_rows_raw = _setting("SINGAPORE_EDA_RENT_BOOTSTRAP_MAX_ROWS") or "200000"
180+
try:
181+
max_rows = max(1000, int(str(max_rows_raw).strip()))
182+
except ValueError:
183+
max_rows = 200000
184+
try:
185+
p.parent.mkdir(parents=True, exist_ok=True)
186+
n = download_median_rent(
187+
p,
188+
rid,
189+
max_rows=max_rows,
190+
skip_if_fresh_hours=24.0,
191+
)
192+
st.sidebar.success(f"Fetched {n:,} rent rows from data.gov.sg.")
193+
if p.exists():
194+
return str(p)
195+
except (OSError, ValueError, RuntimeError) as ex:
196+
st.sidebar.warning(f"Rent auto-download failed; using fixture fallback. ({ex})")
197+
198+
if _FIX_RENT.exists():
199+
return str(_FIX_RENT)
200+
return default_path
201+
202+
203+
def _bootstrap_geo_if_tiny_or_missing(default_path: str) -> str:
204+
p = Path(default_path) if default_path else Path("")
205+
is_tiny = p.exists() and p.name == _TINY_GEO.name
206+
if p.exists() and not is_tiny:
207+
return str(p)
208+
209+
auto_fetch = _default_bool_env(
210+
"SINGAPORE_EDA_AUTO_FETCH_GEO_ON_TINY",
211+
cloud_default=True,
212+
local_default=False,
213+
)
214+
if auto_fetch:
215+
try:
216+
body = get_gov_client().get_json(
217+
PLANNING_AREA_GEOJSON_POLL_URL,
218+
timeout=90,
219+
use_cache=False,
220+
use_file_pace=True,
221+
)
222+
if body.get("code") != 0:
223+
raise RuntimeError(str(body.get("errMsg") or body.get("errorMsg") or "poll failed"))
224+
blob_url = (body.get("data") or {}).get("url")
225+
if not blob_url:
226+
raise RuntimeError("poll-download response missing URL")
227+
r = requests.get(str(blob_url), timeout=180)
228+
r.raise_for_status()
229+
_PLANNING_GEO.parent.mkdir(parents=True, exist_ok=True)
230+
_PLANNING_GEO.write_bytes(r.content)
231+
st.sidebar.success("Fetched full planning-area GeoJSON.")
232+
return str(_PLANNING_GEO)
233+
except (OSError, ValueError, RuntimeError, requests.RequestException) as ex:
234+
st.sidebar.warning(f"Geo auto-fetch failed; keeping current GeoJSON. ({ex})")
235+
236+
return default_path
237+
238+
162239
def _ols_coef_table(model: Any, top_n: int = 12) -> pd.DataFrame:
163240
ci = model.conf_int()
164241
rows: list[dict[str, object]] = []
@@ -420,12 +497,14 @@ def main() -> None:
420497

421498
default = os.environ.get("SINGAPORE_EDA_CSV", str(DEFAULT_RAW_CSV))
422499
default = _bootstrap_resale_if_missing(default)
500+
rent_default = _bootstrap_rent_if_missing(str(_DEFAULT_RENT))
501+
geo_default = _bootstrap_geo_if_tiny_or_missing(_default_geo())
423502
use_path = st.sidebar.text_input("Resale CSV path", value=default)
424503
rent_path = st.sidebar.text_input(
425504
"Median rent CSV (for yields)",
426-
value=str(_DEFAULT_RENT),
505+
value=rent_default,
427506
)
428-
geo_path = st.sidebar.text_input("Planning-area GeoJSON (map)", value=_default_geo())
507+
geo_path = st.sidebar.text_input("Planning-area GeoJSON (map)", value=geo_default)
429508
corr_thr = st.sidebar.slider("Graph min |correlation|", 0.0, 0.99, 0.2, 0.05)
430509

431510
try:
@@ -718,16 +797,22 @@ def main() -> None:
718797
try:
719798
ytab = gross_yield_table(df, Path(rent_path))
720799
ytab = ytab.sort_values("quarter", ascending=False)
721-
_why(
722-
"Annualised gross yield (%) for quarter × town × flat type matched rows.",
723-
"Users can benchmark income-return levels across segments and time with "
724-
"consistent rent/resale cohort alignment.",
725-
)
726-
st.dataframe(
727-
gross_yield_table_for_display(ytab),
728-
width="stretch",
729-
height=400,
730-
)
800+
if ytab.empty:
801+
st.warning(
802+
"No matched rent/resale rows after join on quarter, town, and flat type. "
803+
"Use the official full rent extract or check path/time overlap."
804+
)
805+
else:
806+
_why(
807+
"Annualised gross yield (%) for quarter × town × flat type matched rows.",
808+
"Users can benchmark income-return levels across segments and time with "
809+
"consistent rent/resale cohort alignment.",
810+
)
811+
st.dataframe(
812+
gross_yield_table_for_display(ytab),
813+
width="stretch",
814+
height=400,
815+
)
731816
except Exception as ex: # noqa: BLE001
732817
st.warning(str(ex))
733818
else:

0 commit comments

Comments
 (0)