{ "cells": [ { "cell_type": "markdown", "id": "a1", "metadata": {}, "source": [ "# Intervention Simulation: Challenge Pixels & Before/After\n", "\n", "**Inputs** (three files):\n", "1. `sim_input_challenges.csv` — challenge pixels with location, stress type, and whether the robot treated them\n", "2. `sim_input_interventions.csv` — pixels where the robot performed an intervention, with the action taken\n", "3. `de_0100_full_field_source.csv` — original predictions and features from `intervention_predictions.ipynb`\n", "\n", "**Pipeline**:\n", "1. Load field data from NetCDF, train TabPFN (same as intervention_predictions.ipynb)\n", "2. Load challenge CSV → modify healthy pixel features to simulate each stress type (copy from real low-yield templates)\n", "3. Run stress classifier on challenge pixels to verify correct diagnosis\n", "4. Load interventions CSV → combine with treated challenge pixels\n", "5. Simulate 60% recovery on all intervened pixels → predict new yields" ] }, { "cell_type": "code", "execution_count": null, "id": "b1", "metadata": {}, "outputs": [], "source": "import matplotlib\nmatplotlib.use('Agg')\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as mpatches\nimport seaborn as sns\nimport numpy as np\nimport pandas as pd\nimport warnings, time, gc, pickle, hashlib, json\nfrom pathlib import Path\nfrom scipy import stats\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import mean_squared_error, r2_score\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.decomposition import PCA\nfrom tabpfn import TabPFNRegressor\nimport netCDF4 as nclib\n\nwarnings.filterwarnings(\"ignore\")\nsns.set_theme(style=\"whitegrid\", font_scale=1.1)\nnp.random.seed(42)\n\nDATA_ROOT = Path(\"./yieldsat_data\")\nRESULTS_DIR = Path(\"./results\")\nRESULTS_DIR.mkdir(exist_ok=True)\nNC_PATH = DATA_ROOT / \"Preprocessed/Germany/merge_s2-soil-dem-weather-coords.nc\"\n\n# Input CSV files\nCHALLENGES_CSV = RESULTS_DIR / \"sim_input_challenges.csv\"\nINTERVENTIONS_CSV = RESULTS_DIR / \"sim_input_interventions.csv\"\nINTERVENTION_ZONES_CSV = Path(\"data/raw/simulator/real_field/de_0100_full_field_source.csv\")\n\nCLUSTER5_FIELDS = {'DE_0100': 100}\nTABPFN_MAX_TRAIN = 2000\nLOW_YIELD_PERCENTILE = 20\nZSCORE_THRESHOLD = 1.2\nRECOVERY_FRACTION = 0.6\n\nSOIL_DEPTHS = ['0-5', '5-15', '15-30', '30-60', '60-100', '100-200']\nPCA_GROUPS = {\n 'soil_texture': {\n 'cols': [f'{p}_{d}' for p in ['clay', 'sand', 'silt'] for d in SOIL_DEPTHS],\n 'n_components': 2,\n },\n 'soil_chemistry': {\n 'cols': [f'{p}_{d}' for p in ['soc', 'cec', 'nitrogen', 'phh2o'] for d in SOIL_DEPTHS],\n 'n_components': 3,\n },\n 'coarse_fragments': {\n 'cols': [f'cfvo_{d}' for d in SOIL_DEPTHS],\n 'n_components': 1,\n },\n 'temperature': {\n 'cols': ['temp_mean_mean', 'temp_max_mean', 'temp_min_mean'],\n 'n_components': 1,\n },\n}\n\nSTRESS_COLORS = {\n 'drought': '#2196F3',\n 'waterlogging': '#795548',\n 'nutrient_deficiency': '#4CAF50',\n 'compaction': '#FF9800',\n 'poor_drainage': '#00BCD4',\n 'healthy_low_yield_anomaly': '#E91E63',\n 'unclassified': '#9E9E9E',\n}\n\nACTION_MAP = {\n 'drought': 'irrigate',\n 'waterlogging': 'install_drainage',\n 'nutrient_deficiency': 'apply_fertilizer_N',\n 'compaction': 'subsoil',\n 'poor_drainage': 'install_drainage',\n 'healthy_low_yield_anomaly': 'inspect_manually',\n 'unclassified': 'inspect_manually',\n}\n\n# === Cache for trained model + feature engineering ===\n# Invalidates when .nc mtime, fields, or PCA/training config changes.\nCACHE_DIR = RESULTS_DIR / \"_sim_cache\"\nCACHE_DIR.mkdir(exist_ok=True, parents=True)\n\ndef _cache_path():\n cfg = {\n 'nc_mtime': NC_PATH.stat().st_mtime if NC_PATH.exists() else 0,\n 'fields': CLUSTER5_FIELDS,\n 'tabpfn_max_train': TABPFN_MAX_TRAIN,\n 'pca_groups': {k: (v['cols'], v['n_components']) for k, v in PCA_GROUPS.items()},\n 'soil_depths': SOIL_DEPTHS,\n }\n h = hashlib.md5(json.dumps(cfg, sort_keys=True, default=str).encode()).hexdigest()[:12]\n return CACHE_DIR / f\"sim_cache_{h}.pkl\"\n\nCACHE_FILE = _cache_path()\nSIM_CACHE = None\nif CACHE_FILE.exists():\n try:\n with open(CACHE_FILE, 'rb') as f:\n SIM_CACHE = pickle.load(f)\n print(f\"Cache HIT: {CACHE_FILE.name}\")\n except Exception as e:\n print(f\"Cache load failed ({e}), will rebuild\")\n SIM_CACHE = None\nelse:\n print(f\"Cache MISS: {CACHE_FILE.name} (will build)\")\n\nprint(\"Setup complete\")\nprint(f\"Challenge input: {CHALLENGES_CSV}\")\nprint(f\"Intervention input: {INTERVENTIONS_CSV}\")\nprint(f\"Features/predictions: {INTERVENTION_ZONES_CSV}\")" }, { "cell_type": "markdown", "id": "a2", "metadata": {}, "source": [ "## Load Data & Train Model\n", "Reproduce the same pipeline as `intervention_predictions.ipynb`." ] }, { "cell_type": "code", "execution_count": null, "id": "b2", "metadata": {}, "outputs": [], "source": "if SIM_CACHE is not None:\n chunks = SIM_CACHE['chunks']\n print(f\"Loaded {len(chunks)} chunk(s) from cache\")\n for chunk in chunks:\n print(f\" {chunk['field_str']}: {len(chunk['target'])} pixels, \"\n f\"yield={chunk['target'].mean():.1f} t/ha, \"\n f\"{chunk['X'].shape[1]} model features\")\nelse:\n eps = 1e-8\n\n ds = nclib.Dataset(str(NC_PATH), 'r')\n band_names = [str(b) for b in ds['band'][:]]\n fsn_all = ds['field_shared_name'][:]\n\n B02_IDX = band_names.index('B02')\n B03_IDX = band_names.index('B03')\n B04_IDX = band_names.index('B04')\n B05_IDX = band_names.index('B05')\n B06_IDX = band_names.index('B06')\n B07_IDX = band_names.index('B07')\n B08_IDX = band_names.index('B08')\n B11_IDX = band_names.index('B11')\n\n # Soil properties at ALL 6 depth layers (matches intervention_predictions.ipynb)\n SOIL_PROPS = ['clay', 'sand', 'silt', 'soc', 'phh2o', 'cec', 'nitrogen', 'cfvo']\n\n STATIC_BANDS = {}\n for prop in SOIL_PROPS:\n for depth in SOIL_DEPTHS:\n bname = f'{prop}_{depth}'\n if bname in band_names:\n STATIC_BANDS[bname] = band_names.index(bname)\n\n for bname in ['dem', 'slope', 'aspect', 'curvature', 'twi']:\n if bname in band_names:\n STATIC_BANDS[bname] = band_names.index(bname)\n\n WEATHER_BANDS = {}\n for bname in ['temp_mean', 'temp_max', 'temp_min', 'total_prec']:\n if bname in band_names:\n WEATHER_BANDS[bname] = band_names.index(bname)\n\n # Load field data\n chunks = []\n for fid_str, fid_nc in CLUSTER5_FIELDS.items():\n field_indices = np.where(fsn_all == fid_nc)[0]\n if len(field_indices) == 0:\n continue\n i0, i1 = field_indices[0], field_indices[-1] + 1\n chunk = {\n 'field_str': fid_str, 'field_nc': fid_nc,\n 'sample': np.ma.filled(ds['sample'][i0:i1, :, :], np.nan).astype(np.float32),\n 'times': ds['times'][i0:i1, :],\n 'target': np.ma.filled(ds['target'][i0:i1], np.nan).astype(np.float32),\n 'row': ds['row'][i0:i1], 'col': ds['col'][i0:i1],\n }\n chunks.append(chunk)\n print(f\" {fid_str}: {len(field_indices)} pixels, yield={chunk['target'].mean():.1f} t/ha\")\n ds.close()\n\n # Feature engineering (matches intervention_predictions.ipynb)\n def find_valid_timesteps(sample, band_idx=B04_IDX, min_coverage=0.5):\n vals = sample[:, :, band_idx]\n valid_per_ts = np.sum(~np.isnan(vals), axis=0)\n coverage = valid_per_ts / len(vals)\n return np.where(coverage > min_coverage)[0]\n\n for chunk in chunks:\n s = chunk['sample']\n valid_ts = find_valid_timesteps(s)\n chunk['valid_ts'] = valid_ts\n last_t = valid_ts[-1]\n\n nir = s[:, last_t, B08_IDX]\n red = s[:, last_t, B04_IDX]\n grn = s[:, last_t, B03_IDX]\n re1 = s[:, last_t, B05_IDX]\n re2 = s[:, last_t, B06_IDX]\n re3 = s[:, last_t, B07_IDX]\n swir = s[:, last_t, B11_IDX]\n\n feat_dict = {}\n # Static features: soil at ALL 6 depths + topography\n for bname, bidx in STATIC_BANDS.items():\n feat_dict[bname] = s[:, 0, bidx]\n # Weather means\n for bname, bidx in WEATHER_BANDS.items():\n feat_dict[f'{bname}_mean'] = np.nanmean(s[:, valid_ts, bidx], axis=1)\n # Derived vegetation indices\n feat_dict['ndvi_last'] = (nir - red) / (nir + red + eps)\n feat_dict['ndre_last'] = (nir - re1) / (nir + re1 + eps)\n feat_dict['ndmi_last'] = (nir - swir) / (nir + swir + eps)\n feat_dict['cire_last'] = nir / (re1 + eps) - 1\n feat_dict['s2rep_last'] = 705 + 35 * ((red + re3) / 2 - re1) / (re2 - re1 + eps)\n # PSRI: not a model feature, but needed for stress classifier\n feat_dict['psri_last'] = (red - grn) / (re2 + eps)\n\n X_raw = pd.DataFrame(feat_dict)\n X_raw = X_raw.fillna(X_raw.median())\n chunk['X_raw'] = X_raw\n\n # PCA: replace correlated groups with principal components\n # PSRI is excluded from model features (only used for classifier)\n X_pca = X_raw.drop(columns=['psri_last']).copy()\n pca_info = {}\n\n for group_name, group_cfg in PCA_GROUPS.items():\n group_cols = group_cfg['cols']\n n_comp = group_cfg['n_components']\n available = [c for c in group_cols if c in X_pca.columns]\n if len(available) < 2:\n continue\n\n scaler = StandardScaler()\n scaled = scaler.fit_transform(X_pca[available].values)\n\n pca = PCA(n_components=n_comp)\n pcs = pca.fit_transform(scaled)\n\n pca_info[group_name] = {\n 'pca': pca, 'scaler': scaler, 'original_cols': available,\n 'explained_var': pca.explained_variance_ratio_,\n }\n\n X_pca = X_pca.drop(columns=available)\n for i in range(n_comp):\n X_pca[f'{group_name}_pc{i+1}'] = pcs[:, i]\n\n var_str = ', '.join([f'PC{i+1}={v:.1%}' for i, v in enumerate(pca.explained_variance_ratio_)])\n print(f\" {group_name}: {len(available)} -> {n_comp} PC(s) ({var_str})\")\n\n X = X_pca.fillna(X_pca.median())\n chunk['X'] = X\n chunk['feature_cols'] = list(X.columns)\n chunk['pca_info'] = pca_info\n\n # Drop large 'sample' array before caching — not used downstream\n del chunk['sample']\n\n print(f\" {chunk['field_str']}: {len(X_raw.columns)} raw -> {X.shape[1]} model features after PCA\")" }, { "cell_type": "code", "execution_count": null, "id": "b3", "metadata": {}, "outputs": [], "source": "chunk = chunks[0]\nfid = chunk['field_str']\ny_all = chunk['target']\nfeature_cols = chunk['feature_cols']\n\nif SIM_CACHE is not None:\n model = SIM_CACHE['model']\n col_means = SIM_CACHE['col_means']\n X_all = SIM_CACHE['X_all']\n test_rmse = SIM_CACHE.get('test_rmse')\n test_r2 = SIM_CACHE.get('test_r2')\n if test_rmse is not None:\n print(f\"Cached model performance: RMSE={test_rmse:.3f}, R2={test_r2:.3f}\")\nelse:\n X_all = chunk['X'].values.copy()\n\n X_train, X_test, y_train, y_test = train_test_split(X_all, y_all, test_size=0.3, random_state=42)\n\n # Impute NaNs using training-set column means; reuse for all later predicts\n col_means = np.nanmean(X_train, axis=0)\n col_means = np.where(np.isnan(col_means), 0, col_means)\n for j in range(X_all.shape[1]):\n X_train[np.isnan(X_train[:, j]), j] = col_means[j]\n X_test[np.isnan(X_test[:, j]), j] = col_means[j]\n X_all[np.isnan(X_all[:, j]), j] = col_means[j]\n\n model = TabPFNRegressor(n_estimators=4, device='cpu', ignore_pretraining_limits=True)\n model.fit(X_train, y_train)\n\n y_pred_test = model.predict(X_test)\n test_rmse = float(np.sqrt(mean_squared_error(y_test, y_pred_test)))\n test_r2 = float(r2_score(y_test, y_pred_test))\n print(f\"Model performance: RMSE={test_rmse:.3f}, R2={test_r2:.3f}\")\n\n # Persist model + imputation stats + imputed X_all so subsequent runs skip TabPFN fit\n with open(CACHE_FILE, 'wb') as f:\n pickle.dump({\n 'chunks': chunks,\n 'model': model,\n 'col_means': col_means,\n 'X_all': X_all,\n 'test_rmse': test_rmse,\n 'test_r2': test_r2,\n }, f)\n print(f\"Saved cache: {CACHE_FILE.name}\")\n\n# Baseline y_pred_all comes from the precomputed source CSV — no full-field predict needed.\n# Align by (row, col) since CSV order may differ from chunk order.\nsource_df = pd.read_csv(INTERVENTION_ZONES_CSV)\nsource_lookup = {(int(r), int(c)): float(p)\n for r, c, p in zip(source_df['row'], source_df['col'], source_df['yield_pred'])}\nchunk_rows = chunk['row']\nchunk_cols = chunk['col']\ny_pred_all = np.array([source_lookup[(int(r), int(c))]\n for r, c in zip(chunk_rows, chunk_cols)], dtype=np.float64)\nprint(f\"All-pixel predictions (from source CSV): mean={y_pred_all.mean():.2f}, std={y_pred_all.std():.2f}\")" }, { "cell_type": "markdown", "id": "u1gm7jwwyia", "metadata": {}, "source": [ "## Load Challenge CSV & Create Challenge Pixels\n", "\n", "Read challenge locations and stress types from `sim_input_challenges.csv`. Copy features from real low-yield pixels to simulate each stress type, ensuring the model predicts yield below 4 t/ha." ] }, { "cell_type": "code", "execution_count": null, "id": "g7uyj09s4n9", "metadata": {}, "outputs": [], "source": "# === Load challenge CSV ===\nchallenges_df = pd.read_csv(CHALLENGES_CSV)\nprint(f\"Loaded {len(challenges_df)} challenge pixels from {CHALLENGES_CSV.name}\")\nprint(challenges_df.to_string(index=False))\n\nchunk = chunks[0]\nX_raw_orig = chunk['X_raw'].copy()\ny_all = chunk['target']\nrows = chunk['row']\ncols = chunk['col']\nfeature_cols = chunk['feature_cols']\n\n# Match challenge CSV rows to pixel indices\nchallenge_info = []\nfor _, crow in challenges_df.iterrows():\n mask = (rows == crow['row']) & (cols == crow['col'])\n idx = np.where(mask)[0]\n if len(idx) == 0:\n print(f\" WARNING: pixel [{crow['row']},{crow['col']}] not found, skipping\")\n continue\n px_idx = idx[0]\n treated = str(crow.get('treated', 'true')).lower() == 'true'\n challenge_info.append({\n 'pixel_idx': px_idx,\n 'row': int(crow['row']),\n 'col': int(crow['col']),\n 'stress': crow['challenge'],\n 'action': ACTION_MAP.get(crow['challenge'], 'inspect_manually'),\n 'treated': treated,\n 'original_yield': y_all[px_idx],\n })\n print(f\" [{crow['row']},{crow['col']}] {crow['challenge']}, treated={treated}, \"\n f\"yield={y_all[px_idx]:.1f} t/ha\")\n\n# === Copy features from real low-yield pixels as templates ===\n# Z-score shifts don't work (within-field VI variance is tiny).\n# Instead, transplant feature vectors from the worst-yielding real pixels.\n\nlow_mask = y_all <= np.nanpercentile(y_all, LOW_YIELD_PERCENTILE)\nlow_indices = np.where(low_mask)[0]\nlow_yields = y_all[low_mask]\nworst_order = np.argsort(low_yields)\ntemplate_indices = low_indices[worst_order[:len(challenge_info)]]\n\nprint(f\"\\nTemplate pixels (real low-yield):\")\nfor i, ti in enumerate(template_indices):\n print(f\" Template {i+1}: [{rows[ti]},{cols[ti]}], yield={y_all[ti]:.1f} t/ha, \"\n f\"pred={y_pred_all[ti]:.1f} t/ha\")\n\n# Copy features and apply stress-specific adjustments\nX_raw_challenge = X_raw_orig.astype(np.float64).copy()\n\nprint(f\"\\nFeature transplant:\")\nfor ch_i, (info, template_idx) in enumerate(zip(challenge_info, template_indices)):\n px_idx = info['pixel_idx']\n stress = info['stress']\n \n # Copy ALL raw features from template\n for col_name in X_raw_challenge.columns:\n X_raw_challenge.loc[px_idx, col_name] = X_raw_challenge.loc[template_idx, col_name]\n \n # Stress-specific adjustments\n if stress == 'drought':\n X_raw_challenge.loc[px_idx, 'ndmi_last'] *= 0.5\n for d in SOIL_DEPTHS:\n if f'sand_{d}' in X_raw_challenge.columns:\n X_raw_challenge.loc[px_idx, f'sand_{d}'] = X_raw_orig[f'sand_{d}'].quantile(0.95)\n \n elif stress == 'compaction':\n for d in SOIL_DEPTHS:\n if f'cfvo_{d}' in X_raw_challenge.columns:\n X_raw_challenge.loc[px_idx, f'cfvo_{d}'] = X_raw_orig[f'cfvo_{d}'].quantile(0.95)\n if f'clay_{d}' in X_raw_challenge.columns:\n X_raw_challenge.loc[px_idx, f'clay_{d}'] = X_raw_orig[f'clay_{d}'].quantile(0.95)\n X_raw_challenge.loc[px_idx, 'slope'] = X_raw_orig['slope'].quantile(0.05)\n \n elif stress == 'nutrient_deficiency':\n for d in SOIL_DEPTHS:\n for prop in ['nitrogen', 'soc', 'cec']:\n col = f'{prop}_{d}'\n if col in X_raw_challenge.columns:\n X_raw_challenge.loc[px_idx, col] = X_raw_orig[col].quantile(0.05)\n \n print(f\" [{info['row']},{info['col']}] {stress}: \"\n f\"features from template [{rows[template_idx]},{cols[template_idx]}] \"\n f\"(template pred={y_pred_all[template_idx]:.1f} t/ha)\")\n\n# Recompute PCA features\npca_info = chunk['pca_info']\nX_pca_challenge = X_raw_challenge.drop(columns=['psri_last']).copy()\n\nfor group_name in PCA_GROUPS:\n if group_name not in pca_info:\n continue\n info = pca_info[group_name]\n available = info['original_cols']\n n_comp = len(info['explained_var'])\n scaled = info['scaler'].transform(X_pca_challenge[available].values)\n pcs = info['pca'].transform(scaled)\n X_pca_challenge = X_pca_challenge.drop(columns=available)\n for j in range(n_comp):\n X_pca_challenge[f'{group_name}_pc{j+1}'] = pcs[:, j]\n\nX_challenge = X_pca_challenge.fillna(X_pca_challenge.median())\nX_challenge = X_challenge[feature_cols]\n\n# Predict ONLY on the changed (challenge) pixel rows — patch into baseline.\nchallenge_indices = np.array([info['pixel_idx'] for info in challenge_info], dtype=int)\nX_changed = X_challenge.values[challenge_indices].copy()\nfor j in range(X_changed.shape[1]):\n nan_mask = np.isnan(X_changed[:, j])\n if nan_mask.any():\n X_changed[nan_mask, j] = col_means[j]\n\ny_pred_changed = model.predict(X_changed)\n\n# Build full-field y_pred_challenge by patching baseline at changed indices.\ny_pred_challenge = y_pred_all.copy()\ny_pred_challenge[challenge_indices] = y_pred_changed\n\n# Stash challenge predictions on each info dict for downstream use\nfor k, info in enumerate(challenge_info):\n info['challenge_pred'] = float(y_pred_changed[k])\n\nprint(f\"\\nYield prediction check (target < 4 t/ha):\")\nfor info in challenge_info:\n px_idx = info['pixel_idx']\n pred = info['challenge_pred']\n print(f\" [{info['row']},{info['col']}] {info['stress']} (treated={info['treated']}): \"\n f\"original={y_pred_all[px_idx]:.1f} -> challenge={pred:.1f} t/ha \"\n f\"({'OK' if pred < 4 else 'HIGH'})\")" }, { "cell_type": "markdown", "id": "6j7ddh9frlw", "metadata": {}, "source": [ "## Stress Classification on Challenge Pixels\n", "\n", "Run the same z-score-based stress classifier from `intervention_predictions.ipynb` on the 3 challenge pixels. The classifier should correctly identify drought → irrigate, compaction → subsoil, nutrient deficiency → apply fertilizer." ] }, { "cell_type": "code", "execution_count": null, "id": "0xitia1byhh", "metadata": {}, "outputs": [], "source": [ "# === Stress classifier (from intervention_predictions.ipynb) ===\n", "\n", "def classify_stress(z_row, t=ZSCORE_THRESHOLD):\n", " def signal(key, direction):\n", " v = z_row.get(key, None)\n", " if v is None or pd.isna(v):\n", " return (False, 0.0, False)\n", " if direction == '+':\n", " return (v > t, v, True)\n", " elif direction == '-':\n", " return (v < -t, v, True)\n", " else:\n", " return (abs(v) > t, v, True)\n", "\n", " def score(dynamic, static, min_dyn_frac=0.5, min_dyn_available=1, static_weight=0.5):\n", " dyn_available = [s for s in dynamic if s[2]]\n", " if len(dyn_available) < min_dyn_available:\n", " return 0.0, False\n", " dyn_fired = [s for s in dyn_available if s[0]]\n", " if len(dyn_fired) / len(dyn_available) < min_dyn_frac:\n", " return 0.0, False\n", " stat_available = [s for s in static if s[2]]\n", " stat_fired = [s for s in stat_available if s[0]]\n", " dyn_strength = sum(max(0, abs(s[1]) - t) for s in dyn_fired)\n", " stat_strength = sum(max(0, abs(s[1]) - t) for s in stat_fired) * static_weight\n", " max_possible = (len(dyn_available) + len(stat_available) * static_weight) * 2.0\n", " if max_possible <= 0:\n", " return 0.0, False\n", " conf = min(1.0, (dyn_strength + stat_strength) / max_possible)\n", " return conf, True\n", "\n", " stresses = []\n", "\n", " # DROUGHT\n", " dyn = [signal(\"z_NDMI\", '-'), signal(\"z_NDVI\", '-')]\n", " stat = [signal(\"z_sand_0-5\", '+')]\n", " conf, fired = score(dyn, stat, min_dyn_frac=0.5)\n", " if fired: stresses.append((\"drought\", conf))\n", "\n", " # WATERLOGGING\n", " dyn = [signal(\"z_PSRI\", '+'), signal(\"z_NDMI\", '+')]\n", " stat = [signal(\"z_twi\", '+'), signal(\"z_slope\", '-'),\n", " signal(\"z_clay_0-5\", '+'), signal(\"z_dem\", '-')]\n", " conf, fired = score(dyn, stat, min_dyn_frac=0.5)\n", " if fired: stresses.append((\"waterlogging\", conf))\n", "\n", " # NUTRIENT DEFICIENCY\n", " dyn = [signal(\"z_NDRE\", '-'), signal(\"z_CIre\", '-'),\n", " signal(\"z_S2REP\", '-'), signal(\"z_NDVI\", '-')]\n", " stat = [signal(\"z_nitrogen_0-5\", '-'), signal(\"z_soc_0-5\", '-'),\n", " signal(\"z_cec_0-5\", '-'), signal(\"z_phh2o_0-5\", 'abs')]\n", " conf, fired = score(dyn, stat, min_dyn_frac=0.5)\n", " if fired: stresses.append((\"nutrient_deficiency\", conf))\n", "\n", " # COMPACTION\n", " dyn = [signal(\"z_PSRI\", '+'), signal(\"z_NDRE\", '-')]\n", " stat = [signal(\"z_cfvo_0-5\", '+'), signal(\"z_clay_0-5\", '+'),\n", " signal(\"z_slope\", '-')]\n", " conf, fired = score(dyn, stat, min_dyn_frac=0.5)\n", " if fired: stresses.append((\"compaction\", conf))\n", "\n", " # POOR DRAINAGE\n", " dyn = [signal(\"z_PSRI\", '+'), signal(\"z_NDVI\", '-')]\n", " stat = [signal(\"z_sand_0-5\", '-'), signal(\"z_silt_0-5\", '+'),\n", " signal(\"z_clay_0-5\", '+')]\n", " conf, fired = score(dyn, stat, min_dyn_frac=0.5)\n", " if fired: stresses.append((\"poor_drainage\", conf))\n", "\n", " if stresses:\n", " stresses.sort(key=lambda x: -x[1])\n", " while len(stresses) < 2:\n", " stresses.append((\"none\", 0.0))\n", " return stresses[:2], \"classified\"\n", "\n", " dyn_keys = [\"z_NDVI\", \"z_NDRE\", \"z_NDMI\", \"z_PSRI\", \"z_CIre\", \"z_S2REP\"]\n", " available_dyn = [k for k in dyn_keys\n", " if z_row.get(k) is not None and not pd.isna(z_row.get(k))]\n", " if len(available_dyn) < 2:\n", " return [(\"unclassified\", 0.0), (\"none\", 0.0)], \"insufficient_data\"\n", "\n", " ndvi = z_row.get(\"z_NDVI\", np.nan)\n", " ndre = z_row.get(\"z_NDRE\", np.nan)\n", " psri = z_row.get(\"z_PSRI\", np.nan)\n", " cire = z_row.get(\"z_CIre\", np.nan)\n", " healthy_markers = 0\n", " if not pd.isna(ndvi) and ndvi > 0.5: healthy_markers += 1\n", " if not pd.isna(ndre) and ndre > 0.5: healthy_markers += 1\n", " if not pd.isna(psri) and psri < -0.5: healthy_markers += 1\n", " if not pd.isna(cire) and cire > 0.5: healthy_markers += 1\n", " if healthy_markers >= 2:\n", " return [(\"healthy_low_yield_anomaly\", 0.5), (\"none\", 0.0)], \"healthy_anomaly\"\n", "\n", " return [(\"unclassified\", 0.0), (\"none\", 0.0)], \"no_signal\"\n", "\n", "\n", "# === Compute z-scores for challenge pixels using field-level statistics ===\n", "\n", "# Features to z-score (mapping raw column names -> classifier z-score keys)\n", "ZSCORE_FEATURES = {\n", " 'ndvi_last': 'z_NDVI',\n", " 'ndre_last': 'z_NDRE',\n", " 'ndmi_last': 'z_NDMI',\n", " 'psri_last': 'z_PSRI',\n", " 'cire_last': 'z_CIre',\n", " 's2rep_last': 'z_S2REP',\n", " 'clay_0-5': 'z_clay_0-5',\n", " 'sand_0-5': 'z_sand_0-5',\n", " 'silt_0-5': 'z_silt_0-5',\n", " 'soc_0-5': 'z_soc_0-5',\n", " 'phh2o_0-5': 'z_phh2o_0-5',\n", " 'cec_0-5': 'z_cec_0-5',\n", " 'nitrogen_0-5': 'z_nitrogen_0-5',\n", " 'cfvo_0-5': 'z_cfvo_0-5',\n", " 'dem': 'z_dem',\n", " 'slope': 'z_slope',\n", " 'twi': 'z_twi',\n", "}\n", "\n", "# Compute field-level medians and MADs from the MODIFIED raw features\n", "# (3 pixels changed out of 492 — negligible effect on field stats)\n", "field_medians = {}\n", "field_mads = {}\n", "mad_floors = {}\n", "\n", "for raw_col in ZSCORE_FEATURES:\n", " vals = X_raw_challenge[raw_col]\n", " med = vals.median()\n", " mad = (vals - med).abs().median()\n", " q75, q25 = vals.quantile([0.75, 0.25])\n", " iqr = q75 - q25\n", " floor = max(iqr * 0.01, 1e-6)\n", " field_medians[raw_col] = med\n", " field_mads[raw_col] = mad\n", " mad_floors[raw_col] = floor\n", "\n", "# Compute z-scores for challenge pixels\n", "print(\"=\" * 85)\n", "print(\"STRESS CLASSIFICATION RESULTS FOR CHALLENGE PIXELS\")\n", "print(\"=\" * 85)\n", "print(f\"\\n{'Pixel':<12} {'Expected':<22} {'Classified':<22} {'Conf':>6} {'Action':<22} {'Match':>5}\")\n", "print(\"-\" * 85)\n", "\n", "challenge_classified = []\n", "for info in challenge_info:\n", " px_idx = info['pixel_idx']\n", " \n", " # Build z-score dict for this pixel\n", " z_dict = {}\n", " for raw_col, z_key in ZSCORE_FEATURES.items():\n", " val = X_raw_challenge.loc[px_idx, raw_col]\n", " med = field_medians[raw_col]\n", " mad = field_mads[raw_col]\n", " floor = mad_floors[raw_col]\n", " \n", " if pd.isna(val) or pd.isna(med):\n", " z_dict[z_key] = np.nan\n", " continue\n", " \n", " mad_safe = max(mad, floor)\n", " z = (val - med) / (1.4826 * mad_safe)\n", " z = np.clip(z, -10, 10)\n", " z_dict[z_key] = z\n", " \n", " top2, status = classify_stress(z_dict)\n", " primary_stress = top2[0][0]\n", " primary_conf = top2[0][1]\n", " action = ACTION_MAP.get(primary_stress, 'inspect_manually')\n", " match = \"YES\" if primary_stress == info['stress'] else \"NO\"\n", " \n", " info['classified_stress'] = primary_stress\n", " info['classified_conf'] = primary_conf\n", " info['classified_action'] = action\n", " info['z_scores'] = z_dict\n", " \n", " print(f\" [{info['row']:>2},{info['col']:>2}] {info['stress']:<22} {primary_stress:<22} {primary_conf:>5.3f} \"\n", " f\"{action:<22} {match:>5}\")\n", " \n", " challenge_classified.append(info)\n", "\n", "# Print key z-scores for each challenge pixel\n", "print(\"\\nKey z-scores:\")\n", "for info in challenge_classified:\n", " z = info['z_scores']\n", " stress = info['stress']\n", " print(f\"\\n [{info['row']},{info['col']}] ({stress}):\")\n", " relevant_keys = {\n", " 'drought': ['z_NDVI', 'z_NDMI', 'z_sand_0-5'],\n", " 'compaction': ['z_PSRI', 'z_NDRE', 'z_cfvo_0-5', 'z_clay_0-5'],\n", " 'nutrient_deficiency': ['z_NDRE', 'z_CIre', 'z_S2REP', 'z_NDVI', 'z_nitrogen_0-5', 'z_soc_0-5'],\n", " }\n", " for k in relevant_keys.get(stress, []):\n", " v = z.get(k, np.nan)\n", " print(f\" {k}: {v:+.2f}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "cwfg3t1rk9", "metadata": {}, "outputs": [], "source": "# Build the imputed challenge feature matrix once — used as the starting point for X_post below.\n# No model.predict() here: y_pred_challenge was already computed as a subset predict + baseline patch.\nX_challenge_arr = X_challenge.values.copy()\nfor j in range(X_challenge_arr.shape[1]):\n nan_mask = np.isnan(X_challenge_arr[:, j])\n if nan_mask.any():\n X_challenge_arr[nan_mask, j] = col_means[j]\n\n# Show yield drop for challenge pixels\nprint(\"Challenge pixel yield predictions (with stress features):\")\nprint(f\"{'Pixel':<12} {'Stress':<22} {'Actual':>8} {'Original pred':>14} {'Challenge pred':>14} {'Drop':>8}\")\nprint(\"-\" * 80)\nfor info in challenge_info:\n px_idx = info['pixel_idx']\n orig_pred = y_pred_all[px_idx]\n chal_pred = y_pred_challenge[px_idx]\n print(f\" [{info['row']:>2},{info['col']:>2}] {info['stress']:<22} {info['original_yield']:>7.2f} \"\n f\"{orig_pred:>13.2f} {chal_pred:>13.2f} {chal_pred - orig_pred:>+7.2f}\")" }, { "cell_type": "markdown", "id": "a3", "metadata": {}, "source": [ "## Load Robot Interventions & Combine with Challenges\n", "\n", "Read `sim_input_interventions.csv` for pixels where the robot performed an action. Combine with treated challenge pixels for the full intervention set." ] }, { "cell_type": "code", "execution_count": null, "id": "b4", "metadata": {}, "outputs": [], "source": [ "# === Load interventions CSV ===\n", "interventions_df = pd.read_csv(INTERVENTIONS_CSV)\n", "print(f\"Loaded {len(interventions_df)} intervention pixels from {INTERVENTIONS_CSV.name}\")\n", "print(interventions_df.to_string(index=False))\n", "\n", "# Match intervention CSV rows to pixel indices\n", "selected_orig_indices = []\n", "selected_orig_actions = []\n", "selected_orig_stresses = []\n", "\n", "action_to_stress = {v: k for k, v in ACTION_MAP.items()}\n", "\n", "for _, irow in interventions_df.iterrows():\n", " mask = (rows == irow['row']) & (cols == irow['col'])\n", " idx = np.where(mask)[0]\n", " if len(idx) == 0:\n", " print(f\" WARNING: pixel [{irow['row']},{irow['col']}] not found, skipping\")\n", " continue\n", " selected_orig_indices.append(idx[0])\n", " selected_orig_actions.append(irow['action'])\n", " selected_orig_stresses.append(action_to_stress.get(irow['action'], 'unclassified'))\n", "\n", "selected_orig_indices = np.array(selected_orig_indices)\n", "print(f\"\\nMatched {len(selected_orig_indices)} intervention pixels\")\n", "print(f\"Actions: {pd.Series(selected_orig_actions).value_counts().to_dict()}\")" ] }, { "cell_type": "markdown", "id": "a4", "metadata": {}, "source": [ "## Simulate Post-Intervention Features\n", "\n", "**Data-driven approach**: For each intervened pixel, shift the responsive features toward the field's healthy-pixel median. The shift magnitude is controlled by `RECOVERY_FRACTION` (0.6 = 60% of the gap closed after a few weeks).\n", "\n", "**Which features respond to each action:**\n", "- `apply_fertilizer_N` → NDRE, CIre, S2REP, NDVI improve (chlorophyll/N response)\n", "- `irrigate` → NDMI, NDVI improve (water stress relief)\n", "- `install_drainage` → NDMI normalizes (excess water removed), NDVI improves\n", "- `subsoil` → NDRE, NDVI improve (root access restored)\n", "\n", "Static features (soil, DEM, weather) and PCA components do NOT change — only vegetation indices respond to intervention." ] }, { "cell_type": "code", "execution_count": null, "id": "b5", "metadata": {}, "outputs": [], "source": [ "# Define which features respond to each intervention\n", "# Only vegetation indices change — soil/DEM/weather/PCA components are fixed\n", "ACTION_RESPONSIVE_FEATURES = {\n", " 'apply_fertilizer_N': ['ndvi_last', 'ndre_last', 'cire_last', 's2rep_last'],\n", " 'irrigate': ['ndvi_last', 'ndmi_last'],\n", " 'install_drainage': ['ndvi_last', 'ndmi_last'],\n", " 'subsoil': ['ndvi_last', 'ndre_last', 'cire_last'],\n", "}\n", "\n", "feature_cols = chunk['feature_cols']\n", "\n", "# Compute healthy pixel statistics using the original (unmodified) feature matrix\n", "# Challenge pixels were healthy originally, so X_all is the right reference\n", "low_mask = y_all <= np.nanpercentile(y_all, LOW_YIELD_PERCENTILE)\n", "\n", "healthy_medians = {}\n", "healthy_p25 = {}\n", "healthy_p75 = {}\n", "for i, col_name in enumerate(feature_cols):\n", " healthy_vals = X_all[~low_mask, i]\n", " healthy_medians[col_name] = np.median(healthy_vals)\n", " healthy_p25[col_name] = np.percentile(healthy_vals, 25)\n", " healthy_p75[col_name] = np.percentile(healthy_vals, 75)\n", "\n", "print(\"Healthy pixel feature medians (vegetation indices):\")\n", "vi_features = ['ndvi_last', 'ndre_last', 'ndmi_last', 'cire_last', 's2rep_last']\n", "for f in vi_features:\n", " print(f\" {f}: median={healthy_medians[f]:.4f} [P25={healthy_p25[f]:.4f}, P75={healthy_p75[f]:.4f}]\")" ] }, { "cell_type": "code", "execution_count": null, "id": "ntqpn0mhdtf", "metadata": {}, "outputs": [], "source": [ "# Combine TREATED challenge pixels + intervention pixels into unified arrays\n", "# Only challenge pixels with treated=True get the recovery simulation\n", "\n", "treated_challenges = [info for info in challenge_info if info['treated']]\n", "untreated_challenges = [info for info in challenge_info if not info['treated']]\n", "\n", "if untreated_challenges:\n", " print(f\"Untreated challenge pixels (no recovery simulation):\")\n", " for info in untreated_challenges:\n", " print(f\" [{info['row']},{info['col']}] {info['stress']} — NOT treated\")\n", "\n", "# Build combined arrays: treated challenges first, then interventions\n", "all_interv_indices = np.concatenate([\n", " np.array([info['pixel_idx'] for info in treated_challenges]),\n", " selected_orig_indices,\n", "])\n", "\n", "all_interv_actions = (\n", " [info['action'] for info in treated_challenges] +\n", " selected_orig_actions\n", ")\n", "\n", "all_interv_stresses = (\n", " [info.get('classified_stress', info['stress']) for info in treated_challenges] +\n", " selected_orig_stresses\n", ")\n", "\n", "all_interv_is_challenge = (\n", " [True] * len(treated_challenges) +\n", " [False] * len(selected_orig_indices)\n", ")\n", "\n", "print(f\"\\nCombined intervention set: {len(all_interv_indices)} pixels \"\n", " f\"({len(treated_challenges)} treated challenges + {len(selected_orig_indices)} robot interventions)\")\n", "print(f\"Actions: {pd.Series(all_interv_actions).value_counts().to_dict()}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "b6", "metadata": {}, "outputs": [], "source": [ "# Create post-intervention feature matrix (copy of challenge-modified features)\n", "X_post = X_challenge_arr.copy()\n", "\n", "# Apply data-driven feature shifts to ALL 13 intervention pixels\n", "print(f\"Recovery fraction: {RECOVERY_FRACTION:.0%}\")\n", "print(f\"{'Pixel':<10} {'Type':<10} {'Action':<22} {'Feature':<12} {'Before':>8} {'After':>8} {'Healthy med':>12}\")\n", "print(\"-\" * 84)\n", "\n", "for sel_i, px_idx in enumerate(all_interv_indices):\n", " action = all_interv_actions[sel_i]\n", " is_chal = all_interv_is_challenge[sel_i]\n", " responsive = ACTION_RESPONSIVE_FEATURES.get(action, [])\n", " \n", " for feat_name in responsive:\n", " feat_idx = feature_cols.index(feat_name)\n", " current_val = X_post[px_idx, feat_idx]\n", " target_val = healthy_medians[feat_name]\n", " \n", " # Move RECOVERY_FRACTION of the way from current to healthy median\n", " new_val = current_val + RECOVERY_FRACTION * (target_val - current_val)\n", " X_post[px_idx, feat_idx] = new_val\n", " \n", " if sel_i < 6: # Print details for first 6 pixels (3 challenge + 3 original)\n", " label = \"CHAL\" if is_chal else \"orig\"\n", " r_px = int(rows[px_idx])\n", " c_px = int(cols[px_idx])\n", " print(f\" [{r_px:>2},{c_px:>2}] {label:<10} {action:<22} {feat_name:<12} \"\n", " f\"{current_val:>8.4f} {new_val:>8.4f} {target_val:>12.4f}\")\n", "\n", "print(f\"\\n... (showing first 6 of {len(all_interv_indices)} pixels)\")" ] }, { "cell_type": "markdown", "id": "a5", "metadata": {}, "source": [ "## Predict Post-Intervention Yield" ] }, { "cell_type": "code", "execution_count": null, "id": "b7", "metadata": {}, "outputs": [], "source": "# Predict yield ONLY on the intervention pixels — patch into a copy of y_pred_challenge.\nX_post_changed = X_post[all_interv_indices].copy()\nfor j in range(X_post_changed.shape[1]):\n nan_mask = np.isnan(X_post_changed[:, j])\n if nan_mask.any():\n X_post_changed[nan_mask, j] = col_means[j]\n\ny_pred_post_changed = model.predict(X_post_changed)\ny_pred_post = y_pred_challenge.copy()\ny_pred_post[all_interv_indices] = y_pred_post_changed\n\n# Compare before vs after\ncomparison = []\nfor sel_i, px_idx in enumerate(all_interv_indices):\n is_chal = all_interv_is_challenge[sel_i]\n pred_before = y_pred_challenge[px_idx]\n \n comparison.append({\n 'row': int(rows[px_idx]),\n 'col': int(cols[px_idx]),\n 'is_challenge': is_chal,\n 'action': all_interv_actions[sel_i],\n 'stress': all_interv_stresses[sel_i],\n 'yield_actual': y_all[px_idx],\n 'yield_pred_before': pred_before,\n 'yield_pred_after': y_pred_post[px_idx],\n 'yield_improvement': y_pred_post[px_idx] - pred_before,\n 'pct_improvement': (y_pred_post[px_idx] - pred_before) / max(pred_before, 0.1) * 100,\n })\n\ncomp_df = pd.DataFrame(comparison)\n\nn_chal = sum(all_interv_is_challenge)\nn_orig = len(all_interv_indices) - n_chal\n\nprint(\"=\" * 90)\nprint(f\"INTERVENTION SIMULATION RESULTS ({n_chal} challenge + {n_orig} original)\")\nprint(\"=\" * 90)\nprint(f\"\\nRecovery model: {RECOVERY_FRACTION:.0%} shift toward healthy median\")\nprint(f\"\\n{'Type':<6} {'Row':<5} {'Col':<5} {'Stress':<22} {'Action':<22} {'Before':>7} {'After':>7} {'Gain':>7} {'%':>6}\")\nprint(\"-\" * 90)\n\nfor _, r in comp_df.iterrows():\n label = \"CHAL\" if r['is_challenge'] else \"orig\"\n print(f\"{label:<6} {int(r['row']):<5} {int(r['col']):<5} {r['stress']:<22} {r['action']:<22} \"\n f\"{r['yield_pred_before']:>7.2f} {r['yield_pred_after']:>7.2f} \"\n f\"{r['yield_improvement']:>+7.2f} {r['pct_improvement']:>+5.1f}%\")\n\nchal_df = comp_df[comp_df['is_challenge']]\norig_df = comp_df[~comp_df['is_challenge']]\n\nif len(chal_df) > 0:\n print(f\"\\n{'CHALLENGE (' + str(n_chal) + ')':<54} {chal_df['yield_pred_before'].mean():>7.2f} {chal_df['yield_pred_after'].mean():>7.2f} \"\n f\"{chal_df['yield_improvement'].mean():>+7.2f} {chal_df['pct_improvement'].mean():>+5.1f}%\")\nif len(orig_df) > 0:\n print(f\"{'ORIGINAL (' + str(n_orig) + ')':<54} {orig_df['yield_pred_before'].mean():>7.2f} {orig_df['yield_pred_after'].mean():>7.2f} \"\n f\"{orig_df['yield_improvement'].mean():>+7.2f} {orig_df['pct_improvement'].mean():>+5.1f}%\")\nprint(f\"\\nField mean yield: {y_all.mean():.2f} t/ha\")" }, { "cell_type": "code", "execution_count": null, "id": "b8", "metadata": {}, "outputs": [], "source": [ "# Save comparison CSV\n", "comp_df.to_csv(RESULTS_DIR / \"farm_intervention_simulation.csv\", index=False)\n", "print(f\"Saved: farm_intervention_simulation.csv ({len(comp_df)} rows: 3 challenge + 10 original)\")" ] }, { "cell_type": "markdown", "id": "a6", "metadata": {}, "source": [ "## Before / After Comparison Map" ] }, { "cell_type": "code", "execution_count": null, "id": "b9", "metadata": {}, "outputs": [], "source": [ "def pixels_to_image(rows, cols, values):\n", " r_min, c_min = rows.min(), cols.min()\n", " h = rows.max() - r_min + 1\n", " w = cols.max() - c_min + 1\n", " img = np.full((h, w), np.nan)\n", " img[rows - r_min, cols - c_min] = values\n", " return img, r_min, c_min\n", "\n", "fig, axes = plt.subplots(1, 3, figsize=(18, 6))\n", "\n", "r_arr = chunk['row']\n", "c_arr = chunk['col']\n", "vmin, vmax = 0, max(y_all.max(), y_pred_post.max())\n", "_, r_min, c_min = pixels_to_image(r_arr, c_arr, y_pred_challenge)\n", "\n", "for panel_idx, (img_data, title) in enumerate([\n", " (y_pred_challenge, 'Before Intervention'),\n", " (y_pred_post, 'After Intervention'),\n", "]):\n", " img, _, _ = pixels_to_image(r_arr, c_arr, img_data)\n", " im = axes[panel_idx].imshow(img, cmap='RdYlGn', vmin=vmin, vmax=vmax)\n", " axes[panel_idx].set_title(title, fontsize=11)\n", " axes[panel_idx].axis('off')\n", " \n", " # All markers black\n", " for info in challenge_info:\n", " axes[panel_idx].plot(info['col'] - c_min, info['row'] - r_min, 'ko',\n", " markersize=12, markerfacecolor='none', markeredgewidth=2.5)\n", " for px_idx in selected_orig_indices:\n", " axes[panel_idx].plot(c_arr[px_idx] - c_min, r_arr[px_idx] - r_min,\n", " 'kx', markersize=8, markeredgewidth=2)\n", " plt.colorbar(im, ax=axes[panel_idx], shrink=0.7, label='t/ha')\n", "\n", "# Panel 3: Yield improvement\n", "diff = y_pred_post - y_pred_challenge\n", "img_diff, _, _ = pixels_to_image(r_arr, c_arr, diff)\n", "max_abs = max(abs(np.nanmin(img_diff)), abs(np.nanmax(img_diff)), 0.5)\n", "im3 = axes[2].imshow(img_diff, cmap='RdBu', vmin=-max_abs, vmax=max_abs)\n", "axes[2].set_title('Yield Change (After - Before)', fontsize=11)\n", "axes[2].axis('off')\n", "\n", "for info in challenge_info:\n", " axes[2].plot(info['col'] - c_min, info['row'] - r_min, 'ko',\n", " markersize=12, markerfacecolor='none', markeredgewidth=2.5)\n", "for px_idx in selected_orig_indices:\n", " axes[2].plot(c_arr[px_idx] - c_min, r_arr[px_idx] - r_min,\n", " 'kx', markersize=8, markeredgewidth=2)\n", "plt.colorbar(im3, ax=axes[2], shrink=0.7, label='\\u0394 t/ha')\n", "\n", "plt.suptitle(f'Intervention Simulation \\n',\n", " fontsize=12)\n", "plt.tight_layout()\n", "fig.savefig(RESULTS_DIR / \"farm_intervention_simulation_map.png\", dpi=150, bbox_inches=\"tight\")\n", "plt.close(fig)\n", "print(\"Saved: farm_intervention_simulation_map.png\")" ] }, { "cell_type": "code", "execution_count": null, "id": "49b05daf", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "autofarm", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.15" } }, "nbformat": 4, "nbformat_minor": 5 }