Files
guadaloop_lev_control/RL Testing/Use of AI.txt

1777 lines
78 KiB
Plaintext
Raw Normal View History

2025-12-12 08:56:30 -06:00
[1]. Tool: Gemini
Prompt: Could you please set up my RL training environment to load the URDF file I created for my maglev pod and
put a track above it? This should all be done in PyBullet simulation, using the maglev_predictor and
mag_lev_coil files I have provided. The agent should have access to an observation space with 4 gap
heights and 4 gap height velocities from the sim, and should be able to output 4 PWM values as actions.
Output:
# --- 1. Define Action & Observation Spaces ---
# Action: 4 PWM duty cycles between -1 and 1 (4 independent coils)
# [front_left, front_right, back_left, back_right] corresponding to +Y and -Y ends of each U-yoke
self.action_space = spaces.Box(low=-1, high=1, shape=(4,), dtype=np.float32)
# Observation: 4 normalized noisy sensor gap heights + 4 normalized velocities
# Gaps normalized by 0.030m, velocities by 0.1 m/s
self.observation_space = spaces.Box(low=-5.0, high=5.0, shape=(8,), dtype=np.float32)
# --- 2. Setup Physics & Actuators ---
self.dt = 1./240. # PyBullet default timestep
self.coil_front_L = MagLevCoil(1.1, 0.0025, 12, 10.2) # From your specs
self.coil_front_R = MagLevCoil(1.1, 0.0025, 12, 10.2)
self.coil_back_L = MagLevCoil(1.1, 0.0025, 12, 10.2)
self.coil_back_R = MagLevCoil(1.1, 0.0025, 12, 10.2)
# Sensor noise parameters
self.sensor_noise_std = 0.0001 # 0.1mm standard deviation
# Normalization constants for observations
self.gap_scale = 0.015 # Normalize gaps by +-15mm max expected deviation from middle
self.velocity_scale = 0.1 # Normalize velocities by 0.1 m/s max expected velocity
# Maglev force/torque predictor
self.predictor = MaglevPredictor()
# Connect to PyBullet (DIRECT is faster for training, GUI for debugging)
self.client = p.connect(p.GUI if use_gui else p.DIRECT)
p.setAdditionalSearchPath(pybullet_data.getDataPath())
# Store references
self.trackId = None
self.podId = None
self.collision_local_positions = []
self.yoke_indices = [] # For force application
self.yoke_labels = []
self.sensor_indices = [] # For gap height measurement
self.sensor_labels = []
# For velocity calculation
self.prev_sensor_gaps = None
def reset(self, seed=None, options=None):
# Reset PyBullet simulation
p.resetSimulation(physicsClientId=self.client)
p.setGravity(0, 0, -9.81, physicsClientId=self.client)
p.setTimeStep(self.dt, physicsClientId=self.client)
# Create the maglev track (inverted system - track above, pod hangs below)
# Track bottom surface at Z=0
track_collision = p.createCollisionShape(
shapeType=p.GEOM_BOX,
halfExtents=[1.0, 0.2, 0.010],
physicsClientId=self.client
)
track_visual = p.createVisualShape(
shapeType=p.GEOM_BOX,
halfExtents=[1.0, 0.2, 0.010],
rgbaColor=[0.3, 0.3, 0.3, 0.8],
physicsClientId=self.client
)
self.trackId = p.createMultiBody(
baseMass=0, # Static
baseCollisionShapeIndex=track_collision,
baseVisualShapeIndex=track_visual,
basePosition=[0, 0, 0.010], # Track center at Z=10mm, bottom at Z=0
physicsClientId=self.client
)
p.changeDynamics(self.trackId, -1,
lateralFriction=0.3,
restitution=0.1,
physicsClientId=self.client)
start_pos = [0, 0, start_z]
start_orientation = p.getQuaternionFromEuler([0, 0, 0])
self.podId = p.loadURDF(urdf_path, start_pos, start_orientation, physicsClientId=self.client)
[2]. Tool: Gemini
Prompt:
Output:
# Parse collision shapes to identify yokes and sensors
collision_shapes = p.getCollisionShapeData(self.podId, -1, physicsClientId=self.client)
self.collision_local_positions = []
self.yoke_indices = []
self.yoke_labels = []
self.sensor_indices = []
self.sensor_labels = []
# Expected heights for detection
expected_yoke_sensor_z = 0.08585 # Yokes and sensors always at this height
expected_bolt_z = 0.08585 + self.initial_gap_mm / 1000.0 # Bolts at gap-dependent height
for i, shape in enumerate(collision_shapes):
shape_type = shape[2]
local_pos = shape[5]
self.collision_local_positions.append(local_pos)
# Check if at sensor/yoke height (Z ≈ 0.08585m) - NOT bolts
if abs(local_pos[2] - expected_yoke_sensor_z) < 0.001:
if shape_type == p.GEOM_BOX:
# Yokes are BOX type at the four corners (size 0.0254)
self.yoke_indices.append(i)
x_pos = "Front" if local_pos[0] > 0 else "Back"
y_pos = "Left" if local_pos[1] > 0 else "Right"
self.yoke_labels.append(f"{x_pos}_{y_pos}")
elif shape_type == p.GEOM_CYLINDER or shape_type == p.GEOM_MESH:
# Sensors: distinguish by position pattern
if abs(local_pos[0]) < 0.06 or abs(local_pos[1]) < 0.02:
self.sensor_indices.append(i)
if abs(local_pos[0]) < 0.001: # Center sensors (X ≈ 0)
label = "Center_Right" if local_pos[1] > 0 else "Center_Left"
else: # Front/back sensors (Y ≈ 0)
label = "Front" if local_pos[0] > 0 else "Back"
2025-12-12 13:42:41 -06:00
self.sensor_labels.append(label)
[3]. Tool: Claude Code
Prompt: Given the maglev_predictor file, generate code to find the equilibrium height for a pod of a given mass.
This should simply be the gap height at which 0 current in either coil and zero roll (all times 2) creates a
force equivalent to the gravitational force on the 5.8kg pod.
Output:
"""
Find equilibrium gap height for magnetic levitation system.
Given:
- Pod mass: 5.8 kg
- Required force: 5.8 * 9.81 = 56.898 N
- All currents: 0 A
- Roll angle: 0 degrees
Find: Gap height (mm) that produces this force
"""
import numpy as np
from maglev_predictor import MaglevPredictor
# Initialize predictor
predictor = MaglevPredictor()
# Target force
target_force = 5.8 * 9.81 # 56.898 N
print("=" * 70)
print("EQUILIBRIUM GAP HEIGHT FINDER")
print("=" * 70)
print(f"Target Force: {target_force:.3f} N (for 5.8 kg pod)")
print(f"Parameters: currL = 0 A, currR = 0 A, roll = 0°")
print()
# Define objective function
def force_error(gap_height):
"""Calculate difference between predicted force and target force."""
force, _ = predictor.predict(currL=0, currR=0, roll=0, gap_height=gap_height)
return force - target_force
# First, let's scan across gap heights to understand the behavior
print("Scanning gap heights from 5 to 30 mm...")
print("-" * 70)
gap_range = np.linspace(5, 30, 26)
forces = []
for gap in gap_range:
force, torque = predictor.predict(currL=0, currR=0, roll=0, gap_height=gap)
forces.append(force)
if abs(force - target_force) < 5: # Close to target
print(f"Gap: {gap:5.1f} mm → Force: {force:7.3f} N ← CLOSE!")
else:
print(f"Gap: {gap:5.1f} mm → Force: {force:7.3f} N")
forces = np.array(forces)
print()
print("-" * 70)
# Check if target force is within the range
min_force = forces.min()
max_force = forces.max()
if target_force < min_force or target_force > max_force:
print(f"⚠ WARNING: Target force {target_force:.3f} N is outside the range!")
print(f" Force range: {min_force:.3f} N to {max_force:.3f} N")
print(f" Cannot achieve equilibrium with 0A currents.")
else:
# Find the gap height using root finding
# Use brentq for robust bracketing (requires sign change)
# Find bracketing interval
idx_above = np.where(forces > target_force)[0]
idx_below = np.where(forces < target_force)[0]
if len(idx_above) > 0 and len(idx_below) > 0:
# Find the transition point
gap_low = gap_range[idx_below[-1]]
gap_high = gap_range[idx_above[0]]
print(f"✓ Target force is achievable!")
print(f" Bracketing interval: {gap_low:.1f} mm to {gap_high:.1f} mm")
print()
# Use simple bisection method for accurate root finding
tol = 1e-6
while (gap_high - gap_low) > tol:
gap_mid = (gap_low + gap_high) / 2
force_mid, _ = predictor.predict(currL=0, currR=0, roll=0, gap_height=gap_mid)
if force_mid > target_force:
gap_low = gap_mid
else:
gap_high = gap_mid
equilibrium_gap = (gap_low + gap_high) / 2
# Verify the result
final_force, final_torque = predictor.predict(
currL=0, currR=0, roll=0, gap_height=equilibrium_gap
)
print("=" * 70)
print("EQUILIBRIUM FOUND!")
print("=" * 70)
print(f"Equilibrium Gap Height: {equilibrium_gap:.6f} mm")
print(f"Predicted Force: {final_force:.6f} N")
print(f"Target Force: {target_force:.6f} N")
print(f"Error: {abs(final_force - target_force):.9f} N")
print(f"Torque at equilibrium: {final_torque:.6f} mN·m")
print()
print(f"✓ Pod will levitate at {equilibrium_gap:.3f} mm gap height")
print(f" with no current applied (permanent magnets only)")
print("=" * 70)
else:
print("⚠ Could not find bracketing interval for bisection.")
print(" Target force may not be achievable in the scanned range.")
[4]. Tool: Claude Code
Prompt:
How can I fit a polynomial to a system with 4 inputs and two outputs? I need to map the values
in the provided CSV file (currL, currR, roll, and inverse Gap) to Force and Torque readings.
Output:
# 1. Load your Ansys CSV - using invGap for modeling
X = magDf[['currL [A]', 'currR [A]', 'rollDeg [deg]', 'invGap']]
y = magDf[['YokeForce.Force_z [newton]', 'YokeTorque.Torque [mNewtonMeter]']]
# 2. Create Features (e.g. z^2, z^3, z*IL, etc.)
poly = PolynomialFeatures(degree=3)
X_poly = poly.fit_transform(X)
# 3. Fit
model = LinearRegression()
model.fit(X_poly, y)
# 4. Extract Equation
print("Force Coeffs:", model.coef_[0])
print("Torque Coeffs:", model.coef_[1])
[5]. Tool: Claude Code
Prompt:
Can you generate code for comparing a polynomial fit on the entire dataset (the actual
polynomial curve, not just the points that are provided by Ansys) with the ansys points
themselves for a single segment with the currents and roll degree held constant and the
Gap Height on X-axis and force on y-axis? Use the given code for reference.
Output:
# Train best degree polynomial on full dataset
poly_best = PolynomialFeatures(degree=best_degree)
X_poly_best = poly_best.fit_transform(X)
model_best = LinearRegression()
model_best.fit(X_poly_best, y)
print(f"Using polynomial degree: {best_degree}")
# Select a specific operating condition to visualize
# Using the same condition as earlier: -15A, -15A, 0° roll
test_condition = magDf.iloc[0:13].copy()
currL_val = test_condition['currL [A]'].iloc[0]
currR_val = test_condition['currR [A]'].iloc[0]
roll_val = test_condition['rollDeg [deg]'].iloc[0]
# Create very fine grid for gap heights to see polynomial behavior between points
gap_very_fine = np.linspace(5, 30, 500) # 500 points for smooth curve
X_fine = pd.DataFrame({
'currL [A]': [currL_val] * 500,
'currR [A]': [currR_val] * 500,
'rollDeg [deg]': [roll_val] * 500,
'invGap': 1/gap_very_fine # Use inverse gap height for modeling
})
X_fine_poly = poly_best.transform(X_fine)
y_fine_pred = model_best.predict(X_fine_poly)
# Extract actual Ansys data for this condition
gap_actual = test_condition['GapHeight [mm]'].values
force_actual = test_condition['YokeForce.Force_z [newton]'].values
# Plot
fig, ax = plt.subplots(1, 1, figsize=(12, 7))
ax.plot(gap_very_fine, y_fine_pred[:, 0], '-', linewidth=2.5,
label=f'Degree {best_degree} Polynomial Function', color='#2ca02c', alpha=0.8)
# Plot actual Ansys data points
ax.scatter(gap_actual, force_actual, s=120, marker='o',
color='#d62728', edgecolors='black', linewidths=1.5,
label='Ansys Simulation Data', zorder=5)
ax.set_ylabel('Force (N)', fontsize=13)
ax.set_title(f'Degree {best_degree} Polynomial vs Ansys Data (currL={currL_val}A, currR={currR_val}A, roll={roll_val}°)',
fontsize=14, fontweight='bold')
ax.legend(fontsize=12, loc='best')
ax.grid(True, alpha=0.3)
# Add vertical lines at data points to highlight gaps
for gap_point in gap_actual:
ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)
plt.tight_layout()
plt.show()
# Check for excessive oscillations by looking at derivative
print("Checking for overfitting indicators:")
print(f"Number of actual data points in this condition: {len(gap_actual)}")
print(f"Gap spacing: {np.diff(gap_actual).mean():.2f} mm average")
print(f"Polynomial curve resolution: 500 points over {gap_very_fine.max() - gap_very_fine.min():.1f} mm range")
[6]. Tool: Claude Code
Prompt:
Now can we do four different trials with similar graphs but different combinations of the starting conditions?
Output:
# Select 4 different conditions to visualize
# Each condition has 13 gap height measurements (one complete parameter set)
condition_indices = [
(0, 13, '-15A, -15A, 0°'), # First condition
(13, 26, '-15A, -15A, 0.5°'), # Same currents, different roll
(91, 104, '-15A, -10A, 0°'), # Different right current
(182, 195, '-15A, -10A, 1°') # Different right current and roll
]
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
axes = axes.flatten()
for idx, (start, end, label) in enumerate(condition_indices):
ax = axes[idx]
# Get condition data
condition_data = magDf.iloc[start:end]
currL = condition_data['currL [A]'].iloc[0]
currR = condition_data['currR [A]'].iloc[0]
roll = condition_data['rollDeg [deg]'].iloc[0]
# Create fine grid
gap_fine = np.linspace(5, 30, 500)
X_condition_fine = pd.DataFrame({
'currL [A]': [currL] * 500,
'currR [A]': [currR] * 500,
'rollDeg [deg]': [roll] * 500,
'invGap': 1/gap_fine # Use inverse gap height for modeling
})
# Predict with best degree polynomial
X_condition_poly = poly_best.transform(X_condition_fine)
y_condition_pred = model_best.predict(X_condition_poly)
# Plot polynomial curve
ax.plot(gap_fine, y_condition_pred[:, 0], '-', linewidth=2.5,
color='#2ca02c', alpha=0.8, label=f'Degree {best_degree} Polynomial')
# Plot actual data
ax.scatter(condition_data['GapHeight [mm]'],
condition_data['YokeForce.Force_z [newton]'],
s=100, marker='o', color='#d62728',
edgecolors='black', linewidths=1.5,
label='Ansys Data', zorder=5)
# Formatting
ax.set_xlabel('Gap Height (mm)', fontsize=11)
ax.set_ylabel('Force (N)', fontsize=11)
ax.set_title(f'currL={currL:.0f}A, currR={currR:.0f}A, roll={roll:.0f}°',
fontsize=12, fontweight='bold')
ax.legend(fontsize=10, loc='best')
ax.grid(True, alpha=0.3)
# Add vertical lines at data points
for gap_point in condition_data['GapHeight [mm]'].values:
ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)
plt.suptitle(f'Degree {best_degree} Polynomial: Multiple Operating Conditions',
fontsize=15, fontweight='bold', y=1.00)
plt.tight_layout()
plt.show()
print("Visual inspection guide:")
print("✓ Look for smooth curves between data points (good)")
print("✗ Look for wild oscillations or wiggles between points (overfitting)")
print("✓ Polynomial should interpolate naturally without excessive curvature")
[7]. Tool: Claude Code
Prompt:
Let's now find the worst performing segment of 13 gap heights to show the worst possible
performance of our polynomial fit in terms of the R^2 score
Output:
# Calculate R² score for each 13-row segment (each parameter set)
num_segments = len(magDf) // 13
segment_scores = []
segment_info = []
for i in range(num_segments):
start_idx = i * 13
end_idx = start_idx + 13
# Get actual and predicted values for this segment
segment_actual = y.iloc[start_idx:end_idx, 0].values # Force only
segment_pred = model_best.predict(poly_best.transform(X.iloc[start_idx:end_idx]))[:, 0]
# Calculate R² for this segment
segment_r2 = r2_score(segment_actual, segment_pred)
segment_scores.append(segment_r2)
# Store segment info
segment_data = magDf.iloc[start_idx:end_idx]
currL = segment_data['currL [A]'].iloc[0]
currR = segment_data['currR [A]'].iloc[0]
roll = segment_data['rollDeg [deg]'].iloc[0]
segment_info.append({
'start': start_idx,
'end': end_idx,
'currL': currL,
'currR': currR,
'roll': roll,
'r2': segment_r2
})
# Find worst performing segment
worst_idx = np.argmin(segment_scores)
worst_segment = segment_info[worst_idx]
print(f"Total segments analyzed: {num_segments}")
print(f"\nWorst performing segment:")
print(f" Rows: {worst_segment['start']} to {worst_segment['end']}")
print(f" Parameters: currL={worst_segment['currL']:.0f}A, currR={worst_segment['currR']:.0f}A, roll={worst_segment['roll']:.1f}°")
print(f" R² Score: {worst_segment['r2']:.6f}")
print(f"\nBest performing segment R²: {max(segment_scores):.6f}")
print(f"Mean R² across all segments: {np.mean(segment_scores):.6f}")
print(f"Median R² across all segments: {np.median(segment_scores):.6f}")
[8]. Tool: Claude Code
Prompt:
Let's also visualize the worst segment so the evidently good performance is visible to a viewer.
Output:
# Get worst segment data
worst_data = magDf.iloc[worst_segment['start']:worst_segment['end']]
worst_currL = worst_segment['currL']
worst_currR = worst_segment['currR']
worst_roll = worst_segment['roll']
worst_r2 = worst_segment['r2']
# Create fine grid for this worst segment
gap_fine_worst = np.linspace(5, 30, 500)
X_worst_fine = pd.DataFrame({
'currL [A]': [worst_currL] * 500,
'currR [A]': [worst_currR] * 500,
'rollDeg [deg]': [worst_roll] * 500,
'invGap': 1/gap_fine_worst
})
# Get predictions
X_worst_poly = poly_best.transform(X_worst_fine)
y_worst_pred = model_best.predict(X_worst_poly)
# Extract actual data
gap_worst_actual = worst_data['GapHeight [mm]'].values
force_worst_actual = worst_data['YokeForce.Force_z [newton]'].values
# Create the plot
fig, ax = plt.subplots(1, 1, figsize=(12, 7))
# Plot polynomial curve
ax.plot(gap_fine_worst, y_worst_pred[:, 0], '-', linewidth=2.5,
label=f'Degree {best_degree} Polynomial', color='#2ca02c', alpha=0.8)
# Plot actual data points
ax.scatter(gap_worst_actual, force_worst_actual, s=120, marker='o',
color='#d62728', edgecolors='black', linewidths=1.5,
label='Ansys Data', zorder=5)
# Formatting
ax.set_xlabel('Gap Height (mm)', fontsize=13)
ax.set_ylabel('Force (N)', fontsize=13)
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}°',
fontsize=14, fontweight='bold')
ax.legend(fontsize=12, loc='best')
ax.grid(True, alpha=0.3)
# Add vertical lines at data points
for gap_point in gap_worst_actual:
ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)
plt.tight_layout()
plt.show()
print(f"Even in the worst case, the model achieves R² = {worst_r2:.6f}")
print(f"This demonstrates excellent interpolation performance across all operating conditions!")
[9]. Tool: Claude Code
Prompt:
Now let's do something similar for the torque, showing how our polynomial fits to the torque vs
roll trend in ansys data while keeping othe variables constant. Show 4 trials like before, and
generate a continuous plot, like before.
Output:
# Select 4 different gap heights to visualize
# Using currL = -15A, currR = -15A configuration
gap_heights_to_plot = [8, 10, 15, 21] # mm
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
axes = axes.flatten()
for idx, gap_height in enumerate(gap_heights_to_plot):
ax = axes[idx]
# Fixed current configuration
currL = -15
currR = -15
# Get actual data for this gap height
gap_data = magDf[(magDf['GapHeight [mm]'] == gap_height) &
(magDf['currL [A]'] == currL) &
(magDf['currR [A]'] == currR)]
# Create fine grid for smooth curve across roll angles
roll_fine = np.linspace(-5, 5.0, 500)
X_condition_fine = pd.DataFrame({
'currL [A]': [currL] * 500,
'currR [A]': [currR] * 500,
'rollDeg [deg]': roll_fine,
'invGap': [1/gap_height] * 500
})
# Predict with best degree polynomial
X_condition_poly = poly_best.transform(X_condition_fine)
y_condition_pred = model_best.predict(X_condition_poly)
# Plot polynomial curve for TORQUE (index 1)
ax.plot(roll_fine, y_condition_pred[:, 1], '-', linewidth=2.5,
color='#ff7f0e', alpha=0.8, label=f'Degree {best_degree} Polynomial')
# Plot actual torque data
ax.scatter(gap_data['rollDeg [deg]'],
gap_data['YokeTorque.Torque [mNewtonMeter]'],
s=100, marker='o', color='#9467bd',
edgecolors='black', linewidths=1.5,
label='Ansys Data', zorder=5)
# Formatting
ax.set_xlabel('Roll Angle (deg)', fontsize=11)
ax.set_ylabel('Torque (mN·m)', fontsize=11)
ax.set_title(f'Gap Height = {gap_height} mm (currL={currL:.0f}A, currR={currR:.0f}A)',
fontsize=12, fontweight='bold')
ax.legend(fontsize=10, loc='best')
ax.grid(True, alpha=0.3)
# Add vertical lines at actual roll angle data points
for roll_point in gap_data['rollDeg [deg]'].unique():
ax.axvline(roll_point, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)
plt.suptitle(f'Torque vs Roll Angle at Different Gap Heights (Degree {best_degree} Polynomial)',
fontsize=15, fontweight='bold', y=1.00)
plt.tight_layout()
plt.show()
print(f"Showing torque variation with roll angle at {len(gap_heights_to_plot)} different gap heights.")
print("Gap height affects the magnitude of torque but the relationship with roll angle remains consistent.")
[10]. Tool: Claude Code
Prompt:
Since torque also varies with current imbalance between the coils, let's visualize the
response for that as well. Follow the same visualization pattern as before.
Output:
# Select 4 different gap heights to visualize
# Using roll = 0.5 degrees (small but non-zero torque)
gap_heights_to_plot = [8, 10, 15, 21] # mm
roll_target = 0.5
# Use a fixed reference current for currL
currL_ref = -15
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
axes = axes.flatten()
for idx, gap_height in enumerate(gap_heights_to_plot):
ax = axes[idx]
# Get actual data for this gap height, roll angle, AND fixed currL
gap_roll_data = magDf[(magDf['GapHeight [mm]'] == gap_height) &
(magDf['rollDeg [deg]'] == roll_target) &
(magDf['currL [A]'] == currL_ref)]
# Calculate imbalance for actual data points (all have same currL now)
actual_imbalances = []
actual_torques = []
for _, row in gap_roll_data.iterrows():
imbalance = row['currR [A]'] - row['currL [A]']
torque = row['YokeTorque.Torque [mNewtonMeter]']
actual_imbalances.append(imbalance)
actual_torques.append(torque)
# Create fine grid for smooth curve across current imbalances
# Range from -10A to +10A imbalance (currR - currL)
imbalance_fine = np.linspace(0, 35, 500)
# Vary currR while keeping currL fixed
currR_fine = currL_ref + imbalance_fine
X_condition_fine = pd.DataFrame({
'currL [A]': [currL_ref] * 500,
'currR [A]': currR_fine,
'rollDeg [deg]': [roll_target] * 500,
'invGap': [1/gap_height] * 500
})
# Predict with best degree polynomial
X_condition_poly = poly_best.transform(X_condition_fine)
y_condition_pred = model_best.predict(X_condition_poly)
# Plot polynomial curve for TORQUE (index 1)
ax.plot(imbalance_fine, y_condition_pred[:, 1], '-', linewidth=2.5,
color='#ff7f0e', alpha=0.8, label=f'Degree {best_degree} Polynomial')
# Plot actual torque data (now only one point per imbalance value)
ax.scatter(actual_imbalances, actual_torques,
s=100, marker='o', color='#9467bd',
edgecolors='black', linewidths=1.5,
label='Ansys Data', zorder=5)
# Formatting
ax.set_xlabel('Current Imbalance (currR - currL) [A]', fontsize=11)
ax.set_ylabel('Torque (mN·m)', fontsize=11)
ax.set_title(f'Gap Height = {gap_height} mm (currL = {currL_ref}A, roll = {roll_target}°)',
fontsize=12, fontweight='bold')
ax.legend(fontsize=10, loc='best')
ax.grid(True, alpha=0.3)
ax.axhline(0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)
ax.axvline(0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)
# Add vertical lines at actual imbalance data points
for imb in set(actual_imbalances):
ax.axvline(imb, color='gray', linestyle=':', alpha=0.2, linewidth=0.8)
plt.suptitle(f'Torque vs Current Imbalance at Different Gap Heights (currL = {currL_ref}A, Roll = {roll_target}°)',
fontsize=15, fontweight='bold', y=1.00)
plt.tight_layout()
plt.show()
print(f"Showing torque variation with current imbalance at {len(gap_heights_to_plot)} different gap heights.")
print(f"All data uses currL = {currL_ref}A with varying currR to create different imbalances.")
print(f"Current imbalance (currR - currL) affects torque magnitude and direction.")
print(f"Positive imbalance (currR > currL) and negative imbalance (currR < currL) produce opposite torques.")
[11]. Tool: Claude Code
Prompt:
Let's now visualize the worst torque prediction for a given 13-gap-height segment.
What is our worst possible performance? Show in a similar way as with force.
Output:
# Calculate R² score for torque in each 13-row segment
torque_segment_scores = []
torque_segment_info = []
for i in range(num_segments):
start_idx = i * 13
end_idx = start_idx + 13
# Get actual and predicted torque values for this segment
segment_actual_torque = y.iloc[start_idx:end_idx, 1].values # Torque column
segment_pred_torque = model_best.predict(poly_best.transform(X.iloc[start_idx:end_idx]))[:, 1]
# Calculate R² for this segment
segment_r2_torque = r2_score(segment_actual_torque, segment_pred_torque)
torque_segment_scores.append(segment_r2_torque)
# Store segment info
segment_data = magDf.iloc[start_idx:end_idx]
currL = segment_data['currL [A]'].iloc[0]
currR = segment_data['currR [A]'].iloc[0]
roll = segment_data['rollDeg [deg]'].iloc[0]
torque_segment_info.append({
'start': start_idx,
'end': end_idx,
'currL': currL,
'currR': currR,
'roll': roll,
'r2': segment_r2_torque
})
# Find worst and best performing segments for torque
worst_torque_idx = np.argmin(torque_segment_scores)
best_torque_idx = np.argmax(torque_segment_scores)
worst_torque_segment = torque_segment_info[worst_torque_idx]
best_torque_segment = torque_segment_info[best_torque_idx]
print(f"Torque Prediction Analysis (across {num_segments} segments):")
print(f"\nBest R² Score: {max(torque_segment_scores):.6f}")
print(f" Parameters: currL={best_torque_segment['currL']:.0f}A, currR={best_torque_segment['currR']:.0f}A, roll={best_torque_segment['roll']:.1f}°")
print(f"\nWorst R² Score: {min(torque_segment_scores):.6f}")
print(f" Parameters: currL={worst_torque_segment['currL']:.0f}A, currR={worst_torque_segment['currR']:.0f}A, roll={worst_torque_segment['roll']:.1f}°")
print(f"\nMean R²: {np.mean(torque_segment_scores):.6f}")
print(f"Median R²: {np.median(torque_segment_scores):.6f}")
print(f"Std Dev: {np.std(torque_segment_scores):.6f}")
# Create histogram of torque R² scores
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
ax.hist(torque_segment_scores, bins=30, edgecolor='black', alpha=0.7, color='#9467bd')
ax.axvline(np.mean(torque_segment_scores), color='red', linestyle='--', linewidth=2, label=f'Mean = {np.mean(torque_segment_scores):.6f}')
ax.axvline(np.median(torque_segment_scores), color='orange', linestyle='--', linewidth=2, label=f'Median = {np.median(torque_segment_scores):.6f}')
ax.set_xlabel('R² Score', fontsize=12)
ax.set_ylabel('Number of Segments', fontsize=12)
ax.set_title('Distribution of Torque R² Scores Across All Parameter Segments', fontsize=14, fontweight='bold')
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.show()
# Get worst torque segment data
worst_torque_data = magDf.iloc[worst_torque_segment['start']:worst_torque_segment['end']]
worst_torque_currL = worst_torque_segment['currL']
worst_torque_currR = worst_torque_segment['currR']
worst_torque_roll = worst_torque_segment['roll']
worst_torque_r2 = worst_torque_segment['r2']
# Create fine grid for this worst segment
gap_fine_worst_torque = np.linspace(2, 30, 500)
X_worst_torque_fine = pd.DataFrame({
'currL [A]': [worst_torque_currL] * 500,
'currR [A]': [worst_torque_currR] * 500,
'rollDeg [deg]': [worst_torque_roll] * 500,
'invGap': 1/gap_fine_worst_torque
})
# Get predictions
X_worst_torque_poly = poly_best.transform(X_worst_torque_fine)
y_worst_torque_pred = model_best.predict(X_worst_torque_poly)
# Extract actual data
gap_worst_torque_actual = worst_torque_data['GapHeight [mm]'].values
torque_worst_actual = worst_torque_data['YokeTorque.Torque [mNewtonMeter]'].values
# Create the plot
fig, ax = plt.subplots(1, 1, figsize=(12, 7))
# Plot polynomial curve for TORQUE
ax.plot(gap_fine_worst_torque, y_worst_torque_pred[:, 1], '-', linewidth=2.5,
label=f'Degree {best_degree} Polynomial', color='#ff7f0e', alpha=0.8)
# Plot actual data points
ax.scatter(gap_worst_torque_actual, torque_worst_actual, s=120, marker='o',
color='#9467bd', edgecolors='black', linewidths=1.5,
label='Ansys Data', zorder=5)
# Formatting
ax.set_xlabel('Gap Height (mm)', fontsize=13)
ax.set_ylabel('Torque (mN·m)', fontsize=13)
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}°',
fontsize=14, fontweight='bold')
ax.legend(fontsize=12, loc='best')
ax.grid(True, alpha=0.3)
# Add vertical lines at data points
for gap_point in gap_worst_torque_actual:
ax.axvline(gap_point, color='gray', linestyle=':', alpha=0.3, linewidth=0.8)
plt.tight_layout()
plt.show()
print(f"Worst-case torque prediction achieves R² = {worst_torque_r2:.6f}")
[12]. Tool: Claude Code
Prompt:
Can we now export this model for use in a simulator? I want this to be accessible but also quite
fast since the simulator will be running at 240Hz and calculating forces and torques for 2 yokes at once.
Output:
print("=" * 60)
print("MODEL EXPORT SUMMARY")
print("=" * 60)
print(f"\nModel Type: Polynomial Regression")
print(f"Polynomial Degree: {best_degree}")
print(f"Number of Features: {poly_best.n_features_in_}")
print(f"Number of Polynomial Terms: {poly_best.n_output_features_}")
print(f"\nInput Features:")
print(" - currL [A]")
print(" - currR [A]")
print(" - rollDeg [deg]")
print(" - invGap (1/GapHeight)")
print(f"\nOutputs:")
print(" - YokeForce.Force_z [newton]")
print(" - YokeTorque.Torque [mNewtonMeter]")
print(f"\nModel Performance:")
print(f" Force R² Score: {r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]):.6f}")
print(f" Torque R² Score: {torque_r2_overall:.6f}")
print(f"\nModel Coefficients Shape:")
print(f" Force coefficients: {model_best.coef_[0].shape}")
print(f" Torque coefficients: {model_best.coef_[1].shape}")
print("=" * 60)
# Generate standalone predictor class
predictor_code = f'''"""
Magnetic Levitation Force and Torque Predictor
Generated from polynomial regression model (degree {best_degree})
Performance:
- Force R²: {r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]):.6f}
- Torque R²: {torque_r2_overall:.6f}
Usage:
predictor = MaglevPredictor()
force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)
"""
import numpy as np
from itertools import combinations_with_replacement
class MaglevPredictor:
def __init__(self):
"""Initialize the magnetic levitation force/torque predictor."""
self.degree = {best_degree}
self.n_features = 4 # currL, currR, roll, invGap
# Force model coefficients
self.force_intercept = {model_best.intercept_[0]}
self.force_coef = np.array({model_best.coef_[0].tolist()})
# Torque model coefficients
self.torque_intercept = {model_best.intercept_[1]}
self.torque_coef = np.array({model_best.coef_[1].tolist()})
def _polynomial_features(self, X):
"""
Generate polynomial features up to specified degree.
Mimics sklearn's PolynomialFeatures with include_bias=True.
Args:
X: numpy array of shape (n_samples, 4) with [currL, currR, roll, invGap]
Returns:
Polynomial features array
"""
n_samples = X.shape[0]
# Start with bias term (column of ones)
features = [np.ones(n_samples)]
# Add original features
for i in range(self.n_features):
features.append(X[:, i])
# Add polynomial combinations
for deg in range(2, self.degree + 1):
for combo in combinations_with_replacement(range(self.n_features), deg):
term = np.ones(n_samples)
for idx in combo:
term *= X[:, idx]
features.append(term)
return np.column_stack(features)
def predict(self, currL, currR, roll, gap_height):
"""
Predict force and torque for given operating conditions.
Args:
currL: Left coil current in Amps
currR: Right coil current in Amps
roll: Roll angle in degrees
gap_height: Gap height in mm
Returns:
tuple: (force [N], torque [mN·m])
"""
# Compute inverse gap (critical transformation!)
invGap = 1.0 / gap_height
# Create input array
X = np.array([[currL, currR, roll, invGap]])
# Generate polynomial features
X_poly = self._polynomial_features(X)
# Compute predictions
force = self.force_intercept + np.dot(X_poly, self.force_coef)[0]
torque = self.torque_intercept + np.dot(X_poly, self.torque_coef)[0]
return force, torque
def predict_batch(self, currL_array, currR_array, roll_array, gap_height_array):
"""
Predict force and torque for multiple operating conditions.
Args:
currL_array: Array of left coil currents [A]
currR_array: Array of right coil currents [A]
roll_array: Array of roll angles [deg]
gap_height_array: Array of gap heights [mm]
Returns:
tuple: (force_array [N], torque_array [mN·m])
"""
# Convert to numpy arrays
currL_array = np.asarray(currL_array)
currR_array = np.asarray(currR_array)
roll_array = np.asarray(roll_array)
gap_height_array = np.asarray(gap_height_array)
# Compute inverse gaps
invGap_array = 1.0 / gap_height_array
# Stack into feature matrix
X = np.column_stack([currL_array, currR_array, roll_array, invGap_array])
# Generate polynomial features
X_poly = self._polynomial_features(X)
# Compute predictions
force_array = self.force_intercept + np.dot(X_poly, self.force_coef)
torque_array = self.torque_intercept + np.dot(X_poly, self.torque_coef)
return force_array, torque_array
if __name__ == "__main__":
# Example usage
predictor = MaglevPredictor()
# Single prediction
force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)
print(f"Single prediction:")
print(f" Force: {{force:.2f}} N")
print(f" Torque: {{torque:.2f}} mN·m")
# Batch prediction
currL = np.array([-15, -15, -10])
currR = np.array([-15, -10, -10])
roll = np.array([0, 0.5, 1.0])
gap = np.array([10, 12, 15])
forces, torques = predictor.predict_batch(currL, currR, roll, gap)
print(f"\\nBatch prediction:")
for i in range(len(forces)):
print(f" Condition {{i+1}}: Force={{forces[i]:.2f}} N, Torque={{torques[i]:.2f}} mN·m")
'''
# Save to file
predictor_filename = 'maglev_predictor.py'
with open(predictor_filename, 'w') as f:
f.write(predictor_code)
print(f"✓ Saved standalone predictor: {predictor_filename}")
print(f"\nThis module:")
print(f" - Has NO scikit-learn dependency (only numpy)")
print(f" - Can be imported directly into your simulator")
print(f" - Includes both single and batch prediction methods")
print(f" - Implements polynomial feature generation internally")
print(f"\nTo use:")
print(f"```python")
print(f"from maglev_predictor import MaglevPredictor")
print(f"predictor = MaglevPredictor()")
print(f"force, torque = predictor.predict(currL=-15, currR=-15, roll=1.0, gap_height=10.0)")
print(f"```")
# Import the standalone predictor (reload to get latest version)
import importlib
import maglev_predictor
importlib.reload(maglev_predictor)
from maglev_predictor import MaglevPredictor
# Create predictor instance
standalone_predictor = MaglevPredictor()
# Test cases
test_cases = [
{'currL': -15, 'currR': -15, 'roll': 0.0, 'gap': 10.0},
{'currL': -15, 'currR': -15, 'roll': 1.0, 'gap': 10.0},
{'currL': -15, 'currR': -10, 'roll': 0.5, 'gap': 12.0},
{'currL': -10, 'currR': -10, 'roll': 2.0, 'gap': 15.0},
]
print("Validation: Comparing Standalone Predictor vs Original Model")
print("=" * 80)
for i, case in enumerate(test_cases):
# Standalone predictor
force_standalone, torque_standalone = standalone_predictor.predict(
case['currL'], case['currR'], case['roll'], case['gap']
)
# Original model
X_test = pd.DataFrame({
'currL [A]': [case['currL']],
'currR [A]': [case['currR']],
'rollDeg [deg]': [case['roll']],
'invGap': [1/case['gap']]
})
X_test_poly = poly_best.transform(X_test)
y_test_pred = model_best.predict(X_test_poly)
force_original = y_test_pred[0, 0]
torque_original = y_test_pred[0, 1]
# Compare
force_diff = abs(force_standalone - force_original)
torque_diff = abs(torque_standalone - torque_original)
print(f"\nTest Case {i+1}: currL={case['currL']}A, currR={case['currR']}A, " +
f"roll={case['roll']}°, gap={case['gap']}mm")
print(f" Force: Standalone={force_standalone:9.4f} N | Original={force_original:9.4f} N | Diff={force_diff:.2e}")
print(f" Torque: Standalone={torque_standalone:9.4f} mN·m | Original={torque_original:9.4f} mN·m | Diff={torque_diff:.2e}")
# Verify match (should be essentially identical, accounting for floating point)
assert force_diff < 1e-8, f"Force mismatch in test case {i+1}"
assert torque_diff < 1e-8, f"Torque mismatch in test case {i+1}"
print("\n" + "=" * 80)
print("✓ All tests passed! Standalone predictor matches original model perfectly.")
print("\nThe standalone predictor is ready for integration into your simulator!")
import joblib
import pickle
# Save the polynomial transformer and model
model_data = {
'poly_features': poly_best,
'model': model_best,
'degree': best_degree,
'feature_names': ['currL [A]', 'currR [A]', 'rollDeg [deg]', 'invGap'],
'performance': {
'force_r2': r2_score(y.iloc[:, 0].values, y_pred_full[:, 0]),
'torque_r2': torque_r2_overall
}
}
# Save using joblib (preferred for sklearn models)
joblib.dump(model_data, 'maglev_model.pkl')
print("✓ Saved sklearn model: maglev_model.pkl")
print(f"\nFile size: {os.path.getsize('maglev_model.pkl') / 1024:.2f} KB")
print(f"\nContents:")
print(f" - PolynomialFeatures transformer (degree {best_degree})")
print(f" - LinearRegression model")
print(f" - Feature names and metadata")
print(f" - Performance metrics")
print(f"\nTo use:")
print(f"```python")
print(f"import joblib")
print(f"import numpy as np")
print(f"import pandas as pd")
print(f"")
print(f"# Load model")
print(f"data = joblib.load('maglev_model.pkl')")
print(f"poly = data['poly_features']")
print(f"model = data['model']")
print(f"")
print(f"# Make prediction")
print(f"X = pd.DataFrame({{'currL [A]': [-15], 'currR [A]': [-15], ")
print(f" 'rollDeg [deg]': [1.0], 'invGap': [1/10.0]}})")
print(f"X_poly = poly.transform(X)")
print(f"force, torque = model.predict(X_poly)[0]")
print(f"```")
import time
# Load the sklearn model
loaded_data = joblib.load('maglev_model.pkl')
loaded_poly = loaded_data['poly_features']
loaded_model = loaded_data['model']
# Create test data (1000 predictions)
n_tests = 1000
test_currL = np.random.uniform(-15, 0, n_tests)
test_currR = np.random.uniform(-15, 0, n_tests)
test_roll = np.random.uniform(-5, 5, n_tests)
test_gap = np.random.uniform(6, 24, n_tests)
print("Performance Comparison (1000 predictions)")
print("=" * 60)
# Benchmark 1: Sklearn model (loaded from pickle)
start = time.perf_counter()
X_test = pd.DataFrame({
'currL [A]': test_currL,
'currR [A]': test_currR,
'rollDeg [deg]': test_roll,
'invGap': 1/test_gap
})
X_test_poly = loaded_poly.transform(X_test)
sklearn_results = loaded_model.predict(X_test_poly)
sklearn_time = time.perf_counter() - start
print(f"Sklearn Model (pickled): {sklearn_time*1000:.2f} ms")
# Benchmark 2: Standalone predictor
start = time.perf_counter()
standalone_results = standalone_predictor.predict_batch(test_currL, test_currR, test_roll, test_gap)
standalone_time = time.perf_counter() - start
print(f"Standalone Predictor: {standalone_time*1000:.2f} ms")
# Speedup
speedup = standalone_time / sklearn_time
print(f"\nSpeedup: {speedup:.2f}x {'faster' if speedup > 1 else 'slower'} with sklearn model")
# Verify results match
force_diff = np.abs(sklearn_results[:, 0] - standalone_results[0]).max()
torque_diff = np.abs(sklearn_results[:, 1] - standalone_results[1]).max()
print(f"\nResult verification:")
print(f" Max force difference: {force_diff:.2e} N")
print(f" Max torque difference: {torque_diff:.2e} mN·m")
print(f" ✓ Results match!" if (force_diff < 1e-8 and torque_diff < 1e-8) else " ✗ Results differ!")
# Per-prediction timing
print(f"\nPer-prediction time:")
print(f" Sklearn: {sklearn_time/n_tests*1e6:.2f} μs")
print(f" Standalone: {standalone_time/n_tests*1e6:.2f} μs")
print(f"\n{'='*60}")
print(f"Recommendation:")
if speedup > 1.2:
print(f" Use sklearn model (maglev_model.pkl) - {speedup:.1f}x faster!")
elif speedup < 0.8:
print(f" Use standalone predictor - {1/speedup:.1f}x faster!")
else:
print(f" Performance is similar - choose based on dependencies:")
print(f" • Sklearn model: Faster, requires sklearn+joblib")
print(f" • Standalone: No sklearn dependency, portable")
[13]. Tool: Claude Code
Prompt:
Now can we use our model to find the equilibrium gab height? ie the gap height at which,
with currents and roll at 0, our force from a single yoke * 2 = the weight of the pod in newtons?
Output:
# Find equilibrium gap height for 5.8 kg pod using polynomial root finding
import numpy as np
from maglev_predictor import MaglevPredictor
from scipy.optimize import fsolve
# Initialize predictor
predictor = MaglevPredictor()
# Target force for 5.8 kg pod (total force = weight)
# Since we have TWO yokes (front and back), each produces this force
target_force_per_yoke = 5.8 * 9.81 / 2 # 28.449 N per yoke
print("=" * 70)
print("EQUILIBRIUM GAP HEIGHT FINDER (Analytical Solution)")
print("=" * 70)
print(f"Pod mass: 5.8 kg")
print(f"Total weight: {5.8 * 9.81:.3f} N")
print(f"Target force per yoke: {target_force_per_yoke:.3f} N")
print(f"Parameters: currL = 0 A, currR = 0 A, roll = 0°")
print()
# Method 2: Use scipy.optimize.fsolve for verification
def force_error(gap_height):
# Handle array input from fsolve (convert to scalar)
gap_height = float(np.atleast_1d(gap_height)[0])
force, _ = predictor.predict(currL=0, currR=0, roll=0, gap_height=gap_height)
return force - target_force_per_yoke
# Try multiple initial guesses to find all solutions
initial_guesses = [8, 10, 15, 20, 25]
scipy_solutions = []
print("using scipy.optimize.fsolve:")
for guess in initial_guesses:
solution = fsolve(force_error, guess)[0]
if 5 <= solution <= 30: # Valid range
force, torque = predictor.predict(currL=0, currR=0, roll=0, gap_height=solution)
error = abs(force - target_force_per_yoke)
if error < 0.01: # Valid solution (within 10 mN)
scipy_solutions.append((solution, force))
# Remove duplicates (solutions within 0.1 mm)
unique_solutions = []
for sol, force in scipy_solutions:
is_duplicate = False
for unique_sol, _ in unique_solutions:
if abs(sol - unique_sol) < 0.1:
is_duplicate = True
break
if not is_duplicate:
unique_solutions.append((sol, force))
for gap_val, force in unique_solutions:
print(f" Gap: {gap_val:.6f} mm → Force: {force:.6f} N")
print()
[14]. Tool: Gemini
Prompt:
Can we make a step function that uses our ansys-based regression model to find
forces on each yoke and apply them to the Bullet simulation? Also, we must
calculate a reward for any given pod position and orientation, prioritizing
stable levitation and penalizing sticking to the track or falling off more heavily
Output:
def step(self, action):
# Check if PyBullet connection is still active (GUI might be closed)
try:
p.getConnectionInfo(physicsClientId=self.client)
except p.error:
# Connection lost - GUI was closed
return self._get_obs(), -100.0, True, True, {'error': 'GUI closed'}
# Update Coil Currents from PWM Actions
pwm_front_L = action[0] # yoke +x,+y
pwm_front_R = action[1] # yoke +x,-y
pwm_back_L = action[2] # yoke -x,+y
pwm_back_R = action[3] # yoke -x,-y
curr_front_L = self.coil_front_L.update(pwm_front_L, self.dt)
curr_front_R = self.coil_front_R.update(pwm_front_R, self.dt)
curr_back_L = self.coil_back_L.update(pwm_back_L, self.dt)
curr_back_R = self.coil_back_R.update(pwm_back_R, self.dt)
# --- 2. Get Current Pod State ---
pos, orn = p.getBasePositionAndOrientation(self.podId, physicsClientId=self.client)
lin_vel, ang_vel = p.getBaseVelocity(self.podId, physicsClientId=self.client)
# Convert quaternion to rotation matrix
rot_matrix = np.array(p.getMatrixFromQuaternion(orn)).reshape(3, 3)
# --- 3. Calculate Gap Heights at Yoke Positions (for force prediction) ---
# Calculate world positions of the 4 yokes (ends of U-yokes)
yoke_gap_heights_dict = {} # Store by label for easy access
for i, yoke_idx in enumerate(self.yoke_indices):
local_pos = self.collision_local_positions[yoke_idx]
local_vec = np.array(local_pos)
world_offset = rot_matrix @ local_vec
world_pos = np.array(pos) + world_offset
# Top surface of yoke box (add half-height = 5mm)
yoke_top_z = world_pos[2] + 0.005
# Gap height: track bottom (Z=0) to yoke top (negative Z)
gap_height = -yoke_top_z # Convert to positive gap in meters
yoke_gap_heights_dict[self.yoke_labels[i]] = gap_height
# Average gap heights for each U-shaped yoke (average left and right ends)
# Front yoke: average of Front_Left and Front_Right
# Back yoke: average of Back_Left and Back_Right
avg_gap_front = (yoke_gap_heights_dict.get('Front_Left', 0.010) +
yoke_gap_heights_dict.get('Front_Right', 0.010)) / 2
avg_gap_back = (yoke_gap_heights_dict.get('Back_Left', 0.010) +
yoke_gap_heights_dict.get('Back_Right', 0.010)) / 2
front_left_gap = yoke_gap_heights_dict.get('Front_Left', 0.010)
front_right_gap = yoke_gap_heights_dict.get('Front_Right', 0.010)
back_left_gap = yoke_gap_heights_dict.get('Back_Left', 0.010)
back_right_gap = yoke_gap_heights_dict.get('Back_Right', 0.010)
y_distance = 0.1016 # 2 * 0.0508m (left to right distance)
x_distance = 0.2518 # 2 * 0.1259m (front to back distance)
# Roll angle
# When right side has larger gap, roll is negative
roll_angle_front = np.arcsin(-(front_right_gap - front_left_gap) / y_distance)
roll_angle_back = np.arcsin(-(back_right_gap - back_left_gap) / y_distance)
roll_angle = (roll_angle_front + roll_angle_back) / 2
# When back has larger gap, pitch is positive
pitch_angle_left = np.arcsin((back_left_gap - front_left_gap) / x_distance)
pitch_angle_right = np.arcsin((back_right_gap - front_right_gap) / x_distance)
pitch_angle = (pitch_angle_left + pitch_angle_right) / 2
# Predict Forces and Torques using Maglev Predictor
# Gap heights in mm
gap_front_mm = avg_gap_front * 1000
gap_back_mm = avg_gap_back * 1000
# Roll angle in degrees
roll_deg = np.degrees(roll_angle)
# Predict force and torque for each U-shaped yoke
# Front yoke
force_front, torque_front = self.predictor.predict(
curr_front_L, curr_front_R, roll_deg, gap_front_mm
)
# Back yoke
force_back, torque_back = self.predictor.predict(
curr_back_L, curr_back_R, roll_deg, gap_back_mm
)
# --- 5. Apply Forces and Torques to Pod ---
# Forces are applied at Y=0 (center of U-yoke) at each X position
# This is where the actual magnetic force acts on the U-shaped yoke
# Apply force at front yoke center (X=+0.1259, Y=0)
front_yoke_center = [0.1259, 0, 0.08585] # From pod.xml yoke positions
p.applyExternalForce(
self.podId, -1,
forceObj=[0, 0, force_front],
posObj=front_yoke_center,
flags=p.LINK_FRAME,
physicsClientId=self.client
)
# Apply force at back yoke center (X=-0.1259, Y=0)
back_yoke_center = [-0.1259, 0, 0.08585]
p.applyExternalForce(
self.podId, -1,
forceObj=[0, 0, force_back],
posObj=back_yoke_center,
flags=p.LINK_FRAME,
physicsClientId=self.client
)
# Apply roll torques
# Each yoke produces its own torque about X axis
torque_front_Nm = torque_front / 1000 # Convert from mN·m to N·m
torque_back_Nm = torque_back / 1000
# Apply torques at respective yoke positions
p.applyExternalTorque(
self.podId, -1,
torqueObj=[torque_front_Nm, 0, 0],
flags=p.LINK_FRAME,
physicsClientId=self.client
)
p.applyExternalTorque(
self.podId, -1,
torqueObj=[torque_back_Nm, 0, 0],
flags=p.LINK_FRAME,
physicsClientId=self.client
)
# --- 6. Step Simulation ---
p.stepSimulation(physicsClientId=self.client)
self.current_step += 1
# Check for physical contact with track (bolts touching)
contact_points = p.getContactPoints(bodyA=self.podId, bodyB=self.trackId, physicsClientId=self.client)
has_contact = len(contact_points) > 0
# --- 7. Get New Observation ---
obs = self._get_obs()
# --- 8. Calculate Reward ---
# Goal: Hover at target gap (16.5mm), minimize roll/pitch, minimize power
target_gap = TARGET_GAP # 16.5mm in meters
avg_gap = (avg_gap_front + avg_gap_back) / 2
gap_error = abs(avg_gap - target_gap)
# Power dissipation (all 4 coils)
power = (curr_front_L**2 * self.coil_front_L.R +
curr_front_R**2 * self.coil_front_R.R +
curr_back_L**2 * self.coil_back_L.R +
curr_back_R**2 * self.coil_back_R.R)
# --- Improved Reward Function ---
# Use reward shaping with reasonable scales to enable learning
# 1. Gap Error Reward (most important)
# Use exponential decay for smooth gradient near target
gap_error_mm = gap_error * 1000 # Convert to mm
gap_reward = 10.0 * np.exp(-0.5 * (gap_error_mm / 3.0)**2) # Peak at 0mm error, 3mm std dev
# 2. Orientation Penalties (smaller scale)
roll_penalty = abs(np.degrees(roll_angle)) * 0.02
pitch_penalty = abs(np.degrees(pitch_angle)) * 0.02
# 3. Velocity Penalty (discourage rapid oscillations)
z_velocity = lin_vel[2]
velocity_penalty = abs(z_velocity) * 0.1
# 4. Contact Penalty
contact_points = p.getContactPoints(bodyA=self.podId, bodyB=self.trackId)
contact_penalty = len(contact_points) * 0.2
# 5. Power Penalty (encourage efficiency, but small weight)
power_penalty = power * 0.001
# Combine rewards (scaled to ~[-5, +1] range per step)
reward = gap_reward - roll_penalty - pitch_penalty - velocity_penalty - contact_penalty - power_penalty
# Check Termination (tighter bounds for safety)
terminated = False
truncated = False
# Terminate if gap is too small (crash) or too large (lost)
if avg_gap < 0.003 or avg_gap > 0.035:
terminated = True
reward = -10.0 # Failure penalty (scaled down)
# Terminate if orientation is too extreme
if abs(roll_angle) > np.radians(15) or abs(pitch_angle) > np.radians(15):
terminated = True
reward = -10.0
# Success bonus for stable hovering near target
if gap_error_mm < 1.0 and abs(np.degrees(roll_angle)) < 2.0 and abs(np.degrees(pitch_angle)) < 2.0:
reward += 2.0 # Bonus for excellent control
info = {
'curr_front_L': curr_front_L,
'curr_front_R': curr_front_R,
'curr_back_L': curr_back_L,
'curr_back_R': curr_back_R,
'gap_front_yoke': avg_gap_front,
'gap_back_yoke': avg_gap_back,
'roll': roll_angle,
'force_front': force_front,
'force_back': force_back,
'torque_front': torque_front,
'torque_back': torque_back
}
return obs, reward, terminated, truncated, info
[15]. Tool: Gemini
Prompt:
Let's also have an observation function that constructs the input vector that the RL agent sees.
This should consist of the noisy sensor readings and the velocities of each of these sensors.
They should be normalized in a way that is friendly to a neural network, ie floating point values
on [-1,1] ideally.
Output:
def _get_obs(self, initial_reset=False):
"""
Returns observation: [gaps(4), velocities(4)]
Uses noisy sensor readings + computed velocities for microcontroller-friendly deployment
"""
pos, orn = p.getBasePositionAndOrientation(self.podId, physicsClientId=self.client)
# Convert quaternion to rotation matrix
rot_matrix = np.array(p.getMatrixFromQuaternion(orn)).reshape(3, 3)
# Calculate sensor gap heights with noise
sensor_gap_heights = {}
for i, sensor_idx in enumerate(self.sensor_indices):
local_pos = self.collision_local_positions[sensor_idx]
local_vec = np.array(local_pos)
world_offset = rot_matrix @ local_vec
world_pos = np.array(pos) + world_offset
# Top surface of sensor (add half-height = 5mm)
sensor_top_z = world_pos[2] + 0.005
# Gap height: track bottom (Z=0) to sensor top
gap_height = -sensor_top_z
# Add measurement noise
noisy_gap = gap_height + np.random.normal(0, self.sensor_noise_std)
sensor_gap_heights[self.sensor_labels[i]] = noisy_gap
sensor_gap_heights[self.sensor_labels[i]] = gap_height
# Pack sensor measurements in consistent order
# [center_right, center_left, front, back]
gaps = np.array([
sensor_gap_heights.get('Center_Right', 0.010),
sensor_gap_heights.get('Center_Left', 0.010),
sensor_gap_heights.get('Front', 0.010),
sensor_gap_heights.get('Back', 0.010)
], dtype=np.float32)
# Compute velocities (d_gap/dt)
if initial_reset or (self.prev_sensor_gaps is None):
# First observation - no velocity information yet
velocities = np.zeros(4, dtype=np.float32)
else:
# Compute velocity as finite difference
velocities = (gaps - self.prev_sensor_gaps) / self.dt
# Store for next step
self.prev_sensor_gaps = gaps.copy()
# Normalize observations
gaps_normalized = (gaps - TARGET_GAP) / self.gap_scale
velocities_normalized = velocities / self.velocity_scale
# Concatenate: [normalized_gaps, normalized_velocities]
obs = np.concatenate([gaps_normalized, velocities_normalized])
return obs
[16]. Tool: Gemini
Prompt:
I now need a function that will allow the initial height setting to modify
the position of the bolts in the Bullet sim, mirroring how the rig is set
up in real life, as detailed in previous chats.
Output:
def _create_modified_urdf(self):
"""
Create a modified URDF with bolt positions adjusted based on initial gap height.
Bolts are at Z = 0.08585 + gap_mm/1000 (relative to pod origin).
Yokes and sensors remain at Z = 0.08585 (relative to pod origin).
"""
import tempfile
# Calculate bolt Z position
bolt_z = 0.08585 + self.initial_gap_mm / 1000.0
# Read the original URDF template
urdf_template_path = os.path.join(os.path.dirname(__file__), "pod.xml")
with open(urdf_template_path, 'r') as f:
urdf_content = f.read()
# Replace the bolt Z positions (originally at 0.09585)
# There are 4 bolts at different X,Y positions but same Z
urdf_modified = urdf_content.replace(
'xyz="0.285 0.03 0.09585"',
f'xyz="0.285 0.03 {bolt_z:.6f}"'
).replace(
'xyz="0.285 -0.03 0.09585"',
f'xyz="0.285 -0.03 {bolt_z:.6f}"'
).replace(
'xyz="-0.285 0.03 0.09585"',
f'xyz="-0.285 0.03 {bolt_z:.6f}"'
).replace(
'xyz="-0.285 -0.03 0.09585"',
f'xyz="-0.285 -0.03 {bolt_z:.6f}"'
)
# Write to a temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.urdf', delete=False) as f:
f.write(urdf_modified)
temp_urdf_path = f.name
return temp_urdf_path
[17]. Tool: Gemini
Prompt:
Can you generate a python class that simulates the coil inductance and resistance,
taking in a PWM duty cycle and applying a voltage based on that duty cycle to get
an output current?
Output:
class MagLevCoil:
def __init__(self, r_resistance, l_inductance, source_voltage, maxCurrent):
self.R = r_resistance
self.L = l_inductance
self.current = 0.0
self.Vs = source_voltage
self.Imax = maxCurrent
def update(self, pwm_duty_cycle, dt):
"""
Simulates the coil circuit and force generation.
pwm_duty_cycle: -1.0 to 1.0
"""
# Simple First-order RL circuit approximation
# V_in = Duty * V_source
voltage = pwm_duty_cycle * self.Vs
# di/dt = (V - I*R) / L
di = (voltage - self.current * self.R) / self.L
self.current += di * dt
self.current = min(max(-self.Imax, self.current), self.Imax)
return self.current
[18]. Tool: Claude Code
Prompt:
Can you generate a testing script that allows me to input a starting height
and dummy PWM values to the levitation environment so I can do a sanity check
and see if the environment behaves correctly?
Output:
"""
Test script for LevPodEnv
Runs a simple episode with constant actions to verify the environment works
"""
from lev_pod_env import LevPodEnv
import numpy as np
import time
# Create environment with GUI for visualization
env = LevPodEnv(use_gui=True, initial_gap_mm=15)
print("=" * 60)
print("Testing LevPodEnv")
print("=" * 60)
print(f"Action space: {env.action_space}")
print(f" 4 PWM duty cycles: [front_L, front_R, back_L, back_R]")
print(f"Observation space: {env.observation_space}")
print(f" 8 values: [gaps(4), velocities(4)]")
print("=" * 60)
# Reset environment
obs, info = env.reset()
print(f"\nInitial observation:")
print(f" Gaps: CR={obs[0]*1000:.2f}mm, CL={obs[1]*1000:.2f}mm, F={obs[2]*1000:.2f}mm, B={obs[3]*1000:.2f}mm")
print(f" Velocities: CR={obs[4]*1000:.2f}mm/s, CL={obs[5]*1000:.2f}mm/s, F={obs[6]*1000:.2f}mm/s, B={obs[7]*1000:.2f}mm/s")
print(f" Average gap: {np.mean(obs[:4])*1000:.2f} mm")
# Run a few steps with constant action to test force application
print("\nRunning test episode...")
for step in range(500):
# Apply constant moderate PWM to all 4 coils
# 50% PWM should generate current that produces upward force
action = np.array([0,0,0,0], dtype=np.float32)
obs, reward, terminated, truncated, info = env.step(action)
if step % 5 == 0:
print(f"\nStep {step} (t={step/240:.2f}s):")
print(f" Sensor gaps: CR={obs[0]*1000:.2f}mm, CL={obs[1]*1000:.2f}mm, " +
f"F={obs[2]*1000:.2f}mm, B={obs[3]*1000:.2f}mm")
print(f" Velocities: CR={obs[4]*1000:.2f}mm/s, CL={obs[5]*1000:.2f}mm/s, " +
f"F={obs[6]*1000:.2f}mm/s, B={obs[7]*1000:.2f}mm/s")
print(f" Yoke gaps: front={info['gap_front_yoke']*1000:.2f}mm, back={info['gap_back_yoke']*1000:.2f}mm")
print(f" Roll: {np.degrees(info['roll']):.2f}°")
print(f" Currents: FL={info['curr_front_L']:.2f}A, FR={info['curr_front_R']:.2f}A, " +
f"BL={info['curr_back_L']:.2f}A, BR={info['curr_back_R']:.2f}A")
print(f" Forces: front={info['force_front']:.2f}N, back={info['force_back']:.2f}N")
print(f" Torques: front={info['torque_front']:.2f}mN·m, back={info['torque_back']:.2f}mN·m")
print(f" Reward: {reward:.2f}")
if terminated or truncated:
print(f"\nEpisode terminated at step {step}")
break
time.sleep(0.01)
print("\n" + "=" * 60)
print("Test complete!")
print("=" * 60)
env.close()
[19]. Tool: Claude Code
Prompt:
Can we intialize the existing model structure in the rest of the file
around our use case, with the environment being the lev_pod_env? Additionally,
I want to be able to see what the theoretical maximum reward is, so I can see how
well the agent is performing over time without having to use the GUI simulation
environment.
Output:
environ = LevPodEnv(use_gui=False, initial_gap_mm=14, max_steps=500) # Start below target
model = ActorCriticNetwork(environ.observation_space.shape[0], environ.action_space.shape[0]).to(device)
train_data, reward, gap_error = rollout(model, environ)
print("Reward:", reward)
print("Episode length:", len(train_data[0]))
print("Reward per step:", reward / len(train_data[0]))
print(f"Average gap error: {gap_error:.3f} mm")
# Calculate theoretical maximum reward for reference (NEW REWARD STRUCTURE)
max_steps = 500
# Best case: gap_reward=1.0, no penalties, success_bonus=2.0
theoretical_max_per_step = 3.0 # 1.0 (gap) + 2.0 (success bonus) + 0 (no penalties)
# Realistic good case: gap_reward~0.9, small penalties
realistic_good_per_step = 0.8
theoretical_max_total = theoretical_max_per_step * max_steps
realistic_good_total = realistic_good_per_step * max_steps
print(f"\nTheoretical Maximum:")
print(f" Best per step: {theoretical_max_per_step:.2f}")
print(f" Realistic good per step: {realistic_good_per_step:.2f}")
print(f" Best total (500 steps): {theoretical_max_total:.0f}")
print(f" Current % of realistic: {(reward/realistic_good_total)*100:.1f}%")
[20]. Tool: Claude Code
Prompt:
Can we improve our reward function, and see how it drops off as the gap error increases?
It seems the pod is getting too comfortable sitting at the track or just falling away and
not learning to levitate at a very specific gap height.
Output:
# Visualize the new reward function
gap_errors_mm = np.linspace(0, 20, 100)
gap_rewards = np.exp(-0.5 * (gap_errors_mm / 3.0)**2)
plt.figure(figsize=(10, 6))
plt.plot(gap_errors_mm, gap_rewards, linewidth=2)
plt.axvline(x=1.0, color='g', linestyle='--', label='Success bonus threshold (1mm)')
plt.axvline(x=3.0, color='orange', linestyle='--', label='1 std dev (3mm)')
plt.xlabel('Gap Error (mm)', fontsize=12)
plt.ylabel('Gap Reward Component', fontsize=12)
plt.title('New Reward Function: Smooth Exponential Decay', fontsize=14, fontweight='bold')
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
print("Reward examples at different gap errors:")
for err in [0, 1, 3, 5, 10, 15]:
reward = np.exp(-0.5 * (err / 3.0)**2)
print(f" {err:2d}mm error → {reward:.4f} reward")
print("\nCompare to old reward: (error*100)² would be:")
for err in [1, 3, 5, 10]:
old_penalty = (err * 100)**2
print(f" {err:2d}mm error → -{old_penalty:,} penalty")
[21]. Tool: Claude Code
Prompt:
I want to store log files that show the accumulated reward over time
for each trial, as well as a mean gap error chart that should show whether
the model is learning to control at 0 gap error.
Output:
import os
from datetime import datetime
# Create RL_Trials folder if it doesn't exist
os.makedirs('RL_Trials', exist_ok=True)
# Create timestamped log file
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file_path = f'RL_Trials/training_log_{timestamp}.txt'
plot_file_path = f'RL_Trials/gap_error_plot_{timestamp}.png'
# Define training params
num_episodes = 2000 # Increased for more learning time
print_freq = 20 # Print less frequently
gui_freq = 100 # Show GUI every 100 episodes
# Create PPO trainer with improved hyperparameters
ppo = PPOTrainer(
model,
policy_lr=5e-4, # Higher learning rate
value_lr=1e-3, # Even higher for value function
target_kl_div=0.02, # Allow more policy updates
max_policy_train_iters=40,
value_train_iters=40,
entropy_coef=0.02 # More exploration
)
# Open log file
log_file = open(log_file_path, 'w')
log_file.write(f"Training Started: {timestamp}\n")
log_file.write(f"Number of Episodes: {num_episodes}\n")
log_file.write(f"Print Frequency: {print_freq}\n")
log_file.write(f"Target Gap Height: {16.491741} mm\n")
log_file.write(f"Network: 256 hidden units with LayerNorm\n")
log_file.write(f"Policy LR: 5e-4, Value LR: 1e-3, Entropy: 0.02\n")
log_file.write("="*70 + "\n\n")
log_file.flush()
print(f"Logging to: {log_file_path}")
print(f"Plot will be saved to: {plot_file_path}")
# Create and save gap error plot
plt.figure(figsize=(12, 6))
plt.plot(ep_gap_errors, alpha=0.3, label='Per Episode')
# Calculate moving average (window size = print_freq)
window_size = print_freq
if len(ep_gap_errors) >= window_size:
moving_avg = np.convolve(ep_gap_errors, np.ones(window_size)/window_size, mode='valid')
plt.plot(range(window_size-1, len(ep_gap_errors)), moving_avg, linewidth=2, label=f'{window_size}-Episode Moving Average')
plt.xlabel('Episode', fontsize=12)
plt.ylabel('Average Gap Height Error (mm)', fontsize=12)
plt.title('Gap Height Error Over Training, n_steps=500', fontsize=14, fontweight='bold')
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(plot_file_path, dpi=150, bbox_inches='tight')
plt.show()
print(f"\n✓ Training log saved to: {log_file_path}")
print(f"✓ Gap error plot saved to: {plot_file_path}")