{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "91e4c90f",
   "metadata": {},
   "source": [
    "Step 1: Clean out non-model simulation points: gap heights below 5mm"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "e4ff9106",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import os\n",
    "plt.rcParams['text.usetex'] = True"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "32946653",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "<class 'pandas.core.frame.DataFrame'>\n",
      "RangeIndex: 4471 entries, 0 to 4470\n",
      "Data columns (total 6 columns):\n",
      " #   Column                            Non-Null Count  Dtype  \n",
      "---  ------                            --------------  -----  \n",
      " 0   currL [A]                         4471 non-null   int64  \n",
      " 1   currR [A]                         4471 non-null   int64  \n",
      " 2   rollDeg [deg]                     4471 non-null   float64\n",
      " 3   GapHeight [mm]                    4471 non-null   float64\n",
      " 4   YokeForce.Force_z [newton]        4471 non-null   float64\n",
      " 5   YokeTorque.Torque [mNewtonMeter]  4471 non-null   float64\n",
      "dtypes: float64(4), int64(2)\n",
      "memory usage: 209.7 KB\n",
      "\n",
      "After adding mirrored data:\n",
      "<class 'pandas.core.frame.DataFrame'>\n",
      "RangeIndex: 8281 entries, 0 to 8280\n",
      "Data columns (total 6 columns):\n",
      " #   Column                            Non-Null Count  Dtype  \n",
      "---  ------                            --------------  -----  \n",
      " 0   currL [A]                         8281 non-null   int64  \n",
      " 1   currR [A]                         8281 non-null   int64  \n",
      " 2   rollDeg [deg]                     8281 non-null   float64\n",
      " 3   GapHeight [mm]                    8281 non-null   float64\n",
      " 4   YokeForce.Force_z [newton]        8281 non-null   float64\n",
      " 5   YokeTorque.Torque [mNewtonMeter]  8281 non-null   float64\n",
      "dtypes: float64(4), int64(2)\n",
      "memory usage: 388.3 KB\n"
     ]
    }
   ],
   "source": [
    "magDf = pd.read_csv(\"Ansys Results 12-9.csv\")\n",
    "magDf.info()\n",
    "\n",
    "condition = magDf['GapHeight [mm]'] < 5\n",
    "\n",
    "magDf = magDf[~condition]\n",
    "\n",
    "nzRoll = magDf[magDf['rollDeg [deg]'] != 0]\n",
    "\n",
    "mirrored_data = nzRoll.copy()\n",
    "mirrored_data['rollDeg [deg]'] = -nzRoll['rollDeg [deg]']\n",
    "mirrored_data['currL [A]'], mirrored_data['currR [A]'] = nzRoll['currR [A]'].values, nzRoll['currL [A]'].values\n",
    "mirrored_data['YokeTorque.Torque [mNewtonMeter]'] = -nzRoll['YokeTorque.Torque [mNewtonMeter]']\n",
    "\n",
    "magDf = pd.concat([magDf, mirrored_data], ignore_index=True)\n",
    "\n",
    "magDf = magDf.sort_values(['rollDeg [deg]', 'currL [A]', 'currR [A]', 'GapHeight [mm]']).reset_index(drop=True)\n",
    "\n",
    "print()\n",
    "print(\"After adding mirrored data:\")\n",
    "magDf.info()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ecd1f124",
   "metadata": {},
   "source": [
    "### After removing non-model rows, we have 4459 rows * ~2 for the mirrored roll angles, which matches the number of simulations conducted in Ansys."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "1a76017e",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 26544 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "first_subset = magDf.iloc[0:13]\n",
    "x = first_subset[\"GapHeight [mm]\"]\n",
    "x = 1/x\n",
    "y = first_subset[\"YokeForce.Force_z [newton]\"]\n",
    "plt.plot(x, y)\n",
    "plt.title(r\"$-15A, -15A, 0^\\circ roll$\")\n",
    "plt.xlabel(\"Inverse Gap Height (1/mm)\")\n",
    "plt.ylabel(\"Force on magnetic yoke (N)\")\n",
    "plt.show()\n",
    "\n",
    "# Experimented here with ways to represent gap-height-to-force relationship."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "id": "1ba9a1b6",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "<class 'pandas.core.frame.DataFrame'>\n",
      "RangeIndex: 8281 entries, 0 to 8280\n",
      "Data columns (total 7 columns):\n",
      " #   Column                            Non-Null Count  Dtype  \n",
      "---  ------                            --------------  -----  \n",
      " 0   currL [A]                         8281 non-null   int64  \n",
      " 1   currR [A]                         8281 non-null   int64  \n",
      " 2   rollDeg [deg]                     8281 non-null   float64\n",
      " 3   GapHeight [mm]                    8281 non-null   float64\n",
      " 4   YokeForce.Force_z [newton]        8281 non-null   float64\n",
      " 5   YokeTorque.Torque [mNewtonMeter]  8281 non-null   float64\n",
      " 6   invGap                            8281 non-null   float64\n",
      "dtypes: float64(5), int64(2)\n",
      "memory usage: 453.0 KB\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>currL [A]</th>\n",
       "      <th>currR [A]</th>\n",
       "      <th>rollDeg [deg]</th>\n",
       "      <th>GapHeight [mm]</th>\n",
       "      <th>YokeForce.Force_z [newton]</th>\n",
       "      <th>YokeTorque.Torque [mNewtonMeter]</th>\n",
       "      <th>invGap</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>-15</td>\n",
       "      <td>-15</td>\n",
       "      <td>-4.0</td>\n",
       "      <td>6.0</td>\n",
       "      <td>260.518631</td>\n",
       "      <td>-6976.851677</td>\n",
       "      <td>0.166667</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>-15</td>\n",
       "      <td>-15</td>\n",
       "      <td>-4.0</td>\n",
       "      <td>8.0</td>\n",
       "      <td>163.529872</td>\n",
       "      <td>-3335.704070</td>\n",
       "      <td>0.125000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>-15</td>\n",
       "      <td>-15</td>\n",
       "      <td>-4.0</td>\n",
       "      <td>9.0</td>\n",
       "      <td>136.554807</td>\n",
       "      <td>-2506.094902</td>\n",
       "      <td>0.111111</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>-15</td>\n",
       "      <td>-15</td>\n",
       "      <td>-4.0</td>\n",
       "      <td>10.0</td>\n",
       "      <td>117.403213</td>\n",
       "      <td>-1959.725693</td>\n",
       "      <td>0.100000</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>-15</td>\n",
       "      <td>-15</td>\n",
       "      <td>-4.0</td>\n",
       "      <td>10.5</td>\n",
       "      <td>109.107025</td>\n",
       "      <td>-1759.467304</td>\n",
       "      <td>0.095238</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   currL [A]  currR [A]  rollDeg [deg]  GapHeight [mm]  \\\n",
       "0        -15        -15           -4.0             6.0   \n",
       "1        -15        -15           -4.0             8.0   \n",
       "2        -15        -15           -4.0             9.0   \n",
       "3        -15        -15           -4.0            10.0   \n",
       "4        -15        -15           -4.0            10.5   \n",
       "\n",
       "   YokeForce.Force_z [newton]  YokeTorque.Torque [mNewtonMeter]    invGap  \n",
       "0                  260.518631                      -6976.851677  0.166667  \n",
       "1                  163.529872                      -3335.704070  0.125000  \n",
       "2                  136.554807                      -2506.094902  0.111111  \n",
       "3                  117.403213                      -1959.725693  0.100000  \n",
       "4                  109.107025                      -1759.467304  0.095238  "
      ]
     },
     "execution_count": 29,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# sooooo it looks like we can invert the entire gap height column and see how that works out...\n",
    "magDf[\"invGap\"] = magDf[\"GapHeight [mm]\"].transform(lambda x: 1/x)\n",
    "magDf.info()\n",
    "magDf.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9cd13fd5",
   "metadata": {},
   "source": [
    "## Let's try fitting a polynomial to this..."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "bd16a120",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Force Coeffs: [-8.65837181e-02  2.83478672e-01  2.86948934e-01  3.01765452e-09\n",
      "  3.53295632e+02 -1.79173937e-03 -1.72965608e-03 -2.24271786e-02\n",
      " -1.14467613e+01 -2.16073380e-03  2.24271783e-02 -1.16266321e+01\n",
      "  3.17540504e-01 -1.63205830e-08  6.56934791e+03 -4.22076677e-05\n",
      " -1.06778310e-05 -2.15844497e-04  1.18908945e-01 -7.64609651e-06\n",
      " -6.00663086e-11  1.01686164e-01 -1.01983869e-02  1.54885340e-01\n",
      " -1.03778353e+02 -4.02599166e-05  2.15844499e-04  1.29958896e-01\n",
      " -1.01471000e-02 -1.54885337e-01 -1.00615041e+02  2.44554291e-10\n",
      "  2.06864028e+00 -5.79189873e-07 -4.19530515e+04 -3.84322448e-07\n",
      "  3.91430177e-07 -7.41838903e-07  7.93047135e-04 -4.85816599e-07\n",
      " -2.53746609e-06  1.17129797e-04 -2.29814329e-04  4.03023264e-04\n",
      "  2.97296754e-01 -1.05011480e-07  2.53746711e-06 -3.78159442e-06\n",
      " -4.82390444e-05  6.31672492e-12 -5.68285023e-01  3.49652510e-03\n",
      "  4.38290920e-01 -8.84140415e+00  7.71042932e+02  1.32237346e-07\n",
      "  7.41837766e-07  7.27510018e-04 -2.31026415e-04 -4.03023107e-04\n",
      "  1.91281532e-01 -3.49652510e-03  4.35555849e-01  8.84140411e+00\n",
      "  7.49444071e+02 -3.20760957e-02  7.25108196e-10 -1.33481028e+02\n",
      "  5.39284901e-06  7.71176013e+04  3.63953134e-08  1.21055308e-08\n",
      "  1.38327010e-07 -2.26843442e-05  2.02886312e-08 -1.26534253e-07\n",
      " -5.30119439e-06  8.63664127e-08  4.12729541e-05 -8.00925426e-04\n",
      "  2.31829560e-08  1.45661261e-13  1.06948469e-05 -7.75307058e-07\n",
      "  3.44198882e-05  7.75195525e-04  9.67991207e-06  3.94281513e-03\n",
      "  1.52654026e-01 -5.85408108e-01  2.66529749e-08  1.26534701e-07\n",
      " -1.16266847e-07 -8.31896955e-07 -3.44199448e-05  1.37334978e-03\n",
      "  3.41060513e-13  7.56972800e-04 -5.81983350e-10  1.67361830e+00\n",
      " -1.53624150e-04 -5.22167175e-02 -4.26268983e+00 -1.86825485e+01\n",
      " -1.80981191e+03  3.91169848e-08 -1.38327266e-07 -2.90007854e-05\n",
      "  5.72626959e-08 -4.12729217e-05 -4.53297520e-04 -9.67991258e-06\n",
      "  3.95070565e-03 -1.52654027e-01 -2.35783105e-01 -1.49754214e-04\n",
      "  5.22167175e-02 -4.24980327e+00  1.86825486e+01 -1.75997408e+03\n",
      "  8.13571432e-13  4.47231699e-01 -4.49006710e-09  1.08183457e+03\n",
      " -1.37863751e-05  4.14731645e+04]\n",
      "Torque Coeffs: [ 8.38589158e-01  8.73851984e+00 -8.81670175e+00 -6.05820759e+01\n",
      " -9.82724462e+01 -8.14346461e-02 -8.55573216e-03 -1.26586339e+00\n",
      " -2.28782707e+02  7.92025829e-02 -1.26586339e+00  2.32329852e+02\n",
      "  1.59724442e-01  2.40322153e+02  1.91664541e+03 -1.60378740e-03\n",
      "  1.33688455e-03 -1.09883429e-02  4.99001686e+00 -1.13086452e-03\n",
      " -5.01228566e-04  2.95245993e-01 -5.53027250e-01  1.40436061e+01\n",
      " -7.51330187e+03  1.58514086e-03 -1.09883430e-02 -5.01157087e+00\n",
      "  5.39147323e-01  1.40436061e+01  7.47720714e+03  8.04333346e+00\n",
      " -9.27393815e-01  5.61703430e+04 -1.36592025e+04 -8.85043095e-05\n",
      "  5.78297146e-05 -3.32036715e-04  1.42290241e-02  9.30856299e-06\n",
      " -1.81943314e-04  4.52452056e-03 -1.03937269e-02 -1.18018278e-01\n",
      "  1.97297584e+01 -5.50804572e-05 -1.81943331e-04 -3.44842175e-03\n",
      "  5.10215386e-05 -1.04187648e-01 -3.02785064e+00  2.03017291e-01\n",
      "  2.26755123e+01 -3.31637755e+02  4.95131780e+04  9.86640682e-05\n",
      " -3.32036717e-04 -1.38740169e-02  1.04393181e-02 -1.18018278e-01\n",
      " -1.90316043e+01  2.03017291e-01 -2.25845778e+01 -3.31637755e+02\n",
      " -4.94386582e+04 -4.28909175e-03 -2.85648016e+02 -2.71181234e+00\n",
      " -4.00600713e+05  2.87297365e+04  1.94950204e-06 -5.18825317e-06\n",
      " -7.73369460e-06  5.36214594e-04  1.10845519e-06 -1.84292549e-06\n",
      " -4.65903428e-04  6.42878210e-06  3.08081235e-03 -1.09668591e-03\n",
      " -1.62664696e-06  6.42940023e-06 -1.00144481e-04 -2.50111810e-06\n",
      "  1.71620767e-03 -2.14977215e-02  7.20307351e-04  1.76411744e-01\n",
      "  8.07086541e+00 -3.68279934e+01  4.55521419e-06 -1.84292367e-06\n",
      "  4.37143508e-04 -1.60120084e-06  1.71620770e-03  1.99691899e-02\n",
      "  1.48517200e-04 -1.19885601e-03  1.78969342e+00  9.92388686e+00\n",
      " -7.19411511e-03 -2.98153220e+00 -2.04344115e+02 -1.80247523e+03\n",
      " -1.11849627e+05 -1.66017404e-06 -7.73369428e-06 -7.23356811e-04\n",
      " -4.16883619e-06  3.08081229e-03 -6.83985220e-03  7.20307349e-04\n",
      " -1.75664631e-01  8.07086541e+00  3.49676909e+01  7.76960323e-03\n",
      " -2.98153220e+00  2.03737293e+02 -1.80247523e+03  1.12167507e+05\n",
      "  4.69772030e-02 -2.00828167e-02  2.13341632e+03  5.37309113e+01\n",
      "  1.24121750e+06  1.52681419e+04]\n"
     ]
    }
   ],
   "source": [
    "from sklearn.preprocessing import PolynomialFeatures\n",
    "from sklearn.linear_model import LinearRegression\n",
    "\n",
    "# The following was written by AI - see [4]\n",
    "# 1. Load your Ansys CSV - using invGap for modeling\n",
    "X = magDf[['currL [A]', 'currR [A]', 'rollDeg [deg]', 'invGap']]\n",
    "y = magDf[['YokeForce.Force_z [newton]', 'YokeTorque.Torque [mNewtonMeter]']]\n",
    "\n",
    "# 2. Create Features (e.g. z^2, z^3, z*IL, etc.)\n",
    "poly = PolynomialFeatures(degree=5) \n",
    "X_poly = poly.fit_transform(X)\n",
    "\n",
    "# 3. Fit\n",
    "model = LinearRegression()\n",
    "model.fit(X_poly, y)\n",
    "\n",
    "# 4. Extract Equation\n",
    "print(\"Force Coeffs:\", model.coef_[0])\n",
    "print(\"Torque Coeffs:\", model.coef_[1])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dd6861d0",
   "metadata": {},
   "source": [
    "## Compare Model Predictions with Raw Data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "id": "dc79d9fe",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 78080 characters]",
      "text/plain": [
       "<Figure size 1400x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Force R² Score: 0.999873\n",
      "Torque R² Score: 0.999661\n"
     ]
    }
   ],
   "source": [
    "y_pred = model.predict(X_poly)\n",
    "\n",
    "force_pred = y_pred[:, 0]\n",
    "torque_pred = y_pred[:, 1]\n",
    "\n",
    "force_actual = y.iloc[:, 0].values\n",
    "torque_actual = y.iloc[:, 1].values\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
    "\n",
    "# Force comparison\n",
    "axes[0].scatter(force_actual, force_pred, alpha=0.5, s=10)\n",
    "axes[0].plot([force_actual.min(), force_actual.max()], \n",
    "             [force_actual.min(), force_actual.max()], \n",
    "             'r--', linewidth=2, label='Perfect Fit')\n",
    "axes[0].set_xlabel('Actual Force (N)', fontsize=12)\n",
    "axes[0].set_ylabel('Predicted Force (N)', fontsize=12)\n",
    "axes[0].set_title('Force: Predicted vs Actual', fontsize=14)\n",
    "axes[0].legend()\n",
    "axes[0].grid(True, alpha=0.3)\n",
    "\n",
    "# Torque comparison\n",
    "axes[1].scatter(torque_actual, torque_pred, alpha=0.5, s=10)\n",
    "axes[1].plot([torque_actual.min(), torque_actual.max()], \n",
    "             [torque_actual.min(), torque_actual.max()], \n",
    "             'r--', linewidth=2, label='Perfect Fit')\n",
    "axes[1].set_xlabel('Actual Torque (mN·m)', fontsize=12)\n",
    "axes[1].set_ylabel('Predicted Torque (mN·m)', fontsize=12)\n",
    "axes[1].set_title('Torque: Predicted vs Actual', fontsize=14)\n",
    "axes[1].legend()\n",
    "axes[1].grid(True, alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "from sklearn.metrics import r2_score\n",
    "print(f\"Force R² Score: {r2_score(force_actual, force_pred):.6f}\")\n",
    "print(f\"Torque R² Score: {r2_score(torque_actual, torque_pred):.6f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5a5d0634",
   "metadata": {},
   "source": [
    "### Visualize a specific subset (like the first 13 rows)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "id": "837a814f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 48912 characters]",
      "text/plain": [
       "<Figure size 1000x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# For the first subset\n",
    "subset_indices = range(0, 13)\n",
    "x_subset = magDf.iloc[subset_indices][\"GapHeight [mm]\"]\n",
    "y_actual_subset = magDf.iloc[subset_indices][\"YokeForce.Force_z [newton]\"]\n",
    "y_pred_subset = force_pred[subset_indices]\n",
    "\n",
    "# Plot actual vs predicted for this specific condition\n",
    "plt.figure(figsize=(10, 6))\n",
    "plt.plot(x_subset, y_actual_subset, 'o-', label='Actual (Ansys)', linewidth=2, markersize=8)\n",
    "plt.plot(x_subset, y_pred_subset, 's--', label='Predicted (Model)', linewidth=2, markersize=6)\n",
    "plt.title(r\"$-15A, -15A, 0^\\circ$ roll: Model vs Ansys\", fontsize=14)\n",
    "plt.xlabel(\"Gap Height (mm)\", fontsize=12)\n",
    "plt.ylabel(\"Force on magnetic yoke (N)\", fontsize=12)\n",
    "plt.legend(fontsize=11)\n",
    "plt.grid(True, alpha=0.3)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b46ce772",
   "metadata": {},
   "source": [
    "## Better Interpolation Methods\n",
    "\n",
    "Let's improve interpolation accuracy without manually guessing polynomial order."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a019f402",
   "metadata": {},
   "source": [
    "### Method 1: Cross-Validation to Find Best Polynomial Degree\n",
    "\n",
    "Automatically test different polynomial degrees and find the one with best validation score."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 33,
   "id": "fd12d56b",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Degree 1: Train R² = 0.752592, Test R² = 0.757407\n",
      "Degree 2: Train R² = 0.965022, Test R² = 0.965732\n",
      "Degree 3: Train R² = 0.993618, Test R² = 0.993764\n",
      "Degree 4: Train R² = 0.998598, Test R² = 0.998637\n",
      "Degree 5: Train R² = 0.999764, Test R² = 0.999752\n",
      "Degree 6: Train R² = 0.999932, Test R² = 0.999927\n",
      "Degree 7: Train R² = 0.999950, Test R² = 0.999947\n",
      "Degree 8: Train R² = 0.999889, Test R² = 0.999802\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 36012 characters]",
      "text/plain": [
       "<Figure size 1000x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Best polynomial degree: 7\n",
      "Best test R² score: 0.999947\n"
     ]
    }
   ],
   "source": [
    "from sklearn.model_selection import cross_val_score\n",
    "from sklearn.pipeline import Pipeline\n",
    "\n",
    "from sklearn.model_selection import train_test_split\n",
    "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)\n",
    "\n",
    "# Test polynomial degrees from 1 to 8\n",
    "degrees = range(1, 9)\n",
    "train_scores_force = []\n",
    "test_scores_force = []\n",
    "train_scores_torque = []\n",
    "test_scores_torque = []\n",
    "\n",
    "for degree in degrees:\n",
    "    pipe = Pipeline([\n",
    "        ('poly', PolynomialFeatures(degree=degree)),\n",
    "        ('model', LinearRegression())\n",
    "    ])\n",
    "    \n",
    "    pipe.fit(X_train, y_train)\n",
    "    \n",
    "    train_score = pipe.score(X_train, y_train)\n",
    "    test_score = pipe.score(X_test, y_test)\n",
    "    \n",
    "    train_scores_force.append(train_score)\n",
    "    test_scores_force.append(test_score)\n",
    "    \n",
    "    print(f\"Degree {degree}: Train R² = {train_score:.6f}, Test R² = {test_score:.6f}\")\n",
    "\n",
    "plt.figure(figsize=(10, 6))\n",
    "plt.plot(degrees, train_scores_force, 'o-', label='Training Score', linewidth=2, markersize=8)\n",
    "plt.plot(degrees, test_scores_force, 's-', label='Test Score', linewidth=2, markersize=8)\n",
    "plt.xlabel('Polynomial Degree', fontsize=12)\n",
    "plt.ylabel('R² Score', fontsize=12)\n",
    "plt.title('Model Performance vs Polynomial Degree', fontsize=14)\n",
    "plt.legend(fontsize=11)\n",
    "plt.grid(True, alpha=0.3)\n",
    "plt.xticks(degrees)\n",
    "plt.show()\n",
    "\n",
    "best_degree = degrees[np.argmax(test_scores_force)]\n",
    "print(f\"\\nBest polynomial degree: {best_degree}\")\n",
    "print(f\"Best test R² score: {max(test_scores_force):.6f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "86aaa7f5",
   "metadata": {},
   "source": [
    "### Visualize Best Polynomial Function vs Ansys Data\n",
    "\n",
    "Plot the continuous polynomial function to check for overfitting/oscillations between data points."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 34,
   "id": "8270efa4",
   "metadata": {},
   "outputs": [],
   "source": [
    "best_degree = 6 # Degree 7 actually overfits to the data points and struggles to generalize"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 35,
   "id": "d94aa4c1",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Using polynomial degree: 6\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 91588 characters]",
      "text/plain": [
       "<Figure size 1200x700 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Checking for overfitting indicators:\n",
      "Number of actual data points in this condition: 13\n",
      "Gap spacing: 1.50 mm average\n",
      "Polynomial curve resolution: 500 points over 25.0 mm range\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [5]\n",
    "# Train best degree polynomial on full dataset\n",
    "poly_best = PolynomialFeatures(degree=best_degree)\n",
    "X_poly_best = poly_best.fit_transform(X)\n",
    "model_best = LinearRegression()\n",
    "model_best.fit(X_poly_best, y)\n",
    "\n",
    "print(f\"Using polynomial degree: {best_degree}\")\n",
    "\n",
    "# Select a specific operating condition to visualize\n",
    "# Using the same condition as earlier: -15A, -15A, 0° roll\n",
    "test_condition = magDf.iloc[0:13].copy()\n",
    "currL_val = test_condition['currL [A]'].iloc[0]\n",
    "currR_val = test_condition['currR [A]'].iloc[0]\n",
    "roll_val = test_condition['rollDeg [deg]'].iloc[0]\n",
    "\n",
    "# Create very fine grid for gap heights to see polynomial behavior between points\n",
    "gap_very_fine = np.linspace(5, 30, 500)  # 500 points for smooth curve\n",
    "X_fine = pd.DataFrame({\n",
    "    'currL [A]': [currL_val] * 500,\n",
    "    'currR [A]': [currR_val] * 500,\n",
    "    'rollDeg [deg]': [roll_val] * 500,\n",
    "    'invGap': 1/gap_very_fine  # Use inverse gap height for modeling\n",
    "})\n",
    "X_fine_poly = poly_best.transform(X_fine)\n",
    "y_fine_pred = model_best.predict(X_fine_poly)\n",
    "\n",
    "# Extract actual Ansys data for this condition\n",
    "gap_actual = test_condition['GapHeight [mm]'].values\n",
    "force_actual = test_condition['YokeForce.Force_z [newton]'].values\n",
    "\n",
    "# Plot\n",
    "fig, ax = plt.subplots(1, 1, figsize=(12, 7))\n",
    "\n",
    "ax.plot(gap_very_fine, y_fine_pred[:, 0], '-', linewidth=2.5, \n",
    "        label=f'Degree {best_degree} Polynomial Function', color='#2ca02c', alpha=0.8)\n",
    "\n",
    "# Plot actual Ansys data points\n",
    "ax.scatter(gap_actual, force_actual, s=120, marker='o', \n",
    "          color='#d62728', edgecolors='black', linewidths=1.5,\n",
    "          label='Ansys Simulation Data', zorder=5)\n",
    "\n",
    "ax.set_ylabel('Force (N)', fontsize=13)\n",
    "ax.set_title(f'Degree {best_degree} Polynomial vs Ansys Data (currL={currL_val}A, currR={currR_val}A, roll={roll_val}°)', \n",
    "            fontsize=14, fontweight='bold')\n",
    "ax.legend(fontsize=12, loc='best')\n",
    "ax.grid(True, alpha=0.3)\n",
    "\n",
    "# Add vertical lines at data points to highlight gaps\n",
    "for gap_point in gap_actual:\n",
    "    ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "# Check for excessive oscillations by looking at derivative\n",
    "print(\"Checking for overfitting indicators:\")\n",
    "print(f\"Number of actual data points in this condition: {len(gap_actual)}\")\n",
    "\n",
    "print(f\"Gap spacing: {np.diff(gap_actual).mean():.2f} mm average\")\n",
    "print(f\"Polynomial curve resolution: 500 points over {gap_very_fine.max() - gap_very_fine.min():.1f} mm range\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8e4487e0",
   "metadata": {},
   "source": [
    "### Compare Multiple Conditions Side-by-Side\n",
    "\n",
    "Let's check several different operating conditions to ensure consistent behavior."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 36,
   "id": "74d6e98f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 233968 characters]",
      "text/plain": [
       "<Figure size 1600x1200 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Visual inspection guide:\n",
      "✓ Look for smooth curves between data points (good)\n",
      "✗ Look for wild oscillations or wiggles between points (overfitting)\n",
      "✓ Polynomial should interpolate naturally without excessive curvature\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [6]\n",
    "# Select 4 different conditions to visualize\n",
    "# Each condition has 13 gap height measurements (one complete parameter set)\n",
    "condition_indices = [\n",
    "    (0, 13, '-15A, -15A, 0°'),      # First condition\n",
    "    (13, 26, '-15A, -15A, 0.5°'),   # Same currents, different roll\n",
    "    (91, 104, '-15A, -10A, 0°'),    # Different right current\n",
    "    (182, 195, '-15A, -10A, 1°')    # Different right current and roll\n",
    "]\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(16, 12))\n",
    "axes = axes.flatten()\n",
    "\n",
    "for idx, (start, end, label) in enumerate(condition_indices):\n",
    "    ax = axes[idx]\n",
    "    \n",
    "    # Get condition data\n",
    "    condition_data = magDf.iloc[start:end]\n",
    "    currL = condition_data['currL [A]'].iloc[0]\n",
    "    currR = condition_data['currR [A]'].iloc[0]\n",
    "    roll = condition_data['rollDeg [deg]'].iloc[0]\n",
    "    \n",
    "    # Create fine grid\n",
    "    gap_fine = np.linspace(5, 30, 500)\n",
    "    X_condition_fine = pd.DataFrame({\n",
    "        'currL [A]': [currL] * 500,\n",
    "        'currR [A]': [currR] * 500,\n",
    "        'rollDeg [deg]': [roll] * 500,\n",
    "        'invGap': 1/gap_fine  # Use inverse gap height for modeling\n",
    "    })\n",
    "    \n",
    "    # Predict with best degree polynomial\n",
    "    X_condition_poly = poly_best.transform(X_condition_fine)\n",
    "    y_condition_pred = model_best.predict(X_condition_poly)\n",
    "    \n",
    "    # Plot polynomial curve\n",
    "    ax.plot(gap_fine, y_condition_pred[:, 0], '-', linewidth=2.5, \n",
    "            color='#2ca02c', alpha=0.8, label=f'Degree {best_degree} Polynomial')\n",
    "    \n",
    "    # Plot actual data\n",
    "    ax.scatter(condition_data['GapHeight [mm]'], \n",
    "              condition_data['YokeForce.Force_z [newton]'],\n",
    "              s=100, marker='o', color='#d62728', \n",
    "              edgecolors='black', linewidths=1.5,\n",
    "              label='Ansys Data', zorder=5)\n",
    "    \n",
    "    # Formatting\n",
    "    ax.set_xlabel('Gap Height (mm)', fontsize=11)\n",
    "    ax.set_ylabel('Force (N)', fontsize=11)\n",
    "    ax.set_title(f'currL={currL:.0f}A, currR={currR:.0f}A, roll={roll:.0f}°', \n",
    "                fontsize=12, fontweight='bold')\n",
    "    ax.legend(fontsize=10, loc='best')\n",
    "    ax.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add vertical lines at data points\n",
    "    for gap_point in condition_data['GapHeight [mm]'].values:\n",
    "        ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)\n",
    "\n",
    "plt.suptitle(f'Degree {best_degree} Polynomial: Multiple Operating Conditions', \n",
    "            fontsize=15, fontweight='bold', y=1.00)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(\"Visual inspection guide:\")\n",
    "print(\"✓ Look for smooth curves between data points (good)\")\n",
    "print(\"✗ Look for wild oscillations or wiggles between points (overfitting)\")\n",
    "print(\"✓ Polynomial should interpolate naturally without excessive curvature\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "81a46ab5",
   "metadata": {},
   "source": [
    "### Find Worst-Performing Segment\n",
    "\n",
    "Let's identify which parameter set has the worst R² score to showcase model performance even in challenging cases."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 37,
   "id": "35f4438e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Total segments analyzed: 637\n",
      "\n",
      "Worst performing segment:\n",
      "  Rows: 1261 to 1274\n",
      "  Parameters: currL=15A, currR=15A, roll=-3.0°\n",
      "  R² Score: 0.999302\n",
      "\n",
      "Best performing segment R²: 0.999992\n",
      "Mean R² across all segments: 0.999934\n",
      "Median R² across all segments: 0.999953\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [7]\n",
    "# Calculate R² score for each 13-row segment (each parameter set)\n",
    "num_segments = len(magDf) // 13\n",
    "segment_scores = []\n",
    "segment_info = []\n",
    "\n",
    "for i in range(num_segments):\n",
    "    start_idx = i * 13\n",
    "    end_idx = start_idx + 13\n",
    "    \n",
    "    # Get actual and predicted values for this segment\n",
    "    segment_actual = y.iloc[start_idx:end_idx, 0].values  # Force only\n",
    "    segment_pred = model_best.predict(poly_best.transform(X.iloc[start_idx:end_idx]))[:, 0]\n",
    "    \n",
    "    # Calculate R² for this segment\n",
    "    segment_r2 = r2_score(segment_actual, segment_pred)\n",
    "    segment_scores.append(segment_r2)\n",
    "    \n",
    "    # Store segment info\n",
    "    segment_data = magDf.iloc[start_idx:end_idx]\n",
    "    currL = segment_data['currL [A]'].iloc[0]\n",
    "    currR = segment_data['currR [A]'].iloc[0]\n",
    "    roll = segment_data['rollDeg [deg]'].iloc[0]\n",
    "    segment_info.append({\n",
    "        'start': start_idx,\n",
    "        'end': end_idx,\n",
    "        'currL': currL,\n",
    "        'currR': currR,\n",
    "        'roll': roll,\n",
    "        'r2': segment_r2\n",
    "    })\n",
    "\n",
    "# Find worst performing segment\n",
    "worst_idx = np.argmin(segment_scores)\n",
    "worst_segment = segment_info[worst_idx]\n",
    "\n",
    "print(f\"Total segments analyzed: {num_segments}\")\n",
    "print(f\"\\nWorst performing segment:\")\n",
    "print(f\"  Rows: {worst_segment['start']} to {worst_segment['end']}\")\n",
    "print(f\"  Parameters: currL={worst_segment['currL']:.0f}A, currR={worst_segment['currR']:.0f}A, roll={worst_segment['roll']:.1f}°\")\n",
    "print(f\"  R² Score: {worst_segment['r2']:.6f}\")\n",
    "print(f\"\\nBest performing segment R²: {max(segment_scores):.6f}\")\n",
    "print(f\"Mean R² across all segments: {np.mean(segment_scores):.6f}\")\n",
    "print(f\"Median R² across all segments: {np.median(segment_scores):.6f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "080f954b",
   "metadata": {},
   "source": [
    "### Visualize Worst-Performing Segment\n",
    "\n",
    "Even our worst case shows excellent model performance!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 38,
   "id": "6ceaf2b2",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 92080 characters]",
      "text/plain": [
       "<Figure size 1200x700 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Even in the worst case, the model achieves R² = 0.999302\n",
      "This demonstrates excellent interpolation performance across all operating conditions!\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [8]\n",
    "# Get worst segment data\n",
    "worst_data = magDf.iloc[worst_segment['start']:worst_segment['end']]\n",
    "worst_currL = worst_segment['currL']\n",
    "worst_currR = worst_segment['currR']\n",
    "worst_roll = worst_segment['roll']\n",
    "worst_r2 = worst_segment['r2']\n",
    "\n",
    "# Create fine grid for this worst segment\n",
    "gap_fine_worst = np.linspace(5, 30, 500)\n",
    "X_worst_fine = pd.DataFrame({\n",
    "    'currL [A]': [worst_currL] * 500,\n",
    "    'currR [A]': [worst_currR] * 500,\n",
    "    'rollDeg [deg]': [worst_roll] * 500,\n",
    "    'invGap': 1/gap_fine_worst\n",
    "})\n",
    "\n",
    "# Get predictions\n",
    "X_worst_poly = poly_best.transform(X_worst_fine)\n",
    "y_worst_pred = model_best.predict(X_worst_poly)\n",
    "\n",
    "# Extract actual data\n",
    "gap_worst_actual = worst_data['GapHeight [mm]'].values\n",
    "force_worst_actual = worst_data['YokeForce.Force_z [newton]'].values\n",
    "\n",
    "# Create the plot\n",
    "fig, ax = plt.subplots(1, 1, figsize=(12, 7))\n",
    "\n",
    "# Plot polynomial curve\n",
    "ax.plot(gap_fine_worst, y_worst_pred[:, 0], '-', linewidth=2.5, \n",
    "        label=f'Degree {best_degree} Polynomial', color='#2ca02c', alpha=0.8)\n",
    "\n",
    "# Plot actual data points\n",
    "ax.scatter(gap_worst_actual, force_worst_actual, s=120, marker='o', \n",
    "          color='#d62728', edgecolors='black', linewidths=1.5,\n",
    "          label='Ansys Data', zorder=5)\n",
    "\n",
    "# Formatting\n",
    "ax.set_xlabel('Gap Height (mm)', fontsize=13)\n",
    "ax.set_ylabel('Force (N)', fontsize=13)\n",
    "ax.set_title(f'Worst Case Performance (R² = {worst_r2:.6f}) - currL={worst_currL:.0f}A, currR={worst_currR:.0f}A, roll={worst_roll:.1f}°', \n",
    "            fontsize=14, fontweight='bold')\n",
    "ax.legend(fontsize=12, loc='best')\n",
    "ax.grid(True, alpha=0.3)\n",
    "\n",
    "# Add vertical lines at data points\n",
    "for gap_point in gap_worst_actual:\n",
    "    ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"Even in the worst case, the model achieves R² = {worst_r2:.6f}\")\n",
    "print(f\"This demonstrates excellent interpolation performance across all operating conditions!\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a8f24086",
   "metadata": {},
   "source": [
    "## Torque Prediction Analysis\n",
    "\n",
    "Now let's examine torque predictions. Torque is influenced by multiple variables:\n",
    "- **Roll angle** (primary driver of torque asymmetry)\n",
    "- **Current imbalance** (currL vs currR difference)\n",
    "- **Gap height** (via invGap feature)\n",
    "\n",
    "We'll visualize torque accuracy by sweeping across roll angles first, then examining current variations."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d896f5a7",
   "metadata": {},
   "source": [
    "### Overall Torque Prediction Accuracy\n",
    "\n",
    "First, let's check the overall R² score for torque predictions across all data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 39,
   "id": "c120048d",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 63864 characters]",
      "text/plain": [
       "<Figure size 1000x800 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Overall Torque R² Score: 0.999895\n",
      "Overall Force R² Score: 0.999967\n",
      "\n",
      "Torque prediction is excellent!\n"
     ]
    }
   ],
   "source": [
    "y_pred_full = model_best.predict(X_poly_best)\n",
    "torque_actual_full = y.iloc[:, 1].values\n",
    "torque_pred_full = y_pred_full[:, 1]\n",
    "\n",
    "torque_r2_overall = r2_score(torque_actual_full, torque_pred_full)\n",
    "\n",
    "fig, ax = plt.subplots(1, 1, figsize=(10, 8))\n",
    "\n",
    "ax.scatter(torque_actual_full, torque_pred_full, alpha=0.4, s=15, color='#1f77b4')\n",
    "ax.plot([torque_actual_full.min(), torque_actual_full.max()], \n",
    "        [torque_actual_full.min(), torque_actual_full.max()], \n",
    "        'r--', linewidth=2, label='Perfect Fit')\n",
    "\n",
    "ax.set_xlabel('Actual Torque (mN·m)', fontsize=13)\n",
    "ax.set_ylabel('Predicted Torque (mN·m)', fontsize=13)\n",
    "ax.set_title(f'Torque: Predicted vs Actual (R² = {torque_r2_overall:.6f})', \n",
    "            fontsize=14, fontweight='bold')\n",
    "ax.legend(fontsize=12)\n",
    "ax.grid(True, alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"Overall Torque R² Score: {torque_r2_overall:.6f}\")\n",
    "print(f\"Overall Force R² Score: {r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]):.6f}\")\n",
    "print(f\"\\nTorque prediction is {'excellent' if torque_r2_overall > 0.99 else 'good' if torque_r2_overall > 0.95 else 'moderate'}!\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1dd6a624",
   "metadata": {},
   "source": [
    "### Torque vs Roll Angle (Fixed Currents, Multiple Gap Heights)\n",
    "\n",
    "Examine how torque varies with roll angle for different gap heights. Roll angle is the primary driver of torque asymmetry."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 40,
   "id": "70b9b4e1",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 237000 characters]",
      "text/plain": [
       "<Figure size 1600x1200 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Showing torque variation with roll angle at 4 different gap heights.\n",
      "Gap height affects the magnitude of torque but the relationship with roll angle remains consistent.\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [9]\n",
    "# Select 4 different gap heights to visualize\n",
    "# Using currL = -15A, currR = -15A configuration\n",
    "gap_heights_to_plot = [8, 10, 15, 21]  # mm\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(16, 12))\n",
    "axes = axes.flatten()\n",
    "\n",
    "for idx, gap_height in enumerate(gap_heights_to_plot):\n",
    "    ax = axes[idx]\n",
    "    \n",
    "    # Fixed current configuration\n",
    "    currL = -15\n",
    "    currR = -15\n",
    "    \n",
    "    # Get actual data for this gap height\n",
    "    gap_data = magDf[(magDf['GapHeight [mm]'] == gap_height) & \n",
    "                      (magDf['currL [A]'] == currL) & \n",
    "                      (magDf['currR [A]'] == currR)]\n",
    "    \n",
    "    # Create fine grid for smooth curve across roll angles\n",
    "    roll_fine = np.linspace(-5, 5.0, 500)\n",
    "    X_condition_fine = pd.DataFrame({\n",
    "        'currL [A]': [currL] * 500,\n",
    "        'currR [A]': [currR] * 500,\n",
    "        'rollDeg [deg]': roll_fine,\n",
    "        'invGap': [1/gap_height] * 500\n",
    "    })\n",
    "    \n",
    "    # Predict with best degree polynomial\n",
    "    X_condition_poly = poly_best.transform(X_condition_fine)\n",
    "    y_condition_pred = model_best.predict(X_condition_poly)\n",
    "    \n",
    "    # Plot polynomial curve for TORQUE (index 1)\n",
    "    ax.plot(roll_fine, y_condition_pred[:, 1], '-', linewidth=2.5, \n",
    "            color='#ff7f0e', alpha=0.8, label=f'Degree {best_degree} Polynomial')\n",
    "    \n",
    "    # Plot actual torque data\n",
    "    ax.scatter(gap_data['rollDeg [deg]'], \n",
    "              gap_data['YokeTorque.Torque [mNewtonMeter]'],\n",
    "              s=100, marker='o', color='#9467bd', \n",
    "              edgecolors='black', linewidths=1.5,\n",
    "              label='Ansys Data', zorder=5)\n",
    "    \n",
    "    # Formatting\n",
    "    ax.set_xlabel('Roll Angle (deg)', fontsize=11)\n",
    "    ax.set_ylabel('Torque (mN·m)', fontsize=11)\n",
    "    ax.set_title(f'Gap Height = {gap_height} mm (currL={currL:.0f}A, currR={currR:.0f}A)', \n",
    "                fontsize=12, fontweight='bold')\n",
    "    ax.legend(fontsize=10, loc='best')\n",
    "    ax.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add vertical lines at actual roll angle data points\n",
    "    for roll_point in gap_data['rollDeg [deg]'].unique():\n",
    "        ax.axvline(roll_point, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)\n",
    "\n",
    "plt.suptitle(f'Torque vs Roll Angle at Different Gap Heights (Degree {best_degree} Polynomial)', \n",
    "            fontsize=15, fontweight='bold', y=1.00)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"Showing torque variation with roll angle at {len(gap_heights_to_plot)} different gap heights.\")\n",
    "print(\"Gap height affects the magnitude of torque but the relationship with roll angle remains consistent.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "02d02793",
   "metadata": {},
   "source": [
    "### Torque vs Current Imbalance (Fixed Roll Angle, Multiple Gap Heights)\n",
    "\n",
    "Now examine how torque varies with current imbalance at different gap heights for a fixed roll angle."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 41,
   "id": "3b8d7f6a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 194340 characters]",
      "text/plain": [
       "<Figure size 1600x1200 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Showing torque variation with current imbalance at 4 different gap heights.\n",
      "All data uses currL = -15A with varying currR to create different imbalances.\n",
      "Current imbalance (currR - currL) affects torque magnitude and direction.\n",
      "Positive imbalance (currR > currL) and negative imbalance (currR < currL) produce opposite torques.\n"
     ]
    }
   ],
   "source": [
    "# The following was written by AI - see [10]\n",
    "# Select 4 different gap heights to visualize\n",
    "# Using roll = 0.5 degrees (small but non-zero torque)\n",
    "gap_heights_to_plot = [8, 10, 15, 21]  # mm\n",
    "roll_target = 0.5\n",
    "\n",
    "# Use a fixed reference current for currL\n",
    "currL_ref = -15\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(16, 12))\n",
    "axes = axes.flatten()\n",
    "\n",
    "for idx, gap_height in enumerate(gap_heights_to_plot):\n",
    "    ax = axes[idx]\n",
    "    \n",
    "    # Get actual data for this gap height, roll angle, AND fixed currL\n",
    "    gap_roll_data = magDf[(magDf['GapHeight [mm]'] == gap_height) & \n",
    "                          (magDf['rollDeg [deg]'] == roll_target) &\n",
    "                          (magDf['currL [A]'] == currL_ref)]\n",
    "    \n",
    "    # Calculate imbalance for actual data points (all have same currL now)\n",
    "    actual_imbalances = []\n",
    "    actual_torques = []\n",
    "    for _, row in gap_roll_data.iterrows():\n",
    "        imbalance = row['currR [A]'] - row['currL [A]']\n",
    "        torque = row['YokeTorque.Torque [mNewtonMeter]']\n",
    "        actual_imbalances.append(imbalance)\n",
    "        actual_torques.append(torque)\n",
    "    \n",
    "    # Create fine grid for smooth curve across current imbalances\n",
    "    # Range from -10A to +10A imbalance (currR - currL)\n",
    "    imbalance_fine = np.linspace(0, 35, 500)\n",
    "    \n",
    "    # Vary currR while keeping currL fixed\n",
    "    currR_fine = currL_ref + imbalance_fine\n",
    "    \n",
    "    X_condition_fine = pd.DataFrame({\n",
    "        'currL [A]': [currL_ref] * 500,\n",
    "        'currR [A]': currR_fine,\n",
    "        'rollDeg [deg]': [roll_target] * 500,\n",
    "        'invGap': [1/gap_height] * 500\n",
    "    })\n",
    "    \n",
    "    # Predict with best degree polynomial\n",
    "    X_condition_poly = poly_best.transform(X_condition_fine)\n",
    "    y_condition_pred = model_best.predict(X_condition_poly)\n",
    "    \n",
    "    # Plot polynomial curve for TORQUE (index 1)\n",
    "    ax.plot(imbalance_fine, y_condition_pred[:, 1], '-', linewidth=2.5, \n",
    "            color='#ff7f0e', alpha=0.8, label=f'Degree {best_degree} Polynomial')\n",
    "    \n",
    "    # Plot actual torque data (now only one point per imbalance value)\n",
    "    ax.scatter(actual_imbalances, actual_torques,\n",
    "              s=100, marker='o', color='#9467bd', \n",
    "              edgecolors='black', linewidths=1.5,\n",
    "              label='Ansys Data', zorder=5)\n",
    "    \n",
    "    # Formatting\n",
    "    ax.set_xlabel('Current Imbalance (currR - currL) [A]', fontsize=11)\n",
    "    ax.set_ylabel('Torque (mN·m)', fontsize=11)\n",
    "    ax.set_title(f'Gap Height = {gap_height} mm (currL = {currL_ref}A, roll = {roll_target}°)', \n",
    "                fontsize=12, fontweight='bold')\n",
    "    ax.legend(fontsize=10, loc='best')\n",
    "    ax.grid(True, alpha=0.3)\n",
    "    ax.axhline(0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)\n",
    "    ax.axvline(0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)\n",
    "    \n",
    "    # Add vertical lines at actual imbalance data points\n",
    "    for imb in set(actual_imbalances):\n",
    "        ax.axvline(imb, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)\n",
    "\n",
    "plt.suptitle(f'Torque vs Current Imbalance at Different Gap Heights (currL = {currL_ref}A, Roll = {roll_target}°)', \n",
    "            fontsize=15, fontweight='bold', y=1.00)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"Showing torque variation with current imbalance at {len(gap_heights_to_plot)} different gap heights.\")\n",
    "print(f\"All data uses currL = {currL_ref}A with varying currR to create different imbalances.\")\n",
    "print(f\"Current imbalance (currR - currL) affects torque magnitude and direction.\")\n",
    "print(f\"Positive imbalance (currR > currL) and negative imbalance (currR < currL) produce opposite torques.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d71ac3cc",
   "metadata": {},
   "source": [
    "### Torque Segment-Wise Performance\n",
    "\n",
    "Calculate R² scores for torque predictions across all segments, similar to what we did for force."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 42,
   "id": "902343b2",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Torque Prediction Analysis (across 637 segments):\n",
      "\n",
      "Best R² Score: 0.999964\n",
      "  Parameters: currL=15A, currR=-15A, roll=-1.5°\n",
      "\n",
      "Worst R² Score: -0.638044\n",
      "  Parameters: currL=-10A, currR=-10A, roll=0.0°\n",
      "\n",
      "Mean R²: 0.981564\n",
      "Median R²: 0.999392\n",
      "Std Dev: 0.124378\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 32028 characters]",
      "text/plain": [
       "<Figure size 1000x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Following generated by AI - see [11]\n",
    "# Calculate R² score for torque in each 13-row segment\n",
    "torque_segment_scores = []\n",
    "torque_segment_info = []\n",
    "\n",
    "for i in range(num_segments):\n",
    "    start_idx = i * 13\n",
    "    end_idx = start_idx + 13\n",
    "    \n",
    "    # Get actual and predicted torque values for this segment\n",
    "    segment_actual_torque = y.iloc[start_idx:end_idx, 1].values  # Torque column\n",
    "    segment_pred_torque = model_best.predict(poly_best.transform(X.iloc[start_idx:end_idx]))[:, 1]\n",
    "    \n",
    "    # Calculate R² for this segment\n",
    "    segment_r2_torque = r2_score(segment_actual_torque, segment_pred_torque)\n",
    "    torque_segment_scores.append(segment_r2_torque)\n",
    "    \n",
    "    # Store segment info\n",
    "    segment_data = magDf.iloc[start_idx:end_idx]\n",
    "    currL = segment_data['currL [A]'].iloc[0]\n",
    "    currR = segment_data['currR [A]'].iloc[0]\n",
    "    roll = segment_data['rollDeg [deg]'].iloc[0]\n",
    "    torque_segment_info.append({\n",
    "        'start': start_idx,\n",
    "        'end': end_idx,\n",
    "        'currL': currL,\n",
    "        'currR': currR,\n",
    "        'roll': roll,\n",
    "        'r2': segment_r2_torque\n",
    "    })\n",
    "\n",
    "# Find worst and best performing segments for torque\n",
    "worst_torque_idx = np.argmin(torque_segment_scores)\n",
    "best_torque_idx = np.argmax(torque_segment_scores)\n",
    "worst_torque_segment = torque_segment_info[worst_torque_idx]\n",
    "best_torque_segment = torque_segment_info[best_torque_idx]\n",
    "\n",
    "print(f\"Torque Prediction Analysis (across {num_segments} segments):\")\n",
    "print(f\"\\nBest R² Score: {max(torque_segment_scores):.6f}\")\n",
    "print(f\"  Parameters: currL={best_torque_segment['currL']:.0f}A, currR={best_torque_segment['currR']:.0f}A, roll={best_torque_segment['roll']:.1f}°\")\n",
    "print(f\"\\nWorst R² Score: {min(torque_segment_scores):.6f}\")\n",
    "print(f\"  Parameters: currL={worst_torque_segment['currL']:.0f}A, currR={worst_torque_segment['currR']:.0f}A, roll={worst_torque_segment['roll']:.1f}°\")\n",
    "print(f\"\\nMean R²: {np.mean(torque_segment_scores):.6f}\")\n",
    "print(f\"Median R²: {np.median(torque_segment_scores):.6f}\")\n",
    "print(f\"Std Dev: {np.std(torque_segment_scores):.6f}\")\n",
    "\n",
    "# Create histogram of torque R² scores\n",
    "fig, ax = plt.subplots(1, 1, figsize=(10, 6))\n",
    "ax.hist(torque_segment_scores, bins=30, edgecolor='black', alpha=0.7, color='#9467bd')\n",
    "ax.axvline(np.mean(torque_segment_scores), color='red', linestyle='--', linewidth=2, label=f'Mean = {np.mean(torque_segment_scores):.6f}')\n",
    "ax.axvline(np.median(torque_segment_scores), color='orange', linestyle='--', linewidth=2, label=f'Median = {np.median(torque_segment_scores):.6f}')\n",
    "ax.set_xlabel('R² Score', fontsize=12)\n",
    "ax.set_ylabel('Number of Segments', fontsize=12)\n",
    "ax.set_title('Distribution of Torque R² Scores Across All Parameter Segments', fontsize=14, fontweight='bold')\n",
    "ax.legend(fontsize=11)\n",
    "ax.grid(True, alpha=0.3, axis='y')\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5bffc9a9",
   "metadata": {},
   "source": [
    "### Worst-Case Torque Prediction Visualization\n",
    "\n",
    "Let's visualize the worst-performing torque segment to assess model robustness."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 43,
   "id": "9d5580f3",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 79308 characters]",
      "text/plain": [
       "<Figure size 1200x700 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Worst-case torque prediction achieves R² = -0.638044\n"
     ]
    }
   ],
   "source": [
    "# Following generated by AI - see [11]\n",
    "# Get worst torque segment data\n",
    "worst_torque_data = magDf.iloc[worst_torque_segment['start']:worst_torque_segment['end']]\n",
    "worst_torque_currL = worst_torque_segment['currL']\n",
    "worst_torque_currR = worst_torque_segment['currR']\n",
    "worst_torque_roll = worst_torque_segment['roll']\n",
    "worst_torque_r2 = worst_torque_segment['r2']\n",
    "\n",
    "# Create fine grid for this worst segment\n",
    "gap_fine_worst_torque = np.linspace(2, 30, 500)\n",
    "X_worst_torque_fine = pd.DataFrame({\n",
    "    'currL [A]': [worst_torque_currL] * 500,\n",
    "    'currR [A]': [worst_torque_currR] * 500,\n",
    "    'rollDeg [deg]': [worst_torque_roll] * 500,\n",
    "    'invGap': 1/gap_fine_worst_torque\n",
    "})\n",
    "\n",
    "# Get predictions\n",
    "X_worst_torque_poly = poly_best.transform(X_worst_torque_fine)\n",
    "y_worst_torque_pred = model_best.predict(X_worst_torque_poly)\n",
    "\n",
    "# Extract actual data\n",
    "gap_worst_torque_actual = worst_torque_data['GapHeight [mm]'].values\n",
    "torque_worst_actual = worst_torque_data['YokeTorque.Torque [mNewtonMeter]'].values\n",
    "\n",
    "# Create the plot\n",
    "fig, ax = plt.subplots(1, 1, figsize=(12, 7))\n",
    "\n",
    "# Plot polynomial curve for TORQUE\n",
    "ax.plot(gap_fine_worst_torque, y_worst_torque_pred[:, 1], '-', linewidth=2.5, \n",
    "        label=f'Degree {best_degree} Polynomial', color='#ff7f0e', alpha=0.8)\n",
    "\n",
    "# Plot actual data points\n",
    "ax.scatter(gap_worst_torque_actual, torque_worst_actual, s=120, marker='o', \n",
    "          color='#9467bd', edgecolors='black', linewidths=1.5,\n",
    "          label='Ansys Data', zorder=5)\n",
    "\n",
    "# Formatting\n",
    "ax.set_xlabel('Gap Height (mm)', fontsize=13)\n",
    "ax.set_ylabel('Torque (mN·m)', fontsize=13)\n",
    "ax.set_title(f'Worst Torque Case (R² = {worst_torque_r2:.6f}) - currL={worst_torque_currL:.0f}A, currR={worst_torque_currR:.0f}A, roll={worst_torque_roll:.1f}°', \n",
    "            fontsize=14, fontweight='bold')\n",
    "ax.legend(fontsize=12, loc='best')\n",
    "ax.grid(True, alpha=0.3)\n",
    "\n",
    "# Add vertical lines at data points\n",
    "for gap_point in gap_worst_torque_actual:\n",
    "    ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"Worst-case torque prediction achieves R² = {worst_torque_r2:.6f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c63aca1e",
   "metadata": {},
   "source": [
    "### We can see here that at roll angle and current differential of 0 (when torque will be very close to 0):\n",
    "\n",
    "There is significant overfitting to noise, but within the operating range of 6 - 24 mm, there is never more variation than there would be with simple linear interpolation.\n",
    "\n",
    "Hopefully this means we are ok"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1e1c384f",
   "metadata": {},
   "source": [
    "### Export Model Summary\n",
    "\n",
    "Display key model information before exporting."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 44,
   "id": "778f58df",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "============================================================\n",
      "MODEL EXPORT SUMMARY\n",
      "============================================================\n",
      "\n",
      "Model Type: Polynomial Regression\n",
      "Polynomial Degree: 6\n",
      "Number of Features: 4\n",
      "Number of Polynomial Terms: 210\n",
      "\n",
      "Input Features:\n",
      "  - currL [A]\n",
      "  - currR [A]\n",
      "  - rollDeg [deg]\n",
      "  - invGap (1/GapHeight)\n",
      "\n",
      "Outputs:\n",
      "  - YokeForce.Force_z [newton]\n",
      "  - YokeTorque.Torque [mNewtonMeter]\n",
      "\n",
      "Model Performance:\n",
      "  Force R² Score: 0.999967\n",
      "  Torque R² Score: 0.999895\n",
      "\n",
      "Model Coefficients Shape:\n",
      "  Force coefficients: (210,)\n",
      "  Torque coefficients: (210,)\n",
      "============================================================\n"
     ]
    }
   ],
   "source": [
    "# The following was generated by AI - see [12]\n",
    "print(\"=\" * 60)\n",
    "print(\"MODEL EXPORT SUMMARY\")\n",
    "print(\"=\" * 60)\n",
    "print(f\"\\nModel Type: Polynomial Regression\")\n",
    "print(f\"Polynomial Degree: {best_degree}\")\n",
    "print(f\"Number of Features: {poly_best.n_features_in_}\")\n",
    "print(f\"Number of Polynomial Terms: {poly_best.n_output_features_}\")\n",
    "print(f\"\\nInput Features:\")\n",
    "print(\"  - currL [A]\")\n",
    "print(\"  - currR [A]\")\n",
    "print(\"  - rollDeg [deg]\")\n",
    "print(\"  - invGap (1/GapHeight)\")\n",
    "print(f\"\\nOutputs:\")\n",
    "print(\"  - YokeForce.Force_z [newton]\")\n",
    "print(\"  - YokeTorque.Torque [mNewtonMeter]\")\n",
    "print(f\"\\nModel Performance:\")\n",
    "print(f\"  Force R² Score: {r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]):.6f}\")\n",
    "print(f\"  Torque R² Score: {torque_r2_overall:.6f}\")\n",
    "print(f\"\\nModel Coefficients Shape:\")\n",
    "print(f\"  Force coefficients: {model_best.coef_[0].shape}\")\n",
    "print(f\"  Torque coefficients: {model_best.coef_[1].shape}\")\n",
    "print(\"=\" * 60)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "gf4ju6ai5xg",
   "metadata": {},
   "source": [
    "### Extract Explicit Polynomial Equations\n",
    "\n",
    "Since `PolynomialFeatures` + `LinearRegression` is literally a polynomial, we can extract the full closed-form equations for force and torque as functions of the four input variables."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 45,
   "id": "t1olo3k5j7q",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "================================================================================\n",
      "FORCE POLYNOMIAL — Top 20 terms by |coefficient|\n",
      "================================================================================\n",
      "  Constant: -1.383776e+01\n",
      "  -5.340149e+03  *  invGap^3\n",
      "  -2.235411e+03  *  invGap^4\n",
      "  +1.376722e+03  *  invGap^2\n",
      "  +9.205603e+02  *  rollDeg [deg]^2 * invGap^3\n",
      "  +6.340440e+02  *  invGap\n",
      "  -5.962319e+02  *  invGap^5\n",
      "  +4.005882e+02  *  rollDeg [deg]^2 * invGap^4\n",
      "  +3.681499e+02  *  currR [A]^2 * invGap^4\n",
      "  +3.595010e+02  *  currL [A]^2 * invGap^4\n",
      "  +3.592905e+02  *  currR [A] * rollDeg [deg] * invGap^4\n",
      "  -3.592904e+02  *  currL [A] * rollDeg [deg] * invGap^4\n",
      "  -2.356399e+02  *  rollDeg [deg]^2 * invGap^2\n",
      "  -1.492356e+02  *  currR [A]^2 * invGap^3\n",
      "  -1.460868e+02  *  currL [A]^2 * invGap^3\n",
      "  -1.315156e+02  *  invGap^6\n",
      "  -1.266494e+02  *  currR [A] * rollDeg [deg] * invGap^3\n",
      "  +1.266493e+02  *  currL [A] * rollDeg [deg] * invGap^3\n",
      "  +1.251491e+02  *  currR [A] * invGap^3\n",
      "  +1.234347e+02  *  currL [A] * invGap^3\n",
      "  +5.262747e+01  *  currR [A] * invGap^4\n",
      "  ... (206 non-zero terms total)\n",
      "\n",
      "================================================================================\n",
      "TORQUE POLYNOMIAL — Top 20 terms by |coefficient|\n",
      "================================================================================\n",
      "  Constant: -2.560964e+00\n",
      "  -9.101655e+04  *  rollDeg [deg] * invGap^3\n",
      "  +4.700717e+04  *  rollDeg [deg] * invGap^2\n",
      "  -3.973067e+04  *  rollDeg [deg] * invGap^4\n",
      "  +1.915057e+04  *  rollDeg [deg]^3 * invGap^3\n",
      "  -1.794691e+04  *  currR [A] * rollDeg [deg] * invGap^4\n",
      "  -1.794691e+04  *  currL [A] * rollDeg [deg] * invGap^4\n",
      "  -1.076582e+04  *  rollDeg [deg] * invGap^5\n",
      "  +8.596591e+03  *  currL [A] * invGap^3\n",
      "  -8.155426e+03  *  currR [A] * invGap^3\n",
      "  +5.456996e+03  *  currR [A] * rollDeg [deg] * invGap^3\n",
      "  +5.456996e+03  *  currL [A] * rollDeg [deg] * invGap^3\n",
      "  -3.769444e+03  *  rollDeg [deg]^3 * invGap^2\n",
      "  +3.652133e+03  *  currL [A] * invGap^4\n",
      "  -3.464337e+03  *  currR [A] * invGap^4\n",
      "  -2.679629e+03  *  currL [A] * invGap^2\n",
      "  +2.538618e+03  *  currR [A] * invGap^2\n",
      "  +1.385161e+03  *  currL [A]^2 * invGap^4\n",
      "  -1.269193e+03  *  currL [A] * rollDeg [deg]^2 * invGap^3\n",
      "  +1.235618e+03  *  currR [A] * rollDeg [deg]^2 * invGap^3\n",
      "  -1.145728e+03  *  currR [A]^2 * invGap^4\n",
      "  ... (209 non-zero terms total)\n",
      "\n",
      "Full equations (all 210 terms) saved to: polynomial_equations.txt\n"
     ]
    }
   ],
   "source": [
    "import re\n",
    "\n",
    "# Extract the explicit polynomial equations from the fitted model\n",
    "\n",
    "feature_names_raw = poly_best.get_feature_names_out()\n",
    "\n",
    "# sklearn already uses the real column names from our DataFrame.\n",
    "# Term separators are spaces followed by a lowercase letter (start of next variable),\n",
    "# while spaces inside names like \"currL [A]\" are followed by \"[\".\n",
    "def prettify_term(raw_name):\n",
    "    if raw_name == '1':\n",
    "        return '1'\n",
    "    return re.sub(r' (?=[a-z])', ' * ', raw_name)\n",
    "\n",
    "pretty_names = [prettify_term(n) for n in feature_names_raw]\n",
    "\n",
    "# The full prediction is:  y = intercept + sum(coef_i * feature_i)\n",
    "# where feature_0 = 1 (bias from PolynomialFeatures), so the effective\n",
    "# constant term = intercept + coef_for_bias_column.\n",
    "\n",
    "force_coefs = model_best.coef_[0]\n",
    "torque_coefs = model_best.coef_[1]\n",
    "force_intercept = model_best.intercept_[0]\n",
    "torque_intercept = model_best.intercept_[1]\n",
    "\n",
    "# Combine intercept with the '1' term coefficient\n",
    "force_constant = force_intercept + force_coefs[0]\n",
    "torque_constant = torque_intercept + torque_coefs[0]\n",
    "\n",
    "def build_equation_string(coefs, intercept, names, threshold=1e-10):\n",
    "    \"\"\"Build a readable polynomial equation string, skipping near-zero terms.\"\"\"\n",
    "    constant = intercept + coefs[0]  # combine intercept with bias column\n",
    "    terms = [f\"{constant:.10e}\"]\n",
    "    for name, c in zip(names[1:], coefs[1:]):  # skip the '1' term\n",
    "        if abs(c) > threshold:\n",
    "            terms.append(f\"({c:+.10e}) * {name}\")\n",
    "    return terms\n",
    "\n",
    "# --- Print top terms by magnitude for quick overview ---\n",
    "print(\"=\" * 80)\n",
    "print(\"FORCE POLYNOMIAL — Top 20 terms by |coefficient|\")\n",
    "print(\"=\" * 80)\n",
    "\n",
    "# Pair names with coefficients (skip bias, fold into constant)\n",
    "force_terms = [(pretty_names[i], force_coefs[i]) for i in range(1, len(pretty_names))]\n",
    "force_terms_sorted = sorted(force_terms, key=lambda x: abs(x[1]), reverse=True)\n",
    "\n",
    "print(f\"  Constant: {force_constant:.6e}\")\n",
    "for name, c in force_terms_sorted[:20]:\n",
    "    print(f\"  {c:+.6e}  *  {name}\")\n",
    "print(f\"  ... ({len([c for _, c in force_terms if abs(c) > 1e-10])} non-zero terms total)\")\n",
    "\n",
    "print()\n",
    "print(\"=\" * 80)\n",
    "print(\"TORQUE POLYNOMIAL — Top 20 terms by |coefficient|\")\n",
    "print(\"=\" * 80)\n",
    "\n",
    "torque_terms = [(pretty_names[i], torque_coefs[i]) for i in range(1, len(pretty_names))]\n",
    "torque_terms_sorted = sorted(torque_terms, key=lambda x: abs(x[1]), reverse=True)\n",
    "\n",
    "print(f\"  Constant: {torque_constant:.6e}\")\n",
    "for name, c in torque_terms_sorted[:20]:\n",
    "    print(f\"  {c:+.6e}  *  {name}\")\n",
    "print(f\"  ... ({len([c for _, c in torque_terms if abs(c) > 1e-10])} non-zero terms total)\")\n",
    "\n",
    "# --- Save complete equations to text file ---\n",
    "with open('polynomial_equations.txt', 'w') as f:\n",
    "    f.write(\"Magnetic Levitation Force & Torque — Explicit Polynomial Equations\\n\")\n",
    "    f.write(f\"Polynomial Degree: {best_degree}\\n\")\n",
    "    f.write(f\"Variables:\\n\")\n",
    "    f.write(f\"  currL [A]      = Left coil current\\n\")\n",
    "    f.write(f\"  currR [A]      = Right coil current\\n\")\n",
    "    f.write(f\"  rollDeg [deg]  = Roll angle\\n\")\n",
    "    f.write(f\"  invGap         = 1 / GapHeight [1/mm]\\n\")\n",
    "    f.write(f\"\\n{'='*80}\\n\")\n",
    "    f.write(f\"FORCE [N] = \\n\")\n",
    "    force_eq = build_equation_string(force_coefs, force_intercept, pretty_names)\n",
    "    for i, term in enumerate(force_eq):\n",
    "        prefix = \"    \" if i == 0 else \"  + \"\n",
    "        f.write(f\"{prefix}{term}\\n\")\n",
    "    f.write(f\"\\n{'='*80}\\n\")\n",
    "    f.write(f\"TORQUE [mN*m] = \\n\")\n",
    "    torque_eq = build_equation_string(torque_coefs, torque_intercept, pretty_names)\n",
    "    for i, term in enumerate(torque_eq):\n",
    "        prefix = \"    \" if i == 0 else \"  + \"\n",
    "        f.write(f\"{prefix}{term}\\n\")\n",
    "\n",
    "print(f\"\\nFull equations (all {len(pretty_names)} terms) saved to: polynomial_equations.txt\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "88208b13",
   "metadata": {},
   "source": [
    "### Create Standalone Python Predictor Class\n",
    "\n",
    "Generate a self-contained Python module that can be imported into your simulator without scikit-learn dependencies."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 46,
   "id": "edb239e1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# # The following was generated by AI - see [12]\n",
    "# # Generate standalone predictor class\n",
    "# predictor_code = f'''\"\"\"\n",
    "# Magnetic Levitation Force and Torque Predictor\n",
    "# Generated from polynomial regression model (degree {best_degree})\n",
    "\n",
    "# Performance:\n",
    "#   - Force R²: {r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]):.6f}\n",
    "#   - Torque R²: {torque_r2_overall:.6f}\n",
    "\n",
    "# Usage:\n",
    "#     predictor = MaglevPredictor()\n",
    "#     force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)\n",
    "# \"\"\"\n",
    "\n",
    "# import numpy as np\n",
    "# from itertools import combinations_with_replacement\n",
    "\n",
    "# class MaglevPredictor:\n",
    "#     def __init__(self):\n",
    "#         \"\"\"Initialize the magnetic levitation force/torque predictor.\"\"\"\n",
    "#         self.degree = {best_degree}\n",
    "#         self.n_features = 4  # currL, currR, roll, invGap\n",
    "        \n",
    "#         # Force model coefficients\n",
    "#         self.force_intercept = {model_best.intercept_[0]}\n",
    "#         self.force_coef = np.array({model_best.coef_[0].tolist()})\n",
    "        \n",
    "#         # Torque model coefficients  \n",
    "#         self.torque_intercept = {model_best.intercept_[1]}\n",
    "#         self.torque_coef = np.array({model_best.coef_[1].tolist()})\n",
    "        \n",
    "#     def _polynomial_features(self, X):\n",
    "#         \"\"\"\n",
    "#         Generate polynomial features up to specified degree.\n",
    "#         Mimics sklearn's PolynomialFeatures with include_bias=True.\n",
    "        \n",
    "#         Args:\n",
    "#             X: numpy array of shape (n_samples, 4) with [currL, currR, roll, invGap]\n",
    "            \n",
    "#         Returns:\n",
    "#             Polynomial features array\n",
    "#         \"\"\"\n",
    "#         n_samples = X.shape[0]\n",
    "        \n",
    "#         # Start with bias term (column of ones)\n",
    "#         features = [np.ones(n_samples)]\n",
    "        \n",
    "#         # Add original features\n",
    "#         for i in range(self.n_features):\n",
    "#             features.append(X[:, i])\n",
    "        \n",
    "#         # Add polynomial combinations\n",
    "#         for deg in range(2, self.degree + 1):\n",
    "#             for combo in combinations_with_replacement(range(self.n_features), deg):\n",
    "#                 term = np.ones(n_samples)\n",
    "#                 for idx in combo:\n",
    "#                     term *= X[:, idx]\n",
    "#                 features.append(term)\n",
    "        \n",
    "#         return np.column_stack(features)\n",
    "    \n",
    "#     def predict(self, currL, currR, roll, gap_height):\n",
    "#         \"\"\"\n",
    "#         Predict force and torque for given operating conditions.\n",
    "        \n",
    "#         Args:\n",
    "#             currL: Left coil current in Amps\n",
    "#             currR: Right coil current in Amps\n",
    "#             roll: Roll angle in degrees\n",
    "#             gap_height: Gap height in mm\n",
    "            \n",
    "#         Returns:\n",
    "#             tuple: (force [N], torque [mN·m])\n",
    "#         \"\"\"\n",
    "#         # Compute inverse gap (critical transformation!)\n",
    "#         invGap = 1.0 / gap_height\n",
    "        \n",
    "#         # Create input array\n",
    "#         X = np.array([[currL, currR, roll, invGap]])\n",
    "        \n",
    "#         # Generate polynomial features\n",
    "#         X_poly = self._polynomial_features(X)\n",
    "        \n",
    "#         # Compute predictions\n",
    "#         force = self.force_intercept + np.dot(X_poly, self.force_coef)[0]\n",
    "#         torque = self.torque_intercept + np.dot(X_poly, self.torque_coef)[0]\n",
    "        \n",
    "#         return force, torque\n",
    "    \n",
    "#     def predict_batch(self, currL_array, currR_array, roll_array, gap_height_array):\n",
    "#         \"\"\"\n",
    "#         Predict force and torque for multiple operating conditions.\n",
    "        \n",
    "#         Args:\n",
    "#             currL_array: Array of left coil currents [A]\n",
    "#             currR_array: Array of right coil currents [A]\n",
    "#             roll_array: Array of roll angles [deg]\n",
    "#             gap_height_array: Array of gap heights [mm]\n",
    "            \n",
    "#         Returns:\n",
    "#             tuple: (force_array [N], torque_array [mN·m])\n",
    "#         \"\"\"\n",
    "#         # Convert to numpy arrays\n",
    "#         currL_array = np.asarray(currL_array)\n",
    "#         currR_array = np.asarray(currR_array)\n",
    "#         roll_array = np.asarray(roll_array)\n",
    "#         gap_height_array = np.asarray(gap_height_array)\n",
    "        \n",
    "#         # Compute inverse gaps\n",
    "#         invGap_array = 1.0 / gap_height_array\n",
    "        \n",
    "#         # Stack into feature matrix\n",
    "#         X = np.column_stack([currL_array, currR_array, roll_array, invGap_array])\n",
    "        \n",
    "#         # Generate polynomial features\n",
    "#         X_poly = self._polynomial_features(X)\n",
    "        \n",
    "#         # Compute predictions\n",
    "#         force_array = self.force_intercept + np.dot(X_poly, self.force_coef)\n",
    "#         torque_array = self.torque_intercept + np.dot(X_poly, self.torque_coef)\n",
    "        \n",
    "#         return force_array, torque_array\n",
    "\n",
    "\n",
    "# if __name__ == \"__main__\":\n",
    "#     # Example usage\n",
    "#     predictor = MaglevPredictor()\n",
    "    \n",
    "#     # Single prediction\n",
    "#     force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)\n",
    "#     print(f\"Single prediction:\")\n",
    "#     print(f\"  Force: {{force:.2f}} N\")\n",
    "#     print(f\"  Torque: {{torque:.2f}} mN·m\")\n",
    "    \n",
    "#     # Batch prediction\n",
    "#     currL = np.array([-15, -15, -10])\n",
    "#     currR = np.array([-15, -10, -10])\n",
    "#     roll = np.array([0, 0.5, 1.0])\n",
    "#     gap = np.array([10, 12, 15])\n",
    "    \n",
    "#     forces, torques = predictor.predict_batch(currL, currR, roll, gap)\n",
    "#     print(f\"\\\\nBatch prediction:\")\n",
    "#     for i in range(len(forces)):\n",
    "#         print(f\"  Condition {{i+1}}: Force={{forces[i]:.2f}} N, Torque={{torques[i]:.2f}} mN·m\")\n",
    "# '''\n",
    "\n",
    "# # Save to file\n",
    "# predictor_filename = 'maglev_predictor.py'\n",
    "# with open(predictor_filename, 'w') as f:\n",
    "#     f.write(predictor_code)\n",
    "\n",
    "# print(f\"✓ Saved standalone predictor: {predictor_filename}\")\n",
    "# print(f\"\\nThis module:\")\n",
    "# print(f\"  - Has NO scikit-learn dependency (only numpy)\")\n",
    "# print(f\"  - Can be imported directly into your simulator\")\n",
    "# print(f\"  - Includes both single and batch prediction methods\")\n",
    "# print(f\"  - Implements polynomial feature generation internally\")\n",
    "# print(f\"\\nTo use:\")\n",
    "# print(f\"```python\")\n",
    "# print(f\"from maglev_predictor import MaglevPredictor\")\n",
    "# print(f\"predictor = MaglevPredictor()\")\n",
    "# print(f\"force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)\")\n",
    "# print(f\"```\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e4f40fb4",
   "metadata": {},
   "source": [
    "### Test the Exported Predictor\n",
    "\n",
    "Verify the standalone predictor produces identical results to the original model."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 47,
   "id": "4a1ddacd",
   "metadata": {},
   "outputs": [],
   "source": [
    "# # The following was generated by AI - see [12]\n",
    "# # Import the standalone predictor (reload to get latest version)\n",
    "# import importlib\n",
    "# import maglev_predictor\n",
    "# importlib.reload(maglev_predictor)\n",
    "# from maglev_predictor import MaglevPredictor\n",
    "\n",
    "# # Create predictor instance\n",
    "# standalone_predictor = MaglevPredictor()\n",
    "\n",
    "# # Test cases\n",
    "# test_cases = [\n",
    "#     {'currL': -15, 'currR': -15, 'roll': 0.0, 'gap': 10.0},\n",
    "#     {'currL': -15, 'currR': -15, 'roll': 1.0, 'gap': 10.0},\n",
    "#     {'currL': -15, 'currR': -10, 'roll': 0.5, 'gap': 12.0},\n",
    "#     {'currL': -10, 'currR': -10, 'roll': 2.0, 'gap': 15.0},\n",
    "# ]\n",
    "\n",
    "# print(\"Validation: Comparing Standalone Predictor vs Original Model\")\n",
    "# print(\"=\" * 80)\n",
    "\n",
    "# for i, case in enumerate(test_cases):\n",
    "#     # Standalone predictor\n",
    "#     force_standalone, torque_standalone = standalone_predictor.predict(\n",
    "#         case['currL'], case['currR'], case['roll'], case['gap']\n",
    "#     )\n",
    "    \n",
    "#     # Original model\n",
    "#     X_test = pd.DataFrame({\n",
    "#         'currL [A]': [case['currL']],\n",
    "#         'currR [A]': [case['currR']],\n",
    "#         'rollDeg [deg]': [case['roll']],\n",
    "#         'invGap': [1/case['gap']]\n",
    "#     })\n",
    "#     X_test_poly = poly_best.transform(X_test)\n",
    "#     y_test_pred = model_best.predict(X_test_poly)\n",
    "#     force_original = y_test_pred[0, 0]\n",
    "#     torque_original = y_test_pred[0, 1]\n",
    "    \n",
    "#     # Compare\n",
    "#     force_diff = abs(force_standalone - force_original)\n",
    "#     torque_diff = abs(torque_standalone - torque_original)\n",
    "    \n",
    "#     print(f\"\\nTest Case {i+1}: currL={case['currL']}A, currR={case['currR']}A, \" + \n",
    "#           f\"roll={case['roll']}°, gap={case['gap']}mm\")\n",
    "#     print(f\"  Force:  Standalone={force_standalone:9.4f} N | Original={force_original:9.4f} N | Diff={force_diff:.2e}\")\n",
    "#     print(f\"  Torque: Standalone={torque_standalone:9.4f} mN·m | Original={torque_original:9.4f} mN·m | Diff={torque_diff:.2e}\")\n",
    "    \n",
    "#     # Verify match (should be essentially identical, accounting for floating point)\n",
    "#     assert force_diff < 1e-8, f\"Force mismatch in test case {i+1}\"\n",
    "#     assert torque_diff < 1e-8, f\"Torque mismatch in test case {i+1}\"\n",
    "\n",
    "# print(\"\\n\" + \"=\" * 80)\n",
    "# print(\"✓ All tests passed! Standalone predictor matches original model perfectly.\")\n",
    "# print(\"\\nThe standalone predictor is ready for integration into your simulator!\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d473d5fa",
   "metadata": {},
   "source": [
    "### Export Sklearn Model (Lossless & Faster)\n",
    "\n",
    "Save the actual sklearn model using joblib for lossless serialization. This is also faster than the standalone predictor"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 48,
   "id": "5fd33470",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "✓ Saved sklearn model: maglev_model.pkl\n",
      "\n",
      "File size: 6.13 KB\n",
      "\n",
      "Contents:\n",
      "  - PolynomialFeatures transformer (degree 6)\n",
      "  - LinearRegression model\n",
      "  - Feature names and metadata\n",
      "  - Performance metrics\n",
      "\n",
      "To use:\n",
      "```python\n",
      "import joblib\n",
      "import numpy as np\n",
      "import pandas as pd\n",
      "\n",
      "# Load model\n",
      "data = joblib.load('maglev_model.pkl')\n",
      "poly = data['poly_features']\n",
      "model = data['model']\n",
      "\n",
      "# Make prediction\n",
      "X = pd.DataFrame({'currL [A]': [-15], 'currR [A]': [-15], \n",
      "                   'rollDeg [deg]': [1.0], 'invGap': [1/10.0]})\n",
      "X_poly = poly.transform(X)\n",
      "force, torque = model.predict(X_poly)[0]\n",
      "```\n"
     ]
    }
   ],
   "source": [
    "# The following was generated by AI - see [12]\n",
    "import joblib\n",
    "import pickle\n",
    "\n",
    "# Save the polynomial transformer and model\n",
    "model_data = {\n",
    "    'poly_features': poly_best,\n",
    "    'model': model_best,\n",
    "    'degree': best_degree,\n",
    "    'feature_names': ['currL [A]', 'currR [A]', 'rollDeg [deg]', 'invGap'],\n",
    "    'performance': {\n",
    "        'force_r2': r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]),\n",
    "        'torque_r2': torque_r2_overall\n",
    "    }\n",
    "}\n",
    "\n",
    "# Save using joblib (preferred for sklearn models)\n",
    "joblib.dump(model_data, 'maglev_model.pkl')\n",
    "\n",
    "print(\"✓ Saved sklearn model: maglev_model.pkl\")\n",
    "print(f\"\\nFile size: {os.path.getsize('maglev_model.pkl') / 1024:.2f} KB\")\n",
    "print(f\"\\nContents:\")\n",
    "print(f\"  - PolynomialFeatures transformer (degree {best_degree})\")\n",
    "print(f\"  - LinearRegression model\")\n",
    "print(f\"  - Feature names and metadata\")\n",
    "print(f\"  - Performance metrics\")\n",
    "print(f\"\\nTo use:\")\n",
    "print(f\"```python\")\n",
    "print(f\"import joblib\")\n",
    "print(f\"import numpy as np\")\n",
    "print(f\"import pandas as pd\")\n",
    "print(f\"\")\n",
    "print(f\"# Load model\")\n",
    "print(f\"data = joblib.load('maglev_model.pkl')\")\n",
    "print(f\"poly = data['poly_features']\")\n",
    "print(f\"model = data['model']\")\n",
    "print(f\"\")\n",
    "print(f\"# Make prediction\")\n",
    "print(f\"X = pd.DataFrame({{'currL [A]': [-15], 'currR [A]': [-15], \")\n",
    "print(f\"                   'rollDeg [deg]': [1.0], 'invGap': [1/10.0]}})\")\n",
    "print(f\"X_poly = poly.transform(X)\")\n",
    "print(f\"force, torque = model.predict(X_poly)[0]\")\n",
    "print(f\"```\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "99b93e74",
   "metadata": {},
   "source": [
    "### Compare Performance: Sklearn Model vs Standalone Predictor\n",
    "\n",
    "Benchmark both approaches to show the speed advantage of using sklearn directly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 49,
   "id": "889df820",
   "metadata": {},
   "outputs": [],
   "source": [
    "# # The following was generated by AI - see [12]\n",
    "# import time\n",
    "\n",
    "# # Load the sklearn model\n",
    "# loaded_data = joblib.load('maglev_model.pkl')\n",
    "# loaded_poly = loaded_data['poly_features']\n",
    "# loaded_model = loaded_data['model']\n",
    "\n",
    "# # Create test data (1000 predictions)\n",
    "# n_tests = 1000\n",
    "# test_currL = np.random.uniform(-15, 0, n_tests)\n",
    "# test_currR = np.random.uniform(-15, 0, n_tests)\n",
    "# test_roll = np.random.uniform(-5, 5, n_tests)\n",
    "# test_gap = np.random.uniform(6, 24, n_tests)\n",
    "\n",
    "# print(\"Performance Comparison (1000 predictions)\")\n",
    "# print(\"=\" * 60)\n",
    "\n",
    "# # Benchmark 1: Sklearn model (loaded from pickle)\n",
    "# start = time.perf_counter()\n",
    "# X_test = pd.DataFrame({\n",
    "#     'currL [A]': test_currL,\n",
    "#     'currR [A]': test_currR,\n",
    "#     'rollDeg [deg]': test_roll,\n",
    "#     'invGap': 1/test_gap\n",
    "# })\n",
    "# X_test_poly = loaded_poly.transform(X_test)\n",
    "# sklearn_results = loaded_model.predict(X_test_poly)\n",
    "# sklearn_time = time.perf_counter() - start\n",
    "\n",
    "# print(f\"Sklearn Model (pickled):  {sklearn_time*1000:.2f} ms\")\n",
    "\n",
    "# # Benchmark 2: Standalone predictor\n",
    "# start = time.perf_counter()\n",
    "# standalone_results = standalone_predictor.predict_batch(test_currL, test_currR, test_roll, test_gap)\n",
    "# standalone_time = time.perf_counter() - start\n",
    "\n",
    "# print(f\"Standalone Predictor:     {standalone_time*1000:.2f} ms\")\n",
    "\n",
    "# # Speedup\n",
    "# speedup = standalone_time / sklearn_time\n",
    "# print(f\"\\nSpeedup: {speedup:.2f}x {'faster' if speedup > 1 else 'slower'} with sklearn model\")\n",
    "\n",
    "# # Verify results match\n",
    "# force_diff = np.abs(sklearn_results[:, 0] - standalone_results[0]).max()\n",
    "# torque_diff = np.abs(sklearn_results[:, 1] - standalone_results[1]).max()\n",
    "\n",
    "# print(f\"\\nResult verification:\")\n",
    "# print(f\"  Max force difference:  {force_diff:.2e} N\")\n",
    "# print(f\"  Max torque difference: {torque_diff:.2e} mN·m\")\n",
    "# print(f\"  ✓ Results match!\" if (force_diff < 1e-8 and torque_diff < 1e-8) else \"  ✗ Results differ!\")\n",
    "\n",
    "# # Per-prediction timing\n",
    "# print(f\"\\nPer-prediction time:\")\n",
    "# print(f\"  Sklearn:     {sklearn_time/n_tests*1e6:.2f} μs\")\n",
    "# print(f\"  Standalone:  {standalone_time/n_tests*1e6:.2f} μs\")\n",
    "\n",
    "# print(f\"\\n{'='*60}\")\n",
    "# print(f\"Recommendation:\")\n",
    "# if speedup > 1.2:\n",
    "#     print(f\"  Use sklearn model (maglev_model.pkl) - {speedup:.1f}x faster!\")\n",
    "# elif speedup < 0.8:\n",
    "#     print(f\"  Use standalone predictor - {1/speedup:.1f}x faster!\")\n",
    "# else:\n",
    "#     print(f\"  Performance is similar - choose based on dependencies:\")\n",
    "#     print(f\"    • Sklearn model: Faster, requires sklearn+joblib\")\n",
    "#     print(f\"    • Standalone: No sklearn dependency, portable\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "21babeb3",
   "metadata": {},
   "source": [
    "### Now, let's find the equilibrium height for our pod, given mass of 9.4 kg. \n",
    "\n",
    "9.4 kg * 9.81 $m/s^2$ = 56.898 N"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "badbc379",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Loading maglev model from /Users/adipu/Documents/guadaloop_lev_control/RL Testing/maglev_model.pkl...\n",
      "Model loaded. Degree: 6\n",
      "Force R2: 1.0000\n",
      "Torque R2: 0.9999\n",
      "======================================================================\n",
      "EQUILIBRIUM GAP HEIGHT FINDER (Analytical Solution)\n",
      "======================================================================\n",
      "Pod mass: 9.4 kg\n",
      "Total weight: 92.214 N\n",
      "Target force per yoke: 46.107 N\n",
      "Parameters: currL = 0 A, currR = 0 A, roll = 0°\n",
      "\n",
      "using scipy.optimize.fsolve:\n",
      "  Gap: 11.857530 mm  →  Force: 46.107000 N\n",
      "\n"
     ]
    }
   ],
   "source": [
    "# The following was generated by AI - see [13]\n",
    "# Find equilibrium gap height for 9.4 kg pod using polynomial root finding\n",
    "import numpy as np\n",
    "from maglev_predictor import MaglevPredictor\n",
    "from scipy.optimize import fsolve\n",
    "\n",
    "# Initialize predictor\n",
    "predictor = MaglevPredictor()\n",
    "\n",
    "# Target force for 9.4 kg pod (total force = weight)\n",
    "# Since we have TWO yokes (front and back), each produces this force\n",
    "target_force_per_yoke = 9.4 * 9.81 / 2  # 28.449 N per yoke\n",
    "\n",
    "print(\"=\" * 70)\n",
    "print(\"EQUILIBRIUM GAP HEIGHT FINDER (Analytical Solution)\")\n",
    "print(\"=\" * 70)\n",
    "print(f\"Pod mass: 9.4 kg\")\n",
    "print(f\"Total weight: {9.4 * 9.81:.3f} N\")\n",
    "print(f\"Target force per yoke: {target_force_per_yoke:.3f} N\")\n",
    "print(f\"Parameters: currL = 0 A, currR = 0 A, roll = 0°\")\n",
    "print()\n",
    "\n",
    "# Method 2: Use scipy.optimize.fsolve for verification\n",
    "def force_error(gap_height):\n",
    "    # Handle array input from fsolve (convert to scalar)\n",
    "    gap_height = float(np.atleast_1d(gap_height)[0])\n",
    "    force, _ = predictor.predict(currL=0, currR=0, roll=0, gap_height=gap_height)\n",
    "    return force - target_force_per_yoke\n",
    "\n",
    "# Try multiple initial guesses to find all solutions\n",
    "initial_guesses = [8, 10, 15, 20, 25]\n",
    "scipy_solutions = []\n",
    "\n",
    "print(\"using scipy.optimize.fsolve:\")\n",
    "for guess in initial_guesses:\n",
    "    solution = fsolve(force_error, guess)[0]\n",
    "    if 5 <= solution <= 30:  # Valid range\n",
    "        force, torque = predictor.predict(currL=0, currR=0, roll=0, gap_height=solution)\n",
    "        error = abs(force - target_force_per_yoke)\n",
    "        if error < 0.01:  # Valid solution (within 10 mN)\n",
    "            scipy_solutions.append((solution, force))\n",
    "\n",
    "# Remove duplicates (solutions within 0.1 mm)\n",
    "unique_solutions = []\n",
    "for sol, force in scipy_solutions:\n",
    "    is_duplicate = False\n",
    "    for unique_sol, _ in unique_solutions:\n",
    "        if abs(sol - unique_sol) < 0.1:\n",
    "            is_duplicate = True\n",
    "            break\n",
    "    if not is_duplicate:\n",
    "        unique_solutions.append((sol, force))\n",
    "\n",
    "for gap_val, force in unique_solutions:\n",
    "    print(f\"  Gap: {gap_val:.6f} mm  →  Force: {force:.6f} N\")\n",
    "print()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "RLenv",
   "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.10.9"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
