|
10 | 10 | import networkx as nx |
11 | 11 | import pandas as pd |
12 | 12 | import plotly.graph_objects as go |
| 13 | +import requests |
13 | 14 | import streamlit as st |
14 | 15 | from streamlit_folium import st_folium |
15 | 16 |
|
|
24 | 25 | DEFAULT_RENT_CSV, |
25 | 26 | HDB_CITATION_URL, |
26 | 27 | HDB_MEDIAN_RENT_CITATION_URL, |
| 28 | + HDB_MEDIAN_RENT_RESOURCE_ID, |
| 29 | + PLANNING_AREA_GEOJSON_POLL_URL, |
27 | 30 | ) |
28 | 31 | from singapore_eda.display_table import ( |
29 | 32 | block_table_for_display, |
|
36 | 39 | from singapore_eda.eip import eip_match_stats |
37 | 40 | from singapore_eda.features import model_design_subset |
38 | 41 | from singapore_eda.forecasting import backtest_rmse, monthly_median_price |
| 42 | +from singapore_eda.gov_http import get_gov_client |
39 | 43 | from singapore_eda.graph_analytics import ( |
40 | 44 | correlation_community_table, |
41 | 45 | correlation_edges_dataframe, |
|
53 | 57 | ) |
54 | 58 | from singapore_eda.pipeline import load_enriched |
55 | 59 | from singapore_eda.rent_cache import age_hours, rent_csv_is_fresh |
| 60 | +from singapore_eda.rent_ingest import download_median_rent |
56 | 61 | from singapore_eda.rental_yields import gross_yield_table |
57 | 62 | from singapore_eda.stats import ( |
58 | 63 | numeric_correlation, |
@@ -159,6 +164,78 @@ def _bootstrap_resale_if_missing(default_path: str) -> str: |
159 | 164 | return default_path |
160 | 165 |
|
161 | 166 |
|
| 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 | + |
162 | 239 | def _ols_coef_table(model: Any, top_n: int = 12) -> pd.DataFrame: |
163 | 240 | ci = model.conf_int() |
164 | 241 | rows: list[dict[str, object]] = [] |
@@ -420,12 +497,14 @@ def main() -> None: |
420 | 497 |
|
421 | 498 | default = os.environ.get("SINGAPORE_EDA_CSV", str(DEFAULT_RAW_CSV)) |
422 | 499 | 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()) |
423 | 502 | use_path = st.sidebar.text_input("Resale CSV path", value=default) |
424 | 503 | rent_path = st.sidebar.text_input( |
425 | 504 | "Median rent CSV (for yields)", |
426 | | - value=str(_DEFAULT_RENT), |
| 505 | + value=rent_default, |
427 | 506 | ) |
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) |
429 | 508 | corr_thr = st.sidebar.slider("Graph min |correlation|", 0.0, 0.99, 0.2, 0.05) |
430 | 509 |
|
431 | 510 | try: |
@@ -718,16 +797,22 @@ def main() -> None: |
718 | 797 | try: |
719 | 798 | ytab = gross_yield_table(df, Path(rent_path)) |
720 | 799 | 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 | + ) |
731 | 816 | except Exception as ex: # noqa: BLE001 |
732 | 817 | st.warning(str(ex)) |
733 | 818 | else: |
|
0 commit comments