This commit is contained in:
saji 2024-12-11 19:59:37 -06:00
parent ea6fdd33c6
commit 7a8fbda82c
8 changed files with 1591 additions and 16 deletions

215
notebooks/v1sim.ipynb Normal file

File diff suppressed because one or more lines are too long

952
pdm.lock

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@ description = "A solar car racing simulation library and GUI tool"
authors = [
{name = "saji", email = "saji@saji.dev"},
]
dependencies = ["pyqtgraph>=0.13.7", "jax>=0.4.35", "pytest>=8.3.3"]
requires-python = ">=3.12"
dependencies = ["pyqtgraph>=0.13.7", "jax>=0.4.35", "pytest>=8.3.3", "pyside6>=6.8.0.2", "matplotlib>=3.9.2", "gymnasium>=1.0.0", "pyvista>=0.44.2", "pyvistaqt>=0.11.1"]
requires-python = ">=3.10,<3.13"
readme = "README.md"
license = {text = "MIT"}
@ -17,3 +17,16 @@ build-backend = "pdm.backend"
[tool.pdm]
distribution = true
[tool.ruff.lint]
[tool.basedpyright]
reportInvalidTypeForm = false
typeCheckingMode = "off"
[dependency-groups]
dev = [
"ipykernel>=6.29.5",
]

View file

@ -1,3 +1,121 @@
# models to generate different environments that the car can drive in.
# This includes terrain, clouds, wind, solar conditions, and the route along the terrain.
import jax
import jax.numpy as jnp
from jax import random
import pyqtgraph as pg
from functools import partial
from pyqtgraph.Qt import QtCore, QtGui
from typing import NamedTuple
import matplotlib.pyplot as plt
import sys
class TerrainParams(NamedTuple):
size: int = 256
octaves: int = 6
persistence: float = 0.5
lacunarity: float = 2.0
seed: int = 42
def lerp(a, b, t):
# assume a and b are pairs of numbers
x = jnp.array([0,1])
f = jnp.array([a,b])
return jnp.interp(t, x, f)
# @partial(jax.jit, static_argnums=(2,))
# def _make_noise_layer(key: random.PRNGKey, frequency: float, shape) -> jnp.ndarray:
#
# noise = random.normal(key, shape)
# # create the grid.
# x = jnp.linspace(0, shape[0] - 1, )
import jax
import jax.numpy as jnp
def generate_permutation():
"""Generate a permutation table."""
p = jnp.arange(256, dtype=jnp.int32)
return jnp.concatenate([p, p])
@jax.jit
def fade(t):
"""Fade function for smooth interpolation."""
return t * t * t * (t * (t * 6 - 15) + 10)
@jax.jit
def lerp(t, a, b):
"""Linear interpolation."""
return a + t * (b - a)
@jax.jit
def grad(hash, x, y):
"""Calculate gradient."""
h = hash & 15
grad_x = jnp.where(h < 8, x, y)
grad_y = jnp.where(h < 4, y, jnp.where((h == 12) | (h == 14), x, y))
return jnp.where(h & 1, -grad_x, grad_x) + jnp.where(h & 2, -grad_y, grad_y)
def perlin(pos):
""" Perlin noise. Shape (N) where N = n_dims (2,3) """
cellpos = pos % 1.0 # get the position inside the cell
upos = fade(pos)
@jax.jit
def perlin_noise_2d(x, y, p):
"""Generate 2D Perlin noise value."""
# Floor coordinates
xi = jnp.floor(x).astype(jnp.int32) & 255
yi = jnp.floor(y).astype(jnp.int32) & 255
# Fractional coordinates
xf = x - jnp.floor(x)
yf = y - jnp.floor(y)
# Fade curves
u = fade(xf)
v = fade(yf)
# Hash coordinates of cube corners
aa = p[p[xi] + yi]
ab = p[p[xi] + yi + 1]
ba = p[p[xi + 1] + yi]
bb = p[p[xi + 1] + yi + 1]
# Gradients
g1 = grad(aa, xf, yf)
g2 = grad(ba, xf - 1, yf)
g3 = grad(ab, xf, yf - 1)
g4 = grad(bb, xf - 1, yf - 1)
# Interpolate
x1 = lerp(u, g1, g2)
x2 = lerp(u, g3, g4)
return lerp(v, x1, x2)
def generate_noise_grid(width, height, scale=50.0):
"""Generate a grid of Perlin noise values."""
p = generate_permutation()
# compute the gradient grid.
gradgrid =
x = jnp.linspace(0, width/scale, width)
y = jnp.linspace(0, height/scale, height)
X, Y = jnp.meshgrid(x, y)
return perlin_noise_2d(X, Y, p)
# Example usage:
key = jax.random.PRNGKey(23)
noise = generate_noise_grid(256, 256)
plt.imshow(noise)
plt.savefig("output.png")
def GymV1():
""" Makes a version 1 gym - simply an elevation profile. """

34
src/solarcarsim/gym.py Normal file
View file

@ -0,0 +1,34 @@
from typing import Optional
import numpy as np
import gymnasium as gym
from solarcarsim.physsim import CarParams, DefaultCar
class SolarRaceV1(gym.Env):
""" A primitive hill climber. Aims to solve the given route optimizing
for energy usage and on-time arrival. Does not have wind or cloud simulations.
Does simulate drag, rolling resistance, and slope power. The action space is the
velocity of the car.
"""
def __init__(self, car: CarParams = DefaultCar(), terrain = None, timestep: float = 1.0):
# TODO: terrain parameters
# car max speed.
self.params = {
"timestep": timestep,
"car": car
}
self.observation_space = gym.spaces.Dict({
"position": gym.spaces.Box(0, 1000.0, shape=(1,)),
"time": gym.spaces.Box(0,1000),
"energy": gym.spaces.Box(-1.0e6, 0.)
# TODO: add the elevation profile to the observations.
})
self.action_space = gym.spaces.Box(0, 5.0, shape=(1,)) #velocity, m/s

137
src/solarcarsim/perlin.py Normal file
View file

@ -0,0 +1,137 @@
import jax
import jax.numpy as jnp
from jax import grad, jit, vmap
from functools import partial
from typing import Optional, Tuple, Union
def generate_permutation(key: jax.random.PRNGKey, size: int = 256) -> jnp.ndarray:
"""Generate a random permutation table."""
perm = jax.random.permutation(key, size)
# Double the permutation to avoid need for wrapping
return jnp.concatenate([perm, perm])
@partial(jit, static_argnums=(1,))
def generate_gradients(key: jax.random.PRNGKey, dim: int, size: int = 256) -> jnp.ndarray:
"""Generate random unit vectors for gradient table."""
# Generate random vectors
grads = jax.random.normal(key, (size, dim))
# Normalize to unit vectors
grads = grads / jnp.linalg.norm(grads, axis=1, keepdims=True)
# Double the gradients to avoid need for wrapping
return jnp.concatenate([grads, grads], axis=0)
@jit
def fade(t: jnp.ndarray) -> jnp.ndarray:
"""Fade function 6t^5 - 15t^4 + 10t^3."""
return t * t * t * (t * (t * 6 - 15) + 10)
def perlin_noise(coords: jnp.ndarray,
gradients: jnp.ndarray,
perm: jnp.ndarray,
octaves: int = 1,
persistence: float = 0.5,
lacunarity: float = 2.0,
ridged: bool = False,
billow: bool = False) -> jnp.ndarray:
"""
Generate N-dimensional Perlin noise.
Args:
coords: Array of shape (..., dim) containing coordinates
gradients: Gradient vectors of shape (size, dim)
perm: Permutation table
octaves: Number of octaves for fractal noise
persistence: Amplitude multiplier for each octave
lacunarity: Frequency multiplier for each octave
ridged: If True, generates ridged multifractal noise
billow: If True, generates billow noise (squared)
Returns:
Array of noise values
"""
def single_noise(p):
# Get integer coordinates
pi = jnp.floor(p).astype(jnp.int32)
# Get decimal part
pf = p - pi
# Generate corner coordinates
corners = jnp.stack(jnp.meshgrid(
*[jnp.array([0, 1]) for _ in range(p.shape[0])],
indexing='ij'
)).reshape(p.shape[0], -1).T
# Get gradients for each corner
corner_gradients = jnp.zeros((corners.shape[0], p.shape[0]))
for i in range(corners.shape[0]):
corner_coords = pi + corners[i]
# Hash coordinates to get gradient index
index = corner_coords[0]
for j in range(1, p.shape[0]):
index = perm[index] + corner_coords[j]
index = perm[index] % (gradients.shape[0] // 2)
corner_gradients = corner_gradients.at[i].set(gradients[index])
# Calculate dot products
vectors = pf - corners
dots = jnp.sum(vectors * corner_gradients, axis=1)
# Interpolate
fade_coords = fade(pf)
for i in range(p.shape[0]):
n_corners = 2 ** i
for j in range(0, dots.shape[0], n_corners * 2):
t = fade_coords[i]
dots = dots.at[j:j+n_corners].set(
dots[j:j+n_corners] + t * (dots[j+n_corners:j+2*n_corners] - dots[j:j+n_corners])
)
return dots[0]
# Vectorize over multiple coordinates
noise_fn = vmap(single_noise)
# Initialize accumulator
result = jnp.zeros(coords.shape[:-1])
frequency = 1.0
amplitude = 1.0
max_value = 0.0
# Generate fractal noise
for _ in range(octaves):
noise = noise_fn(coords * frequency)
if ridged:
noise = 1.0 - jnp.abs(noise)
elif billow:
noise = noise * noise
result += noise * amplitude
max_value += amplitude
frequency *= lacunarity
amplitude *= persistence
# Normalize
result /= max_value
return result
# Example usage for 1D noise
key = jax.random.PRNGKey(0)
key1, key2 = jax.random.split(key)
# Generate tables
perm = generate_permutation(key1)
gradients = generate_gradients(key2, dim=1)
# Generate 1D coordinates
x = jnp.linspace(0, 10, 1000)
coords = jnp.expand_dims(x, axis=1)
# Generate different types of noise
basic_noise = perlin_noise(coords, gradients, perm)
fractal_noise = perlin_noise(coords, gradients, perm, octaves=6)
ridged_noise = perlin_noise(coords, gradients, perm, octaves=6, ridged=True)
billow_noise = perlin_noise(coords, gradients, perm, octaves=6, billow=True)

View file

@ -1,16 +1,133 @@
import jax.numpy as jnp
from jax import grad, jit, vmap, lax
from functools import partial
def calculate_energy_consumption(car_params, elevation_profile):
from typing import NamedTuple, Tuple
class MotorParams(NamedTuple):
kv: float
kt: float
resistance: float
friction_coeff: float
iron_coeff: float
class BatteryParams(NamedTuple):
shape: Tuple[int, int] # (series,parallel) array of batteries
resistance: float # ohms
initial_energy: float # joules
class CarParams(NamedTuple):
""" Physical Data for Solar Car Parameters """
mass: float = 800 # kg
frontal_area: float = 1.3 # m^2
drag_coeff: float = 0.018 # drag coefficient, dimensionless
rolling_coeff: float = 0.002 # rolling resistance.
moter_eff: float = 0.93 # 0 < x < 1 scaling factor
solar_area: float = 5.0 # m^2, typically 5.0
solar_eff: float = 0.20 # 0 < x < 1, typically ~.25
n_motors: int = 2 # how many motors we have.
motor: MotorParams = MotorParams(8.43, 1.1, 100.0, 0.001, 0.001) # mitsuba m2090 estimate
battery: BatteryParams = BatteryParams((36,19), 0.0126, 66.6e3) # freebasing 50s pack.
def DefaultCar() -> CarParams:
""" Creates a basic car """
return CarParams(1000, 1.3, 0.18, 0.002, 0.85, 5.0, 0.23)
# some physics equations using jax
@jit
def normal_force(mass, theta):
return mass * 9.8 * jnp.cos(theta)
@jit
def downslope_force(mass, theta):
return mass * 9.8 * jnp.sin(theta)
@partial(jit, static_argnames=['crr'])
def rolling_force(mass, theta, crr):
return normal_force(mass, theta) * crr
@partial(jit, static_argnames=['area', 'cd', 'rho'])
def drag_force(u, area, cd, rho):
return 0.5 * rho * jnp.pow(u, 2) * cd * area
# we can use those forces above to determine what forces we have to overcome. Sum(F)=0
@partial(jit, static_argnums=(3,4,5,6,7,))
def bldc_power_draw(torque, velocity, resistance=0.1, kt=0.1,
Cf=0.01, iron_loss_coeff=0.005):
"""
Calculate energy consumption based on car parameters and elevation profile.
Approximates power draw of a BLDC motor outputting a torque at a given velocity
Args:
car_params (dict): Dictionary containing car parameters.
elevation_profile (list): List of elevation points.
torq: Applied force in Newton/meters
velocity: Angular velocity in rad/s
resistance: Motor phase resistance (ohms)
kt: Torque constant (Nm/A)
friction_coeff: Mechanical friction coefficient
iron_loss_coeff: Iron loss coefficient (core losses)
Returns:
float: Total energy consumed.
Total electrical power draw in Watts
"""
# Placeholder for actual calculations
return 0.0
# Current required for torque (simplified relationship)
current = torque / kt
# Copper losses (I²R)
copper_losses = resistance * current**2
# Mechanical friction losses
friction_losses = Cf * velocity**2
# Iron losses (simplified model - primarily dependent on speed)
iron_losses = iron_loss_coeff * velocity**2
# Mechanical power output
mechanical_power = torque * velocity
# Total electrical power input
total_power = mechanical_power + copper_losses + friction_losses + iron_losses
return total_power
@partial(jit, static_argnames=['resistance', 'kt', 'kv', 'vmax', 'Cf'])
def bldc_torque(velocity, current_limit, resistance, kt, kv, vmax, Cf):
bemf = velocity / kv
v_avail = jnp.clip(vmax - bemf, 0.0, vmax)
current = jnp.clip(v_avail / resistance, 0.0, current_limit)
torque = kt * current
friction_torque = Cf * velocity
net_torque = jnp.maximum(torque - friction_torque, 0.0)
stall_torque = kt * current_limit
return jnp.where(velocity < 0.01, stall_torque, net_torque)
@partial(jit, static_argnums=(2,3,))
def battery_powerloss(current,cell_r, battery_shape: Tuple[int,int]):
r_array = jnp.full(battery_shape, cell_r)
branch_current = current / battery_shape[1]
I_array = jnp.full(battery_shape, branch_current)
cell_Ploss = jnp.square(I_array) * r_array
return jnp.sum(cell_Ploss)
def forward(state, timestep, control, params: CarParams):
# state is (position, time, velocity, energy)
# control is -1 to 1 (motor max current percent.)
# timestep is >0 time to advance
# params is the params dictionary.
# returns the next state with (position', time + timestep, velocity', energy')
# TODO: terrain, weather, solar
# determine the forces acting on the car.
dragf = drag_force(state[2], params.frontal_area, params.drag_coeff, 1.184)
rollf = rolling_force(params.mass, 0, params.rolling_coeff)
hillforce = downslope_force(params.mass, 0)
pass

View file

@ -0,0 +1 @@
import pyray as ray