Download simulation_core.py from nandutt/RMT52: direct link, hf CLI and curl.
- Browser
- Download file 40.9 kB
-
https://huggingface.co/spaces/nandutt/RMT52/resolve/main/simulation_core.py
- Command line
-
hf download hf://spaces/nandutt/RMT52/simulation_core.py
-
curl -L -o simulation_core.py https://huggingface.co/spaces/nandutt/RMT52/resolve/main/simulation_core.py
40.9 kB
| # simulation_core.py | |
| import numpy as np | |
| import pandas as pd | |
| import inference_core | |
| from fe_avm_bdo26 import ( | |
| map_kecamatan, | |
| transform_area_group, | |
| transform_area_features, | |
| transform_structure_features, | |
| transform_interaction_features, | |
| fit_desirability_features, | |
| transform_desirability_features, | |
| ) | |
| from gemini_utils import gemini_client, GEMINI_ENABLED | |
| # ========================================================= | |
| # CONSTANTS | |
| # ========================================================= | |
| DIMENSIONS = [ | |
| "size_fit", | |
| "room_functionality_fit", | |
| "family_fit", | |
| "school_fit", | |
| "rentability_fit", | |
| "lifestyle_fit", | |
| "prestige_fit", | |
| "access_convenience_fit", | |
| "mixed_use_fit", | |
| "affordability_fit", | |
| "rumah_fit", | |
| "apartemen_fit", | |
| ] | |
| DIMENSION_LABELS = { | |
| "size_fit": "larger typical space", | |
| "room_functionality_fit": "more practical room layout", | |
| "family_fit": "stronger family-oriented environment", | |
| "school_fit": "better school access", | |
| "rentability_fit": "better rental appeal", | |
| "lifestyle_fit": "stronger lifestyle access", | |
| "prestige_fit": "more premium area profile", | |
| "access_convenience_fit": "better day-to-day convenience", | |
| "mixed_use_fit": "more mixed-use flexibility", | |
| "affordability_fit": "better affordability", | |
| "rumah_fit": "better fit for house-seeking buyers", | |
| "apartemen_fit": "better fit for apartment-seeking buyers", | |
| } | |
| BUYER_ORIENTATION_LIBRARY = { | |
| "family_oriented": { | |
| "size_fit": 9, | |
| "room_functionality_fit": 8, | |
| "family_fit": 10, | |
| "school_fit": 10, | |
| "rentability_fit": 1, | |
| "lifestyle_fit": 3, | |
| "prestige_fit": 2, | |
| "access_convenience_fit": 7, | |
| "mixed_use_fit": 0, | |
| "affordability_fit": 7, | |
| "rumah_fit": 10, | |
| "apartemen_fit": 1, | |
| }, | |
| "investment_oriented": { | |
| "size_fit": 6, | |
| "room_functionality_fit": 7, | |
| "family_fit": 1, | |
| "school_fit": 1, | |
| "rentability_fit": 10, | |
| "lifestyle_fit": 6, | |
| "prestige_fit": 5, | |
| "access_convenience_fit": 7, | |
| "mixed_use_fit": 8, | |
| "affordability_fit": 8, | |
| "rumah_fit": 4, | |
| "apartemen_fit": 4, | |
| }, | |
| "lifestyle_oriented": { | |
| "size_fit": 4, | |
| "room_functionality_fit": 6, | |
| "family_fit": 1, | |
| "school_fit": 1, | |
| "rentability_fit": 2, | |
| "lifestyle_fit": 10, | |
| "prestige_fit": 8, | |
| "access_convenience_fit": 8, | |
| "mixed_use_fit": 4, | |
| "affordability_fit": 4, | |
| "rumah_fit": 3, | |
| "apartemen_fit": 9, | |
| }, | |
| "value_oriented": { | |
| "size_fit": 7, | |
| "room_functionality_fit": 7, | |
| "family_fit": 5, | |
| "school_fit": 4, | |
| "rentability_fit": 4, | |
| "lifestyle_fit": 3, | |
| "prestige_fit": 1, | |
| "access_convenience_fit": 6, | |
| "mixed_use_fit": 2, | |
| "affordability_fit": 10, | |
| "rumah_fit": 5, | |
| "apartemen_fit": 6, | |
| }, | |
| } | |
| ORIENTATION_TO_CORE_PROFILE = { | |
| "family_oriented": "family", | |
| "investment_oriented": "investment", | |
| "lifestyle_oriented": "lifestyle", | |
| "value_oriented": "affordability", | |
| } | |
| # ========================================================= | |
| # HELPERS | |
| # ========================================================= | |
| def norm01(series): | |
| s = pd.to_numeric(series, errors="coerce") | |
| mn = s.min() | |
| mx = s.max() | |
| if pd.isna(mn) or pd.isna(mx) or mx == mn: | |
| return pd.Series(0.0, index=s.index) | |
| return (s - mn) / (mx - mn) | |
| def _safe_numeric(df, col, fill_value=0.0): | |
| if col not in df.columns: | |
| return pd.Series(fill_value, index=df.index, dtype=float) | |
| return pd.to_numeric(df[col], errors="coerce").fillna(fill_value) | |
| def _clean_orientation_name(name): | |
| if name is None: | |
| return None | |
| name = str(name).strip().lower() | |
| if name in BUYER_ORIENTATION_LIBRARY: | |
| return name | |
| alias_map = { | |
| "family": "family_oriented", | |
| "investment": "investment_oriented", | |
| "lifestyle": "lifestyle_oriented", | |
| "affordability": "value_oriented", | |
| "value": "value_oriented", | |
| } | |
| return alias_map.get(name, name) | |
| def _orientation_label(name): | |
| if not name: | |
| return "general" | |
| return str(name).replace("_", " ").title() | |
| def _core_profile_from_orientation(name): | |
| clean = _clean_orientation_name(name) | |
| return ORIENTATION_TO_CORE_PROFILE.get(clean, None) | |
| def _build_listing_key(df): | |
| if "url" in df.columns: | |
| return "url" | |
| if "listing_summary" in df.columns: | |
| return "listing_summary" | |
| return None | |
| # ========================================================= | |
| # BASE PREP | |
| # ========================================================= | |
| def prepare_listing_features_for_simulation(raw_df): | |
| """ | |
| Rebuild engineered features for simulation layer from raw listing data. | |
| """ | |
| df = raw_df.copy() | |
| df = map_kecamatan(df) | |
| df = transform_area_group(df) | |
| df = transform_area_features(df) | |
| df = transform_structure_features(df) | |
| df = transform_interaction_features(df) | |
| kec_map_sim, loc_map_sim = fit_desirability_features(df, trusted_cutoff=10) | |
| df = transform_desirability_features(df, kec_map_sim, loc_map_sim) | |
| return df, kec_map_sim, loc_map_sim | |
| def build_location_affordability_table(listing_df, loc_map_sim): | |
| """ | |
| Weighted land price / m² reference by area. | |
| """ | |
| land_ref = ( | |
| listing_df.groupby("location", as_index=False) | |
| .agg(typical_land_area_m2=("land_area_m2", "median")) | |
| ) | |
| aff = loc_map_sim.merge(land_ref, on="location", how="left").copy() | |
| aff["weighted_land_price_per_m2"] = np.where( | |
| aff["typical_land_area_m2"].fillna(0) > 0, | |
| aff["loc_weighted_price"] / aff["typical_land_area_m2"], | |
| np.nan, | |
| ) | |
| return aff | |
| def build_listing_dimension_table(listing_sim_df, location_afford_df): | |
| """ | |
| Listing-level 12-dimension table for Buyer Fit Simulation. | |
| """ | |
| df = listing_sim_df.copy() | |
| df = df.merge( | |
| location_afford_df[["location", "weighted_land_price_per_m2"]], | |
| on="location", | |
| how="left", | |
| ) | |
| df["is_rumah"] = (df["building_type"] == "Rumah").astype(float) | |
| df["is_apartemen"] = (df["building_type"] == "Apartemen").astype(float) | |
| df["is_ruko"] = (df["building_type"] == "Ruko").astype(float) | |
| bath_bed_ratio = df["bath_bed_ratio"].fillna(0) | |
| df["bath_bed_ratio_closeness"] = 1 - np.minimum(np.abs(bath_bed_ratio - 1), 1) | |
| df["size_fit"] = ( | |
| 0.6 * norm01(np.log1p(_safe_numeric(df, "building_area_m2"))) + | |
| 0.4 * norm01(np.log1p(_safe_numeric(df, "land_area_m2"))) | |
| ) | |
| df["room_functionality_fit"] = ( | |
| 0.4 * norm01(_safe_numeric(df, "bedroom_total")) + | |
| 0.4 * norm01(_safe_numeric(df, "bathroom_total")) + | |
| 0.2 * norm01(_safe_numeric(df, "carport_total")) | |
| ) | |
| df["family_fit"] = ( | |
| 0.5 * norm01(_safe_numeric(df, "family_access_score")) + | |
| 0.3 * norm01(_safe_numeric(df, "family_density_score")) + | |
| 0.2 * norm01(_safe_numeric(df, "family_access_zone_score")) | |
| ) | |
| df["school_fit"] = ( | |
| 0.6 * norm01(_safe_numeric(df, "school_access_score")) + | |
| 0.4 * norm01(_safe_numeric(df, "fav_school_score")) | |
| ) | |
| df["rentability_fit"] = ( | |
| 0.35 * norm01(_safe_numeric(df, "univ_access_score")) + | |
| 0.30 * df["bath_bed_ratio_closeness"] + | |
| 0.20 * norm01(_safe_numeric(df, "bedroom_total")) + | |
| 0.15 * _safe_numeric(df, "investment_property_flag") | |
| ) | |
| df["lifestyle_fit"] = ( | |
| 0.65 * norm01(_safe_numeric(df, "lifestyle_score")) + | |
| 0.35 * norm01(_safe_numeric(df, "mall_access_score")) | |
| ) | |
| df["prestige_fit"] = norm01(_safe_numeric(df, "location_premium_score")) | |
| df["access_convenience_fit"] = norm01(_safe_numeric(df, "access_quality_score")) | |
| df["mixed_use_fit"] = ( | |
| 0.5 * df["is_ruko"] + | |
| 0.25 * norm01(_safe_numeric(df, "lifestyle_score")) + | |
| 0.25 * norm01(_safe_numeric(df, "access_quality_score")) | |
| ) | |
| df["affordability_fit"] = 1 - norm01(_safe_numeric(df, "weighted_land_price_per_m2")) | |
| df["rumah_fit"] = df["is_rumah"] | |
| df["apartemen_fit"] = df["is_apartemen"] | |
| keep_cols = [ | |
| "location", | |
| "building_type", | |
| "price_million_idr", | |
| "building_area_m2", | |
| "land_area_m2", | |
| "bedroom_total", | |
| "bathroom_total", | |
| "carport_total", | |
| "listing_summary", | |
| "url", | |
| ] | |
| keep_cols = [c for c in keep_cols if c in df.columns] | |
| return df[keep_cols + DIMENSIONS].copy() | |
| def build_area_simulation_table(listing_sim_df, loc_map_sim, location_afford_df): | |
| """ | |
| Area-level 12-dimension table for price-shock simulation. | |
| """ | |
| area_df = ( | |
| listing_sim_df.groupby("location", as_index=False) | |
| .agg( | |
| kecamatan_mapped=("kecamatan_mapped", "first"), | |
| typical_price=("price_million_idr", "median"), | |
| typical_building_area=("building_area_m2", "median"), | |
| typical_land_area=("land_area_m2", "median"), | |
| bedroom_total=("bedroom_total", "median"), | |
| bathroom_total=("bathroom_total", "median"), | |
| carport_total=("carport_total", "median"), | |
| bath_bed_ratio=("bath_bed_ratio", "median"), | |
| family_access_score=("family_access_score", "mean"), | |
| family_density_score=("family_density_score", "mean"), | |
| family_access_zone_score=("family_access_zone_score", "mean"), | |
| school_access_score=("school_access_score", "mean"), | |
| fav_school_score=("fav_school_score", "mean"), | |
| univ_access_score=("univ_access_score", "mean"), | |
| lifestyle_score=("lifestyle_score", "mean"), | |
| mall_access_score=("mall_access_score", "mean"), | |
| access_quality_score=("access_quality_score", "mean"), | |
| investment_property_flag=("investment_property_flag", "mean"), | |
| rumah_fit=("building_type", lambda s: (s == "Rumah").mean()), | |
| apartemen_fit=("building_type", lambda s: (s == "Apartemen").mean()), | |
| ruko_share=("building_type", lambda s: (s == "Ruko").mean()), | |
| ) | |
| ) | |
| area_df = area_df.merge( | |
| loc_map_sim[["location", "loc_weighted_price", "location_premium_score"]], | |
| on="location", | |
| how="left", | |
| ) | |
| area_df = area_df.merge( | |
| location_afford_df[["location", "weighted_land_price_per_m2"]], | |
| on="location", | |
| how="left", | |
| ) | |
| area_df["size_fit"] = ( | |
| 0.6 * norm01(np.log1p(_safe_numeric(area_df, "typical_building_area"))) + | |
| 0.4 * norm01(np.log1p(_safe_numeric(area_df, "typical_land_area"))) | |
| ) | |
| area_df["room_functionality_fit"] = ( | |
| 0.4 * norm01(_safe_numeric(area_df, "bedroom_total")) + | |
| 0.4 * norm01(_safe_numeric(area_df, "bathroom_total")) + | |
| 0.2 * norm01(_safe_numeric(area_df, "carport_total")) | |
| ) | |
| area_df["family_fit"] = ( | |
| 0.5 * norm01(_safe_numeric(area_df, "family_access_score")) + | |
| 0.3 * norm01(_safe_numeric(area_df, "family_density_score")) + | |
| 0.2 * norm01(_safe_numeric(area_df, "family_access_zone_score")) | |
| ) | |
| area_df["school_fit"] = ( | |
| 0.6 * norm01(_safe_numeric(area_df, "school_access_score")) + | |
| 0.4 * norm01(_safe_numeric(area_df, "fav_school_score")) | |
| ) | |
| bath_bed_ratio_closeness = 1 - np.minimum( | |
| np.abs(_safe_numeric(area_df, "bath_bed_ratio") - 1), | |
| 1, | |
| ) | |
| area_df["rentability_fit"] = ( | |
| 0.35 * norm01(_safe_numeric(area_df, "univ_access_score")) + | |
| 0.30 * bath_bed_ratio_closeness + | |
| 0.20 * norm01(_safe_numeric(area_df, "bedroom_total")) + | |
| 0.15 * _safe_numeric(area_df, "investment_property_flag") | |
| ) | |
| area_df["lifestyle_fit"] = ( | |
| 0.65 * norm01(_safe_numeric(area_df, "lifestyle_score")) + | |
| 0.35 * norm01(_safe_numeric(area_df, "mall_access_score")) | |
| ) | |
| area_df["prestige_fit"] = norm01(_safe_numeric(area_df, "location_premium_score")) | |
| area_df["access_convenience_fit"] = norm01(_safe_numeric(area_df, "access_quality_score")) | |
| area_df["mixed_use_fit"] = ( | |
| 0.5 * _safe_numeric(area_df, "ruko_share") + | |
| 0.25 * norm01(_safe_numeric(area_df, "lifestyle_score")) + | |
| 0.25 * norm01(_safe_numeric(area_df, "access_quality_score")) | |
| ) | |
| area_df["affordability_fit"] = 1 - norm01(_safe_numeric(area_df, "weighted_land_price_per_m2")) | |
| keep_cols = [ | |
| "location", | |
| "kecamatan_mapped", | |
| "typical_price", | |
| "loc_weighted_price", | |
| "weighted_land_price_per_m2", | |
| ] + DIMENSIONS | |
| return area_df[keep_cols].copy() | |
| def prepare_simulation_base(raw_listing_df=None): | |
| """ | |
| One-stop prep for both simulation modules. | |
| """ | |
| if raw_listing_df is None: | |
| raw_listing_df = inference_core.listing_df.copy() | |
| listing_sim_df, kec_map_sim, loc_map_sim = prepare_listing_features_for_simulation(raw_listing_df) | |
| location_afford_df = build_location_affordability_table(listing_sim_df, loc_map_sim) | |
| listing_dimension_df = build_listing_dimension_table(listing_sim_df, location_afford_df) | |
| area_sim_df = build_area_simulation_table(listing_sim_df, loc_map_sim, location_afford_df) | |
| return { | |
| "raw_listing_df": raw_listing_df, | |
| "listing_sim_df": listing_sim_df, | |
| "kec_map_sim": kec_map_sim, | |
| "loc_map_sim": loc_map_sim, | |
| "location_afford_df": location_afford_df, | |
| "listing_dimension_df": listing_dimension_df, | |
| "area_sim_df": area_sim_df, | |
| } | |
| # ========================================================= | |
| # BUYER FIT SIMULATION | |
| # ========================================================= | |
| def compute_clarity_score(ratings_dict): | |
| s = pd.Series(ratings_dict, dtype=float).clip(lower=0) | |
| s = s.reindex(DIMENSIONS).fillna(0.0) | |
| clarity = s.std() / 3.0 | |
| return float(np.clip(clarity, 0, 1)) | |
| def ratings_to_weights(ratings_dict): | |
| s = pd.Series(ratings_dict, dtype=float).clip(lower=0) | |
| s = s.reindex(DIMENSIONS).fillna(0.0) | |
| total = s.sum() | |
| if total == 0: | |
| return pd.Series(1 / len(s), index=s.index) | |
| return s / total | |
| def get_orientation_template(name): | |
| clean = _clean_orientation_name(name) | |
| if clean not in BUYER_ORIENTATION_LIBRARY: | |
| raise ValueError(f"Unknown buyer orientation: {name}") | |
| return BUYER_ORIENTATION_LIBRARY[clean].copy() | |
| def build_final_client_weights(client_ratings, buyer_orientation): | |
| clean_orientation = _clean_orientation_name(buyer_orientation) | |
| fallback_ratings = BUYER_ORIENTATION_LIBRARY[clean_orientation] | |
| client_w = ratings_to_weights(client_ratings) | |
| fallback_w = ratings_to_weights(fallback_ratings) | |
| clarity = compute_clarity_score(client_ratings) | |
| final_w = clarity * client_w + (1 - clarity) * fallback_w | |
| final_w = final_w / final_w.sum() | |
| return final_w, clarity | |
| def build_single_client_input( | |
| location, | |
| buyer_orientation, | |
| budget_million_idr=None, | |
| consider_similar_areas=False, | |
| custom_ratings=None, | |
| ): | |
| """ | |
| Creates a single-client row for deployment use. | |
| """ | |
| clean_orientation = _clean_orientation_name(buyer_orientation) | |
| if clean_orientation not in BUYER_ORIENTATION_LIBRARY: | |
| raise ValueError(f"Unknown buyer orientation: {buyer_orientation}") | |
| base_ratings = BUYER_ORIENTATION_LIBRARY[clean_orientation].copy() | |
| if custom_ratings: | |
| for k, v in custom_ratings.items(): | |
| if k in DIMENSIONS and v is not None: | |
| base_ratings[k] = float(v) | |
| row = { | |
| "client_id": "client_1", | |
| "location": location, | |
| "buyer_orientation": clean_orientation, | |
| "budget_million_idr": budget_million_idr, | |
| "strict_location": not bool(consider_similar_areas), | |
| "fallback_to_kecamatan": bool(consider_similar_areas), | |
| "fallback_to_alternatives": bool(consider_similar_areas), | |
| } | |
| row.update(base_ratings) | |
| return row | |
| def get_candidate_listings_for_client( | |
| client_row, | |
| listing_dim_df, | |
| min_candidates=12, | |
| use_core_recs=True, | |
| ): | |
| target_location = client_row["location"] | |
| strict_location = bool(client_row.get("strict_location", True)) | |
| fallback_to_kecamatan = bool(client_row.get("fallback_to_kecamatan", True)) | |
| fallback_to_alternatives = bool(client_row.get("fallback_to_alternatives", False)) | |
| exact_df = listing_dim_df[ | |
| listing_dim_df["location"] == target_location | |
| ].copy() | |
| if strict_location: | |
| return exact_df | |
| candidate_df = exact_df.copy() | |
| if len(candidate_df) < min_candidates and fallback_to_kecamatan: | |
| target_kec = inference_core.location_to_kec.get(target_location) | |
| if target_kec is not None: | |
| same_kec_locations = [ | |
| loc for loc, kec in inference_core.location_to_kec.items() | |
| if kec == target_kec | |
| ] | |
| kec_df = listing_dim_df[ | |
| listing_dim_df["location"].isin(same_kec_locations) | |
| ].copy() | |
| candidate_df = pd.concat([candidate_df, kec_df], ignore_index=True) | |
| if len(candidate_df) < min_candidates and fallback_to_alternatives and use_core_recs: | |
| try: | |
| user_info = { | |
| "location": client_row["location"], | |
| "budget_million_idr": client_row.get("budget_million_idr"), | |
| "profile": _core_profile_from_orientation(client_row.get("buyer_orientation")), | |
| } | |
| recs = inference_core.recommend_locations(user_info, top_n=5) | |
| if not recs.empty: | |
| alt_areas = recs["Alternative Area"].tolist() | |
| alt_df = listing_dim_df[ | |
| listing_dim_df["location"].isin(alt_areas) | |
| ].copy() | |
| candidate_df = pd.concat([candidate_df, alt_df], ignore_index=True) | |
| except Exception as e: | |
| print(f"Alternative-area fallback failed: {e}") | |
| listing_key = _build_listing_key(candidate_df) | |
| if listing_key: | |
| candidate_df = candidate_df.drop_duplicates(subset=listing_key) | |
| else: | |
| candidate_df = candidate_df.drop_duplicates() | |
| return candidate_df | |
| def score_candidate_listings_for_client( | |
| client_row, | |
| candidate_df, | |
| dimensions=DIMENSIONS, | |
| ): | |
| if candidate_df.empty: | |
| return pd.DataFrame() | |
| df = candidate_df.copy() | |
| client_ratings = {dim: client_row[dim] for dim in dimensions} | |
| buyer_orientation = client_row["buyer_orientation"] | |
| final_w, clarity = build_final_client_weights( | |
| client_ratings=client_ratings, | |
| buyer_orientation=buyer_orientation, | |
| ) | |
| df["base_match_score"] = 0.0 | |
| for dim in dimensions: | |
| df["base_match_score"] += final_w[dim] * df[dim] | |
| budget = client_row.get("budget_million_idr", None) | |
| if budget is not None and "price_million_idr" in df.columns: | |
| over_ratio = (df["price_million_idr"] - budget) / max(budget, 1) | |
| df["budget_penalty"] = np.where( | |
| df["price_million_idr"] <= budget, | |
| 1.0, | |
| np.clip(1 - over_ratio, 0.2, 1.0), | |
| ) | |
| else: | |
| df["budget_penalty"] = 1.0 | |
| df["match_score"] = df["base_match_score"] * df["budget_penalty"] | |
| df["client_id"] = client_row["client_id"] | |
| df["buyer_orientation"] = buyer_orientation | |
| df["clarity_score"] = clarity | |
| return df.sort_values("match_score", ascending=False) | |
| def _make_listing_reason(row, buyer_orientation): | |
| orientation = _clean_orientation_name(buyer_orientation) | |
| if orientation == "family_oriented": | |
| return "Strong school and family fit with practical household layout." | |
| if orientation == "investment_oriented": | |
| return "Good rentability profile with relatively efficient buyer economics." | |
| if orientation == "lifestyle_oriented": | |
| return "Better lifestyle access with a stronger convenience profile." | |
| if orientation == "value_oriented": | |
| return "More cost-efficient option while preserving overall fit." | |
| return "Balanced fit across buyer priorities." | |
| def format_buyer_fit_results(scored_df, client_row, top_n=5): | |
| if scored_df.empty: | |
| return { | |
| "client_summary": { | |
| "Preferred Area": client_row.get("location"), | |
| "Buyer Orientation": _orientation_label(client_row.get("buyer_orientation")), | |
| "Budget (IDR Mn)": client_row.get("budget_million_idr"), | |
| "Consider Similar Areas": bool(client_row.get("fallback_to_alternatives", False)), | |
| "Clarity Score": None, | |
| }, | |
| "summary": f"No suitable listings were found for {client_row.get('location')}.", | |
| "listing_table": pd.DataFrame(), | |
| "details_table": pd.DataFrame(), | |
| } | |
| top_df = scored_df.head(top_n).copy() | |
| top_df["Why it Fits"] = top_df.apply( | |
| lambda row: _make_listing_reason(row, client_row.get("buyer_orientation")), | |
| axis=1, | |
| ) | |
| display_cols = [] | |
| rename_map = {} | |
| for old, new in [ | |
| ("location", "Location"), | |
| ("building_type", "Type"), | |
| ("price_million_idr", "Price"), | |
| ("building_area_m2", "Size (m²)"), | |
| ("bedroom_total", "Bed"), | |
| ("bathroom_total", "Bath"), | |
| ("match_score", "Match Score"), | |
| ("Why it Fits", "Why it Fits"), | |
| ]: | |
| if old in top_df.columns: | |
| display_cols.append(old) | |
| rename_map[old] = new | |
| if "url" in top_df.columns: | |
| display_cols.append("url") | |
| listing_table = top_df[display_cols].rename(columns=rename_map).copy() | |
| if "Match Score" in listing_table.columns: | |
| listing_table["Match Score"] = listing_table["Match Score"].round(3) | |
| if "Price" in listing_table.columns: | |
| listing_table["Price"] = pd.to_numeric(listing_table["Price"], errors="coerce").round(1) | |
| if "Size (m²)" in listing_table.columns: | |
| listing_table["Size (m²)"] = pd.to_numeric(listing_table["Size (m²)"], errors="coerce").round(1) | |
| clarity_score = float(top_df["clarity_score"].iloc[0]) if "clarity_score" in top_df.columns else None | |
| consider_similar = bool(client_row.get("fallback_to_alternatives", False)) | |
| target_area = client_row.get("location") | |
| orientation_label = _orientation_label(client_row.get("buyer_orientation")) | |
| if consider_similar: | |
| summary = ( | |
| f"For a {orientation_label.lower()} buyer targeting {target_area}, " | |
| f"these listings provide the strongest fit after allowing similar-area substitutes." | |
| ) | |
| else: | |
| summary = ( | |
| f"For a {orientation_label.lower()} buyer targeting {target_area}, " | |
| f"these are the strongest in-area listing matches." | |
| ) | |
| client_summary = { | |
| "Preferred Area": target_area, | |
| "Buyer Orientation": orientation_label, | |
| "Budget (IDR Mn)": client_row.get("budget_million_idr"), | |
| "Consider Similar Areas": consider_similar, | |
| "Clarity Score": round(clarity_score, 2) if clarity_score is not None else None, | |
| } | |
| details_cols = [ | |
| c for c in [ | |
| "location", | |
| "building_type", | |
| "price_million_idr", | |
| "building_area_m2", | |
| "land_area_m2", | |
| "bedroom_total", | |
| "bathroom_total", | |
| "carport_total", | |
| "base_match_score", | |
| "budget_penalty", | |
| "match_score", | |
| "listing_summary", | |
| "url", | |
| ] if c in top_df.columns | |
| ] | |
| details_table = top_df[details_cols].copy() | |
| return { | |
| "client_summary": client_summary, | |
| "summary": summary, | |
| "listing_table": listing_table.reset_index(drop=True), | |
| "details_table": details_table.reset_index(drop=True), | |
| } | |
| def run_buyer_fit_simulation( | |
| location, | |
| buyer_orientation, | |
| budget_million_idr=None, | |
| consider_similar_areas=False, | |
| custom_ratings=None, | |
| simulation_base=None, | |
| top_n=5, | |
| min_candidates=12, | |
| ): | |
| """ | |
| Deployment wrapper for single-client buyer fit simulation. | |
| """ | |
| if simulation_base is None: | |
| simulation_base = prepare_simulation_base() | |
| listing_dimension_df = simulation_base["listing_dimension_df"] | |
| client_row = build_single_client_input( | |
| location=location, | |
| buyer_orientation=buyer_orientation, | |
| budget_million_idr=budget_million_idr, | |
| consider_similar_areas=consider_similar_areas, | |
| custom_ratings=custom_ratings, | |
| ) | |
| candidate_df = get_candidate_listings_for_client( | |
| client_row=client_row, | |
| listing_dim_df=listing_dimension_df, | |
| min_candidates=min_candidates, | |
| use_core_recs=True, | |
| ) | |
| scored_df = score_candidate_listings_for_client( | |
| client_row=client_row, | |
| candidate_df=candidate_df, | |
| ) | |
| formatted = format_buyer_fit_results( | |
| scored_df=scored_df, | |
| client_row=client_row, | |
| top_n=top_n, | |
| ) | |
| return { | |
| "client_input": client_row, | |
| "candidate_count": len(candidate_df), | |
| "scored_candidates": scored_df, | |
| **formatted, | |
| } | |
| # ========================================================= | |
| # AREA PRICE-SHOCK SIMULATION | |
| # ========================================================= | |
| def get_substitute_area_pool(target_area, buyer_orientation=None, top_n=12): | |
| user_info = { | |
| "location": target_area, | |
| "profile": _core_profile_from_orientation(buyer_orientation), | |
| } | |
| recs = inference_core.recommend_locations(user_info, top_n=top_n) | |
| pool = [target_area] | |
| similarity_map = {target_area: 1.0} | |
| if not recs.empty: | |
| for _, row in recs.iterrows(): | |
| area = row["Alternative Area"] | |
| pool.append(area) | |
| similarity_map[area] = row["Similarity Score"] | |
| pool = list(dict.fromkeys(pool)) | |
| return pool, similarity_map | |
| def simulate_target_area_price_shock( | |
| area_sim_df, | |
| target_area, | |
| buyer_orientation=None, | |
| shock_pct=0.10, | |
| top_n_pool=12, | |
| similarity_weight=0.60, | |
| orientation_weight=0.40, | |
| ): | |
| """ | |
| Target-relative substitute simulation. | |
| If orientation is None, defaults to value-oriented for a general affordability-sensitive view. | |
| """ | |
| df = area_sim_df.copy() | |
| clean_orientation = _clean_orientation_name(buyer_orientation) | |
| if clean_orientation is None: | |
| clean_orientation = "value_oriented" | |
| pool, similarity_map = get_substitute_area_pool( | |
| target_area=target_area, | |
| buyer_orientation=clean_orientation, | |
| top_n=top_n_pool, | |
| ) | |
| df = df[df["location"].isin(pool)].copy() | |
| if df.empty: | |
| return pd.DataFrame() | |
| df["similarity_to_target"] = df["location"].map(similarity_map).fillna(0.0) | |
| df["weighted_land_price_per_m2_after_shock"] = df["weighted_land_price_per_m2"].copy() | |
| mask = df["location"] == target_area | |
| df.loc[mask, "weighted_land_price_per_m2_after_shock"] = ( | |
| df.loc[mask, "weighted_land_price_per_m2_after_shock"] * (1 + shock_pct) | |
| ) | |
| df["affordability_fit_before"] = 1 - norm01(df["weighted_land_price_per_m2"]) | |
| df["affordability_fit_after"] = 1 - norm01(df["weighted_land_price_per_m2_after_shock"]) | |
| orientation_ratings = BUYER_ORIENTATION_LIBRARY[clean_orientation] | |
| weights = ratings_to_weights(orientation_ratings) | |
| df["orientation_score_before"] = 0.0 | |
| for dim in DIMENSIONS: | |
| df["orientation_score_before"] += weights[dim] * df[dim] | |
| df["orientation_score_after"] = ( | |
| df["orientation_score_before"] | |
| - weights["affordability_fit"] * df["affordability_fit_before"] | |
| + weights["affordability_fit"] * df["affordability_fit_after"] | |
| ) | |
| df["substitute_score_before"] = ( | |
| similarity_weight * df["similarity_to_target"] + | |
| orientation_weight * df["orientation_score_before"] | |
| ) | |
| df["substitute_score_after"] = ( | |
| similarity_weight * df["similarity_to_target"] + | |
| orientation_weight * df["orientation_score_after"] | |
| ) | |
| for col in [ | |
| "similarity_to_target", | |
| "orientation_score_before", | |
| "orientation_score_after", | |
| "substitute_score_before", | |
| "substitute_score_after", | |
| ]: | |
| df[col] = pd.to_numeric(df[col], errors="coerce") | |
| df = df.dropna( | |
| subset=["substitute_score_before", "substitute_score_after"] | |
| ).copy() | |
| df["rank_before"] = df["substitute_score_before"].rank(ascending=False, method="dense") | |
| df["rank_after"] = df["substitute_score_after"].rank(ascending=False, method="dense") | |
| df["rank_change"] = df["rank_before"] - df["rank_after"] | |
| df["score_change"] = df["substitute_score_after"] - df["substitute_score_before"] | |
| out = df.copy() | |
| out["Area"] = out["location"] | |
| out["Role"] = np.where(out["location"] == target_area, "Target", "Alternative") | |
| out["Buyer Fit Before"] = out["orientation_score_before"].round(3) | |
| out["Buyer Fit After"] = out["orientation_score_after"].round(3) | |
| out["Similarity"] = out["similarity_to_target"].round(2) | |
| out["Rank Before"] = out["rank_before"].round().astype("Int64") | |
| out["Rank After"] = out["rank_after"].round().astype("Int64") | |
| out["Rank Change"] = out["rank_change"].round().astype("Int64") | |
| out["Price / m² Before"] = out["weighted_land_price_per_m2"].round(2) | |
| out["Price / m² After Shock"] = out["weighted_land_price_per_m2_after_shock"].round(2) | |
| out["Substitute Strength Before"] = out["substitute_score_before"].round(3) | |
| out["Substitute Strength After"] = out["substitute_score_after"].round(3) | |
| target_row = out[out["Role"] == "Target"].copy() | |
| alt_rows = out[out["Role"] == "Alternative"].copy() | |
| alt_rows = alt_rows.sort_values( | |
| by=["Buyer Fit After", "Substitute Strength After", "Similarity"], | |
| ascending=[False, False, False], | |
| ) | |
| out = pd.concat([target_row, alt_rows], ignore_index=True) | |
| return out | |
| def summarize_target_area_price_shock( | |
| shock_result, | |
| target_area, | |
| shock_pct, | |
| buyer_orientation=None, | |
| top_n=3, | |
| ): | |
| if shock_result.empty: | |
| return f"No strong substitutes were found for {target_area}." | |
| alt_only = shock_result[shock_result["Role"] == "Alternative"].copy() | |
| if alt_only.empty: | |
| return f"No strong substitutes were found for {target_area}." | |
| gainers = alt_only.head(top_n)["Area"].tolist() | |
| orientation_label = _orientation_label( | |
| _clean_orientation_name(buyer_orientation) or "value_oriented" | |
| ).lower() | |
| return ( | |
| f"{target_area} increased in price by {shock_pct:.0%}. " | |
| f"Under this scenario, the areas most likely to become stronger substitutes " | |
| f"for {orientation_label} buyers are {', '.join(gainers)}." | |
| ) | |
| def format_price_shock_results( | |
| shock_result, | |
| target_area, | |
| shock_pct, | |
| buyer_orientation=None, | |
| top_n=5, | |
| ): | |
| if shock_result.empty: | |
| return { | |
| "case_summary": f"No substitute signal was found for {target_area}.", | |
| "target_impact": {}, | |
| "substitute_table": pd.DataFrame(), | |
| "details_table": pd.DataFrame(), | |
| } | |
| target_row = shock_result[shock_result["Role"] == "Target"].copy() | |
| alt_rows = shock_result[shock_result["Role"] == "Alternative"].copy() | |
| case_summary = summarize_target_area_price_shock( | |
| shock_result=shock_result, | |
| target_area=target_area, | |
| shock_pct=shock_pct, | |
| buyer_orientation=buyer_orientation, | |
| top_n=min(top_n, 3), | |
| ) | |
| target_impact = {} | |
| if not target_row.empty: | |
| tr = target_row.iloc[0] | |
| target_impact = { | |
| "Target Area": target_area, | |
| "Shock Applied": f"{shock_pct:.0%}", | |
| "Buyer Orientation": _orientation_label( | |
| _clean_orientation_name(buyer_orientation) or "value_oriented" | |
| ), | |
| "Buyer Fit Before": round(float(tr["Buyer Fit Before"]), 3), | |
| "Buyer Fit After": round(float(tr["Buyer Fit After"]), 3), | |
| "Rank Before": int(tr["Rank Before"]) if pd.notna(tr["Rank Before"]) else None, | |
| "Rank After": int(tr["Rank After"]) if pd.notna(tr["Rank After"]) else None, | |
| "Rank Change": int(tr["Rank Change"]) if pd.notna(tr["Rank Change"]) else None, | |
| "Price / m² Before": round(float(tr["Price / m² Before"]), 2), | |
| "Price / m² After Shock": round(float(tr["Price / m² After Shock"]), 2), | |
| } | |
| substitute_cols = [ | |
| "Area", | |
| "Buyer Fit After", | |
| "Buyer Fit Before", | |
| "Similarity", | |
| "Rank Change", | |
| ] | |
| substitute_cols = [c for c in substitute_cols if c in alt_rows.columns] | |
| substitute_table = ( | |
| alt_rows[substitute_cols] | |
| .head(top_n) | |
| .reset_index(drop=True) | |
| .copy() | |
| ) | |
| details_cols = [ | |
| "Area", | |
| "Role", | |
| "Buyer Fit Before", | |
| "Buyer Fit After", | |
| "Similarity", | |
| "Rank Before", | |
| "Rank After", | |
| "Rank Change", | |
| "Price / m² Before", | |
| "Price / m² After Shock", | |
| "Substitute Strength Before", | |
| "Substitute Strength After", | |
| ] | |
| details_cols = [c for c in details_cols if c in shock_result.columns] | |
| details_table = shock_result[details_cols].copy().reset_index(drop=True) | |
| return { | |
| "case_summary": case_summary, | |
| "target_impact": target_impact, | |
| "substitute_table": substitute_table, | |
| "details_table": details_table, | |
| } | |
| #prep for gemini summary | |
| def extract_area_tradeoff_drivers(area_sim_df, areas, top_k=3): | |
| """ | |
| For each area, identify the strongest fit dimensions to help explain | |
| why it may become a stronger substitute. | |
| """ | |
| if area_sim_df is None or area_sim_df.empty: | |
| return {} | |
| df = area_sim_df.copy() | |
| df = df[df["location"].isin(areas)].copy() | |
| if df.empty: | |
| return {} | |
| driver_map = {} | |
| for _, row in df.iterrows(): | |
| area = row["location"] | |
| dim_scores = {} | |
| for dim in DIMENSIONS: | |
| dim_scores[dim] = float(row.get(dim, 0.0)) | |
| top_dims = sorted( | |
| dim_scores.items(), | |
| key=lambda x: x[1], | |
| reverse=True | |
| )[:top_k] | |
| driver_map[area] = [ | |
| { | |
| "dimension": dim, | |
| "label": DIMENSION_LABELS.get(dim, dim), | |
| "score": round(score, 3) | |
| } | |
| for dim, score in top_dims | |
| ] | |
| return driver_map | |
| def build_shock_reasoning_payload(shock_result, area_sim_df, target_area, top_n=3): | |
| """ | |
| Build a compact comparison payload for Gemini so it explains using | |
| deterministic outputs, not invented logic. | |
| """ | |
| if shock_result is None or shock_result.empty: | |
| return {} | |
| top_subs = ( | |
| shock_result[shock_result["Role"] == "Alternative"] | |
| .head(top_n) | |
| .copy() | |
| ) | |
| if top_subs.empty: | |
| return {} | |
| areas = [target_area] + top_subs["Area"].tolist() | |
| driver_map = extract_area_tradeoff_drivers(area_sim_df, areas, top_k=3) | |
| payload = { | |
| "target_area": target_area, | |
| "target_drivers": driver_map.get(target_area, []), | |
| "substitutes": [] | |
| } | |
| for _, row in top_subs.iterrows(): | |
| area = row["Area"] | |
| payload["substitutes"].append({ | |
| "area": area, | |
| "buyer_fit_before": round(float(row.get("Buyer Fit Before", 0)), 3), | |
| "buyer_fit_after": round(float(row.get("Buyer Fit After", 0)), 3), | |
| "similarity": round(float(row.get("Similarity", 0)), 3), | |
| "rank_change": ( | |
| int(row["Rank Change"]) | |
| if pd.notna(row.get("Rank Change")) | |
| else None | |
| ), | |
| "drivers": driver_map.get(area, []) | |
| }) | |
| return payload | |
| def gemini_price_shock_summary(result, area_sim_df, client=None): | |
| """ | |
| Optional LLM explanation for area price-shock simulation. | |
| Uses deterministic outputs + extracted drivers to explain why | |
| areas became stronger substitutes. | |
| """ | |
| fallback = result.get("case_summary", "") | |
| active_client = client if client is not None else gemini_client | |
| if active_client is None: | |
| return fallback | |
| try: | |
| target_impact = result.get("target_impact", {}) | |
| substitute_table = result.get("substitute_table", pd.DataFrame()) | |
| if substitute_table is None or substitute_table.empty: | |
| return fallback | |
| target_area = target_impact.get("Target Area") | |
| reasoning_payload = build_shock_reasoning_payload( | |
| shock_result=result.get("shock_result"), | |
| area_sim_df=area_sim_df, | |
| target_area=target_area, | |
| top_n=3 | |
| ) | |
| prompt = f""" | |
| Explain a deterministic Bandung housing price-shock simulation. | |
| Target impact: | |
| {target_impact} | |
| Top substitute areas: | |
| {substitute_table.head(3).to_dict(orient="records")} | |
| Reasoning payload: | |
| {reasoning_payload} | |
| Rules: | |
| - Explain WHY these areas became stronger substitutes | |
| - Use only the supplied metrics and driver labels, DO NOT repeat raw metrics. | |
| - Mention tradeoffs briefly | |
| - Do not invent new data, scores, or claims | |
| - Similarity means how close the substitute remains to the original target profile | |
| - Buyer Fit reflects post-shock suitability for that buyer orientation | |
| - Rank Change means upward movement is stronger substitute positioning | |
| - Max 4 sentences | |
| - Under 90 words | |
| - Plain text only, CONCRETE comparison wording. | |
| """ | |
| response = active_client.models.generate_content( | |
| model="gemini-2.5-flash-lite", | |
| contents=prompt, | |
| config={ | |
| "max_output_tokens": 120, | |
| "temperature": 0.3, | |
| "top_p": 0.95 | |
| } | |
| ) | |
| if hasattr(response, "text") and response.text: | |
| text = response.text.strip() | |
| else: | |
| text = str(response).strip() | |
| if text and len(text.split()) >= 10: | |
| return text | |
| return fallback | |
| except Exception as e: | |
| print("Gemini price-shock summary failed:", e) | |
| return fallback | |
| #wrapper | |
| def run_area_price_shock_simulation( | |
| target_area, | |
| shock_pct=0.10, | |
| buyer_orientation=None, | |
| simulation_base=None, | |
| top_n=5, | |
| top_n_pool=12, | |
| similarity_weight=0.60, | |
| orientation_weight=0.40, | |
| use_llm_summary=False, | |
| client=None, | |
| ): | |
| """ | |
| Deployment wrapper for area price-shock simulation. | |
| """ | |
| if simulation_base is None: | |
| simulation_base = prepare_simulation_base() | |
| area_sim_df = simulation_base["area_sim_df"] | |
| shock_result = simulate_target_area_price_shock( | |
| area_sim_df=area_sim_df, | |
| target_area=target_area, | |
| buyer_orientation=buyer_orientation, | |
| shock_pct=shock_pct, | |
| top_n_pool=top_n_pool, | |
| similarity_weight=similarity_weight, | |
| orientation_weight=orientation_weight, | |
| ) | |
| formatted = format_price_shock_results( | |
| shock_result=shock_result, | |
| target_area=target_area, | |
| shock_pct=shock_pct, | |
| buyer_orientation=buyer_orientation, | |
| top_n=top_n, | |
| ) | |
| llm_summary = formatted["case_summary"] | |
| if use_llm_summary: | |
| llm_summary = gemini_price_shock_summary( | |
| result={ | |
| "shock_result": shock_result, | |
| **formatted | |
| }, | |
| area_sim_df=area_sim_df, | |
| client=client | |
| ) | |
| return { | |
| "shock_result": shock_result, | |
| **formatted, | |
| "summary": llm_summary, | |
| } |