RMT52 / simulation_core.py
nandutt's picture
Upload 13 files
83c090a verified
Raw History Blame Contribute Delete
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,
}