Spaces:
Sleeping
Sleeping
| """ | |
| GridGuard AI — Synthetic Data Generation | |
| ========================================= | |
| Generates a synthetic distribution-grid dataset that mimics a Nigerian DISCO | |
| feeder structure: | |
| Substation -> Transformers -> Neighbourhoods -> Households -> 12 months of readings | |
| A subset of households are seeded as "bypass/fraud" cases: from a random | |
| month onward, their RECORDED (metered) consumption drops sharply while their | |
| TRUE (actual) consumption stays close to normal — i.e. they keep using power | |
| but stop paying for most of it. This creates the energy-balance gap at the | |
| transformer level that the model is designed to detect. | |
| Run this cell first in Colab, then run 02_train_model.py. | |
| Outputs (written to ./data/): | |
| households.csv - static household attributes | |
| readings_monthly.csv - long-format household x month consumption | |
| transformers.csv - transformer roll-up (injected vs metered energy) | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| import os | |
| RNG_SEED = 42 | |
| rng = np.random.default_rng(RNG_SEED) | |
| N_TRANSFORMERS = 12 | |
| HOUSEHOLDS_PER_TRANSFORMER = (40, 90) # min, max households served per transformer | |
| N_MONTHS = 12 | |
| FRAUD_RATE = 0.07 # ~7% of households are bypassing | |
| TECH_LOSS_RATE = 0.06 # normal technical loss baseline (6%) | |
| OUT_DIR = os.path.join(os.path.dirname(__file__), "data") | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| INCOME_CLASSES = ["low", "mid", "high"] | |
| INCOME_WEIGHTS = [0.45, 0.40, 0.15] | |
| INCOME_BASE_KWH = {"low": 90, "mid": 220, "high": 480} # baseline monthly kWh | |
| TARIFF_BAND = {"low": "B", "mid": "B", "high": "A"} | |
| months = pd.date_range("2025-01-01", periods=N_MONTHS, freq="MS").strftime("%Y-%m") | |
| households = [] | |
| readings = [] | |
| hh_id_counter = 1 | |
| for t_id in range(1, N_TRANSFORMERS + 1): | |
| n_hh = rng.integers(*HOUSEHOLDS_PER_TRANSFORMER) | |
| # Each transformer feeds 1-3 neighbourhoods (clusters of similar socioeconomic status) | |
| n_neighbourhoods = rng.integers(1, 4) | |
| neighbourhood_ids = [f"T{t_id:02d}-N{n+1}" for n in range(n_neighbourhoods)] | |
| # bias each neighbourhood toward a dominant income class (drives peer similarity) | |
| neighbourhood_income_bias = { | |
| nb: rng.choice(INCOME_CLASSES, p=INCOME_WEIGHTS) for nb in neighbourhood_ids | |
| } | |
| for _ in range(n_hh): | |
| nb = rng.choice(neighbourhood_ids) | |
| dominant_class = neighbourhood_income_bias[nb] | |
| # 80% of households in a neighbourhood match its dominant class, 20% differ | |
| income_class = dominant_class if rng.random() < 0.8 else rng.choice(INCOME_CLASSES, p=INCOME_WEIGHTS) | |
| house_size = { | |
| "low": rng.normal(45, 8), "mid": rng.normal(90, 15), "high": rng.normal(200, 35) | |
| }[income_class] | |
| house_size = max(20, house_size) | |
| occupants = max(1, int(rng.normal({"low": 5, "mid": 4, "high": 4}[income_class], 1.3))) | |
| appliance_score = np.clip( | |
| rng.normal({"low": 2.5, "mid": 5.0, "high": 8.0}[income_class], 1.2), 1, 10 | |
| ) | |
| hh_id = f"HH{hh_id_counter:05d}" | |
| hh_id_counter += 1 | |
| is_fraud = rng.random() < FRAUD_RATE | |
| fraud_start_month = int(rng.integers(3, N_MONTHS - 1)) if is_fraud else None | |
| bypass_severity = float(rng.uniform(0.45, 0.85)) if is_fraud else 0.0 # fraction of true usage hidden | |
| # Genuine low consumers: real households (small family, frequently away, | |
| # energy-efficient) whose usage is low FROM THE START — no sudden drop, | |
| # no transformer-level loss contribution. These exist to make the | |
| # detection problem realistic: low consumption alone must NOT be enough | |
| # to flag fraud (this is the nuance the model has to learn). | |
| is_genuine_low = (not is_fraud) and (rng.random() < 0.05) | |
| # Lifestyle-change households: a real drop in TRUE consumption (e.g. fewer | |
| # occupants, moved out for part of year) with NO theft — metered tracks | |
| # true exactly, transformer loss stays normal. These overlap with fraud's | |
| # drop_ratio/trend_slope signature on purpose, so flagging can't rely on | |
| # consumption drop alone and must lean on the transformer/peer signals too. | |
| is_lifestyle_change = (not is_fraud) and (not is_genuine_low) and (rng.random() < 0.04) | |
| lifestyle_change_month = int(rng.integers(3, N_MONTHS - 1)) if is_lifestyle_change else None | |
| lifestyle_drop_severity = float(rng.uniform(0.3, 0.6)) if is_lifestyle_change else 0.0 | |
| households.append({ | |
| "household_id": hh_id, | |
| "transformer_id": f"T{t_id:02d}", | |
| "neighbourhood_id": nb, | |
| "income_class": income_class, | |
| "tariff_band": TARIFF_BAND[income_class], | |
| "house_size_sqm": round(house_size, 1), | |
| "occupants": occupants, | |
| "appliance_score": round(appliance_score, 2), | |
| "is_fraud_ground_truth": is_fraud, # hidden label, used only for evaluation | |
| "is_genuine_low_consumer": is_genuine_low, # hidden label, used only for evaluation | |
| "is_lifestyle_change": is_lifestyle_change, # hidden label, used only for evaluation | |
| "fraud_start_month_idx": fraud_start_month, | |
| "bypass_severity": round(bypass_severity, 3), | |
| }) | |
| # baseline monthly true consumption (kWh) with seasonality + noise | |
| base = INCOME_BASE_KWH[income_class] * (1 + 0.15 * (appliance_score - 5) / 5) | |
| base *= (0.85 + 0.05 * occupants) | |
| if is_genuine_low: | |
| base *= rng.uniform(0.3, 0.5) # genuinely low usage, consistent across all months | |
| seasonal = 1 + 0.12 * np.sin(np.linspace(0, 2 * np.pi, N_MONTHS)) # dry-season AC/fan bump | |
| for m_idx in range(N_MONTHS): | |
| true_kwh = max(15, base * seasonal[m_idx] * rng.normal(1.0, 0.07)) | |
| if is_lifestyle_change and m_idx >= lifestyle_change_month: | |
| true_kwh *= (1 - lifestyle_drop_severity) | |
| if is_fraud and m_idx >= fraud_start_month: | |
| metered_kwh = true_kwh * (1 - bypass_severity) | |
| else: | |
| metered_kwh = true_kwh | |
| readings.append({ | |
| "household_id": hh_id, | |
| "month": months[m_idx], | |
| "month_idx": m_idx, | |
| "true_kwh": round(true_kwh, 2), | |
| "metered_kwh": round(metered_kwh, 2), | |
| }) | |
| households_df = pd.DataFrame(households) | |
| readings_df = pd.DataFrame(readings) | |
| # ---- Transformer-level rollup: injected energy vs sum of metered readings ---- | |
| roll = readings_df.merge(households_df[["household_id", "transformer_id"]], on="household_id") | |
| tx_monthly = roll.groupby(["transformer_id", "month", "month_idx"]).agg( | |
| sum_true_kwh=("true_kwh", "sum"), | |
| sum_metered_kwh=("metered_kwh", "sum"), | |
| ).reset_index() | |
| # injected energy = true consumption + normal technical losses (line/transformer losses) | |
| tx_monthly["technical_loss_kwh"] = tx_monthly["sum_true_kwh"] * TECH_LOSS_RATE * rng.normal(1, 0.1, len(tx_monthly)).clip(0.7, 1.3) | |
| tx_monthly["energy_injected_kwh"] = tx_monthly["sum_true_kwh"] + tx_monthly["technical_loss_kwh"] | |
| tx_monthly["unaccounted_kwh"] = tx_monthly["energy_injected_kwh"] - tx_monthly["sum_metered_kwh"] | |
| tx_monthly["loss_pct"] = tx_monthly["unaccounted_kwh"] / tx_monthly["energy_injected_kwh"] | |
| households_df.to_csv(os.path.join(OUT_DIR, "households.csv"), index=False) | |
| readings_df.to_csv(os.path.join(OUT_DIR, "readings_monthly.csv"), index=False) | |
| tx_monthly.to_csv(os.path.join(OUT_DIR, "transformers.csv"), index=False) | |
| print(f"Households generated : {len(households_df)}") | |
| print(f"Fraud (ground truth) : {households_df['is_fraud_ground_truth'].sum()} " | |
| f"({households_df['is_fraud_ground_truth'].mean()*100:.1f}%)") | |
| print(f"Transformers : {N_TRANSFORMERS}") | |
| print(f"Avg transformer loss% (latest month): " | |
| f"{tx_monthly[tx_monthly.month_idx==N_MONTHS-1]['loss_pct'].mean()*100:.1f}%") | |
| print(f"Files written to: {OUT_DIR}") | |