diff --git a/.gitignore b/.gitignore index d1f98afe..0783e3fb 100644 --- a/.gitignore +++ b/.gitignore @@ -219,6 +219,11 @@ pythontex-files-*/ # easy-todo *.lod +# CVT plotting artifacts +cvtModel/generated_graphs/ +cvtModel/ramp_plots/ +cvtModel/theta_ramp_plots/ + # xcolor *.xcp diff --git a/backend/app/api/endpoints.py b/backend/app/api/endpoints.py index f2b7c612..11ad577e 100644 --- a/backend/app/api/endpoints.py +++ b/backend/app/api/endpoints.py @@ -47,10 +47,13 @@ def get_constants(): @router.post("/run", response_model=FormattedResultModel) def run(payload: SimulationArgsInput | None = None): # type: ignore """Run CVT simulation with optional custom parameters.""" - args = payload.model_dump(exclude_none=True) if payload else {} - args = SimulationArgs.from_mapping(args) - result = simulate_cvt_model(args) - return result + try: + args = payload.model_dump(exclude_none=True) if payload else {} + args = SimulationArgs.from_mapping(args) + result = simulate_cvt_model(args) + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid simulation input: {e}") @router.post("/run/stream", responses={200: {"model": StreamMessage}}) @@ -168,11 +171,14 @@ def run_simulation_thread(): @router.post("/solvers", response_model=AllSolverResultsModel) def run_solvers(payload: SimulationArgsInput | None = None): # type: ignore - args = payload.model_dump(exclude_none=True) if payload else {} - args = SimulationArgs.from_mapping(args) + try: + args = payload.model_dump(exclude_none=True) if payload else {} + args = SimulationArgs.from_mapping(args) - # Run all solvers in one call - return solve_all(args) + # Run all solvers in one call + return solve_all(args) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid solver input: {e}") # TODO: Remove this logic from endpoints / bake into cvtModel simulator diff --git a/cvtModel/README.md b/cvtModel/README.md index bcc26ce9..6c7175a9 100644 --- a/cvtModel/README.md +++ b/cvtModel/README.md @@ -22,3 +22,20 @@ flake8 src/ test/ ```bash pre-commit install ``` + +## Local Simulation + Graph Validation + +1. Generate a simulation CSV +```bash +python -c "from cvt_simulator import simulate_cvt_model, SimulationArgs; simulate_cvt_model(SimulationArgs(), out_csv='simulation_output.csv')" +``` + +2. Generate validation graphs from the CSV +```bash +python src/cvt_simulator/utils/generate_graphs.py --csv simulation_output.csv --out-dir generated_graphs +``` + +3. Optional: show interactive plots while generating files +```bash +python src/cvt_simulator/utils/generate_graphs.py --csv simulation_output.csv --out-dir generated_graphs --show +``` diff --git a/cvtModel/src/cvt_simulator/__init__.py b/cvtModel/src/cvt_simulator/__init__.py index 663ac1de..1ec44aea 100644 --- a/cvtModel/src/cvt_simulator/__init__.py +++ b/cvtModel/src/cvt_simulator/__init__.py @@ -1,11 +1,11 @@ from .main import simulate_cvt_model from .utils.simulation_args import SimulationArgs from .models.dataTypes import ( - CvtSystemForceBreakdown, - CarForceBreakdown, - EngineForceBreakdown, + CvtDynamicsBreakdown, + SecondaryPulleyDynamicsBreakdown, + PrimaryPulleyDynamicsBreakdown, SlipBreakdown, - SystemBreakdown, + DrivetrainBreakdown, ) from .utils.frontend_output import FormattedSimulationResult from .models.ramps.ramp_config import PiecewiseRampConfig @@ -16,11 +16,11 @@ __all__ = [ "simulate_cvt_model", "SimulationArgs", - "CvtSystemForceBreakdown", - "CarForceBreakdown", - "EngineForceBreakdown", + "CvtDynamicsBreakdown", + "SecondaryPulleyDynamicsBreakdown", + "PrimaryPulleyDynamicsBreakdown", "SlipBreakdown", - "SystemBreakdown", + "DrivetrainBreakdown", "FormattedSimulationResult", "PiecewiseRampConfig", "PiecewiseRamp", diff --git a/cvtModel/src/cvt_simulator/constants/car_specs.py b/cvtModel/src/cvt_simulator/constants/car_specs.py index 8838898a..1d77de2e 100644 --- a/cvtModel/src/cvt_simulator/constants/car_specs.py +++ b/cvtModel/src/cvt_simulator/constants/car_specs.py @@ -14,8 +14,16 @@ class CarSpecs(BaseModel): """ # Inertia values - # TODO: Look into inertia as it should be of all spinning parts engine_inertia: float = Field(default=0.1, description="Engine inertia in kg*m^2") + secondary_inertia: float = Field( + default=0.1, description="Secondary CVT pulley inertia in kg*m^2" + ) + gearbox_inertia: float = Field( + default=0.05, description="Gearbox inertia in kg*m^2" + ) + wheel_inertia: float = Field( + default=0.2, description="Wheel inertia in kg*m^2 (all wheels)" + ) driveline_inertia: float = Field( default=0.5, description="Driveline inertia in kg*m^2 (includes sec CVT, gearbox, axles, wheels, hubs, etc)", @@ -32,6 +40,9 @@ class CarSpecs(BaseModel): drag_coefficient: float = Field( default=0.6, description="Drag coefficient (unitless)" ) + rolling_resistance_coefficient: float = Field( + default=0.015, description="Rolling resistance coefficient (unitless)" + ) # Pulley geometry # TODO: These are all guesses, need to be gotten @@ -39,7 +50,7 @@ class CarSpecs(BaseModel): default=deg_to_rad(11.5 * 2), description="Sheave angle in radians" ) initial_flyweight_radius: float = Field( - default=0.05, description="Initial flyweight radius in meters" + default=0.04878, description="Initial flyweight radius in meters" ) helix_radius: float = Field(default=0.04445, description="Helix radius in meters") @@ -127,10 +138,14 @@ class Config: # Export constants as module-level variables for backward compatibility ENGINE_INERTIA = _default_specs.engine_inertia +SECONDARY_INERTIA = _default_specs.secondary_inertia +GEARBOX_INERTIA = _default_specs.gearbox_inertia +WHEEL_INERTIA = _default_specs.wheel_inertia DRIVELINE_INERTIA = _default_specs.driveline_inertia GEARBOX_RATIO = _default_specs.gearbox_ratio FRONTAL_AREA = _default_specs.frontal_area DRAG_COEFFICIENT = _default_specs.drag_coefficient +ROLLING_RESISTANCE_COEFFICIENT = _default_specs.rolling_resistance_coefficient WHEEL_RADIUS = _default_specs.wheel_radius SHEAVE_ANGLE = _default_specs.sheave_angle INITIAL_FLYWEIGHT_RADIUS = _default_specs.initial_flyweight_radius diff --git a/cvtModel/src/cvt_simulator/constants/constants.py b/cvtModel/src/cvt_simulator/constants/constants.py index f52e0541..e576067b 100644 --- a/cvtModel/src/cvt_simulator/constants/constants.py +++ b/cvtModel/src/cvt_simulator/constants/constants.py @@ -3,5 +3,5 @@ AIR_DENSITY = 1.225 # kg/m^3 GRAVITY = g # m/s^2 RUBBER_DENSITY = 1100 # kg/m^3 -RUBBER_ALUMINUM_STATIC_FRICTION = 0.8 # unitless +RUBBER_ALUMINUM_STATIC_FRICTION = 0.51 # unitless RUBBER_ALUMINUM_KINETIC_FRICTION = 0.65 # unitless diff --git a/cvtModel/src/cvt_simulator/models/car_model.py b/cvtModel/src/cvt_simulator/models/car_model.py deleted file mode 100644 index 157d0e31..00000000 --- a/cvtModel/src/cvt_simulator/models/car_model.py +++ /dev/null @@ -1,41 +0,0 @@ -from cvt_simulator.utils.system_state import SystemState -from cvt_simulator.models.dataTypes import CarForceBreakdown -from cvt_simulator.models.external_load_model import LoadModel -from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm -from cvt_simulator.constants.car_specs import ( - GEARBOX_RATIO, - WHEEL_RADIUS, - DRIVELINE_INERTIA, -) - - -class CarModel: - def __init__( - self, - car_mass: float, - load_model: LoadModel, - ): - self.car_mass = car_mass - self.load_model = load_model - - def get_breakdown( - self, state: SystemState, coupling_torque: float - ) -> CarForceBreakdown: - load_force = self.load_model.get_breakdown(state.car_velocity).net - load_torque = load_force * WHEEL_RADIUS - - engine_to_wheel_ratio = ( - tm.current_cvt_ratio(state.shift_distance) * GEARBOX_RATIO - ) - accel = ( - WHEEL_RADIUS - * (coupling_torque * engine_to_wheel_ratio - load_torque) - / (DRIVELINE_INERTIA + self.car_mass * WHEEL_RADIUS**2) - ) - - return CarForceBreakdown( - coupling_torque_at_wheel=coupling_torque * engine_to_wheel_ratio, - load_torque_at_wheel=load_torque, - external_forces=self.load_model.get_breakdown(state.car_velocity), - acceleration=accel, - ) diff --git a/cvtModel/src/cvt_simulator/models/cvt_shift_model.py b/cvtModel/src/cvt_simulator/models/cvt_shift_model.py index bbe2d421..eed5a4cb 100644 --- a/cvtModel/src/cvt_simulator/models/cvt_shift_model.py +++ b/cvtModel/src/cvt_simulator/models/cvt_shift_model.py @@ -1,4 +1,4 @@ -from cvt_simulator.models.dataTypes import CvtSystemForceBreakdown +from cvt_simulator.models.dataTypes import CvtDynamicsBreakdown from cvt_simulator.models.pulley.primary_pulley_interface import PrimaryPulleyModel from cvt_simulator.models.pulley.secondary_pulley_interface import SecondaryPulleyModel from cvt_simulator.utils.system_state import SystemState @@ -12,8 +12,8 @@ class CvtShiftModel: This model: 1. Takes generic pulley models (any implementation: physical, PID, lookup, etc.) - 2. Calculates radial forces from each pulley using their specific mechanisms - 3. Determines net shift force and acceleration from the force balance + 2. Uses total axial forces from each pulley + 3. Determines net shift force and acceleration from the axial force balance 4. Handles friction and system dynamics The abstraction allows swapping pulley implementations without changing @@ -33,21 +33,21 @@ def __init__( def get_breakdown( self, state: SystemState, coupling_torque: float - ) -> CvtSystemForceBreakdown: + ) -> CvtDynamicsBreakdown: primary_state, secondary_state = self._get_pulley_states(state, coupling_torque) - prim_radial = primary_state.forces.radial_force - sec_radial = secondary_state.forces.radial_force - net = prim_radial - sec_radial + prim_axial = primary_state.forces.axial_force_total + sec_axial = secondary_state.forces.axial_force_total + net = prim_axial - sec_axial shift_velocity = state.shift_velocity friction = self._frictional_force(net, shift_velocity) acceleration = (net + friction) / self.cvt_moving_mass - cvt_ratio = tm.current_cvt_ratio(state.shift_distance) + cvt_ratio = tm.current_effective_cvt_ratio(state.shift_distance) - return CvtSystemForceBreakdown( + return CvtDynamicsBreakdown( primary_state, secondary_state, friction, @@ -71,7 +71,7 @@ def _get_pulley_states(self, state: SystemState, coupling_torque: float): primary_state = self.primary_pulley.get_pulley_state(state) # Calculate CVT ratio for torque scaling to secondary - cvt_ratio = tm.current_cvt_ratio(state.shift_distance) + cvt_ratio = tm.current_effective_cvt_ratio(state.shift_distance) # Get secondary pulley state (torque-reactive, needs scaled torque) secondary_state = self.secondary_pulley.get_pulley_state( @@ -80,11 +80,9 @@ def _get_pulley_states(self, state: SystemState, coupling_torque: float): return primary_state, secondary_state - def _frictional_force( - self, sum_of_radial_forces: float, shift_velocity: float - ) -> float: + def _frictional_force(self, net_axial_force: float, shift_velocity: float) -> float: raw_friction = 20 # TODO: Update to use calculation - friction_magnitude = min(raw_friction, abs(sum_of_radial_forces)) + friction_magnitude = min(raw_friction, abs(net_axial_force)) if shift_velocity > 0: return -friction_magnitude return friction_magnitude diff --git a/cvtModel/src/cvt_simulator/models/dataTypes.py b/cvtModel/src/cvt_simulator/models/dataTypes.py index 63780e89..302334b8 100644 --- a/cvtModel/src/cvt_simulator/models/dataTypes.py +++ b/cvtModel/src/cvt_simulator/models/dataTypes.py @@ -51,6 +51,55 @@ class SecondaryForceBreakdown: net: float +@dataclass +class PrimaryTorqueNumeratorBreakdown: + clamping_term: float + load_term: float + shift_term: float + net: float + + +@dataclass +class PrimaryTorqueDenominatorBreakdown: + inverse_radius_term: float + inertial_feedback_term: float + net: float + + +@dataclass +class PrimaryTorqueBoundsBreakdown: + tau_lower: float + tau_upper: float + numerator: PrimaryTorqueNumeratorBreakdown + denominator_upper: PrimaryTorqueDenominatorBreakdown + denominator_lower: PrimaryTorqueDenominatorBreakdown + + +@dataclass +class SecondaryTorqueNumeratorBreakdown: + spring_term: float + load_term: float + shift_term: float + net: float + + +@dataclass +class SecondaryTorqueDenominatorBreakdown: + inverse_radius_term: float + helix_feedback_term: float + inertial_feedback_term: float + net: float + + +@dataclass +class SecondaryTorqueBoundsBreakdown: + tau_negative: float + tau_positive: float + numerator: SecondaryTorqueNumeratorBreakdown + denominator_positive: SecondaryTorqueDenominatorBreakdown + denominator_negative: SecondaryTorqueDenominatorBreakdown + + # All possible pulley breakdown types PulleyBreakdowns = Union[PrimaryForceBreakdown, SecondaryForceBreakdown] @@ -60,13 +109,15 @@ class PulleyForces: """ Core outputs that every pulley must provide. - These three values are sufficient to drive the CVT simulation regardless + These values are sufficient to drive the CVT simulation regardless of the internal mechanism (flyweights, helix, PID, etc.). """ - clamping_force: float # Axial force pushing pulley halves together [N] - radial_force: float # Total radial force on belt [N] - max_torque: float # Maximum transferable torque before slip [N⋅m] + axial_clamping_force: float # Axial force generated by sheave mechanisms [N] + axial_centrifugal_from_belt: ( + float # Axial force contribution from belt centrifugal loading [N] + ) + axial_force_total: float # Total axial force on the pulley [N] @dataclass @@ -86,16 +137,12 @@ class PulleyState: angular_velocity: float # Pulley angular velocity [rad/s] angular_position: float # Pulley angular position [rad] - # Force components (for analysis/debugging) - radial_from_clamping: float # Radial force contribution from clamping [N] - radial_from_centrifugal: float # Radial force from belt centrifugal effect [N] - # Implementation-specific breakdown (Union of all concrete breakdown types) breakdown: PulleyBreakdowns @dataclass -class CvtSystemForceBreakdown: +class CvtDynamicsBreakdown: primaryPulleyState: PulleyState secondaryPulleyState: PulleyState friction: float @@ -107,28 +154,37 @@ class CvtSystemForceBreakdown: ## External load @dataclass class ExternalLoadForceBreakdown: + rolling_resistance_force: float incline_force: float drag_force: float - net: float + net_force_at_car: float + rolling_resistance_torque_at_secondary: float + incline_torque_at_secondary: float + drag_torque_at_secondary: float + net_torque_at_secondary: float + + @property + def net(self) -> float: + return self.net_torque_at_secondary ## Car @dataclass -class CarForceBreakdown: - coupling_torque_at_wheel: float - load_torque_at_wheel: float +class SecondaryPulleyDynamicsBreakdown: + coupling_torque_at_secondary_pulley: float + external_load_torque_at_secondary_pulley: float external_forces: ExternalLoadForceBreakdown - acceleration: float + secondary_pulley_angular_acceleration: float ## Engine @dataclass -class EngineForceBreakdown: - torque: float - coupling_torque_at_engine: float +class PrimaryPulleyDynamicsBreakdown: + primary_pulley_drive_torque: float + coupling_torque_at_primary_pulley: float power: float - angular_velocity: float - angular_acceleration: float + primary_pulley_angular_velocity: float + primary_pulley_angular_acceleration: float # Slip shenanigans @@ -136,15 +192,18 @@ class EngineForceBreakdown: class SlipBreakdown: coupling_torque: float torque_demand: float - t_max_prim: float - t_max_sec: float - cvt_ratio_derivative: float + relative_velocity: float + tau_upper: float + tau_lower: float + primary_tau_bounds: PrimaryTorqueBoundsBreakdown + secondary_tau_bounds: SecondaryTorqueBoundsBreakdown + effective_cvt_ratio_time_derivative: float is_slipping: bool ## System-level breakdown (single source of truth) @dataclass -class SystemBreakdown: +class DrivetrainBreakdown: """ Single source of truth for the entire system state. @@ -154,14 +213,14 @@ class SystemBreakdown: 3. Eliminating duplication while maintaining clean interfaces Usage: - system = system_model.get_breakdown(state) - slip_data = system.slip - engine_data = system.engine - car_data = system.car - cvt_data = system.cvt + drivetrain = system_model.get_breakdown(state) + slip_data = drivetrain.belt_slip + primary_data = drivetrain.primary_pulley + secondary_data = drivetrain.secondary_pulley + cvt_data = drivetrain.cvt_dynamics """ - slip: SlipBreakdown - engine: EngineForceBreakdown - car: CarForceBreakdown - cvt: CvtSystemForceBreakdown + belt_slip: SlipBreakdown + primary_pulley: PrimaryPulleyDynamicsBreakdown + secondary_pulley: SecondaryPulleyDynamicsBreakdown + cvt_dynamics: CvtDynamicsBreakdown diff --git a/cvtModel/src/cvt_simulator/models/engine_accel_model.py b/cvtModel/src/cvt_simulator/models/engine_accel_model.py deleted file mode 100644 index 9b50a46f..00000000 --- a/cvtModel/src/cvt_simulator/models/engine_accel_model.py +++ /dev/null @@ -1,29 +0,0 @@ -from cvt_simulator.models.dataTypes import EngineForceBreakdown -from cvt_simulator.models.engine_model import EngineModel -from cvt_simulator.utils.system_state import SystemState -from cvt_simulator.constants.car_specs import ENGINE_INERTIA - - -class EngineAccelModel: - """Handles engine dynamics that depend on slip calculations.""" - - def __init__(self, engine_model: EngineModel): - self.engine_model = engine_model - - def get_breakdown( - self, state: SystemState, coupling_torque: float - ) -> EngineForceBreakdown: - - angular_velocity = state.engine_angular_velocity - torque = self.engine_model.get_torque(angular_velocity) - power = self.engine_model.get_power(angular_velocity) - - angular_accel = (torque - coupling_torque) / ENGINE_INERTIA - - return EngineForceBreakdown( - torque=torque, - coupling_torque_at_engine=coupling_torque, - power=power, - angular_velocity=angular_velocity, - angular_acceleration=angular_accel, - ) diff --git a/cvtModel/src/cvt_simulator/models/external_load_model.py b/cvtModel/src/cvt_simulator/models/external_load_model.py index 330b0a59..7f6f687c 100644 --- a/cvtModel/src/cvt_simulator/models/external_load_model.py +++ b/cvtModel/src/cvt_simulator/models/external_load_model.py @@ -5,12 +5,24 @@ DRAG_COEFFICIENT, WHEEL_RADIUS, GEARBOX_RATIO, + ROLLING_RESISTANCE_COEFFICIENT, +) +from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.utils.state_computations import ( + secondary_pulley_angular_velocity_to_car_velocity, ) from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm from cvt_simulator.models.dataTypes import ExternalLoadForceBreakdown class LoadModel: + # Velocity range for smooth rolling resistance transition [m/s] + # Below min: no resistance (lets drivetrain accelerate freely) + # Between min and max: smooth interpolation (avoids hard cutoff) + # Above max: full rolling resistance + ROLLING_RESISTANCE_MIN_VELOCITY = 0.001 + ROLLING_RESISTANCE_MAX_VELOCITY = 0.01 + def __init__( self, car_mass: float, # kg @@ -22,42 +34,100 @@ def __init__( # Car specs self.car_mass = car_mass self.drag_coefficient = DRAG_COEFFICIENT + self.rolling_resistance_coefficient = ROLLING_RESISTANCE_COEFFICIENT self.frontal_area = FRONTAL_AREA self.incline_angle = incline_angle # Gear reduction self.wheel_radius = WHEEL_RADIUS self.gearbox_ratio = GEARBOX_RATIO - def get_breakdown(self, velocity: float) -> ExternalLoadForceBreakdown: - """Calculate the total load force on the wheels due to drag and incline.""" + def get_breakdown(self, state: SystemState) -> ExternalLoadForceBreakdown: + """ + Calculate equivalent road load as seen at the secondary pulley using: + + η_load(t) = (r_w/G) * [C_rr*m*g*cos(α)*sgn(v) + m*g*sin(α) + (1/2)*ρ*c_d*A_f*v²*sgn(v)] + + The velocity term v is derived from the state vector's secondary pulley + angular velocity ω_s. + """ + velocity = secondary_pulley_angular_velocity_to_car_velocity( + state.secondary_pulley_angular_velocity + ) + + rolling_resistance_force = self._calculate_rolling_resistance_force(velocity) incline_force = self._calculate_incline_force() drag_force = self._calculate_drag_force(velocity) - total_load_force = incline_force + drag_force + + # Sum all road-load forces at the car contact patch. + net_force_at_car = rolling_resistance_force + incline_force + drag_force + + # Convert each force component to equivalent torque at the secondary side. + force_to_secondary_torque_scale = self.wheel_radius / self.gearbox_ratio + rolling_resistance_torque_at_secondary = ( + force_to_secondary_torque_scale * rolling_resistance_force + ) + incline_torque_at_secondary = force_to_secondary_torque_scale * incline_force + drag_torque_at_secondary = force_to_secondary_torque_scale * drag_force + net_torque_at_secondary = force_to_secondary_torque_scale * net_force_at_car return ExternalLoadForceBreakdown( - incline_force=incline_force, drag_force=drag_force, net=total_load_force + rolling_resistance_force=rolling_resistance_force, + incline_force=incline_force, + drag_force=drag_force, + net_force_at_car=net_force_at_car, + rolling_resistance_torque_at_secondary=rolling_resistance_torque_at_secondary, + incline_torque_at_secondary=incline_torque_at_secondary, + drag_torque_at_secondary=drag_torque_at_secondary, + net_torque_at_secondary=net_torque_at_secondary, ) - def _calculate_incline_force(self) -> float: - """Calculate the incline force due to gravity.""" - return self.car_mass * self.g * math.sin(self.incline_angle) + def _calculate_rolling_resistance_force(self, velocity: float) -> float: + """ + Calculate rolling resistance force with smooth activation: -C_rr*m*g*cos(α)*smooth(|v|)*sgn(v) - def _calculate_drag_force(self, velocity: float) -> float: - """Calculate the drag force on the car.""" - drag_force = tm.air_resistance( - self.air_density, velocity, self.frontal_area, self.drag_coefficient + Smoothly ramps from 0 to full resistance over MIN to MAX velocity range. + This avoids circular dependency at low speeds while preventing hard cutoffs. + """ + rolling_force_magnitude = tm.rolling_resistance( + self.rolling_resistance_coefficient, + self.car_mass, + self.g, + self.incline_angle, ) - if velocity < 0: - drag_force *= -1 + # Calculate smooth activation factor (0 to 1) based on absolute velocity + abs_velocity = abs(velocity) - return drag_force + if abs_velocity < self.ROLLING_RESISTANCE_MIN_VELOCITY: + # Below min threshold: no resistance + activation_factor = 0.0 + elif abs_velocity > self.ROLLING_RESISTANCE_MAX_VELOCITY: + # Above max threshold: full resistance + activation_factor = 1.0 + else: + # Smooth interpolation between min and max (smoothstep) + t = (abs_velocity - self.ROLLING_RESISTANCE_MIN_VELOCITY) / ( + self.ROLLING_RESISTANCE_MAX_VELOCITY + - self.ROLLING_RESISTANCE_MIN_VELOCITY + ) + # Smoothstep formula: 3t² - 2t³ (smooth cubic interpolation) + activation_factor = 3 * t**2 - 2 * t**3 - # TODO: Why does this exist - def calculate_gearbox_load(self, velocity: float) -> float: - """Calculate the torque at the gearbox""" - return ( - self.calculate_total_load_force(velocity) - * self.wheel_radius - / self.gearbox_ratio + # Apply activation factor and oppose motion direction + return rolling_force_magnitude * activation_factor * tm.sgn(velocity) + + def _calculate_incline_force(self) -> float: + """Calculate the incline force due to gravity: m*g*sin(α)""" + return self.car_mass * self.g * math.sin(self.incline_angle) + + def _calculate_drag_force(self, velocity: float) -> float: + """ + Calculate the aerodynamic drag force: (1/2)*ρ*c_d*A_f*v²*sgn(v) + Opposes motion, so direction determined by sign of velocity. + """ + # Calculate drag magnitude (always positive) + drag_magnitude = tm.air_resistance( + self.air_density, velocity, self.frontal_area, self.drag_coefficient ) + # Apply sign to ensure it opposes motion direction + return drag_magnitude * tm.sgn(velocity) diff --git a/cvtModel/src/cvt_simulator/models/model_initializer.py b/cvtModel/src/cvt_simulator/models/model_initializer.py index fa064885..aca95fcb 100644 --- a/cvtModel/src/cvt_simulator/models/model_initializer.py +++ b/cvtModel/src/cvt_simulator/models/model_initializer.py @@ -1,4 +1,4 @@ -from cvt_simulator.models.car_model import CarModel +from cvt_simulator.models.secondary_pulley_model import SecondaryPulleyModel from cvt_simulator.models.external_load_model import LoadModel from cvt_simulator.models.engine_model import EngineModel from cvt_simulator.models.pulley.primary_pulley_flyweight import PhysicalPrimaryPulley @@ -10,9 +10,11 @@ from cvt_simulator.utils.conversions import deg_to_rad from cvt_simulator.utils.simulation_args import SimulationArgs from cvt_simulator.models.slip_model import SlipModel -from cvt_simulator.models.engine_accel_model import EngineAccelModel +from cvt_simulator.models.primary_pulley_model import PrimaryPulleyModel from cvt_simulator.models.system_model import SystemModel from cvt_simulator.models.ramps.piecewise_ramp import PiecewiseRamp +from cvt_simulator.models.ramps.theta_ramp import ThetaRamp +from cvt_simulator.constants.car_specs import HELIX_RADIUS def get_models(args: SimulationArgs): @@ -35,7 +37,10 @@ def get_models(args: SimulationArgs): spring_coeff_comp=args.secondary_compression_spring_rate, initial_rotation=deg_to_rad(args.secondary_rotational_spring_pretension), initial_compression=args.secondary_linear_spring_pretension, - ramp=PiecewiseRamp.from_config(args.secondary_ramp_config), + ramp=ThetaRamp( + PiecewiseRamp.from_config(args.secondary_ramp_config), + HELIX_RADIUS, + ), ) cvt_shift = CvtShiftModel( @@ -44,24 +49,26 @@ def get_models(args: SimulationArgs): secondary_pulley=secondary_pulley, ) + secondary_pulley_model = SecondaryPulleyModel( + car_mass=args.vehicle_weight + args.driver_weight, + load_model=load_model, + ) + primary_pulley_model = PrimaryPulleyModel(engine_model=engine_model) + slip_model = SlipModel( load_model=load_model, engine_model=engine_model, car_mass=args.vehicle_weight + args.driver_weight, primary_pulley=primary_pulley, secondary_pulley=secondary_pulley, + primary_pulley_model=primary_pulley_model, + secondary_pulley_model=secondary_pulley_model, ) - car_model = CarModel( - car_mass=args.vehicle_weight + args.driver_weight, - load_model=load_model, - ) - engine_accel_model = EngineAccelModel(engine_model=engine_model) - system_model = SystemModel( slip_model=slip_model, - engine_accel_model=engine_accel_model, - car_model=car_model, + primary_pulley_model=primary_pulley_model, + secondary_pulley_model=secondary_pulley_model, cvt_shift_model=cvt_shift, ) diff --git a/cvtModel/src/cvt_simulator/models/primary_pulley_model.py b/cvtModel/src/cvt_simulator/models/primary_pulley_model.py new file mode 100644 index 00000000..07ea9ec5 --- /dev/null +++ b/cvtModel/src/cvt_simulator/models/primary_pulley_model.py @@ -0,0 +1,37 @@ +from cvt_simulator.models.dataTypes import PrimaryPulleyDynamicsBreakdown +from cvt_simulator.models.engine_model import EngineModel +from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.constants.car_specs import ENGINE_INERTIA + + +class PrimaryPulleyModel: + """Primary-pulley-side angular acceleration model.""" + + def __init__(self, engine_model: EngineModel): + self.engine_model = engine_model + # I_p: primary-side rotational inertia used across coupled dynamics. + self.inertia = ENGINE_INERTIA + + def get_breakdown( + self, state: SystemState, coupling_torque: float + ) -> PrimaryPulleyDynamicsBreakdown: + + # Primary pulley angular velocity is the engine speed + primary_pulley_angular_velocity = state.primary_pulley_angular_velocity + primary_pulley_drive_torque = self.engine_model.get_torque( + primary_pulley_angular_velocity + ) + power = self.engine_model.get_power(primary_pulley_angular_velocity) + + # Torque balance at primary pulley: I * alpha = T_drive - T_coupling + primary_pulley_angular_accel = ( + primary_pulley_drive_torque - coupling_torque + ) / self.inertia + + return PrimaryPulleyDynamicsBreakdown( + primary_pulley_drive_torque=primary_pulley_drive_torque, + coupling_torque_at_primary_pulley=coupling_torque, + power=power, + primary_pulley_angular_velocity=primary_pulley_angular_velocity, + primary_pulley_angular_acceleration=primary_pulley_angular_accel, + ) diff --git a/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_flyweight.py b/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_flyweight.py index 15e3e733..3d5eded3 100644 --- a/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_flyweight.py +++ b/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_flyweight.py @@ -1,8 +1,11 @@ -import math import numpy as np from cvt_simulator.models.pulley.primary_pulley_interface import PrimaryPulleyModel +from cvt_simulator.models.pulley.pulley_interface import get_kwarg from cvt_simulator.models.dataTypes import ( PrimaryForceBreakdown, + PrimaryTorqueBoundsBreakdown, + PrimaryTorqueDenominatorBreakdown, + PrimaryTorqueNumeratorBreakdown, flyweightForceBreakdown, springCompForceBreakdown, ) @@ -14,9 +17,12 @@ PiecewiseRamp, ) from cvt_simulator.constants.car_specs import ( + BELT_CROSS_SECTIONAL_AREA, MAX_SHIFT, INITIAL_FLYWEIGHT_RADIUS, + SHEAVE_ANGLE, ) +from cvt_simulator.constants.constants import RUBBER_DENSITY from cvt_simulator.utils.system_state import SystemState @@ -36,16 +42,16 @@ def create_default_flyweight_ramp() -> PiecewiseRamp: # This is the default "Enman" ramp at McMaster baja - # Linear section: ~0.125 inches at -25 degrees - line = LinearSegment(length=inch_to_meter(0.125), angle=-25) + # Linear section: ~0.125 inches at 25 degrees (from horizontal) + line = LinearSegment(length=inch_to_meter(0.125), angle=25) # Circular section: remaining length # Approximating the original curve with a circular arc circle = CircularSegment( length=inch_to_meter(1.0), - angle_start=33.4248111826, # degrees + angle_start=33.4248111826, # degrees (steeper at circle start) angle_end=20.8067910127, # degrees - quadrant=3, # Negative slopes + quadrant=2, # Mirrored Q3: positive slope while keeping steep-to-gentle shape ) ramp.add_segment(line) @@ -64,7 +70,7 @@ class PhysicalPrimaryPulley(PrimaryPulleyModel): Physics: - Flyweights experience centrifugal force: F_c = m * ω² * r - - Ramp converts radial force to axial: F_axial = F_c * tan(ramp_angle) + - Ramp converts flyweight motion directly to axial force through dr_f/ds - Spring opposes shift: F_spring = k * x - Net clamping: F_clamp = F_flyweight - F_spring """ @@ -92,74 +98,131 @@ def __init__( self.flyweight_mass = flyweight_mass self.initial_flyweight_radius = INITIAL_FLYWEIGHT_RADIUS self.ramp = ramp + self._validate_primary_ramp_admissibility() - def calculate_clamping_force( + def calculate_axial_clamping_force( self, state: SystemState, **kwargs ) -> tuple[float, PrimaryForceBreakdown]: """ - Calculate clamping force from flyweight centrifugal force minus spring force. + Calculate mechanism axial clamping force from flyweight force minus spring force. Args: state: Current system state **kwargs: Not used for physical primary (speed-reactive only) Returns: - tuple: (net_clamping_force, detailed_breakdown) + tuple: (axial_clamping_force, detailed_breakdown) """ shift_distance = state.shift_distance - angular_velocity = state.engine_angular_velocity + # Primary pulley angular velocity is the engine speed + primary_pulley_angular_velocity = state.primary_pulley_angular_velocity # Calculate flyweight centrifugal force on ramp flyweight_force_breakdown = self._calculate_flyweight_force( - shift_distance, angular_velocity + shift_distance, primary_pulley_angular_velocity ) # Calculate spring resistance spring_force_breakdown = self._calculate_spring_comp_force(shift_distance) - # Net clamping force (flyweight pushes, spring resists) - net_clamping_force = flyweight_force_breakdown.net - spring_force_breakdown.net + # Mechanism-only axial clamping force (flyweight pushes, spring resists) + axial_clamping_force = ( + flyweight_force_breakdown.net - spring_force_breakdown.net + ) # Package detailed breakdown breakdown = PrimaryForceBreakdown( flyweight_force_breakdown, spring_force_breakdown, - net_clamping_force, + axial_clamping_force, ) - return net_clamping_force, breakdown + return axial_clamping_force, breakdown - def calculate_max_torque( + # TODO: Use updated math here + def calculate_torque_bounds( self, state: SystemState, - ) -> float: + **kwargs, + ) -> PrimaryTorqueBoundsBreakdown: """ - Calculate maximum transferable torque using Capstan equation. - - Calculates radial force internally from current clamping force. - - Args: - state: Current system state + Calculate primary traction torque bounds. Returns: - max_torque: Maximum torque before slip [N⋅m] + PrimaryTorqueBoundsBreakdown with: + - tau_lower / tau_upper limits [N·m] + - numerator term decomposition + - denominator decomposition for upper/lower branches """ - # Calculate clamping force internally - clamping_force, _ = self.calculate_clamping_force(state) - _, _, total_radial = self.calculate_radial_force(state, clamping_force) + shift_distance = np.clip(state.shift_distance, 0, MAX_SHIFT) + # angular_velocity = self._get_angular_velocity(state) + belt_angular_velocity = ( + state.secondary_pulley_angular_velocity + * tm.current_effective_cvt_ratio(state.shift_distance) + ) + + # Geometry terms + r_eff = self._get_radius(shift_distance) + r_cm = self._get_belt_centroid_radius(shift_distance) + r_dot = self._get_radius_rate_of_change(shift_distance) * state.shift_velocity + phi = self._get_wrap_angle(shift_distance) + beta = SHEAVE_ANGLE / 2 + + # Dynamic terms + tau_eng = get_kwarg(kwargs, "engine_drive_torque", None) + I_p = get_kwarg(kwargs, "primary_inertia", None) + if I_p is None or tau_eng is None: + raise ValueError( + "primary_inertia and engine_drive_torque are required for primary traction bounds" + ) + + # This must be ONLY the mechanism clamping force term, not total force with belt corrections + axial_clamping_force, _ = self.calculate_axial_clamping_force(state) + + belt_mass_term = RUBBER_DENSITY * BELT_CROSS_SECTIONAL_AREA * r_cm * phi + + clamping_numerator_term = 2.0 * self.μ * np.tan(beta) * axial_clamping_force + load_numerator_term = -belt_mass_term * ((r_cm * tau_eng) / I_p) + shift_numerator_term = -belt_mass_term * (2.0 * r_dot * belt_angular_velocity) + common_numerator = ( + clamping_numerator_term + load_numerator_term + shift_numerator_term + ) - # Get geometric properties - wrap_angle = self._get_wrap_angle(state.shift_distance) - radius = self._get_radius(state.shift_distance) + denominator_inverse_radius = 1.0 / r_eff + denominator_inertial_feedback = belt_mass_term * r_cm / I_p - # Capstan equation with V-belt friction enhancement - exp_term = math.exp(self.μ * wrap_angle) - capstan_term = (exp_term - 1) / (exp_term + 1) - radial_force_term = total_radial * radius / np.sin(wrap_angle / 2) + upper_denominator = denominator_inverse_radius - denominator_inertial_feedback + lower_denominator = denominator_inverse_radius + denominator_inertial_feedback - max_torque = capstan_term * radial_force_term + tau_upper = common_numerator / upper_denominator + tau_lower = -common_numerator / lower_denominator - return max(0.0, max_torque) # Ensure non-negative + numerator_breakdown = PrimaryTorqueNumeratorBreakdown( + clamping_term=clamping_numerator_term, + load_term=load_numerator_term, + shift_term=shift_numerator_term, + net=common_numerator, + ) + + denominator_upper_breakdown = PrimaryTorqueDenominatorBreakdown( + inverse_radius_term=denominator_inverse_radius, + inertial_feedback_term=-denominator_inertial_feedback, + net=upper_denominator, + ) + + denominator_lower_breakdown = PrimaryTorqueDenominatorBreakdown( + inverse_radius_term=denominator_inverse_radius, + inertial_feedback_term=denominator_inertial_feedback, + net=lower_denominator, + ) + + return PrimaryTorqueBoundsBreakdown( + tau_lower=tau_lower, + tau_upper=tau_upper, + numerator=numerator_breakdown, + denominator_upper=denominator_upper_breakdown, + denominator_lower=denominator_lower_breakdown, + ) # Private helper methods for force calculations @@ -171,9 +234,8 @@ def _calculate_flyweight_force( # TODO: Remove extra clamp shift_distance = np.clip(shift_distance, 0, MAX_SHIFT) - # Calculate flyweight radius at current shift position - # Ramp starts at 0 and goes negative, so subtract - flyweight_radius = self.initial_flyweight_radius - self.ramp.height( + # Height is modeled as additional radial displacement from center. + flyweight_radius = self.initial_flyweight_radius + self.ramp.height( shift_distance ) @@ -184,24 +246,53 @@ def _calculate_flyweight_force( flyweight_radius, ) - # Ramp angle at current position - # Ramp is default negative slope, so negate for angle - # If a positive slope ramp is passed, it will generate a force against shifting - # which is expected for such a stupid ramp design. - angle = np.arctan(-self.ramp.slope(shift_distance)) + # Ramp derivative dr_f/ds at current position. + ramp_gradient = self.ramp.slope(shift_distance) - # Convert centrifugal force to axial clamping force through ramp angle - net = centrifugal_force * np.tan(angle) + # F_p,ax = m_f * omega_p^2 * (r_f,0 + r_f(s)) * dr_f/ds + net = centrifugal_force * ramp_gradient + + # Retain angle output for debug/plots while using derivative for force law. + angle = np.arctan(ramp_gradient) return flyweightForceBreakdown( radius=flyweight_radius, angular_velocity=angular_velocity, angle=angle, centrifugal_force=centrifugal_force, - angle_multiplier=np.tan(angle), + angle_multiplier=ramp_gradient, net=net, ) + def _validate_primary_ramp_admissibility(self) -> None: + """Validate primary ramp profile constraints for r_f(s).""" + if not self.ramp.segments: + raise ValueError("Primary ramp must contain at least one segment") + + for segment in self.ramp.segments: + sample_points = [ + segment.x_start, + (segment.x_start + segment.x_end) / 2, + segment.x_end, + ] + for x in sample_points: + slope = self.ramp.slope(x) + if not np.isfinite(slope): + raise ValueError( + f"Primary ramp slope must be finite on [0, {MAX_SHIFT}], got {slope} at x={x}." + ) + if slope < 0: + raise ValueError( + f"Primary ramp slope must be non-negative on [0, {MAX_SHIFT}], got {slope} at x={x}." + ) + + angle_deg = np.degrees(np.arctan(slope)) + if angle_deg < 0 or angle_deg >= 90: + raise ValueError( + "Primary ramp angle must be in [0, 90) degrees from horizontal; " + f"got {angle_deg} degrees at x={x}." + ) + def _calculate_spring_comp_force( self, shift_distance: float ) -> springCompForceBreakdown: diff --git a/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_interface.py b/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_interface.py index c37702fb..370d2d03 100644 --- a/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_interface.py +++ b/cvtModel/src/cvt_simulator/models/pulley/primary_pulley_interface.py @@ -1,8 +1,5 @@ from cvt_simulator.models.pulley.pulley_interface import PulleyModel from pyparsing import ABC -from cvt_simulator.constants.car_specs import ( - BELT_HEIGHT, -) from cvt_simulator.utils.system_state import SystemState from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm @@ -12,7 +9,7 @@ class PrimaryPulleyModel(PulleyModel, ABC): Abstract base for primary (engine-side) pulley implementations. Primary pulleys typically: - - Run at engine speed + - Run at primary pulley speed (engine speed) - Generate clamping force from centrifugal mechanisms (flyweights) or active control - Start at large radius (low ratio) and shift to small radius (high ratio) @@ -26,12 +23,20 @@ def _get_wrap_angle(self, shift_distance: float) -> float: def _get_radius(self, shift_distance: float) -> float: """Get primary effective pitch radius at current shift position [m].""" - return tm.outer_prim_radius(shift_distance) - BELT_HEIGHT / 2 + return tm.primary_effective_radius(shift_distance) + + def _get_radius_rate_of_change(self, shift_distance: float): + """Get dr/dt at current shift position [m/m].""" + return tm.primary_radius_rate_of_change(shift_distance) def _get_angular_velocity(self, state: SystemState) -> float: - """Get primary pulley angular velocity (engine speed) [rad/s].""" - return state.engine_angular_velocity + """Get primary pulley angular velocity [rad/s].""" + return state.primary_pulley_angular_velocity def _get_angular_position(self, state: SystemState) -> float: - """Get primary pulley angular position (engine position) [rad].""" - return state.engine_angular_position + """Get primary pulley angular position (engine position) [rad]. + + Note: Angular position is not part of the core 4 DOF state. + This method is kept for compatibility but should not be used for ODE integration. + """ + return 0.0 # Placeholder - position is not integrated as part of core dynamics diff --git a/cvtModel/src/cvt_simulator/models/pulley/pulley_interface.py b/cvtModel/src/cvt_simulator/models/pulley/pulley_interface.py index 6b0b0ca5..c944450e 100644 --- a/cvtModel/src/cvt_simulator/models/pulley/pulley_interface.py +++ b/cvtModel/src/cvt_simulator/models/pulley/pulley_interface.py @@ -6,7 +6,7 @@ to be swapped without changing the rest of the simulation. Key Design Principles: -- Each pulley must provide: clamping force, radial force, and max torque +- Each pulley must provide: clamping force and max torque - Implementation details (flyweights, helix, PID, etc.) are encapsulated - Breakdowns provide detailed internal state for debugging/visualization - **kwargs pattern allows flexible, future-proof parameter passing @@ -22,19 +22,19 @@ from typing import Any import numpy as np from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm from cvt_simulator.constants.car_specs import ( SHEAVE_ANGLE, BELT_CROSS_SECTIONAL_AREA, BELT_HEIGHT, - GEARBOX_RATIO, - WHEEL_RADIUS, + BELT_WIDTH_TOP, + BELT_WIDTH_BOTTOM, ) from cvt_simulator.constants.constants import ( RUBBER_DENSITY, RUBBER_ALUMINUM_STATIC_FRICTION, ) from cvt_simulator.models.dataTypes import PulleyState, PulleyForces, PulleyBreakdowns -from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm def get_kwarg(kwargs: dict[str, Any], key: str, default: Any = None) -> Any: @@ -49,7 +49,7 @@ def get_kwarg(kwargs: dict[str, Any], key: str, default: Any = None) -> Any: target_rpm = get_kwarg(kwargs, 'target_rpm') Args: - kwargs: The kwargs dict from calculate_clamping_force + kwargs: The kwargs dict from calculate_axial_clamping_force key: The parameter name to extract default: Default value if key not found (default: None) @@ -74,7 +74,7 @@ def get_required_kwarg(kwargs: dict[str, Any], key: str, error_msg: str = None) ) Args: - kwargs: The kwargs dict from calculate_clamping_force + kwargs: The kwargs dict from calculate_axial_clamping_force key: The parameter name to extract error_msg: Custom error message (optional, auto-generated if not provided) @@ -104,17 +104,17 @@ class PulleyModel(ABC): - etc. The abstraction allows the simulation to work with any mechanism that - can provide the required outputs (clamping force, radial force, max torque). + can provide the required outputs (clamping force and max torque). """ def __init__(self): """Initialize pulley model with V-belt friction coefficient.""" # Calculate friction coefficient with V-belt wedging effect # The sheave angle enhances friction through wedging action - self.μ = RUBBER_ALUMINUM_STATIC_FRICTION / np.sin(SHEAVE_ANGLE / 2) + self.μ = RUBBER_ALUMINUM_STATIC_FRICTION @abstractmethod - def calculate_clamping_force( + def calculate_axial_clamping_force( self, state: SystemState, **kwargs ) -> tuple[float, PulleyBreakdowns]: """ @@ -136,93 +136,83 @@ def calculate_clamping_force( - Any future parameters needed by new implementations Returns: - tuple: (clamping_force, breakdown) - - clamping_force: Net axial force [N] + tuple: (axial_clamping_force, breakdown) + - axial_clamping_force: Axial force from pulley hardware [N] - breakdown: Implementation-specific detailed breakdown """ pass - def calculate_radial_force( - self, - state: SystemState, - clamping_force: float, - ) -> tuple[float, float, float]: + def axial_centrifugal_from_belt(self, state: SystemState) -> float: """ - Calculate total radial force on belt from clamping and centrifugal effects. - - This implements the fundamental physics of V-belt operation (same for all pulleys): - 1. Clamping force → radial force through sheave angle - 2. Belt centrifugal tension adds to radial force - 3. Combined radial force determines friction and torque capacity + Calculate centrifugal belt contribution projected into axial direction. - See: docs/Kai's folder of derivations/ShiftingAndSlip.png + Implements: + F_c,ax = rho_b * A_b * omega^2 * r_cm^2 * phi / (2 * tan(beta)) - Args: - state: Current system state - clamping_force: Axial clamping force [N] - - Returns: - tuple: (radial_from_clamping, radial_from_centrifugal, total_radial) - - radial_from_clamping: Radial force from pulley clamping [N] - - radial_from_centrifugal: Radial force from belt rotation [N] - - total_radial: Sum of both components [N] + where beta is the sheave half-angle. """ - wrap_angle = self._get_wrap_angle(state.shift_distance) - # TODO: Re-use these later? - # radius = self._get_radius(state.shift_distance) - # angular_velocity = self._get_angular_velocity(state) - - # Radial force from pulley clamping (through V-belt wedging) - radial_from_clamping = ( - 2 * (clamping_force * np.tan(SHEAVE_ANGLE / 2)) / wrap_angle + shift_distance = state.shift_distance + wrap_angle = self._get_wrap_angle(shift_distance) + angular_velocity = ( + state.secondary_pulley_angular_velocity + * tm.secondary_effective_radius(shift_distance) + / self._get_radius(shift_distance) ) + r_cm = self._get_belt_centroid_radius(shift_distance) + beta = SHEAVE_ANGLE / 2 - # To future Kai: - # Trust me! If you're using your current assumption - # (Which the belt isn't slipping from the secondary, i.e. belt speed = sec speed) - # Then the formula for both pulleys ends up the same. - # Pretty miraculous I know, but you did this sooo... - sec_radius = tm.outer_sec_radius(state.shift_distance) - BELT_HEIGHT / 2 - - wheel_to_sec_ratio = GEARBOX_RATIO / WHEEL_RADIUS - sec_angular_velocity = state.car_velocity * wheel_to_sec_ratio - - radial_from_centrifugal = ( - sec_angular_velocity**2 - * sec_radius**2 + return ( + RUBBER_DENSITY * BELT_CROSS_SECTIONAL_AREA - * RUBBER_DENSITY + * angular_velocity**2 + * r_cm**2 + * wrap_angle + / (2 * np.tan(beta)) ) - # Total radial force (determines friction capacity) - total_radial = ( - 2 - * np.sin(wrap_angle / 2) - * (radial_from_clamping + radial_from_centrifugal) + def _get_belt_centroid_radius(self, shift_distance: float) -> float: + """Get belt mass-centroid radius r_cm at current shift position [m].""" + # Delta from trapezoidal belt cross-section centroid (measured from outer face). + delta_r_cm = ( + BELT_HEIGHT + * (BELT_WIDTH_TOP + 2 * BELT_WIDTH_BOTTOM) + / (3 * (BELT_WIDTH_TOP + BELT_WIDTH_BOTTOM)) ) + r_out = self._get_radius(shift_distance) + BELT_HEIGHT / 2 + return r_out - delta_r_cm - return radial_from_clamping, radial_from_centrifugal, total_radial + def calculate_integrated_normal_load(self, axial_force_total: float) -> float: + """ + Get integrated normal load over wrap from total axial force (N_phi). + + Uses N_phi = 2 * F_ax * tan(beta), where beta is sheave half-angle. + """ + return 2 * axial_force_total * np.tan(SHEAVE_ANGLE / 2) @abstractmethod - def calculate_max_torque( + def calculate_torque_bounds( self, state: SystemState, - ) -> float: + **kwargs, + ) -> tuple[float, float]: """ Calculate maximum transferable torque before belt slip. - Uses Capstan equation (or Eytelwein formula) modified for V-belts. - The pulley calculates its own radial force internally based on - its current clamping force and operating conditions. + Uses Capstan equation (or Eytelwein formula) in an axial-load formulation. + The pulley calculates its own axial force internally based on + current operating conditions. The limiting torque depends on: - Belt-pulley friction (enhanced by V-groove wedging) - Wrap angle (more wrap = more capacity) - - Radial tension (from clamping + centrifugal) + - Total axial loading (sheave clamp + belt centrifugal contribution) - Effective radius Args: state: Current system state + **kwargs: Optional implementation-specific parameters used by + some pulley models (for example external load torque or + equivalent side inertia terms). Returns: max_torque: Maximum torque capacity [N⋅m] @@ -234,27 +224,28 @@ def get_pulley_state(self, state: SystemState, **kwargs) -> PulleyState: Calculate complete pulley state (main entry point). This orchestrates the three core calculations in sequence: - 1. Calculate clamping force (mechanism-specific) - 2. Calculate radial force (physics-based) - 3. Calculate max torque (Capstan equation) + 1. Calculate axial clamping force from the pulley mechanism + 2. Calculate axial centrifugal belt contribution + 3. Form total axial force + 4. Calculate max torque (Capstan equation) Args: state: Current system state - **kwargs: Implementation-specific parameters (see calculate_clamping_force) + **kwargs: Implementation-specific parameters (see calculate_axial_clamping_force) Returns: PulleyState with all forces, geometry, and detailed breakdown """ - # Step 1: Get clamping force and breakdown - clamping_force, breakdown = self.calculate_clamping_force(state, **kwargs) - - # Step 2: Calculate radial force components - radial_from_clamping, radial_from_centrifugal, total_radial = ( - self.calculate_radial_force(state, clamping_force) + # Step 1: Get mechanism-generated axial clamping force and breakdown + axial_clamping_force, breakdown = self.calculate_axial_clamping_force( + state, **kwargs ) - # Step 3: Calculate max transferable torque - max_torque = self.calculate_max_torque(state) + # Step 2: Axial centrifugal belt contribution + axial_centrifugal_from_belt = self.axial_centrifugal_from_belt(state) + + # Step 3: Total axial force + axial_force_total = axial_clamping_force + axial_centrifugal_from_belt # Get geometric properties wrap_angle = self._get_wrap_angle(state.shift_distance) @@ -264,9 +255,9 @@ def get_pulley_state(self, state: SystemState, **kwargs) -> PulleyState: # Package into PulleyForces forces = PulleyForces( - clamping_force=clamping_force, - radial_force=total_radial, - max_torque=max_torque, + axial_clamping_force=axial_clamping_force, + axial_centrifugal_from_belt=axial_centrifugal_from_belt, + axial_force_total=axial_force_total, ) # Return complete state @@ -276,8 +267,6 @@ def get_pulley_state(self, state: SystemState, **kwargs) -> PulleyState: radius=radius, angular_velocity=angular_velocity, angular_position=angular_position, - radial_from_clamping=radial_from_clamping, - radial_from_centrifugal=radial_from_centrifugal, breakdown=breakdown, ) @@ -292,6 +281,11 @@ def _get_radius(self, shift_distance: float) -> float: """Get effective pitch radius at current shift position [m].""" pass + @abstractmethod + def _get_radius_rate_of_change(self, shift_distance: float) -> float: + """Get dr/dt at current shift position [m/m].""" + pass + @abstractmethod def _get_angular_velocity(self, state: SystemState) -> float: """Get pulley angular velocity [rad/s].""" diff --git a/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_interface.py b/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_interface.py index 91b2e8ac..005a91db 100644 --- a/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_interface.py +++ b/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_interface.py @@ -1,10 +1,5 @@ from cvt_simulator.models.pulley.pulley_interface import PulleyModel from pyparsing import ABC -from cvt_simulator.constants.car_specs import ( - BELT_HEIGHT, - GEARBOX_RATIO, - WHEEL_RADIUS, -) from cvt_simulator.utils.system_state import SystemState from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm @@ -14,7 +9,7 @@ class SecondaryPulleyModel(PulleyModel, ABC): Abstract base for secondary (driven-side) pulley implementations. Secondary pulleys typically: - - Run at wheel speed (through gearbox) + - Run at secondary pulley speed (wheel speed / gearbox) - Generate clamping force from torque feedback (helix) or active control - Start at small radius (low ratio) and shift to large radius (high ratio) - Must react to torque to provide back-pressure for shifting @@ -29,14 +24,20 @@ def _get_wrap_angle(self, shift_distance: float) -> float: def _get_radius(self, shift_distance: float) -> float: """Get secondary effective pitch radius at current shift position [m].""" - return tm.outer_sec_radius(shift_distance) - BELT_HEIGHT / 2 + return tm.secondary_effective_radius(shift_distance) + + def _get_radius_rate_of_change(self, shift_distance): + """Get dr/dt at current shift position [m/m].""" + return tm.secondary_radius_rate_of_change(shift_distance) def _get_angular_velocity(self, state: SystemState) -> float: - """Get secondary pulley angular velocity (wheel speed / gearbox) [rad/s].""" - wheel_to_sec_ratio = GEARBOX_RATIO / WHEEL_RADIUS - return state.car_velocity * wheel_to_sec_ratio + """Get secondary pulley angular velocity [rad/s].""" + return state.secondary_pulley_angular_velocity def _get_angular_position(self, state: SystemState) -> float: - """Get secondary pulley angular position (wheel position / gearbox) [rad].""" - wheel_to_sec_ratio = GEARBOX_RATIO / WHEEL_RADIUS - return state.car_position * wheel_to_sec_ratio + """Get secondary pulley angular position (wheel position / gearbox) [rad]. + + Note: Angular position is not part of the core 4 DOF state. + This method is kept for compatibility but should not be used for ODE integration. + """ + return 0.0 # Placeholder - position is not integrated as part of core dynamics diff --git a/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_torque_reactive.py b/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_torque_reactive.py index 9c4faab8..3750df60 100644 --- a/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_torque_reactive.py +++ b/cvtModel/src/cvt_simulator/models/pulley/secondary_pulley_torque_reactive.py @@ -1,41 +1,47 @@ import numpy as np from cvt_simulator.models.pulley.secondary_pulley_interface import SecondaryPulleyModel -from cvt_simulator.models.pulley.pulley_interface import get_required_kwarg +from cvt_simulator.models.pulley.pulley_interface import get_kwarg, get_required_kwarg from cvt_simulator.models.dataTypes import ( HelixForceBreakdown, SecondaryForceBreakdown, + SecondaryTorqueBoundsBreakdown, + SecondaryTorqueDenominatorBreakdown, + SecondaryTorqueNumeratorBreakdown, SpringTorsForceBreakdown, springCompForceBreakdown, ) from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm from cvt_simulator.constants.car_specs import ( - BELT_HEIGHT, + BELT_CROSS_SECTIONAL_AREA, MAX_SHIFT, HELIX_RADIUS, SHEAVE_ANGLE, ) -from cvt_simulator.models.ramps import LinearSegment, PiecewiseRamp +from cvt_simulator.constants.constants import RUBBER_DENSITY +from cvt_simulator.models.ramps import LinearSegment, PiecewiseRamp, ThetaRamp from cvt_simulator.utils.system_state import SystemState -# TODO: Remove this code -def create_default_helix_ramp() -> PiecewiseRamp: +def create_default_helix_ramp() -> ThetaRamp: """ - Create the default (linear) helix cam ramp geometry. + Create the default helix geometry as a theta ramp. - For the helix ramp, the HEIGHT (y) represents shift distance, - and we need to be able to invert it to find x for a given height. + New convention: + - s is axial shift distance [m] + - u(s) = r_h * theta(s) is circumferential displacement [m] + - tan(alpha_s) = 1 / (r_h * dtheta/ds) + - equivalently: du/ds = cot(alpha_s) - This means the ramp should span from height 0 to MAX_SHIFT. + Segment angles passed to ThetaRamp are helix angles from circumferential. + Default helix angle is alpha_s = 36°. Returns: - PiecewiseRamp with linear helix geometry + ThetaRamp using a PiecewiseRamp that stores u(s) """ - ramp = PiecewiseRamp() - # Create a linear segment where y goes from 0 to MAX_SHIFT - # Using negative angle so slope is negative (helix ramps down) - ramp.add_segment(LinearSegment(length=MAX_SHIFT / 0.3, angle=-16.699)) - return ramp + helix_angle_deg = 36.0 + angle_ramp = PiecewiseRamp() + angle_ramp.add_segment(LinearSegment(length=MAX_SHIFT, angle=helix_angle_deg)) + return ThetaRamp(angle_ramp, HELIX_RADIUS) class PhysicalSecondaryPulley(SecondaryPulleyModel): @@ -62,7 +68,7 @@ def __init__( spring_coeff_comp: float, # N/m - Compression spring stiffness initial_rotation: float, # rad - Torsion spring preload initial_compression: float, # m - Compression spring preload - ramp: PiecewiseRamp, # Helix cam geometry + ramp: ThetaRamp, # Helix cam geometry ): """ Initialize physical secondary pulley with helix mechanism. @@ -81,14 +87,16 @@ def __init__( self.initial_rotation = initial_rotation self.initial_compression = initial_compression self.helix_radius = HELIX_RADIUS - # This ramp needs to have a unique x for every height - self.ramp = ramp + self.theta_ramp = ramp - def calculate_clamping_force( + def calculate_axial_clamping_force( self, state: SystemState, **kwargs ) -> tuple[float, SecondaryForceBreakdown]: """ - Calculate clamping force from helix torque feedback + spring forces. + Calculate mechanism axial clamping force from helix torque feedback + spring forces. + + Implements equation 8.16: + F_s,ax(s, τ_s) = [τ_s + k_s,0(θ_s,0 + θ_s(s)) * dθ_s/ds] / 2 + k_s,x(x_s,0 - s) Args: state: Current system state @@ -96,9 +104,9 @@ def calculate_clamping_force( - torque (float): Transmitted torque through CVT [N⋅m] Returns: - tuple: (net_clamping_force, detailed_breakdown) + tuple: (axial_clamping_force, detailed_breakdown) """ - shift_distance = state.shift_distance + shift_distance = np.clip(state.shift_distance, 0, MAX_SHIFT) # Extract torque from kwargs (required for torque-reactive secondary) torque = get_required_kwarg( @@ -110,85 +118,132 @@ def calculate_clamping_force( ), ) - # Calculate helix force from torque feedback + # Calculate helix force from torque feedback (Eq. 8.16 helix term) helix_force_breakdown = self._calculate_helix_force(torque, shift_distance) - # Calculate compression spring force (static clamping) + # Calculate compression spring force (Eq. 8.16 axial spring term) spring_comp_force_breakdown = self._calculate_spring_comp_force(shift_distance) - # Net clamping force (helix + compression spring) - net_clamping_force = helix_force_breakdown.net + spring_comp_force_breakdown.net + # Total axial clamping force + axial_clamping_force = ( + helix_force_breakdown.net + spring_comp_force_breakdown.net + ) - # Package detailed breakdown breakdown = SecondaryForceBreakdown( spring_comp_force_breakdown, helix_force_breakdown, - net_clamping_force, + axial_clamping_force, ) - return net_clamping_force, breakdown + return axial_clamping_force, breakdown - def calculate_max_torque( + def calculate_torque_bounds( self, state: SystemState, - ) -> float: + **kwargs, + ) -> SecondaryTorqueBoundsBreakdown: """ - Calculate maximum transferable torque using modified Capstan equation. + Calculate secondary traction torque bounds. - For secondary, this is more complex because of torque feedback loop: - - Torque creates clamping → clamping creates capacity → capacity limits torque - - Must solve for equilibrium where T_max satisfies torque feedback + Returns: + SecondaryTorqueBoundsBreakdown with: + - tau_negative / tau_positive limits [N·m] + - numerator term decomposition + - denominator decomposition for both +/- branches + """ + shift_distance = np.clip(state.shift_distance, 0, MAX_SHIFT) + angular_velocity = self._get_angular_velocity(state) + + # Geometry terms + r_eff = self._get_radius(shift_distance) + r_cm = self._get_belt_centroid_radius(shift_distance) + cvt_ratio = tm.current_effective_cvt_ratio(shift_distance) + + # _get_radius_rate_of_change() gives dr/ds, so multiply by s_dot + # to obtain the actual time derivative r_dot. + r_cm_dot = ( + self._get_radius_rate_of_change(shift_distance) * state.shift_velocity + ) - See: docs/Kai's folder of derivations/t_maxSecondaryDerivation.png + phi = self._get_wrap_angle(shift_distance) + beta = SHEAVE_ANGLE / 2 + + # Runtime dynamics terms + tau_load = get_kwarg(kwargs, "external_load_torque", None) + I_s = get_kwarg(kwargs, "secondary_inertia", None) + if I_s is None or tau_load is None: + raise ValueError( + "Both 'secondary_inertia' and 'external_load_torque' are required for secondary traction bounds" + ) + + # Helix / spring terms + dtheta_ds = self.theta_ramp.dtheta_dx(shift_distance) + theta_total = self.initial_rotation + self.theta_ramp.theta(shift_distance) + x_total = self.initial_compression - shift_distance + + spring_term = ( + dtheta_ds * self.spring_coeff_tors * theta_total + + 2.0 * self.spring_coeff_comp * x_total + ) - Args: - state: Current system state + belt_mass_term = RUBBER_DENSITY * BELT_CROSS_SECTIONAL_AREA * r_cm * phi - Returns: - max_torque: Maximum torque before slip [N⋅m] - """ - shift_distance = state.shift_distance - wrap_angle = self._get_wrap_angle(shift_distance) - radius = self._get_radius(shift_distance) + spring_numerator_term = self.μ * np.tan(beta) * spring_term + load_numerator_term = belt_mass_term * ((r_cm * tau_load) / I_s) + shift_numerator_term = belt_mass_term * (-2.0 * r_cm_dot * angular_velocity) + common_numerator = ( + spring_numerator_term + load_numerator_term + shift_numerator_term + ) - # Get spring forces (independent of torque) - spring_comp_force = self._calculate_spring_comp_force(shift_distance).net + denominator_inverse_radius = 1.0 / r_eff + denominator_helix_feedback = self.μ * np.tan(beta) * dtheta_ds + denominator_inertial_feedback = belt_mass_term * r_cm / I_s - # Use helix force calculation with zero torque to get torsion spring torque - helix_breakdown = self._calculate_helix_force(0, shift_distance) - spring_tors_torque = helix_breakdown.net + positive_denominator = ( + denominator_inverse_radius + - denominator_helix_feedback + + denominator_inertial_feedback + ) - # Convert to radial force contribution - spring_force_term = (spring_comp_force + spring_tors_torque) * np.tan( - SHEAVE_ANGLE / 2 + negative_denominator = ( + denominator_inverse_radius + + denominator_helix_feedback + - denominator_inertial_feedback ) - # Centrifugal force contribution, used built in calc with 0 clamp (since we only need centrifugal) - _, radial_from_centrifugal, _ = self.calculate_radial_force(state, 0) - centrifugal_force = radial_from_centrifugal * wrap_angle / 2 - - # Capstan term - exp_term = np.exp(self.μ * wrap_angle) - capstan_term = (wrap_angle / (4 * radius)) * (exp_term + 1) / (exp_term - 1) - - # Torque transmission term (feedback loop) - cvt_ratio = tm.current_cvt_ratio(shift_distance) - helix_angle = helix_breakdown.angle - transmission_term = ( - 2 - * cvt_ratio - * (HELIX_RADIUS * np.tan(helix_angle)) - * np.tan(SHEAVE_ANGLE / 2) + tau_positive = (common_numerator / positive_denominator) / cvt_ratio + tau_negative = (-common_numerator / negative_denominator) / cvt_ratio + + numerator_breakdown = SecondaryTorqueNumeratorBreakdown( + spring_term=spring_numerator_term, + load_term=load_numerator_term, + shift_term=shift_numerator_term, + net=common_numerator, ) - # Solve for max torque (equilibrium of torque feedback loop) - numerator = centrifugal_force + spring_force_term - denominator = capstan_term - transmission_term - max_torque = numerator / denominator + denominator_positive_breakdown = SecondaryTorqueDenominatorBreakdown( + inverse_radius_term=denominator_inverse_radius, + helix_feedback_term=-denominator_helix_feedback, + inertial_feedback_term=denominator_inertial_feedback, + net=positive_denominator, + ) - return max(0.0, max_torque) # Ensure non-negative + denominator_negative_breakdown = SecondaryTorqueDenominatorBreakdown( + inverse_radius_term=denominator_inverse_radius, + helix_feedback_term=denominator_helix_feedback, + inertial_feedback_term=-denominator_inertial_feedback, + net=negative_denominator, + ) - # Private helper methods for force calculations + return SecondaryTorqueBoundsBreakdown( + tau_negative=tau_negative, + tau_positive=tau_positive, + numerator=numerator_breakdown, + denominator_positive=denominator_positive_breakdown, + denominator_negative=denominator_negative_breakdown, + ) + + # Private helper methods def _calculate_helix_force( self, torque: float, shift_distance: float @@ -196,34 +251,23 @@ def _calculate_helix_force( """ Calculate helix cam force from transmitted torque. - Helix converts rotational torque to axial force through cam angle. + Uses Eq. 8.16 helix term: + F_s,helix,ax = [τ_s + k_s,0(θ_s,0 + θ_s(s)) * dθ_s/ds] / 2 """ - # Clamp shift distance to valid range - # TODO: Remove shift_distance = np.clip(shift_distance, 0, MAX_SHIFT) - # Calculate torsion spring torque (resists cam rotation) spring_torque_breakdown = self._calculate_spring_tors_torque(shift_distance) + angle_multiplier = self.theta_ramp.angle_multiplier(shift_distance) + dtheta_ds = self.theta_ramp.dtheta_dx(shift_distance) + helix_angle = np.arctan2(1.0, self.helix_radius * dtheta_ds) - # Effective radius at current shift position - secondary_radius = tm.outer_sec_radius(shift_distance) - BELT_HEIGHT / 2 - - # Helix angle at current position - # Negative slope because helix ramps down with increasing shift - helix_angle = np.arctan(-self.ramp.slope(shift_distance)) - - # Convert torque to axial force through helix geometry - # F = (τ + τ_spring) / (2 * tan(α) * r) - angle_multiplier = 2 * np.tan(helix_angle) * secondary_radius - - # Net - net = (torque + spring_torque_breakdown.net) / angle_multiplier + net = (torque + spring_torque_breakdown.net) * dtheta_ds / 2 return HelixForceBreakdown( feedbackTorque=torque, springTorque=spring_torque_breakdown, angle=helix_angle, - radius=secondary_radius, + radius=self.helix_radius, angle_multiplier=angle_multiplier, net=net, ) @@ -232,10 +276,8 @@ def _calculate_spring_comp_force( self, shift_distance: float ) -> springCompForceBreakdown: """Calculate compression spring force (static clamping).""" - # Total compression = preload + shift distance + shift_distance = np.clip(shift_distance, 0, MAX_SHIFT) total_compression = self.initial_compression + shift_distance - - # Hooke's law: F = k * x net = tm.hookes_law_comp(self.spring_coeff_comp, total_compression) return springCompForceBreakdown( @@ -246,37 +288,14 @@ def _calculate_spring_comp_force( def _calculate_spring_tors_torque( self, shift_distance: float ) -> SpringTorsForceBreakdown: - """Calculate torsion spring torque (resists helix cam rotation).""" - # Clamp shift distance to valid range - # TODO: Remove + """Calculate torsion spring torque from preload + ramp rotation.""" shift_distance = np.clip(shift_distance, 0, MAX_SHIFT) - # Calculate cam rotation from shift distance (approximation) - # TODO: Improve relationship between shift and rotation - rotation_from_shift = self._calculate_rotation(shift_distance) - - # Total rotation = preload + rotation from shift + rotation_from_shift = self.theta_ramp.theta(shift_distance) total_rotation = self.initial_rotation + rotation_from_shift - - # Hooke's law for torsion: τ = k * θ net = tm.hookes_law_tors(self.spring_coeff_tors, total_rotation) return SpringTorsForceBreakdown( rotation=total_rotation, net=net, ) - - def _calculate_rotation(self, shift_distance: float) -> float: - """ - Calculate helix cam rotation from shift distance. - - Args: - shift_distance: Current shift distance [m] (this is the ramp HEIGHT) - - Returns: - Rotation angle [rad] - """ - # Find x position that corresponds to this height - x_position = self.ramp.find_x_at_height(-shift_distance) - # Get slope at that x position - return x_position / HELIX_RADIUS diff --git a/cvtModel/src/cvt_simulator/models/ramps/__init__.py b/cvtModel/src/cvt_simulator/models/ramps/__init__.py index 1d30aa3e..98ba578f 100644 --- a/cvtModel/src/cvt_simulator/models/ramps/__init__.py +++ b/cvtModel/src/cvt_simulator/models/ramps/__init__.py @@ -6,6 +6,9 @@ # Main ramp class from .piecewise_ramp import PiecewiseRamp +# Theta ramp wrapper for helix cam geometry +from .theta_ramp import ThetaRamp + # Config dataclasses for API transport (backend auto-generates Pydantic models) from .ramp_config import ( LinearSegmentConfig, diff --git a/cvtModel/src/cvt_simulator/models/ramps/circular_segment.py b/cvtModel/src/cvt_simulator/models/ramps/circular_segment.py index 74634dd5..4f2d3b80 100644 --- a/cvtModel/src/cvt_simulator/models/ramps/circular_segment.py +++ b/cvtModel/src/cvt_simulator/models/ramps/circular_segment.py @@ -8,10 +8,10 @@ class CircularSegment(RampSegment): Circular arc segment parameterized by slope angles and quadrant. The quadrant determines the curve orientation: - - Q1 (top-right): Negative slopes curving from steep down to gentle down - - Q2 (top-left): Positive slopes curving from gentle up to steep up - - Q3 (bottom-left): Negative slopes curving from steep down to gentle down - - Q4 (bottom-right): Positive slopes curving from gentle up to steep up + - Q1 (top-right): Negative slopes, gentle to steep + - Q2 (top-left): Positive slopes, steep to gentle + - Q3 (bottom-left): Negative slopes, steep to gentle + - Q4 (bottom-right): Positive slopes, gentle to steep Slopes are always specified as POSITIVE values (magnitude only). The quadrant determines the actual sign. @@ -63,16 +63,16 @@ def __init__( angle_start = abs(angle_start) angle_end = abs(angle_end) - # Validate angle ordering based on quadrant - # Q1 & Q3: angles should decrease (steep to gentle, going around the quadrant) - # Q2 & Q4: angles should increase (gentle to steep, going around the quadrant) - if quadrant in [1, 3]: + # Validate angle ordering based on quadrant to keep x increasing along the arc. + # Q2 & Q3: steep to gentle (angle_start >= angle_end) + # Q1 & Q4: gentle to steep (angle_start <= angle_end) + if quadrant in [2, 3]: if angle_start < angle_end: raise ValueError( f"In quadrant {quadrant}, angle_start ({angle_start}°) must be >= angle_end ({angle_end}°). " f"The arc should go from steep to gentle (e.g., 89° to 1°)." ) - else: # quadrant in [2, 4] + else: # quadrant in [1, 4] if angle_start > angle_end: raise ValueError( f"In quadrant {quadrant}, angle_start ({angle_start}°) must be <= angle_end ({angle_end}°). " @@ -129,33 +129,19 @@ def _slope_angle_to_position_angle( - Q3 (π to 3π/2): Bottom left, negative slopes, θ from π to 3π/2 - Q4 (3π/2 to 2π): Bottom right, positive slopes, θ from 3π/2 to 2π """ - # For a circle, if the tangent slope is tan(α), then: - # dy/dx = -cot(θ) = tan(α) - # cot(θ) = -tan(α) - # tan(θ) = -1/tan(α) = -cot(α) - # θ = arctan(-cot(α)) - - # Alternatively, for slope angle α: - # At slope angle 0° (horizontal), position is at π/2 or 3π/2 (top or bottom) - # At slope angle 90° (vertical), position is at 0 or π (right or left) - - # The relationship is: θ = π/2 - α for the base angle - # Then we add quadrant offset - - quadrant_offsets = { - 1: 0, # Q1: 0 to π/2 - 2: np.pi / 2, # Q2: π/2 to π - 3: np.pi, # Q3: π to 3π/2 - 4: 3 * np.pi / 2, # Q4: 3π/2 to 2π - } - - # Within each quadrant, as slope angle goes from 90° to 0°, - # position angle goes from quadrant_start to quadrant_end - # So: θ = quadrant_offset + (π/2 - α) - - base_angle = np.pi / 2 - slope_angle_rad - - return quadrant_offsets[quadrant] + base_angle + # Use a tan(angle-from-horizontal) interpretation with sign from quadrant. + # With slope m = -cot(theta), this gives: + # Q1: m = -tan(alpha) -> theta = pi/2 - alpha + # Q2: m = +tan(alpha) -> theta = pi/2 + alpha + # Q3: m = -tan(alpha) -> theta = 3pi/2 - alpha + # Q4: m = +tan(alpha) -> theta = 3pi/2 + alpha + if quadrant == 1: + return (np.pi / 2) - slope_angle_rad + if quadrant == 2: + return (np.pi / 2) + slope_angle_rad + if quadrant == 3: + return (3 * np.pi / 2) - slope_angle_rad + return (3 * np.pi / 2) + slope_angle_rad def height(self, x: float) -> float: """Calculate y-coordinate at position x along the arc.""" diff --git a/cvtModel/src/cvt_simulator/models/ramps/ramp_config.py b/cvtModel/src/cvt_simulator/models/ramps/ramp_config.py index c3efff45..98ec519c 100644 --- a/cvtModel/src/cvt_simulator/models/ramps/ramp_config.py +++ b/cvtModel/src/cvt_simulator/models/ramps/ramp_config.py @@ -11,6 +11,39 @@ from enum import Enum +def _coerce_float(value, field_name: str) -> float: + """Parse numeric payload values to float with clear errors.""" + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid value for {field_name}: {value!r}") from exc + + +def _coerce_quadrant(value) -> int: + """Parse and validate circular-segment quadrant from payload values.""" + if isinstance(value, bool): + raise ValueError(f"Invalid value for quadrant: {value!r}") + + # Accept common frontend forms like "2" or 2.0 + if isinstance(value, str): + value = value.strip() + if value == "": + raise ValueError("Invalid value for quadrant: empty string") + + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid value for quadrant: {value!r}") from exc + + if not numeric.is_integer(): + raise ValueError(f"Invalid value for quadrant: {value!r}") + + quadrant = int(numeric) + if quadrant not in {1, 2, 3, 4}: + raise ValueError(f"quadrant must be 1, 2, 3, or 4 (got {quadrant})") + return quadrant + + class RampSegmentType(str, Enum): """Enum for ramp segment types. Inherits from str for JSON serialization.""" @@ -88,6 +121,30 @@ def from_dict(cls, data: dict) -> "PiecewiseRampConfig": # Remove 'type' from dict before passing to constructor (it's set automatically) seg_dict_copy = {k: v for k, v in seg_dict.items() if k != "type"} + + # Normalize payload numerics from UI/forms before dataclass construction. + if config_class is LinearSegmentConfig: + seg_dict_copy["length"] = _coerce_float( + seg_dict_copy.get("length"), "length" + ) + seg_dict_copy["angle"] = _coerce_float( + seg_dict_copy.get("angle"), "angle" + ) + elif config_class is CircularSegmentConfig: + seg_dict_copy["length"] = _coerce_float( + seg_dict_copy.get("length"), "length" + ) + seg_dict_copy["angle_start"] = _coerce_float( + seg_dict_copy.get("angle_start"), "angle_start" + ) + seg_dict_copy["angle_end"] = _coerce_float( + seg_dict_copy.get("angle_end"), "angle_end" + ) + if "quadrant" in seg_dict_copy: + seg_dict_copy["quadrant"] = _coerce_quadrant( + seg_dict_copy.get("quadrant") + ) + segments.append(config_class(**seg_dict_copy)) return cls(segments=segments) diff --git a/cvtModel/src/cvt_simulator/models/ramps/ramp_preview.py b/cvtModel/src/cvt_simulator/models/ramps/ramp_preview.py index ff7a681b..03ae42be 100644 --- a/cvtModel/src/cvt_simulator/models/ramps/ramp_preview.py +++ b/cvtModel/src/cvt_simulator/models/ramps/ramp_preview.py @@ -90,20 +90,20 @@ def main(): } # Create a ramp with linear and circular segments using config format - # This is the default "Enman" ramp at McMaster baja + # This is the default "Enman" ramp at McMaster baja (updated to positive convention) config = { "segments": [ { "type": "linear", "length": inch_to_meter(0.18126), - "angle": -15, + "angle": 25, # Positive angle from horizontal }, { "type": "circular", "length": inch_to_meter(1.125 - 0.181226), - "angle_start": 89, # degrees - "angle_end": 1, # degrees - "quadrant": 3, # Third quadrant (default) + "angle_start": 33.4248111826, # degrees (steep start) + "angle_end": 20.8067910127, # degrees (gentle end) + "quadrant": 2, # Mirrored Q3: positive slopes, steep-to-gentle }, ] } diff --git a/cvtModel/src/cvt_simulator/models/ramps/theta_ramp.py b/cvtModel/src/cvt_simulator/models/ramps/theta_ramp.py new file mode 100644 index 00000000..ceb56252 --- /dev/null +++ b/cvtModel/src/cvt_simulator/models/ramps/theta_ramp.py @@ -0,0 +1,184 @@ +""" +Theta ramp wrapper for helix cam geometry. + +Input convention: +- The provided PiecewiseRamp segment angles are interpreted as helix angles + measured from the circumferential direction. + +Internal convention: +- The internal PiecewiseRamp stores circumferential displacement u(x), where u + is the distance the cam surface moves as you move axially. + +The rotation angle is: + theta(x) = u(x) / r_helix + +where r_helix is the helix radius. +""" + +import math +from cvt_simulator.models.ramps.piecewise_ramp import PiecewiseRamp +from cvt_simulator.models.ramps.linear_segment import LinearSegment +from cvt_simulator.models.ramps.circular_segment import CircularSegment + + +class ThetaRamp: + """ + Wraps a PiecewiseRamp of helix angles and converts it to u(x). + + The input ramp segment angles are helix angles measured from the + circumferential direction. ThetaRamp converts each segment to an equivalent + displacement-slope ramp and stores that internal representation as u(x). + + From this: + - theta(x) = u(x) / r (direct, no integration needed) + - dtheta/dx = (du/dx) / r = slope(x) / r + + where r is the helix radius [m]. + """ + + def __init__(self, angle_ramp: PiecewiseRamp, helix_radius: float): + """ + Initialize theta ramp wrapper. + + Args: + angle_ramp: PiecewiseRamp whose segment angles are helix angles + from circumferential direction [deg] + helix_radius: Helix radius r [m] + """ + if helix_radius <= 0: + raise ValueError(f"helix_radius must be positive, got {helix_radius}") + + self.angle_ramp = self._convert_helix_angle_ramp_to_u_ramp(angle_ramp) + self.r = helix_radius + + @staticmethod + def _helix_angle_to_slope_angle(helix_angle_deg: float) -> float: + """ + Convert helix angle (from circumferential direction) to slope angle. + + Uses: + du/dx = cot(beta) + slope_angle = atan(du/dx) + where beta is the helix angle. + """ + beta_rad = math.radians(helix_angle_deg) + tan_beta = math.tan(beta_rad) + if abs(tan_beta) < 1e-12: + raise ValueError( + "Helix angle is too close to 0°/180°; cot(beta) is singular" + ) + return math.degrees(math.atan(1.0 / tan_beta)) + + @classmethod + def _convert_helix_angle_ramp_to_u_ramp( + cls, helix_angle_ramp: PiecewiseRamp + ) -> PiecewiseRamp: + """Convert a helix-angle piecewise ramp into a displacement-slope ramp.""" + converted_ramp = PiecewiseRamp() + + for segment in helix_angle_ramp.segments: + if isinstance(segment, LinearSegment): + converted_ramp.add_segment( + LinearSegment( + length=segment.length, + angle=cls._helix_angle_to_slope_angle(segment.angle), + ) + ) + continue + + if isinstance(segment, CircularSegment): + converted_ramp.add_segment( + CircularSegment( + length=segment.length, + angle_start=cls._helix_angle_to_slope_angle( + segment.angle_start + ), + angle_end=cls._helix_angle_to_slope_angle(segment.angle_end), + quadrant=segment.quadrant, + ) + ) + continue + + raise TypeError( + f"Unsupported ramp segment type for ThetaRamp conversion: {type(segment).__name__}" + ) + + return converted_ramp + + def theta(self, x: float) -> float: + """ + Get cam rotation angle at axial position x. + + Args: + x: Axial position [m] + + Returns: + Rotation angle [rad] + """ + u = self.angle_ramp.height(x) + return u / self.r + + def dtheta_dx(self, x: float) -> float: + """ + Get derivative dθ/dx at position x. + + Args: + x: Axial position [m] + + Returns: + Rotation rate [rad/m] + """ + du_dx = self.angle_ramp.slope(x) + return du_dx / self.r + + def angle_multiplier(self, x: float) -> float: + """ + Get angle-only contribution: du/dx. + + Provides the angle-independent part in: + dθ/dx = angle_multiplier(x) / r + + Args: + x: Axial position [m] + + Returns: + du/dx [m/m = dimensionless] + """ + return self.angle_ramp.slope(x) + + def helix_angle_rad(self, x: float) -> float: + """ + Get helix angle at x [rad]. + + The helix angle alpha is defined by: + tan(alpha) = 1 / (r * dtheta/dx) = 1 / du/dx + + Args: + x: Axial position [m] + + Returns: + Helix angle [rad] + """ + du_dx = self.angle_ramp.slope(x) + if abs(du_dx) < 1e-12: + raise ValueError("du/dx is near zero; helix angle is singular") + return math.atan(1.0 / du_dx) + + def helix_angle_deg(self, x: float) -> float: + """Get helix angle at x [deg].""" + return math.degrees(self.helix_angle_rad(x)) + + def get_x_range(self) -> tuple[float, float]: + """Get the axial range [x_min, x_max] of the ramp.""" + if not self.angle_ramp.segments: + raise ValueError("Ramp has no segments") + x_min = self.angle_ramp.segments[0].x_start + x_max = self.angle_ramp.segments[-1].x_end + return x_min, x_max + + def get_theta_range(self) -> tuple[float, float]: + """Get the rotation range [θ_min, θ_max] of the ramp.""" + x_min, x_max = self.get_x_range() + theta_min = self.theta(x_min) + theta_max = self.theta(x_max) + return min(theta_min, theta_max), max(theta_min, theta_max) diff --git a/cvtModel/src/cvt_simulator/models/ramps/theta_ramp_visualization.py b/cvtModel/src/cvt_simulator/models/ramps/theta_ramp_visualization.py new file mode 100644 index 00000000..4d13c75a --- /dev/null +++ b/cvtModel/src/cvt_simulator/models/ramps/theta_ramp_visualization.py @@ -0,0 +1,332 @@ +""" +3D visualization of theta ramp geometry for secondary helix cam. + +Plots the relationship between: +- x: axial shift position +- θ: cam rotation angle +- u: circumferential displacement + +This reveals the helix geometry and how radius affects the θ(x) profile. +""" + +import numpy as np +import matplotlib.pyplot as plt +from cvt_simulator.models.ramps import LinearSegment, PiecewiseRamp +from cvt_simulator.models.ramps.theta_ramp import ThetaRamp +from cvt_simulator.constants.car_specs import HELIX_RADIUS, MAX_SHIFT + + +def visualize_theta_ramp_3d( + theta_ramp: ThetaRamp, + num_points: int = 500, + figsize: tuple = (12, 9), + start_on_bottom: bool = True, +): + """ + Create true 3D visualization of helix ramp geometry in physical space. + + The helix path is traced as: + - z-axis: axial position (shift distance), rises from bottom to top + - x, y-axes: form circle at each z with radius r, rotated by θ(x) + + Draws vertical lines from helix to bottom circle, and fills the cylindrical + region below the ramp with a transparent surface. + + Note: + ThetaRamp is defined relative to +x. The optional + start_on_bottom flag only changes visualization orientation. + + Args: + theta_ramp: ThetaRamp instance to visualize + num_points: Number of points to sample + figsize: Figure size (width, height) + start_on_bottom: If True, apply a -π/2 visual offset so the plotted + helix starts at the bottom of the circle. + """ + # Generate sample points + x_min, x_max = theta_ramp.get_x_range() + x_axial = np.linspace(x_min, x_max, num_points) + + # Compute theta and convert to 3D coordinates + theta_points = np.array([theta_ramp.theta(x) for x in x_axial]) + + # Optional visual offset so the curve can start at bottom for display. + # Core ThetaRamp math remains referenced to +x. + theta_plot = theta_points - np.pi / 2 if start_on_bottom else theta_points + + # 3D helix parameterization (vertical orientation) + z_helix = x_axial # axial direction (vertical) + x_helix = theta_ramp.r * np.cos(theta_plot) # circumferential x + y_helix = theta_ramp.r * np.sin(theta_plot) # circumferential y + + # Bottom circle + z_bottom = z_helix[0] + + # Create figure + fig = plt.figure(figsize=figsize) + ax = fig.add_subplot(111, projection="3d") + + # ========== Plot the helix curve ========== + ax.plot(x_helix, y_helix, z_helix, "b-", linewidth=3, label="Helix path", zorder=10) + + # ========== Plot start and end points ========== + ax.scatter( + [x_helix[0]], + [y_helix[0]], + [z_helix[0]], + color="green", + s=200, + marker="o", + label="Start (bottom)", + zorder=10, + ) + ax.scatter( + [x_helix[-1]], + [y_helix[-1]], + [z_helix[-1]], + color="red", + s=200, + marker="s", + label="End (top)", + zorder=10, + ) + + # ========== Draw vertical lines from each helix point down to bottom circle ========== + for i in range( + 0, len(x_helix), max(1, len(x_helix) // 20) + ): # Draw every ~5% of points for clarity + ax.plot( + [x_helix[i], x_helix[i]], + [y_helix[i], y_helix[i]], + [z_helix[i], z_bottom], + "b--", + alpha=0.4, + linewidth=0.8, + ) + + # ========== Draw prominent vertical line from endpoint ========== + ax.plot( + [x_helix[-1], x_helix[-1]], + [y_helix[-1], y_helix[-1]], + [z_helix[-1], z_bottom], + "r-", + linewidth=2.5, + alpha=0.8, + label="Vertical drop from end", + ) + + # ========== Create ruled surface (cylinder fill below ramp) ========== + # Top edge: helix points + # Bottom edge: projections of helix points onto bottom circle (radially outward at same angle) + x_bottom_proj = theta_ramp.r * np.cos(theta_plot) + y_bottom_proj = theta_ramp.r * np.sin(theta_plot) + z_bottom_proj = np.full_like(z_helix, z_bottom) + + # Create mesh for the ruled surface + x_surf = np.vstack([x_helix, x_bottom_proj]) + y_surf = np.vstack([y_helix, y_bottom_proj]) + z_surf = np.vstack([z_helix, z_bottom_proj]) + + # Plot surface + ax.plot_surface(x_surf, y_surf, z_surf, alpha=0.2, color="cyan", edgecolor="none") + + # ========== Add reference circles ========== + # Bottom circle (at z_min) + circle_angles = np.linspace(0, 2 * np.pi, 100) + circle_phase = -np.pi / 2 if start_on_bottom else 0.0 + x_circle_bot = theta_ramp.r * np.cos(circle_angles + circle_phase) + y_circle_bot = theta_ramp.r * np.sin(circle_angles + circle_phase) + z_circle_bot = np.full(len(circle_angles), z_bottom) + ax.plot( + x_circle_bot, + y_circle_bot, + z_circle_bot, + "g-", + alpha=0.7, + linewidth=2, + label="Bottom circle", + ) + + # Top circle (at z_max) + z_top = z_helix[-1] + z_circle_top = np.full(len(circle_angles), z_top) + ax.plot( + x_circle_bot, + y_circle_bot, + z_circle_top, + "r--", + alpha=0.5, + linewidth=1.5, + label="Top reference circle", + ) + + # ========== Formatting ========== + ax.set_xlabel("x [m]", fontsize=11, fontweight="bold") + ax.set_ylabel("y [m]", fontsize=11, fontweight="bold") + ax.set_zlabel("Axial Position (Height) [m]", fontsize=11, fontweight="bold") + ax.set_title( + f"3D Helix Ramp with Cylindrical Fill Below\n(Helix Radius = {theta_ramp.r:.4f} m)", + fontsize=13, + fontweight="bold", + ) + + # Enforce true 1:1 data scaling using full cylinder bounds. + # Use full radius bounds (not helix-visited arc bounds) so circles stay circles. + z_top = float(np.max(z_helix)) + z_bottom = float(np.min(z_helix)) + ax.set_xlim(-theta_ramp.r, theta_ramp.r) + ax.set_ylim(-theta_ramp.r, theta_ramp.r) + ax.set_zlim(z_bottom, z_top) + + x_range = 2.0 * theta_ramp.r + y_range = 2.0 * theta_ramp.r + z_range = z_top - z_bottom + ax.set_box_aspect((x_range, y_range, z_range)) + ax.set_proj_type("ortho") + + ax.legend(fontsize=10, loc="upper left") + ax.view_init(elev=20, azim=45) + ax.grid(True, alpha=0.3) + + plt.tight_layout() + return fig + + +def visualize_theta_profiles( + theta_ramp: ThetaRamp, + num_points: int = 500, + figsize: tuple = (20, 5), +): + """Plot theta(x), dtheta/dx(x), angle multiplier, and helix angle beta(x).""" + x_min, x_max = theta_ramp.get_x_range() + x_axial = np.linspace(x_min, x_max, num_points) + + theta_values = np.array([theta_ramp.theta(x) for x in x_axial]) + dtheta_dx_values = np.array([theta_ramp.dtheta_dx(x) for x in x_axial]) + angle_multiplier_values = np.array( + [theta_ramp.angle_multiplier(x) for x in x_axial] + ) + beta_values_deg = np.degrees(np.arctan2(1.0, theta_ramp.r * dtheta_dx_values)) + + fig, (ax1, ax2, ax3, ax4) = plt.subplots(1, 4, figsize=figsize) + + ax1.plot(x_axial, theta_values, "b-", linewidth=2.5) + ax1.set_xlabel("Axial Position x [m]") + ax1.set_ylabel("Theta θ [rad]") + ax1.set_title("Theta vs Axial Position") + ax1.grid(True, alpha=0.3) + + ax2.plot(x_axial, dtheta_dx_values, "r-", linewidth=2.5) + ax2.set_xlabel("Axial Position x [m]") + ax2.set_ylabel("dθ/dx [rad/m]") + ax2.set_title("Theta Gradient vs Axial Position") + ax2.grid(True, alpha=0.3) + + ax3.plot(x_axial, angle_multiplier_values, color="tab:orange", linewidth=2.5) + ax3.set_xlabel("Axial Position x [m]") + ax3.set_ylabel("du/dx [unitless]") + ax3.set_title("Angle Multiplier vs Axial Position") + ax3.grid(True, alpha=0.3) + + ax4.plot(x_axial, beta_values_deg, "g-", linewidth=2.5) + ax4.set_xlabel("Axial Position x [m]") + ax4.set_ylabel("β [deg]") + ax4.set_title("Helix Angle vs Axial Position") + ax4.grid(True, alpha=0.3) + + plt.tight_layout() + return fig + + +def main(): + """Example usage: create and visualize a theta ramp in 3D.""" + print("=" * 70) + print("Theta Ramp 3D Visualization") + print("=" * 70) + + # Create a linear helix ramp that rotates halfway around the circle. + # Goal: θ goes from 0 to π (180°) over MAX_SHIFT. + # For linear: dθ/dx = π / MAX_SHIFT and r * dθ/dx = cot(β). + # ThetaRamp now expects segment angle as helix angle β from circumferential. + + target_rotation_rad = np.pi # 180 degrees around the circle + cot_beta = HELIX_RADIUS * target_rotation_rad / MAX_SHIFT + helix_angle_deg = np.degrees(np.arctan(1.0 / cot_beta)) + + print("\nHelix Parameters:") + print(f" Helix radius r = {HELIX_RADIUS:.4f} m") + print(f" Max shift = {MAX_SHIFT:.4f} m") + print( + f" Target rotation = {np.degrees(target_rotation_rad):.1f}° (halfway around)" + ) + print(f" cot(β) = {cot_beta:.4f}") + print(f" Helix angle β = {helix_angle_deg:.2f}°") + + # Create the angle ramp (helix angle defined directly) + angle_ramp = PiecewiseRamp() + angle_ramp.add_segment(LinearSegment(length=MAX_SHIFT, angle=helix_angle_deg)) + + # Wrap in theta ramp + theta_ramp = ThetaRamp(angle_ramp, HELIX_RADIUS) + + x_min, x_max = theta_ramp.get_x_range() + theta_min, theta_max = theta_ramp.get_theta_range() + + print("\nRamp Ranges:") + print(f" Axial position: [{x_min:.4f}, {x_max:.4f}] m") + print(f" Rotation: [{np.degrees(theta_min):.2f}°, {np.degrees(theta_max):.2f}°]") + print(f" Rotation: [{theta_min:.4f}, {theta_max:.4f}] rad") + + # Create visualizations + print("\nGenerating 3D helix visualization...") + visualize_theta_ramp_3d(theta_ramp, num_points=500) + + print("Generating theta profile plots...") + visualize_theta_profiles(theta_ramp, num_points=500) + + # ----------------------------------------------------------------- + # Example 2: Set helix angle to 36° (direct reference) + # ----------------------------------------------------------------- + helix_angle_deg = 36.0 + print("\n" + "-" * 70) + print("Example 2: Helix Angle Set Directly") + print("-" * 70) + + angle_ramp_direct = PiecewiseRamp() + angle_ramp_direct.add_segment( + LinearSegment(length=MAX_SHIFT, angle=helix_angle_deg) + ) + theta_ramp_direct = ThetaRamp(angle_ramp_direct, HELIX_RADIUS) + + x2_min, x2_max = theta_ramp_direct.get_x_range() + theta2_min, theta2_max = theta_ramp_direct.get_theta_range() + dtheta_dx_direct = theta_ramp_direct.dtheta_dx(x2_min) + + print("\nDirect-Angle Parameters:") + print(f" Helix angle β = {helix_angle_deg:.1f}°") + print(f" Helix radius r = {HELIX_RADIUS:.4f} m") + print(f" Max shift = {MAX_SHIFT:.4f} m") + print(f" dθ/dx = {dtheta_dx_direct:.4f} rad/m") + print( + f" Equivalent helix β = {np.degrees(np.arctan(1.0 / (HELIX_RADIUS * dtheta_dx_direct))):.2f}°" + ) + + print("\nDirect-Angle Ramp Ranges:") + print(f" Axial position: [{x2_min:.4f}, {x2_max:.4f}] m") + print(f" Rotation: [{np.degrees(theta2_min):.2f}°, {np.degrees(theta2_max):.2f}°]") + print(f" Rotation: [{theta2_min:.4f}, {theta2_max:.4f}] rad") + + print("Generating 3D helix visualization (direct 36° angle)...") + visualize_theta_ramp_3d(theta_ramp_direct, num_points=500) + + print("Generating theta profile plots (direct 36° angle)...") + visualize_theta_profiles(theta_ramp_direct, num_points=500) + + print("Displaying plot...") + plt.show() + + print("\n" + "=" * 70) + + +if __name__ == "__main__": + main() diff --git a/cvtModel/src/cvt_simulator/models/secondary_pulley_model.py b/cvtModel/src/cvt_simulator/models/secondary_pulley_model.py new file mode 100644 index 00000000..bfaaa6cc --- /dev/null +++ b/cvtModel/src/cvt_simulator/models/secondary_pulley_model.py @@ -0,0 +1,69 @@ +from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.models.dataTypes import SecondaryPulleyDynamicsBreakdown +from cvt_simulator.models.external_load_model import LoadModel +from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm +from cvt_simulator.constants.car_specs import ( + GEARBOX_RATIO, + WHEEL_RADIUS, + SECONDARY_INERTIA, + GEARBOX_INERTIA, + WHEEL_INERTIA, +) + + +class SecondaryPulleyModel: + """Secondary-pulley-side angular acceleration model.""" + + def __init__( + self, + car_mass: float, + load_model: LoadModel, + ): + self.car_mass = car_mass + self.load_model = load_model + # I_s: secondary-side equivalent rotational inertia used across coupled dynamics. + self.inertia = self._calculate_total_inertia() + + def get_breakdown( + self, state: SystemState, coupling_torque: float + ) -> SecondaryPulleyDynamicsBreakdown: + external_forces = self.load_model.get_breakdown(state) + + primary_to_secondary_ratio = ( + tm.current_effective_cvt_ratio(state.shift_distance) * GEARBOX_RATIO + ) + + # Linear acceleration at the vehicle from torque balance at the secondary side. + linear_accel = ( + WHEEL_RADIUS + * ( + coupling_torque * primary_to_secondary_ratio + - external_forces.net_torque_at_secondary + ) + / self.inertia + ) + + return SecondaryPulleyDynamicsBreakdown( + coupling_torque_at_secondary_pulley=( + coupling_torque * primary_to_secondary_ratio + ), + external_load_torque_at_secondary_pulley=( + external_forces.net_torque_at_secondary + ), + external_forces=external_forces, + secondary_pulley_angular_acceleration=linear_accel / WHEEL_RADIUS, + ) + + def _calculate_total_inertia(self) -> float: + """ + Calculate total system inertia using: I_s = I_sec + I_gb + (I_wheel + m*r_w^2) / G^2 + + Returns: + Total inertia in kg*m^2 + """ + wheel_and_car_inertia = WHEEL_INERTIA + self.car_mass * WHEEL_RADIUS**2 + return ( + SECONDARY_INERTIA + + GEARBOX_INERTIA + + wheel_and_car_inertia / (GEARBOX_RATIO**2) + ) diff --git a/cvtModel/src/cvt_simulator/models/slip_model.py b/cvtModel/src/cvt_simulator/models/slip_model.py index 45062271..dff440db 100644 --- a/cvtModel/src/cvt_simulator/models/slip_model.py +++ b/cvtModel/src/cvt_simulator/models/slip_model.py @@ -1,25 +1,29 @@ import numpy as np -from cvt_simulator.models.dataTypes import SlipBreakdown +from cvt_simulator.models.dataTypes import ( + PrimaryTorqueBoundsBreakdown, + SecondaryTorqueBoundsBreakdown, + SlipBreakdown, +) from cvt_simulator.models.external_load_model import LoadModel from cvt_simulator.models.engine_model import EngineModel from cvt_simulator.models.pulley.primary_pulley_interface import PrimaryPulleyModel from cvt_simulator.models.pulley.secondary_pulley_interface import SecondaryPulleyModel +from cvt_simulator.models.primary_pulley_model import ( + PrimaryPulleyModel as PrimaryPulleyDynamicsModel, +) +from cvt_simulator.models.secondary_pulley_model import ( + SecondaryPulleyModel as SecondaryPulleyDynamicsModel, +) from cvt_simulator.utils.system_state import SystemState from cvt_simulator.constants.car_specs import ( - WHEEL_RADIUS, GEARBOX_RATIO, - ENGINE_INERTIA, - DRIVELINE_INERTIA, - SHEAVE_ANGLE, ) from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm -from cvt_simulator.constants.constants import ( - RUBBER_ALUMINUM_STATIC_FRICTION, -) class SlipModel: slip_speed_threshold: float = 2 # rad/s + relative_speed_zero_tolerance: float = 0.5 # rad/s slip_speed_smoothing: float = 5.0 def __init__( @@ -29,15 +33,16 @@ def __init__( car_mass: float, primary_pulley: PrimaryPulleyModel, secondary_pulley: SecondaryPulleyModel, + primary_pulley_model: PrimaryPulleyDynamicsModel, + secondary_pulley_model: SecondaryPulleyDynamicsModel, ): self.load_model = load_model self.engine_model = engine_model self.car_mass = car_mass self.primary_pulley = primary_pulley self.secondary_pulley = secondary_pulley - self.μ = RUBBER_ALUMINUM_STATIC_FRICTION / np.sin( - SHEAVE_ANGLE / 2 - ) # V-belt groove friction enhancement + self.primary_pulley_model = primary_pulley_model + self.secondary_pulley_model = secondary_pulley_model def get_breakdown(self, state: SystemState) -> SlipBreakdown: """ @@ -49,108 +54,172 @@ def get_breakdown(self, state: SystemState) -> SlipBreakdown: Returns: SlipBreakdown with slip analysis """ - cvt_ratio_derivative = tm.current_cvt_ratio_rate_of_change( - state.shift_distance, state.shift_velocity + effective_cvt_ratio_time_derivative = ( + tm.current_effective_cvt_ratio_time_derivative( + state.shift_distance, state.shift_velocity + ) ) - t_max_prim, t_max_sec = self.calculate_t_max(state) - t_max_capacity = min(t_max_prim, t_max_sec) - torque_demand = self.get_torque_demand(state) - - wheel_to_sec_ratio = GEARBOX_RATIO / WHEEL_RADIUS - relative_speed = self._relative_speed( - state.engine_angular_velocity, - state.car_velocity * wheel_to_sec_ratio, - tm.current_cvt_ratio(state.shift_distance), + ( + tau_lower, + tau_upper, + primary_bounds, + secondary_bounds, + ) = self.calculate_coupling_torque_bounds(state) + tau_ns = self.get_no_slip_torque(state) + + v_delta = self._relative_speed( + state.primary_pulley_angular_velocity, + state.secondary_pulley_angular_velocity, + tm.current_effective_cvt_ratio(state.shift_distance), ) - # 1) Smooth Coulomb-like torque based on slip speed - # For large |relative_speed|, tanh -> ±1, so |coupling_torque| -> t_max_capacity - # For small |relative_speed|, torque ~ (t_max_capacity / slip_speed_smoothing) * relative_speed (viscous-ish) - coulomb_torque = t_max_capacity * np.tanh( - relative_speed / self.slip_speed_smoothing + coupling_torque = self._traction_limited_coupling_torque( + v_delta=v_delta, + tau_ns=tau_ns, + tau_lower=tau_lower, + tau_upper=tau_upper, ) - # 2) Optionally respect torque_demand near zero slip by blending - # When relative_speed is small, use torque_demand (clamped); - # as slip grows, fade to the Coulomb model. - v_blend = self.slip_speed_smoothing # you can use same scale or a separate one - alpha = np.clip(abs(relative_speed) / v_blend, 0.0, 1.0) - - torque_demand_clamped = np.clip(torque_demand, -t_max_capacity, t_max_capacity) - - coupling_torque = (1.0 - alpha) * torque_demand_clamped + alpha * coulomb_torque - # 3) Define is_slipping for diagnostics (no effect on dynamics) - is_slipping = abs(relative_speed) > self.slip_speed_threshold + is_slipping = abs(v_delta) > self.slip_speed_threshold return SlipBreakdown( coupling_torque=coupling_torque, - torque_demand=torque_demand, - cvt_ratio_derivative=cvt_ratio_derivative, - t_max_prim=t_max_prim, - t_max_sec=t_max_sec, + torque_demand=tau_ns, + relative_velocity=v_delta, + tau_upper=tau_upper, + tau_lower=tau_lower, + primary_tau_bounds=primary_bounds, + secondary_tau_bounds=secondary_bounds, + effective_cvt_ratio_time_derivative=effective_cvt_ratio_time_derivative, is_slipping=is_slipping, ) - def get_torque_demand(self, state: SystemState): - wheel_inertia = DRIVELINE_INERTIA + self.car_mass * ( - WHEEL_RADIUS**2 - ) # This is the driveline + car's translational mass at wheels - - engine_torque = self.engine_model.get_torque(state.engine_angular_velocity) - load_force = self.load_model.get_breakdown(state.car_velocity).net - load_torque = load_force * WHEEL_RADIUS - wheel_angular_velocity = self.get_wheel_speed(state.car_velocity) - engine_to_wheel_ratio = ( - tm.current_cvt_ratio(state.shift_distance) * GEARBOX_RATIO - ) - engine_to_wheel_ratio_rate_of_change = ( - tm.current_cvt_ratio_rate_of_change( + def get_no_slip_torque(self, state: SystemState): + # Match the normalized closed-form torque-demand equation: + # tau_p = [tau_eng + (I_p/I_s) * R * tau_load - I_p * omega_s * R_dot] + # / [1 + (I_p/I_s) * R^2] + I_p = self.primary_pulley_model.inertia + I_s = self.secondary_pulley_model.inertia + + tau_eng = self.engine_model.get_torque(state.primary_pulley_angular_velocity) + tau_load = self.load_model.get_breakdown(state).net_torque_at_secondary + + R = tm.current_effective_cvt_ratio(state.shift_distance) * GEARBOX_RATIO + R_dot = ( + tm.current_effective_cvt_ratio_time_derivative( state.shift_distance, state.shift_velocity ) * GEARBOX_RATIO ) - # The secret butter - eng_term = engine_torque * wheel_inertia - load_term = ENGINE_INERTIA * load_torque * engine_to_wheel_ratio - shift_term = ( - ENGINE_INERTIA - * wheel_inertia - * wheel_angular_velocity - * engine_to_wheel_ratio_rate_of_change - ) - - numerator = eng_term + load_term - shift_term - denominator = wheel_inertia + ENGINE_INERTIA * engine_to_wheel_ratio**2 - - coupling_torque = numerator / denominator + omega_s = state.secondary_pulley_angular_velocity + inertia_ratio = I_p / I_s - return coupling_torque + numerator = tau_eng + inertia_ratio * R * tau_load - I_p * omega_s * R_dot + denominator = 1 + inertia_ratio * (R**2) - def get_wheel_speed(self, car_velocity: float): - return car_velocity / WHEEL_RADIUS + return numerator / denominator - def calculate_t_max(self, state: SystemState) -> tuple[float, float]: + def calculate_coupling_torque_bounds(self, state: SystemState) -> tuple[ + float, + float, + PrimaryTorqueBoundsBreakdown, + SecondaryTorqueBoundsBreakdown, + ]: """ - Calculate maximum transferable torque using pulley models directly. + Calculate coupling torque limits from both pulleys. - Uses the more restrictive (smaller) T_MAX from either primary or secondary pulley. - This ensures we don't exceed the slip limit of either pulley. + The coupled belt contact must satisfy both primary and secondary traction + bounds. We therefore use: + - Most restrictive positive bound: min(primary_upper, secondary_upper) + - Most restrictive negative bound: max(primary_lower, secondary_lower) Args: state: Current system state Returns: - tuple: (primary_t_max, secondary_t_max) + tuple: + (coupling_tau_lower, coupling_tau_upper, + primary_tau_bounds, secondary_tau_bounds) """ - primary_t_max = self.primary_pulley.calculate_max_torque(state) - secondary_t_max = self.secondary_pulley.calculate_max_torque(state) + primary_bounds = self._get_pulley_torque_bounds_breakdown( + self.primary_pulley, + state, + engine_drive_torque=self.engine_model.get_torque( + state.primary_pulley_angular_velocity + ), + primary_inertia=self.primary_pulley_model.inertia, + ) + + load_torque = self.load_model.get_breakdown(state).net_torque_at_secondary + secondary_bounds = self._get_pulley_torque_bounds_breakdown( + self.secondary_pulley, + state, + external_load_torque=load_torque, + secondary_inertia=self.secondary_pulley_model.inertia, + ) + + primary_tau_lower = primary_bounds.tau_lower + primary_tau_upper = primary_bounds.tau_upper + secondary_tau_lower = secondary_bounds.tau_negative + secondary_tau_upper = secondary_bounds.tau_positive - # Use the more restrictive (smaller) T_MAX - primary_t_max = max(0, primary_t_max) - secondary_t_max = max(0, secondary_t_max) - return primary_t_max, secondary_t_max + coupling_tau_lower = max(primary_tau_lower, secondary_tau_lower) + coupling_tau_upper = min(primary_tau_upper, secondary_tau_upper) + + return ( + coupling_tau_lower, + coupling_tau_upper, + primary_bounds, + secondary_bounds, + ) + + def _get_pulley_torque_bounds_breakdown( + self, + pulley, + state: SystemState, + **kwargs, + ) -> PrimaryTorqueBoundsBreakdown | SecondaryTorqueBoundsBreakdown: + """Get a torque-bounds breakdown object from a pulley model across API variants.""" + if hasattr(pulley, "get_pulley_torque_bounds"): + bounds = pulley.get_pulley_torque_bounds(state, **kwargs) + elif hasattr(pulley, "calculate_torque_bounds"): + bounds = pulley.calculate_torque_bounds(state, **kwargs) + else: + raise AttributeError( + f"{type(pulley).__name__} does not implement a torque-bounds API" + ) + + if hasattr(bounds, "tau_lower") and hasattr(bounds, "tau_upper"): + return bounds + if hasattr(bounds, "tau_negative") and hasattr(bounds, "tau_positive"): + return bounds + + raise TypeError( + f"Unsupported torque-bounds return type from {type(pulley).__name__}: {type(bounds)}" + ) + + def _traction_limited_coupling_torque( + self, + v_delta: float, + tau_ns: float, + tau_lower: float, + tau_upper: float, + ) -> float: + if tau_lower > tau_upper: + return 0.0 + + tau_ns_clamped = np.clip(tau_ns, tau_lower, tau_upper) + tau_mid = 0.5 * (tau_upper + tau_lower) + tau_amp = 0.5 * (tau_upper - tau_lower) + smoothing = self.slip_speed_smoothing + + tau_sliding = tau_mid + tau_amp * np.tanh(v_delta / smoothing) + + # Blend from stick at v=0 to sliding away from zero + alpha = np.tanh(abs(v_delta) / smoothing) + return float((1.0 - alpha) * tau_ns_clamped + alpha * tau_sliding) def _relative_speed( self, @@ -159,13 +228,3 @@ def _relative_speed( cvt_ratio: float, ) -> float: return primary_angular_velocity - (secondary_angular_velocity * cvt_ratio) - - # def _is_slipping( - # self, - # primary_angular_velocity: float, - # secondary_angular_velocity: float, - # cvt_ratio: float, - # tolerance: float = 2, - # ) -> bool: - # expected_secondary_velocity = primary_angular_velocity / cvt_ratio - # return abs(expected_secondary_velocity - secondary_angular_velocity) > tolerance diff --git a/cvtModel/src/cvt_simulator/models/system_model.py b/cvtModel/src/cvt_simulator/models/system_model.py index ceba2d37..4cc2ebdd 100644 --- a/cvtModel/src/cvt_simulator/models/system_model.py +++ b/cvtModel/src/cvt_simulator/models/system_model.py @@ -1,7 +1,7 @@ -from cvt_simulator.models.dataTypes import SystemBreakdown +from cvt_simulator.models.dataTypes import DrivetrainBreakdown from cvt_simulator.models.slip_model import SlipModel -from cvt_simulator.models.engine_accel_model import EngineAccelModel -from cvt_simulator.models.car_model import CarModel +from cvt_simulator.models.primary_pulley_model import PrimaryPulleyModel +from cvt_simulator.models.secondary_pulley_model import SecondaryPulleyModel from cvt_simulator.models.cvt_shift_model import CvtShiftModel from cvt_simulator.utils.system_state import SystemState @@ -12,16 +12,16 @@ class SystemModel: def __init__( self, slip_model: SlipModel, - engine_accel_model: EngineAccelModel, - car_model: CarModel, + primary_pulley_model: PrimaryPulleyModel, + secondary_pulley_model: SecondaryPulleyModel, cvt_shift_model: CvtShiftModel, ): self.slip_model = slip_model - self.engine_accel_model = engine_accel_model - self.car_model = car_model + self.primary_pulley_model = primary_pulley_model + self.secondary_pulley_model = secondary_pulley_model self.cvt_shift_model = cvt_shift_model - def get_breakdown(self, state: SystemState) -> SystemBreakdown: + def get_breakdown(self, state: SystemState) -> DrivetrainBreakdown: """ Calculate the complete system breakdown in dependency order. @@ -40,19 +40,19 @@ def get_breakdown(self, state: SystemState) -> SystemBreakdown: state, slip_breakdown.coupling_torque ) - # Step 3: Calculate engine dynamics (using slip) - engine_breakdown = self.engine_accel_model.get_breakdown( + # Step 3: Calculate primary-pulley-side dynamics (using slip) + primary_pulley_breakdown = self.primary_pulley_model.get_breakdown( state, slip_breakdown.coupling_torque ) - # Step 4: Calculate car dynamics (using slip) - car_breakdown = self.car_model.get_breakdown( + # Step 4: Calculate secondary-pulley-side dynamics (using slip) + secondary_pulley_breakdown = self.secondary_pulley_model.get_breakdown( state, slip_breakdown.coupling_torque ) - return SystemBreakdown( - slip=slip_breakdown, - engine=engine_breakdown, - car=car_breakdown, - cvt=cvt_breakdown, + return DrivetrainBreakdown( + belt_slip=slip_breakdown, + primary_pulley=primary_pulley_breakdown, + secondary_pulley=secondary_pulley_breakdown, + cvt_dynamics=cvt_breakdown, ) diff --git a/cvtModel/src/cvt_simulator/simulation_runner.py b/cvtModel/src/cvt_simulator/simulation_runner.py index ea284c42..aa809c91 100644 --- a/cvtModel/src/cvt_simulator/simulation_runner.py +++ b/cvtModel/src/cvt_simulator/simulation_runner.py @@ -1,13 +1,12 @@ import sys import numpy as np -from typing import Callable, Optional +from typing import Callable, Optional, Any from scipy.integrate import solve_ivp from cvt_simulator.utils.system_state import SystemState from cvt_simulator.utils.simulation_result import SimulationResult from cvt_simulator.models.system_model import SystemModel from cvt_simulator.constants.car_specs import ( GEARBOX_RATIO, - WHEEL_RADIUS, MAX_SHIFT, ) from cvt_simulator.utils.conversions import rpm_to_rad_s @@ -16,6 +15,8 @@ car_velocity_constraint_event, get_shift_steady_event, get_back_shift_event, + get_mid_shift_steady_event, + get_mid_shift_wake_event, shift_constraint_event, ) @@ -30,28 +31,56 @@ def __init__(self, t, y): class SimulationRunner: """Runs a two-phase CVT system simulation.""" - TOTAL_SIM_TIME = 30 # seconds + TOTAL_SIM_TIME = 15 # seconds + # Hysteresis controls to prevent mid-shift lock/unlock chatter. + MID_SHIFT_MIN_HOLD_TIME = 0.02 # seconds + MID_SHIFT_RELOCK_DELAY = 0.05 # seconds INITIAL_STATE = SystemState( - car_velocity=rpm_to_rad_s(0.1) - / (GEARBOX_RATIO * tm.current_cvt_ratio(0)) - * WHEEL_RADIUS, - car_position=0.0, - shift_velocity=0.0, shift_distance=0.0, - engine_angular_velocity=rpm_to_rad_s(1800), - engine_angular_position=0.0, + shift_velocity=0.0, + # Initial secondary pulley angular velocity derived from initial car velocity + secondary_pulley_angular_velocity=rpm_to_rad_s(0.1) + / (GEARBOX_RATIO * tm.current_effective_cvt_ratio(0)), + # Initial primary pulley angular velocity (engine speed) + primary_pulley_angular_velocity=rpm_to_rad_s(1800), ) def __init__( self, system_model: SystemModel, - # Optional progress callback function that takes a float percentage (0-100) - progress_callback: Optional[Callable[[float], None]] = None, + # Optional progress callback. Preferred signature: + # callback(progress_percent, sim_time_s, shift_distance) + # Backward-compatible signature callback(progress_percent) is also supported. + progress_callback: Optional[Callable[..., None]] = None, + transition_callback: Optional[Callable[[dict[str, Any]], None]] = None, ): self.system_model = system_model self.progress_callback = progress_callback + self.transition_callback = transition_callback self._last_callback_percent = -1.0 + def _emit_transition( + self, + from_mode: str, + to_mode: str, + t: float, + state_array: np.ndarray, + reason: str, + ): + if self.transition_callback is None: + return + state = SystemState.from_array(state_array) + self.transition_callback( + { + "from_mode": from_mode, + "to_mode": to_mode, + "time": float(t), + "reason": reason, + "shift_distance": float(state.shift_distance), + "shift_velocity": float(state.shift_velocity), + } + ) + def run_simulation(self) -> SimulationResult: """Run the simulation and return results.""" cvt_system_ode = self._get_ode_function() @@ -65,105 +94,364 @@ def run_simulation(self) -> SimulationResult: current_time = 0 current_state = self.INITIAL_STATE.to_array() - # Phase 1: Normal shifting until full shift is reached - events_phase1 = [ - get_shift_steady_event(self.system_model), - car_velocity_constraint_event, - shift_constraint_event, - ] + mode = "normal" + locked_shift_distance = None + mid_shift_enter_time: float | None = None + last_mid_shift_wake_time = -float("inf") + transition_count = 0 + max_transitions = 20 + termination_context: dict[str, Any] = { + "reason_code": "unknown", + "reason": "Simulation ended without a classified termination reason.", + "mode": mode, + "event": None, + "event_time": None, + "transition_count": 0, + "max_transitions": max_transitions, + "details": {}, + } + + def append_solution_segment(solution): + t_seg = np.asarray(solution.t) + if t_seg.size == 0: + return + y_seg = np.asarray(solution.y) + if y_seg.ndim == 1: + y_seg = y_seg.reshape(-1, 1) + all_t.append(t_seg) + all_y.append(y_seg) + + while current_time < self.TOTAL_SIM_TIME and transition_count < max_transitions: + time_eval_segment = ( + time_eval[time_eval >= current_time] + if current_time == 0 + else time_eval[time_eval > current_time] + ) - time_eval_phase1 = time_eval[time_eval >= current_time] - solution_phase1 = self._solve( - cvt_system_ode, - current_time, - current_state, - time_eval_phase1, - events_phase1, - ) + if time_eval_segment.size == 0: + termination_context = { + "reason_code": "max_time", + "reason": "Simulation reached the configured maximum time.", + "mode": mode, + "event": None, + "event_time": None, + "transition_count": transition_count, + "max_transitions": max_transitions, + "details": { + "time_eval_exhausted": True, + }, + } + break + + if mode == "normal": + base_mid_shift_steady_event = get_mid_shift_steady_event( + self.system_model + ) - all_t.append(solution_phase1.t) - all_y.append(solution_phase1.y) + def guarded_mid_shift_steady_event(t, y): + # After waking from a locked mid-shift state, require a short + # cooldown before allowing another mid-shift lock attempt. + if (t - last_mid_shift_wake_time) < self.MID_SHIFT_RELOCK_DELAY: + return 1.0 + return base_mid_shift_steady_event(t, y) - # Check if we hit full shift (event 0) - if solution_phase1.t_events[0].size > 0: - current_time = solution_phase1.t_events[0][0] - current_state = solution_phase1.y_events[0][0] + guarded_mid_shift_steady_event.terminal = True + guarded_mid_shift_steady_event.direction = -1 - # Phase 2: At full shift - loop to handle potential back-shifting - max_phases = 10 # Prevent infinite loops - phase_count = 0 + events = [ + get_shift_steady_event(self.system_model), + guarded_mid_shift_steady_event, + car_velocity_constraint_event, + shift_constraint_event, + ] + event_names = [ + "shift_steady_event", + "mid_shift_steady_event", + "car_velocity_constraint_event", + "shift_constraint_event", + ] + solution = self._solve( + cvt_system_ode, + current_time, + current_state, + time_eval_segment, + events, + ) - while phase_count < max_phases and current_time < self.TOTAL_SIM_TIME: - cvt_system_full_shift_ode = self._get_full_shift_ode_function() - time_eval_phase2 = time_eval[time_eval > current_time] + append_solution_segment(solution) - if time_eval_phase2.size == 0: - break + if solution.t_events[0].size > 0: + # Enter full-shift locked mode + current_time = solution.t_events[0][0] + current_state = solution.y_events[0][0] + self._emit_transition( + "normal", + "full_shift", + current_time, + current_state, + "shift_steady_event", + ) + mode = "full_shift" + transition_count += 1 + continue + + if solution.t_events[1].size > 0: + # Enter mid-shift locked mode + current_time = solution.t_events[1][0] + current_state = solution.y_events[1][0] + locked_shift_distance = float(current_state[0]) + self._emit_transition( + "normal", + "mid_shift", + current_time, + current_state, + "mid_shift_steady_event", + ) + mode = "mid_shift" + mid_shift_enter_time = float(current_time) + transition_count += 1 + continue + + # No mode-transition event: finished (car stop, end of interval, etc.) + termination_context = self._build_termination_context( + solution=solution, + mode=mode, + event_names=event_names, + transition_count=transition_count, + max_transitions=max_transitions, + ) + break - # At full shift, check for back-shift event - events_phase2 = [ + if mode == "full_shift": + locked_ode = self._get_locked_shift_ode_function(MAX_SHIFT) + events = [ get_back_shift_event(self.system_model), car_velocity_constraint_event, ] - - solution_phase2 = self._solve( - cvt_system_full_shift_ode, + event_names = [ + "back_shift_event", + "car_velocity_constraint_event", + ] + solution = self._solve( + locked_ode, current_time, current_state, - time_eval_phase2, - events_phase2, + time_eval_segment, + events, + ) + + append_solution_segment(solution) + + if solution.t_events[0].size > 0: + # Resume normal shifting dynamics + current_time = solution.t_events[0][0] + current_state = solution.y_events[0][0] + self._emit_transition( + "full_shift", + "normal", + current_time, + current_state, + "back_shift_event", + ) + mode = "normal" + transition_count += 1 + continue + + termination_context = self._build_termination_context( + solution=solution, + mode=mode, + event_names=event_names, + transition_count=transition_count, + max_transitions=max_transitions, ) + break + + if mode == "mid_shift": + if locked_shift_distance is None: + break - all_t.append(solution_phase2.t) - all_y.append(solution_phase2.y) + locked_ode = self._get_locked_shift_ode_function(locked_shift_distance) - # Check if back-shift event occurred (event 0) - if solution_phase2.t_events[0].size > 0: - current_time = solution_phase2.t_events[0][0] - current_state = solution_phase2.y_events[0][0] + base_mid_shift_wake_event = get_mid_shift_wake_event(self.system_model) - # Phase 3: Back-shifting - return to normal dynamics - time_eval_phase3 = time_eval[time_eval > current_time] + def guarded_mid_shift_wake_event(t, y): + # Once we lock into mid-shift, keep that mode for a minimum + # dwell time before evaluating wake logic. + if ( + mid_shift_enter_time is not None + and (t - mid_shift_enter_time) < self.MID_SHIFT_MIN_HOLD_TIME + ): + return -1.0 + return base_mid_shift_wake_event(t, y) - if time_eval_phase3.size == 0: - break + guarded_mid_shift_wake_event.terminal = True + guarded_mid_shift_wake_event.direction = 1 + + events = [ + guarded_mid_shift_wake_event, + car_velocity_constraint_event, + ] + event_names = [ + "mid_shift_wake_event", + "car_velocity_constraint_event", + ] + solution = self._solve( + locked_ode, + current_time, + current_state, + time_eval_segment, + events, + ) - events_phase3 = [ - get_shift_steady_event(self.system_model), - car_velocity_constraint_event, - shift_constraint_event, - ] + append_solution_segment(solution) - solution_phase3 = self._solve( - cvt_system_ode, + if solution.t_events[0].size > 0: + # Resume normal shifting dynamics when imbalance grows again + current_time = solution.t_events[0][0] + current_state = solution.y_events[0][0] + self._emit_transition( + "mid_shift", + "normal", current_time, current_state, - time_eval_phase3, - events_phase3, + "mid_shift_wake_event", ) - - all_t.append(solution_phase3.t) - all_y.append(solution_phase3.y) - - # Check if we reached full shift again (event 0) - if solution_phase3.t_events[0].size > 0: - current_time = solution_phase3.t_events[0][0] - current_state = solution_phase3.y_events[0][0] - phase_count += 1 - # Continue loop to handle next full-shift phase - else: - # Simulation ended without reaching full shift again - break - else: - # Stayed at full shift until end or car stopped - break + mode = "normal" + mid_shift_enter_time = None + last_mid_shift_wake_time = float(current_time) + transition_count += 1 + continue + + termination_context = self._build_termination_context( + solution=solution, + mode=mode, + event_names=event_names, + transition_count=transition_count, + max_transitions=max_transitions, + ) + break + + if transition_count >= max_transitions and current_time < self.TOTAL_SIM_TIME: + termination_context = { + "reason_code": "max_transitions", + "reason": "Simulation stopped after hitting the mode-transition safety limit.", + "mode": mode, + "event": None, + "event_time": None, + "transition_count": transition_count, + "max_transitions": max_transitions, + "details": { + "safety_limit_reached": True, + }, + } # Combine all solution segments - combined_t = np.concatenate(all_t) - combined_y = np.hstack(all_y) + if not all_t or not all_y: + combined_t = np.array([current_time], dtype=float) + combined_y = np.array(current_state, dtype=float).reshape(-1, 1) + else: + combined_t = np.concatenate(all_t) + combined_y = np.hstack(all_y) combined_solution = CombinedSolution(combined_t, combined_y) - return SimulationResult(combined_solution) + + final_state = SystemState.from_array(combined_y[:, -1]) + final_time = float(combined_t[-1]) + termination_context["mode"] = mode + termination_context["final_time"] = final_time + termination_context["reached_max_time"] = final_time >= ( + self.TOTAL_SIM_TIME - 1e-6 + ) + termination_context["transition_count"] = transition_count + termination_context.setdefault("details", {}) + termination_context["details"].update( + { + "final_shift_distance": float(final_state.shift_distance), + "final_shift_velocity": float(final_state.shift_velocity), + "final_primary_pulley_angular_velocity": float( + final_state.primary_pulley_angular_velocity + ), + "final_secondary_pulley_angular_velocity": float( + final_state.secondary_pulley_angular_velocity + ), + } + ) + + if ( + termination_context.get("reason_code") == "unknown" + and termination_context["reached_max_time"] + ): + termination_context["reason_code"] = "max_time" + termination_context["reason"] = ( + "Simulation reached the configured maximum time." + ) + + return SimulationResult( + combined_solution, termination_context=termination_context + ) + + def _build_termination_context( + self, + solution, + mode: str, + event_names: list[str], + transition_count: int, + max_transitions: int, + ) -> dict[str, Any]: + triggered_events: list[tuple[float, str]] = [] + for idx, event_name in enumerate(event_names): + if idx >= len(solution.t_events): + continue + event_times = np.asarray(solution.t_events[idx]) + if event_times.size > 0: + triggered_events.append((float(event_times[0]), event_name)) + + if triggered_events: + event_time, event_name = min(triggered_events, key=lambda item: item[0]) + return { + "reason_code": "event", + "reason": f"Simulation stopped because terminal event '{event_name}' fired.", + "mode": mode, + "event": event_name, + "event_time": event_time, + "transition_count": transition_count, + "max_transitions": max_transitions, + "details": { + "solver_status": int(solution.status), + "solver_message": str(solution.message), + }, + } + + final_time = float(solution.t[-1]) if np.asarray(solution.t).size > 0 else 0.0 + reached_max_time = final_time >= (self.TOTAL_SIM_TIME - 1e-6) + if reached_max_time: + return { + "reason_code": "max_time", + "reason": "Simulation reached the configured maximum time.", + "mode": mode, + "event": None, + "event_time": None, + "transition_count": transition_count, + "max_transitions": max_transitions, + "details": { + "solver_status": int(solution.status), + "solver_message": str(solution.message), + }, + } + + return { + "reason_code": "solver_ended", + "reason": "Simulation ended because the integrator finished without a terminal event.", + "mode": mode, + "event": None, + "event_time": None, + "transition_count": transition_count, + "max_transitions": max_transitions, + "details": { + "solver_status": int(solution.status), + "solver_message": str(solution.message), + }, + } # Get the function without self for scipy def _get_ode_function(self): @@ -178,6 +466,12 @@ def ode_func(t: float, y: list[float]): return ode_func + def _get_locked_shift_ode_function(self, locked_shift_distance: float): + def ode_func(t: float, y: list[float]): + return self._evaluate_locked_shift_system(t, y, locked_shift_distance) + + return ode_func + def _solve( self, ode_func: Callable[[float, list[float]], list[float]], # (t, y) -> dydt @@ -196,7 +490,7 @@ def _solve( rtol=1e-4, ) - def _print_progress(self, t): + def _print_progress(self, t: float, shift_distance: float): # Print progress progress_percent = (t / self.TOTAL_SIM_TIME) * 100 # Print every 0.1% progress @@ -211,12 +505,25 @@ def _print_progress(self, t): rounded_percent = round(progress_percent, 1) if rounded_percent != self._last_callback_percent: self._last_callback_percent = rounded_percent - self.progress_callback(progress_percent) + try: + self.progress_callback( + progress_percent, float(t), float(shift_distance) + ) + except TypeError: + # Backward compatibility for older single-argument callbacks. + self.progress_callback(progress_percent) def _evaluate_cvt_system(self, t: float, y: list[float]): - """Evaluate system dynamics (phase 1: not at full shift).""" + """Evaluate system dynamics (phase 1: not at full shift). + + Returns derivatives of the 4 DOF state vector: + dy[0] = d(shift_distance)/dt = shift_velocity + dy[1] = d(shift_velocity)/dt = shift_acceleration + dy[2] = d(primary_pulley_angular_velocity)/dt = primary_pulley_angular_accel + dy[3] = d(secondary_pulley_angular_velocity)/dt = secondary_pulley_angular_accel + """ state = SystemState.from_array(y) - self._print_progress(t) + self._print_progress(t, state.shift_distance) # TODO: Remove this (should be handled by constraints) shift_velocity = state.shift_velocity @@ -234,12 +541,16 @@ def _evaluate_cvt_system(self, t: float, y: list[float]): y[i] = constrained_y[i] # Get system breakdown (this calculates everything in correct order) - system_breakdown = self.system_model.get_breakdown(state) + drivetrain_breakdown = self.system_model.get_breakdown(state) - # Extract accelerations from breakdown - car_acceleration = system_breakdown.car.acceleration - engine_angular_accel = system_breakdown.engine.angular_acceleration - shift_acceleration = system_breakdown.cvt.acceleration + # Extract accelerations + secondary_pulley_angular_accel_from_torques = ( + drivetrain_breakdown.secondary_pulley.secondary_pulley_angular_acceleration + ) + primary_pulley_angular_accel = ( + drivetrain_breakdown.primary_pulley.primary_pulley_angular_acceleration + ) + shift_acceleration = drivetrain_breakdown.cvt_dynamics.acceleration # Prevent acceleration from pushing past boundaries (metal hitting metal) if shift_distance <= 0 and shift_acceleration < 0: @@ -248,38 +559,74 @@ def _evaluate_cvt_system(self, t: float, y: list[float]): shift_acceleration = 0 return [ - car_acceleration, - state.car_velocity, - shift_acceleration, state.shift_velocity, - engine_angular_accel, - state.engine_angular_velocity, + shift_acceleration, + primary_pulley_angular_accel, + secondary_pulley_angular_accel_from_torques, ] def _evaluate_full_shift_system(self, t: float, y: list[float]): - """Evaluate system dynamics (phase 2: at full shift).""" + """Evaluate system dynamics (phase 2: at full shift). + + At full shift, shift_distance and shift_velocity are held constant. + Only the pulley angular velocities continue to evolve. + """ state = SystemState.from_array(y) - self._print_progress(t) + self._print_progress(t, MAX_SHIFT) # Force the shifting variables to remain constant at full shift. state.shift_distance = MAX_SHIFT state.shift_velocity = 0 - # CRITICAL: Update the actual y array that scipy saves to CSV + # CRITICAL: Update the actual y array that scipy saves constrained_y = state.to_array() for i in range(len(y)): y[i] = constrained_y[i] # Get system breakdown for full shift case - system_breakdown = self.system_model.get_breakdown(state) + drivetrain_breakdown = self.system_model.get_breakdown(state) + + secondary_pulley_angular_accel_from_torques = ( + drivetrain_breakdown.secondary_pulley.secondary_pulley_angular_acceleration + ) + primary_pulley_angular_accel = ( + drivetrain_breakdown.primary_pulley.primary_pulley_angular_acceleration + ) + + return [ + 0, # shift_distance held constant + 0, # shift_velocity held constant + primary_pulley_angular_accel, # primary pulley continues to evolve + secondary_pulley_angular_accel_from_torques, # secondary pulley continues to evolve + ] + + def _evaluate_locked_shift_system( + self, + t: float, + y: list[float], + locked_shift_distance: float, + ): + """Evaluate system dynamics with shift DOF locked at an interior position.""" + state = SystemState.from_array(y) + self._print_progress(t, locked_shift_distance) + + state.shift_distance = locked_shift_distance + state.shift_velocity = 0 + + constrained_y = state.to_array() + for i in range(len(y)): + y[i] = constrained_y[i] - car_acceleration = system_breakdown.car.acceleration - engine_angular_accel = system_breakdown.engine.angular_acceleration + drivetrain_breakdown = self.system_model.get_breakdown(state) + secondary_pulley_angular_accel_from_torques = ( + drivetrain_breakdown.secondary_pulley.secondary_pulley_angular_acceleration + ) + primary_pulley_angular_accel = ( + drivetrain_breakdown.primary_pulley.primary_pulley_angular_acceleration + ) return [ - car_acceleration, - state.car_velocity, 0, 0, - engine_angular_accel, - state.engine_angular_velocity, + primary_pulley_angular_accel, + secondary_pulley_angular_accel_from_torques, ] diff --git a/cvtModel/src/cvt_simulator/solvers/prim_engagement/primary_cvt_engagement_solver.py b/cvtModel/src/cvt_simulator/solvers/prim_engagement/primary_cvt_engagement_solver.py index 5b3ee648..62c208c6 100644 --- a/cvtModel/src/cvt_simulator/solvers/prim_engagement/primary_cvt_engagement_solver.py +++ b/cvtModel/src/cvt_simulator/solvers/prim_engagement/primary_cvt_engagement_solver.py @@ -16,6 +16,7 @@ from cvt_simulator.utils.system_state import SystemState from cvt_simulator.models.model_initializer import get_models from cvt_simulator.utils.conversions import rad_s_to_rpm +from cvt_simulator.constants.car_specs import ENGINE_INERTIA class PrimaryCVTEngagementSolver(SolverBase): @@ -47,9 +48,11 @@ def __init__(self, args: SimulationArgs): """ super().__init__(args) - # Initialize models to get the primary pulley + # Initialize models to get the primary pulley and engine system_model = get_models(args) self.primary_pulley = system_model.cvt_shift_model.primary_pulley + self.engine_model = system_model.slip_model.engine_model + self.primary_inertia = ENGINE_INERTIA @property def solver_name(self) -> str: @@ -146,17 +149,27 @@ def _evaluate_t_max(self, angular_velocity: float) -> float: # Create a mock system state at minimum shift position (engagement) # At engagement, the CVT is at its lowest ratio (largest primary radius) state = SystemState( - engine_angular_velocity=angular_velocity, - engine_angular_position=0.0, # Position doesn't matter for t_max + primary_pulley_angular_velocity=angular_velocity, + secondary_pulley_angular_velocity=0.0, # Stationary shift_distance=0.0, # Minimum shift position shift_velocity=0.0, # Static evaluation - car_velocity=0.0, # Stationary ) - # Calculate t_max using the primary pulley model - t_max = self.primary_pulley.calculate_max_torque(state) + # Get engine torque at this angular velocity + engine_torque = self.engine_model.get_torque(angular_velocity) - return t_max + # Calculate torque bounds using the primary pulley model + # For primary pulley, we want tau_upper (the positive bound) + torque_bounds = self.primary_pulley.calculate_torque_bounds( + state, + engine_drive_torque=engine_torque, + primary_inertia=self.primary_inertia, + ) + + # extract tau_upper from the bounds object + tau_upper = torque_bounds.tau_upper + + return tau_upper def get_engagement_curve( self, diff --git a/cvtModel/src/cvt_simulator/solvers/shift_initiation/shift_initiation_solver.py b/cvtModel/src/cvt_simulator/solvers/shift_initiation/shift_initiation_solver.py index ecee9ee0..8d95e07a 100644 --- a/cvtModel/src/cvt_simulator/solvers/shift_initiation/shift_initiation_solver.py +++ b/cvtModel/src/cvt_simulator/solvers/shift_initiation/shift_initiation_solver.py @@ -2,7 +2,7 @@ Solver for finding the minimum angular velocity at which CVT begins to shift. This solver determines the shift initiation point - the lowest engine speed at which -the primary pulley's radial force overcomes the secondary pulley's radial force, +the primary pulley's axial force overcomes the secondary pulley's axial force, causing the CVT to begin shifting toward higher ratio. All internal calculations use SI units (rad/s, N). Conversion to RPM happens only @@ -21,7 +21,7 @@ class ShiftInitiationSolver(SolverBase): """ - Finds the minimum angular velocity at which primary radial force > secondary radial force. + Finds the minimum angular velocity at which primary axial force > secondary axial force. This represents the shift initiation point - the engine speed where the CVT begins to shift from low ratio toward high ratio. @@ -29,7 +29,7 @@ class ShiftInitiationSolver(SolverBase): The solver: 1. Creates primary and secondary pulley models from SimulationArgs 2. Calculates torque_demand from road load using slip model (no-slip assumption) - 3. Evaluates radial forces at minimum shift position (shift_distance = 0) + 3. Evaluates axial forces at minimum shift position (shift_distance = 0) 4. Uses root-finding to determine the omega where forces cross """ @@ -65,12 +65,12 @@ def solver_name(self) -> str: def solver_description(self) -> str: return ( "Finds the minimum engine angular velocity at which the primary " - "radial force overcomes the secondary radial force, initiating shift." + "axial force overcomes the secondary axial force, initiating shift." ) def solve(self) -> SolverResult: """ - Solve for the minimum omega where primary_radial > secondary_radial. + Solve for the minimum omega where primary_axial > secondary_axial. Finds the FIRST point where the force difference crosses from negative to positive. @@ -143,25 +143,24 @@ def threshold_function(omega): def _evaluate_force_difference(self, angular_velocity: float) -> float: """ - Evaluate the force difference (primary_radial - secondary_radial). + Evaluate the force difference (primary_axial - secondary_axial). Args: angular_velocity: Engine angular velocity [rad/s] Returns: - force_diff: Primary radial force - Secondary radial force [N] + force_diff: Primary axial force - Secondary axial force [N] """ # Create a system state at minimum shift position and stationary state = SystemState( - engine_angular_velocity=angular_velocity, - engine_angular_position=0.0, + primary_pulley_angular_velocity=angular_velocity, + secondary_pulley_angular_velocity=0.0, # Stationary (as specified) shift_distance=0.0, # Minimum shift position shift_velocity=0.0, # Static evaluation - car_velocity=0.0, # Stationary (as specified) ) # Calculate torque demand from road load (before slip limiting) - torque_demand = self.slip_model.get_torque_demand(state) + torque_demand = self.slip_model.get_no_slip_torque(state) # Get the CVT breakdown which includes both pulley states # Use torque_demand to properly account for secondary torque feedback @@ -169,12 +168,14 @@ def _evaluate_force_difference(self, angular_velocity: float) -> float: state, coupling_torque=torque_demand ) - # Extract radial forces - primary_radial_force = cvt_breakdown.primaryPulleyState.forces.radial_force - secondary_radial_force = cvt_breakdown.secondaryPulleyState.forces.radial_force + # Extract axial clamping forces + primary_axial_force = cvt_breakdown.primaryPulleyState.forces.axial_force_total + secondary_axial_force = ( + cvt_breakdown.secondaryPulleyState.forces.axial_force_total + ) # Return difference (positive means primary is winning, shift will occur) - return primary_radial_force - secondary_radial_force + return primary_axial_force - secondary_axial_force def get_force_difference_curve( self, @@ -293,9 +294,9 @@ def main(): # Styling ax.set_xlabel("Engine Speed (RPM)", fontsize=12) - ax.set_ylabel("Radial Force Difference (N)", fontsize=12) + ax.set_ylabel("Axial Force Difference (N)", fontsize=12) ax.set_title( - "CVT Shift Initiation Curve\n(Primary - Secondary Radial Force vs Engine Speed)", + "CVT Shift Initiation Curve\n(Primary - Secondary Axial Force vs Engine Speed)", fontsize=14, weight="bold", ) diff --git a/cvtModel/src/cvt_simulator/utils/analyze_pulley_torque_bounds.py b/cvtModel/src/cvt_simulator/utils/analyze_pulley_torque_bounds.py new file mode 100644 index 00000000..91c70dcd --- /dev/null +++ b/cvtModel/src/cvt_simulator/utils/analyze_pulley_torque_bounds.py @@ -0,0 +1,530 @@ +""" +Plot CVT pulley torque bounds and bound-term breakdowns. + +This utility runs two independent sweeps: +- Primary sweep plotted against primary speed (RPM) +- Secondary sweep plotted against secondary speed (RPM) + +No CSV is produced. The script is plot-first for quick model inspection. +""" + +from __future__ import annotations + +import argparse +from itertools import product +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from cvt_simulator.constants.car_specs import ( + ENGINE_INERTIA, + HELIX_RADIUS, + MAX_SHIFT, + SECONDARY_INERTIA, +) +from cvt_simulator.models.pulley.primary_pulley_flyweight import PhysicalPrimaryPulley +from cvt_simulator.models.pulley.secondary_pulley_torque_reactive import ( + PhysicalSecondaryPulley, +) +from cvt_simulator.models.ramps.piecewise_ramp import PiecewiseRamp +from cvt_simulator.models.ramps.theta_ramp import ThetaRamp +from cvt_simulator.utils.conversions import deg_to_rad, rpm_to_rad_s +from cvt_simulator.utils.simulation_args import SimulationArgs +from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm + + +def _linspace(minimum: float, maximum: float, count: int) -> np.ndarray: + if count <= 0: + raise ValueError("count must be positive") + return np.linspace(minimum, maximum, count) + + +def _build_models( + args: SimulationArgs, +) -> tuple[PhysicalPrimaryPulley, PhysicalSecondaryPulley]: + primary = PhysicalPrimaryPulley( + spring_coeff_comp=args.primary_spring_rate, + initial_compression=args.primary_spring_pretension, + flyweight_mass=args.flyweight_mass, + ramp=PiecewiseRamp.from_config(args.primary_ramp_config), + ) + + secondary = PhysicalSecondaryPulley( + spring_coeff_tors=args.secondary_torsion_spring_rate, + spring_coeff_comp=args.secondary_compression_spring_rate, + initial_rotation=deg_to_rad(args.secondary_rotational_spring_pretension), + initial_compression=args.secondary_linear_spring_pretension, + ramp=ThetaRamp( + PiecewiseRamp.from_config(args.secondary_ramp_config), HELIX_RADIUS + ), + ) + + return primary, secondary + + +def _aggregate_mean( + x_values: np.ndarray, y_values: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + unique_x = np.unique(x_values) + means = np.array([y_values[x_values == x].mean() for x in unique_x], dtype=float) + return unique_x, means + + +def _run_primary_sweep( + model: PhysicalPrimaryPulley, + args: argparse.Namespace, +) -> dict[str, np.ndarray]: + primary_rpm_values = _linspace( + args.primary_rpm_min, args.primary_rpm_max, args.primary_rpm_points + ) + shift_values = _linspace(args.shift_min, args.shift_max, args.shift_points) + shift_velocity_values = _linspace( + args.shift_velocity_min, + args.shift_velocity_max, + args.shift_velocity_points, + ) + engine_torque_values = _linspace( + args.primary_engine_torque_min, + args.primary_engine_torque_max, + args.primary_engine_torque_points, + ) + + data: dict[str, list[float]] = { + "primary_rpm": [], + "tau_max": [], + "tau_min": [], + "numerator_clamping": [], + "numerator_load": [], + "numerator_shift": [], + "numerator_net": [], + "den_up_inv_radius": [], + "den_up_inertial_feedback": [], + "den_up_net": [], + "den_low_inv_radius": [], + "den_low_inertial_feedback": [], + "den_low_net": [], + } + + for primary_rpm, shift_distance, shift_velocity, engine_torque in product( + primary_rpm_values, + shift_values, + shift_velocity_values, + engine_torque_values, + ): + primary_omega = rpm_to_rad_s(float(primary_rpm)) + ratio = float(tm.current_effective_cvt_ratio(float(shift_distance))) + secondary_omega = primary_omega * ratio + + state = SystemState( + shift_distance=float(shift_distance), + shift_velocity=float(shift_velocity), + primary_pulley_angular_velocity=float(primary_omega), + secondary_pulley_angular_velocity=float(secondary_omega), + ) + + bounds = model.calculate_torque_bounds( + state, + engine_drive_torque=float(engine_torque), + primary_inertia=float(args.primary_inertia), + ) + + data["primary_rpm"].append(float(primary_rpm)) + data["tau_max"].append(float(bounds.tau_upper)) + data["tau_min"].append(float(bounds.tau_lower)) + data["numerator_clamping"].append(float(bounds.numerator.clamping_term)) + data["numerator_load"].append(float(bounds.numerator.load_term)) + data["numerator_shift"].append(float(bounds.numerator.shift_term)) + data["numerator_net"].append(float(bounds.numerator.net)) + data["den_up_inv_radius"].append( + float(bounds.denominator_upper.inverse_radius_term) + ) + data["den_up_inertial_feedback"].append( + float(bounds.denominator_upper.inertial_feedback_term) + ) + data["den_up_net"].append(float(bounds.denominator_upper.net)) + data["den_low_inv_radius"].append( + float(bounds.denominator_lower.inverse_radius_term) + ) + data["den_low_inertial_feedback"].append( + float(bounds.denominator_lower.inertial_feedback_term) + ) + data["den_low_net"].append(float(bounds.denominator_lower.net)) + + return {k: np.array(v, dtype=float) for k, v in data.items()} + + +def _run_secondary_sweep( + model: PhysicalSecondaryPulley, + args: argparse.Namespace, +) -> dict[str, np.ndarray]: + secondary_rpm_values = _linspace( + args.secondary_rpm_min, + args.secondary_rpm_max, + args.secondary_rpm_points, + ) + shift_values = _linspace(args.shift_min, args.shift_max, args.shift_points) + shift_velocity_values = _linspace( + args.shift_velocity_min, + args.shift_velocity_max, + args.shift_velocity_points, + ) + load_torque_values = _linspace( + args.secondary_load_torque_min, + args.secondary_load_torque_max, + args.secondary_load_torque_points, + ) + + data: dict[str, list[float]] = { + "secondary_rpm": [], + "tau_max": [], + "tau_min": [], + "numerator_spring": [], + "numerator_load": [], + "numerator_shift": [], + "numerator_net": [], + "den_pos_inv_radius": [], + "den_pos_helix_feedback": [], + "den_pos_inertial_feedback": [], + "den_pos_net": [], + "den_neg_inv_radius": [], + "den_neg_helix_feedback": [], + "den_neg_inertial_feedback": [], + "den_neg_net": [], + } + + for secondary_rpm, shift_distance, shift_velocity, load_torque in product( + secondary_rpm_values, + shift_values, + shift_velocity_values, + load_torque_values, + ): + secondary_omega = rpm_to_rad_s(float(secondary_rpm)) + ratio = float(tm.current_effective_cvt_ratio(float(shift_distance))) + ratio_safe = ratio if abs(ratio) > 1e-9 else 1e-9 + primary_omega = secondary_omega / ratio_safe + + state = SystemState( + shift_distance=float(shift_distance), + shift_velocity=float(shift_velocity), + primary_pulley_angular_velocity=float(primary_omega), + secondary_pulley_angular_velocity=float(secondary_omega), + ) + + torque_bounds = model.calculate_torque_bounds( + state, + external_load_torque=float(load_torque), + secondary_inertia=float(args.secondary_inertia), + ) + + data["secondary_rpm"].append(float(secondary_rpm)) + data["tau_max"].append(float(torque_bounds.tau_positive)) + data["tau_min"].append(float(torque_bounds.tau_negative)) + data["numerator_spring"].append(float(torque_bounds.numerator.spring_term)) + data["numerator_load"].append(float(torque_bounds.numerator.load_term)) + data["numerator_shift"].append(float(torque_bounds.numerator.shift_term)) + data["numerator_net"].append(float(torque_bounds.numerator.net)) + data["den_pos_inv_radius"].append( + float(torque_bounds.denominator_positive.inverse_radius_term) + ) + data["den_pos_helix_feedback"].append( + float(torque_bounds.denominator_positive.helix_feedback_term) + ) + data["den_pos_inertial_feedback"].append( + float(torque_bounds.denominator_positive.inertial_feedback_term) + ) + data["den_pos_net"].append(float(torque_bounds.denominator_positive.net)) + data["den_neg_inv_radius"].append( + float(torque_bounds.denominator_negative.inverse_radius_term) + ) + data["den_neg_helix_feedback"].append( + float(torque_bounds.denominator_negative.helix_feedback_term) + ) + data["den_neg_inertial_feedback"].append( + float(torque_bounds.denominator_negative.inertial_feedback_term) + ) + data["den_neg_net"].append(float(torque_bounds.denominator_negative.net)) + + return {k: np.array(v, dtype=float) for k, v in data.items()} + + +def _plot_primary(primary: dict[str, np.ndarray]) -> plt.Figure: + x = primary["primary_rpm"] + + x_u, tau_max = _aggregate_mean(x, primary["tau_max"]) + _, tau_min = _aggregate_mean(x, primary["tau_min"]) + _, numerator_clamping = _aggregate_mean(x, primary["numerator_clamping"]) + _, numerator_load = _aggregate_mean(x, primary["numerator_load"]) + _, numerator_shift = _aggregate_mean(x, primary["numerator_shift"]) + _, numerator_net = _aggregate_mean(x, primary["numerator_net"]) + _, den_up_inv = _aggregate_mean(x, primary["den_up_inv_radius"]) + _, den_up_inertial = _aggregate_mean(x, primary["den_up_inertial_feedback"]) + _, den_up_net = _aggregate_mean(x, primary["den_up_net"]) + _, den_low_inv = _aggregate_mean(x, primary["den_low_inv_radius"]) + _, den_low_inertial = _aggregate_mean(x, primary["den_low_inertial_feedback"]) + _, den_low_net = _aggregate_mean(x, primary["den_low_net"]) + + fig, axes = plt.subplots(2, 2, figsize=(13, 8), constrained_layout=True) + + ax = axes[0, 0] + ax.plot(x_u, tau_max, linewidth=2, label="tau_max (tau_upper)") + ax.plot(x_u, tau_min, linewidth=2, label="tau_min (tau_lower)") + ax.set_title("Primary Torque Limits") + ax.set_xlabel("Primary speed (RPM)") + ax.set_ylabel("Torque (N m)") + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[0, 1] + ax.plot(x_u, numerator_clamping, linewidth=2, label="Clamping numerator term") + ax.plot(x_u, numerator_load, linewidth=2, label="Load numerator term") + ax.plot(x_u, numerator_shift, linewidth=2, label="Shift numerator term") + ax.plot(x_u, numerator_net, linewidth=2, label="Numerator net") + ax.set_title("tau_max Numerator Breakdown") + ax.set_xlabel("Primary speed (RPM)") + ax.set_ylabel("Numerator terms") + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[1, 0] + ax.plot(x_u, den_up_inv, linewidth=2, label="+ inv radius term") + ax.plot(x_u, den_up_inertial, linewidth=2, label="+ inertial feedback term") + ax.plot(x_u, den_up_net, linewidth=2, label="D_upper net") + ax.set_title("tau_max Denominator (D_upper)") + ax.set_xlabel("Primary speed (RPM)") + ax.set_ylabel("Denominator terms") + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[1, 1] + ax.plot(x_u, den_low_inv, linewidth=2, label="- inv radius term") + ax.plot(x_u, den_low_inertial, linewidth=2, label="- inertial feedback term") + ax.plot(x_u, den_low_net, linewidth=2, label="D_lower net") + ax.set_title("tau_min Denominator (D_lower)") + ax.set_xlabel("Primary speed (RPM)") + ax.set_ylabel("Denominator terms") + ax.grid(True, alpha=0.3) + ax.legend() + + fig.suptitle("Primary Pulley Sweep", fontsize=14) + return fig + + +def _plot_secondary(secondary: dict[str, np.ndarray], rpm_max: float) -> plt.Figure: + x = secondary["secondary_rpm"] + + x_u, tau_max = _aggregate_mean(x, secondary["tau_max"]) + _, tau_min = _aggregate_mean(x, secondary["tau_min"]) + _, numerator_spring = _aggregate_mean(x, secondary["numerator_spring"]) + _, numerator_load = _aggregate_mean(x, secondary["numerator_load"]) + _, numerator_shift = _aggregate_mean(x, secondary["numerator_shift"]) + _, numerator_net = _aggregate_mean(x, secondary["numerator_net"]) + _, den_pos_inv = _aggregate_mean(x, secondary["den_pos_inv_radius"]) + _, den_pos_helix = _aggregate_mean(x, secondary["den_pos_helix_feedback"]) + _, den_pos_inertial = _aggregate_mean(x, secondary["den_pos_inertial_feedback"]) + _, den_pos_net = _aggregate_mean(x, secondary["den_pos_net"]) + _, den_neg_inv = _aggregate_mean(x, secondary["den_neg_inv_radius"]) + _, den_neg_helix = _aggregate_mean(x, secondary["den_neg_helix_feedback"]) + _, den_neg_inertial = _aggregate_mean(x, secondary["den_neg_inertial_feedback"]) + _, den_neg_net = _aggregate_mean(x, secondary["den_neg_net"]) + + fig, axes = plt.subplots(2, 2, figsize=(13, 8), constrained_layout=True) + + ax = axes[0, 0] + ax.plot(x_u, tau_max, linewidth=2, label="tau_max (tau_positive)") + ax.plot(x_u, tau_min, linewidth=2, label="tau_min (tau_negative)") + ax.set_title("Secondary Torque Limits") + ax.set_xlabel("Secondary speed (RPM)") + ax.set_ylabel("Torque (N m)") + ax.set_xlim(0.0, rpm_max) + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[0, 1] + ax.plot(x_u, numerator_spring, linewidth=2, label="Spring numerator term") + ax.plot(x_u, numerator_load, linewidth=2, label="Load numerator term") + ax.plot(x_u, numerator_shift, linewidth=2, label="Shift numerator term") + ax.plot(x_u, numerator_net, linewidth=2, label="Numerator net") + ax.set_title("tau_max Numerator Breakdown") + ax.set_xlabel("Secondary speed (RPM)") + ax.set_ylabel("Numerator terms") + ax.set_xlim(0.0, rpm_max) + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[1, 0] + ax.plot(x_u, den_pos_inv, linewidth=2, label="+ inv radius term") + ax.plot(x_u, den_pos_helix, linewidth=2, label="+ helix feedback term") + ax.plot(x_u, den_pos_inertial, linewidth=2, label="+ inertial feedback term") + ax.plot(x_u, den_pos_net, linewidth=2, label="D_plus net") + ax.set_title("tau_max Denominator (D_plus)") + ax.set_xlabel("Secondary speed (RPM)") + ax.set_ylabel("Denominator terms") + ax.set_xlim(0.0, rpm_max) + ax.grid(True, alpha=0.3) + ax.legend() + + ax = axes[1, 1] + ax.plot(x_u, den_neg_inv, linewidth=2, label="- inv radius term") + ax.plot(x_u, den_neg_helix, linewidth=2, label="- helix feedback term") + ax.plot(x_u, den_neg_inertial, linewidth=2, label="- inertial feedback term") + ax.plot(x_u, den_neg_net, linewidth=2, label="D_minus net") + ax.set_title("tau_min Denominator (D_minus)") + ax.set_xlabel("Secondary speed (RPM)") + ax.set_ylabel("Denominator terms") + ax.set_xlim(0.0, rpm_max) + ax.grid(True, alpha=0.3) + ax.legend() + + fig.suptitle("Secondary tau_max/tau_min Term Breakdown", fontsize=14) + return fig + + +def _print_secondary_single_value( + model: PhysicalSecondaryPulley, args: argparse.Namespace +) -> None: + secondary_rpm = 0.0 + secondary_omega = rpm_to_rad_s(secondary_rpm) + + # Use a single reference operating point for secondary diagnostics. + shift_distance = float(args.shift_min) + shift_velocity = 0.0 + load_torque = float(args.secondary_load_torque_min) + + ratio = float(tm.current_effective_cvt_ratio(shift_distance)) + ratio_safe = ratio if abs(ratio) > 1e-9 else 1e-9 + primary_omega = secondary_omega / ratio_safe + + state = SystemState( + shift_distance=shift_distance, + shift_velocity=shift_velocity, + primary_pulley_angular_velocity=float(primary_omega), + secondary_pulley_angular_velocity=float(secondary_omega), + ) + + bounds = model.calculate_torque_bounds( + state, + external_load_torque=load_torque, + secondary_inertia=float(args.secondary_inertia), + ) + + print("\nSecondary single-point diagnostic (RPM = 0)") + print("-" * 64) + print(f"secondary_rpm: {secondary_rpm}") + print(f"shift_distance: {shift_distance}") + print(f"shift_velocity: {shift_velocity}") + print(f"load_torque: {load_torque}") + + print("\nTorque bound terms") + print(f"tau_min (tau_negative): {bounds.tau_negative}") + print(f"tau_max (tau_positive): {bounds.tau_positive}") + + print("\nNumerator terms") + print(f"numerator_spring_term: {bounds.numerator.spring_term}") + print(f"numerator_load_term: {bounds.numerator.load_term}") + print(f"numerator_shift_term: {bounds.numerator.shift_term}") + print(f"numerator_net: {bounds.numerator.net}") + + print("\nPositive denominator terms (D_plus)") + print(f"inverse_radius_term: {bounds.denominator_positive.inverse_radius_term}") + print(f"helix_feedback_term: {bounds.denominator_positive.helix_feedback_term}") + print( + f"inertial_feedback_term: {bounds.denominator_positive.inertial_feedback_term}" + ) + print(f"denominator_plus_net: {bounds.denominator_positive.net}") + + print("\nNegative denominator terms (D_minus)") + print(f"inverse_radius_term: {bounds.denominator_negative.inverse_radius_term}") + print(f"helix_feedback_term: {bounds.denominator_negative.helix_feedback_term}") + print( + f"inertial_feedback_term: {bounds.denominator_negative.inertial_feedback_term}" + ) + print(f"denominator_minus_net: {bounds.denominator_negative.net}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Plot CVT primary/secondary torque bounds and bound terms" + ) + + parser.add_argument("--primary-rpm-min", type=float, default=1500.0) + parser.add_argument("--primary-rpm-max", type=float, default=4000.0) + parser.add_argument("--primary-rpm-points", type=int, default=25) + + parser.add_argument("--secondary-rpm-min", type=float, default=0.0) + parser.add_argument("--secondary-rpm-max", type=float, default=1000.0) + parser.add_argument("--secondary-rpm-points", type=int, default=25) + + parser.add_argument("--shift-min", type=float, default=0.0) + parser.add_argument("--shift-max", type=float, default=MAX_SHIFT) + parser.add_argument("--shift-points", type=int, default=12) + + parser.add_argument("--shift-velocity-min", type=float, default=-0.3) + parser.add_argument("--shift-velocity-max", type=float, default=0.3) + parser.add_argument("--shift-velocity-points", type=int, default=5) + + parser.add_argument("--primary-engine-torque-min", type=float, default=10.0) + parser.add_argument("--primary-engine-torque-max", type=float, default=60.0) + parser.add_argument("--primary-engine-torque-points", type=int, default=6) + + parser.add_argument("--secondary-load-torque-min", type=float, default=5.0) + parser.add_argument("--secondary-load-torque-max", type=float, default=80.0) + parser.add_argument("--secondary-load-torque-points", type=int, default=6) + + parser.add_argument("--primary-inertia", type=float, default=ENGINE_INERTIA) + parser.add_argument("--secondary-inertia", type=float, default=SECONDARY_INERTIA) + + parser.add_argument( + "--plot-prefix", + type=str, + default="pulley_torque_force_sweep", + help="Optional base file name for saving PNGs: _primary.png and _secondary.png", + ) + parser.add_argument( + "--no-save", + action="store_true", + help="Do not save PNG files", + ) + parser.add_argument( + "--no-show", + action="store_true", + help="Do not open plot windows", + ) + + return parser + + +def main() -> None: + args = build_parser().parse_args() + sim_args = SimulationArgs() + primary_model, secondary_model = _build_models(sim_args) + + primary_data = _run_primary_sweep(primary_model, args) + secondary_data = _run_secondary_sweep(secondary_model, args) + _print_secondary_single_value(secondary_model, args) + + primary_fig = _plot_primary(primary_data) + secondary_fig = _plot_secondary( + secondary_data, rpm_max=float(args.secondary_rpm_max) + ) + + if not args.no_save: + prefix = Path(args.plot_prefix) + prefix.parent.mkdir(parents=True, exist_ok=True) + primary_png = str(prefix.with_name(f"{prefix.name}_primary.png")) + secondary_png = str(prefix.with_name(f"{prefix.name}_secondary.png")) + primary_fig.savefig(primary_png, dpi=150) + secondary_fig.savefig(secondary_png, dpi=150) + print(f"Saved primary plot to {primary_png}") + print(f"Saved secondary plot to {secondary_png}") + + if args.no_show: + plt.close(primary_fig) + plt.close(secondary_fig) + else: + plt.show() + + +if __name__ == "__main__": + main() diff --git a/cvtModel/src/cvt_simulator/utils/cvt_ratio_utils.py b/cvtModel/src/cvt_simulator/utils/cvt_ratio_utils.py index 6df97a51..4d5dcd83 100644 --- a/cvtModel/src/cvt_simulator/utils/cvt_ratio_utils.py +++ b/cvtModel/src/cvt_simulator/utils/cvt_ratio_utils.py @@ -13,10 +13,12 @@ @dataclass -class CVTRatioResult: - r1: float - r2: float - ratio: float +class CVTGeometryResult: + primary_outer_radius: float + secondary_outer_radius: float + primary_effective_radius: float + secondary_effective_radius: float + effective_cvt_ratio: float class CVTGeometry: @@ -46,14 +48,17 @@ def __init__(self): self.d_max = MAX_SHIFT self.c2c = self._compute_center_to_center() - # ---------- 1) Primary radius from axial distance d ---------- - def r_primary(self, d: float) -> float: + # ---------- 1) Primary outer radius from axial distance d ---------- + def primary_outer_radius(self, d: float) -> float: if d < 0.0 or d > self.d_max: raise ValueError(f"d={d} out of bounds [0, {self.d_max}]") return (self.r_1_min + self.h) + max( (d - self.d_contact) / (2 * tan(self.β)), 0.0 ) + def primary_effective_radius(self, d: float) -> float: + return self.primary_outer_radius(d) - self.h / 2 + # ---------- 2) Secondary radius from primary radius r1 ---------- def _open_form_r_sec(self, r2: float, r1: float) -> float: """ @@ -123,48 +128,95 @@ def _solve_r2(self, r1: float) -> float: return brentq(self._open_form_r_sec, lo, hi, args=(r1,), xtol=1e-9) - # Public method to get secondary radius from d - def r_secondary(self, d: float) -> float: - r1 = self.r_primary(d) - return self._solve_r2(r1) - - # ---------- 3) Ratio from d ---------- - def ratio_from_d(self, d: float): - r1 = self.r_primary(d) - r2 = self._solve_r2(r1) - r1_eff = r1 - self.h / 2 - r2_eff = r2 - self.h / 2 - ratio = r2_eff / r1_eff - return CVTRatioResult(r1=r1, r2=r2, ratio=ratio) - - # ---------- 4) CVT Ratio Rate of Change w.r.t. d ---------- - def _cvt_ratio_derivative(self, d: float) -> float: + # Public method to get secondary outer radius from d + def secondary_outer_radius(self, d: float) -> float: + primary_outer_radius = self.primary_outer_radius(d) + return self._solve_r2(primary_outer_radius) + + def secondary_effective_radius(self, d: float) -> float: + return self.secondary_outer_radius(d) - self.h / 2 + + # ---------- 3) Effective ratio from d ---------- + def effective_cvt_ratio(self, d: float) -> float: + primary_effective_radius = self.primary_effective_radius(d) + secondary_effective_radius = self.secondary_effective_radius(d) + return secondary_effective_radius / primary_effective_radius + + def geometry_from_shift_distance(self, d: float) -> CVTGeometryResult: + primary_outer_radius = self.primary_outer_radius(d) + secondary_outer_radius = self.secondary_outer_radius(d) + primary_effective_radius = primary_outer_radius - self.h / 2 + secondary_effective_radius = secondary_outer_radius - self.h / 2 + effective_cvt_ratio = secondary_effective_radius / primary_effective_radius + + return CVTGeometryResult( + primary_outer_radius=primary_outer_radius, + secondary_outer_radius=secondary_outer_radius, + primary_effective_radius=primary_effective_radius, + secondary_effective_radius=secondary_effective_radius, + effective_cvt_ratio=effective_cvt_ratio, + ) + + # ---------- 4) Derivatives w.r.t. shift distance d ---------- + def _primary_outer_radius_shift_derivative(self, d: float) -> float: """ - Computes di/dd using differentiation on the belt length constraint. + Compute dr_p/dd (primary outer radius derivative with respect to shift distance). """ if d < self.d_contact: - return 0 - - prim_deriv = 1 / (2 * tan(self.β)) - r1 = self.r_primary(d) - r2 = self._solve_r2(r1) + return 0.0 + return 1 / (2 * tan(self.β)) - term = pi - # Additional term to include if r1 > r2 - if r1 > r2: - term -= (4 * (r2 - r1)) / (sqrt(self.c2c**2 - (r2 - r1) ** 2)) + def _secondary_outer_radius_shift_derivative(self, d: float) -> float: + """ + Compute dr_s/dd (secondary outer radius derivative with respect to shift distance) + from the differentiated belt-length constraint. + """ + if d < self.d_contact: + return 0.0 - a = 2 * asin((r2 - r1) / self.c2c) - partial_deriv_term = (term - a) / (term + a) + r_p = self.primary_outer_radius(d) + r_s = self._solve_r2(r_p) + angle = asin((r_s - r_p) / self.c2c) - rate = prim_deriv * ( - (partial_deriv_term / (r1 - self.h / 2)) - - ((r2 - self.h / 2) / ((r1 - self.h / 2) ** 2)) + return ( + -self._primary_outer_radius_shift_derivative(d) + * (pi - 2 * angle) + / (pi + 2 * angle) ) - return rate - def cvt_ratio_rate_of_change(self, d: float, d_vel: float) -> float: - return self._cvt_ratio_derivative(d) * d_vel + def _effective_cvt_ratio_shift_derivative(self, d: float) -> float: + """ + Compute dR/dd where R = r_s,eff / r_p,eff. + + Uses: + dR/dd = (1/r_p,eff) * dr_s,eff/dd - (r_s,eff/r_p,eff^2) * dr_p,eff/dd + and dr_eff/dd == dr_outer/dd since belt height offset is constant. + """ + r_p = self.primary_outer_radius(d) + r_s = self._solve_r2(r_p) + r_p_eff = r_p - self.h / 2 + r_s_eff = r_s - self.h / 2 + + dr_p_dd = self._primary_outer_radius_shift_derivative(d) + dr_s_dd = self._secondary_outer_radius_shift_derivative(d) + + return (dr_s_dd / r_p_eff) - (r_s_eff * dr_p_dd / (r_p_eff**2)) + + # ---------- 5) Derivatives w.r.t. time t ---------- + def primary_outer_radius_time_derivative( + self, d: float, shift_velocity: float + ) -> float: + return self._primary_outer_radius_shift_derivative(d) * shift_velocity + + def secondary_outer_radius_time_derivative( + self, d: float, shift_velocity: float + ) -> float: + return self._secondary_outer_radius_shift_derivative(d) * shift_velocity + + def effective_cvt_ratio_time_derivative( + self, d: float, shift_velocity: float + ) -> float: + return self._effective_cvt_ratio_shift_derivative(d) * shift_velocity # ---------- Center-to-center distance calculation ---------- # TODO: Decide to use this in solver or not @@ -287,11 +339,11 @@ def _compute_center_to_center(self) -> float: print("-" * 80) for d in d_values: - result = cvt.ratio_from_d(d) - derivative = cvt._cvt_ratio_derivative(d) + result = cvt.geometry_from_shift_distance(d) + derivative = cvt._effective_cvt_ratio_shift_derivative(d) print( - f"{d:<10.6f} {result.r1:<10.6f} {result.r2:<10.6f} " - f"{result.ratio:<10.6f} {derivative:<12.6f}" + f"{d:<10.6f} {result.primary_outer_radius:<10.6f} {result.secondary_outer_radius:<10.6f} " + f"{result.effective_cvt_ratio:<10.6f} {derivative:<12.6f}" ) print("=" * 80) diff --git a/cvtModel/src/cvt_simulator/utils/diagnostics/solver_slowdown_diagnostic.py b/cvtModel/src/cvt_simulator/utils/diagnostics/solver_slowdown_diagnostic.py new file mode 100644 index 00000000..d27fbc09 --- /dev/null +++ b/cvtModel/src/cvt_simulator/utils/diagnostics/solver_slowdown_diagnostic.py @@ -0,0 +1,395 @@ +"""Diagnostic script for investigating slow CVT simulation segments. + +Runs the phase-1 ODE solve directly and reports: +- solver workload (nfev, steps, min/median internal dt) +- where tiny time steps occur +- relationship to near shift-force balance (|net axial| small) +- cProfile hotspots by cumulative time +""" + +from __future__ import annotations + +import cProfile +import io +import pstats +import time + +import numpy as np +from cvt_simulator.models.model_initializer import get_models +from cvt_simulator.models.ramps.ramp_config import ( + CircularSegmentConfig, + LinearSegmentConfig, + PiecewiseRampConfig, +) +from cvt_simulator.simulation_runner import SimulationRunner +from cvt_simulator.utils.simulation_args import SimulationArgs +from cvt_simulator.utils.simulation_constraints import ( + get_mid_shift_steady_event, + get_mid_shift_wake_event, +) +from cvt_simulator.constants.car_specs import MAX_SHIFT + +MAX_WALL_TIME_SECONDS = 280 +ENABLE_PROFILING = False + + +def _shift_at_time( + time_arr: np.ndarray, shift_arr: np.ndarray, t_query: float +) -> float: + idx = int(np.searchsorted(time_arr, t_query, side="left")) + idx = min(max(idx, 0), len(time_arr) - 1) + return float(shift_arr[idx]) + + +def _estimate_first_quasi_steady_time( + time_arr: np.ndarray, + shift_arr: np.ndarray, + window_s: float = 0.5, + shift_span_tol: float = 5e-5, + vel_tol: float = 5e-4, +) -> float | None: + if len(time_arr) < 3: + return None + + dt = np.diff(time_arr) + if not np.all(dt > 0): + return None + + vel = np.diff(shift_arr) / dt + for i in range(len(time_arr) - 1): + t0 = float(time_arr[i]) + j = int(np.searchsorted(time_arr, t0 + window_s, side="right")) - 1 + if j <= i + 1: + continue + span = float(np.max(shift_arr[i : j + 1]) - np.min(shift_arr[i : j + 1])) + vmax = float(np.max(np.abs(vel[i:j]))) + if span <= shift_span_tol and vmax <= vel_tol: + return t0 + return None + + +def build_user_case_args() -> SimulationArgs: + """Build the exact user-reported custom case for diagnostics.""" + primary_ramp = PiecewiseRampConfig( + segments=[ + CircularSegmentConfig( + length=0.002, + angle_start=75.0, + angle_end=50.0, + quadrant=2, + ), + CircularSegmentConfig( + length=0.022, + angle_start=50.0, + angle_end=20.0, + quadrant=2, + ), + ] + ) + + secondary_ramp = PiecewiseRampConfig( + segments=[ + LinearSegmentConfig( + length=1.0, + angle=50.0, + ) + ] + ) + + return SimulationArgs( + flyweight_mass=0.5, + primary_spring_rate=12784.0, + primary_spring_pretension=0.1, + primary_ramp_config=primary_ramp, + secondary_torsion_spring_rate=3.476, + secondary_compression_spring_rate=7000.0, + secondary_rotational_spring_pretension=800.0, + secondary_linear_spring_pretension=0.1, + secondary_ramp_config=secondary_ramp, + vehicle_weight=225.0, + driver_weight=75.0, + traction=100.0, + angle_of_incline=0.0, + total_distance=200.0, + ) + + +def run_single_case( + case_name: str, args: SimulationArgs, enable_profiling: bool +) -> dict: + print(f"\n=== Input Case: {case_name} ===") + print(args) + system_model = get_models(args) + progress_state = { + "start_wall": time.perf_counter(), + "last_wall": 0.0, + "last_percent": -1.0, + } + transitions: list[dict] = [] + + total_time_hint = float(SimulationRunner.TOTAL_SIM_TIME) + + def progress_callback( + progress_percent: float, + sim_time_s: float | None = None, + shift_distance: float | None = None, + ): + now = time.perf_counter() + elapsed = now - progress_state["start_wall"] + if elapsed >= MAX_WALL_TIME_SECONDS: + raise TimeoutError( + f"Diagnostic exceeded {MAX_WALL_TIME_SECONDS:.0f}s wall time at " + f"~{progress_percent:.2f}% progress." + ) + + if ( + now - progress_state["last_wall"] >= 1.0 + and progress_percent - progress_state["last_percent"] >= 0.2 + ): + t_est = ( + float(sim_time_s) + if sim_time_s is not None + else total_time_hint * (progress_percent / 100.0) + ) + shift_str = ( + "None" if shift_distance is None else f"{float(shift_distance):.9f}" + ) + print( + f"[progress:{case_name}] t~{t_est:8.4f}s ({progress_percent:6.2f}%) shift_d={shift_str} elapsed={elapsed:7.2f}s", + flush=True, + ) + progress_state["last_wall"] = now + progress_state["last_percent"] = progress_percent + + def transition_callback(payload: dict): + transitions.append(payload) + print( + "[transition:{case}] {frm} -> {to} at t={t:.6f}s " + "(reason={reason}, shift_d={d:.9f}, shift_v={v:.9f})".format( + case=case_name, + frm=payload["from_mode"], + to=payload["to_mode"], + t=payload["time"], + reason=payload["reason"], + d=payload["shift_distance"], + v=payload["shift_velocity"], + ), + flush=True, + ) + + runner = SimulationRunner( + system_model, + progress_callback=progress_callback, + transition_callback=transition_callback, + ) + + profiler = cProfile.Profile() if enable_profiling else None + t0 = time.perf_counter() + result = None + timed_out = False + timeout_message = "" + if profiler is not None: + profiler.enable() + try: + result = runner.run_simulation() + except TimeoutError as exc: + timed_out = True + timeout_message = str(exc) + finally: + if profiler is not None: + profiler.disable() + elapsed = time.perf_counter() - t0 + + if timed_out or result is None: + summary = { + "case_name": case_name, + "status": "timeout", + "elapsed_s": float(elapsed), + "message": timeout_message, + "nfev_observed": None, + "steps": 0, + "dt_min": None, + "dt_median": None, + "t_end": None, + "shift_distance": None, + "shift_velocity": None, + "net_axial": None, + "shift_accel": None, + "mid_shift_region": None, + "transition_count": len(transitions), + } + + print("=== Solver Summary ===") + print(f"elapsed_s: {summary['elapsed_s']:.3f}") + print(f"status: {summary['status']}") + print(f"message: {summary['message']}") + print(f"transition_count: {summary['transition_count']}") + + if profiler is not None: + print("\n=== Top cProfile (cumtime, partial run) ===") + s = io.StringIO() + pstats.Stats(profiler, stream=s).sort_stats("cumtime").print_stats(25) + print(s.getvalue()) + return summary + + dt = np.diff(result.time) + print("=== Solver Summary ===") + print(f"elapsed_s: {elapsed:.3f}") + print("status: completed") + print("message: simulation_runner completed") + print(f"steps: {len(result.time)}") + print(f"transition_count: {len(transitions)}") + if dt.size: + print(f"dt_min: {float(dt.min()):.3e}") + print(f"dt_median: {float(np.median(dt)):.3e}") + print(f"dt<1e-6 count: {int((dt < 1e-6).sum())}") + print(f"dt<1e-5 count: {int((dt < 1e-5).sum())}") + + # Evaluate shift model dynamics along returned mesh. + net_axial = [] + shift_accel = [] + shift_velocity = [] + shift_distance = [] + for state in result.states: + breakdown = system_model.get_breakdown(state) + net_axial.append(breakdown.cvt_dynamics.net) + shift_accel.append(breakdown.cvt_dynamics.acceleration) + shift_velocity.append(state.shift_velocity) + shift_distance.append(state.shift_distance) + + net_axial_arr = np.asarray(net_axial) + shift_accel_arr = np.asarray(shift_accel) + shift_velocity_arr = np.asarray(shift_velocity) + shift_distance_arr = np.asarray(shift_distance) + + print("\n=== Dynamics Summary ===") + print(f"|net_axial| median [N]: {float(np.median(np.abs(net_axial_arr))):.6f}") + print( + f"|shift_accel| median [m/s^2]: {float(np.median(np.abs(shift_accel_arr))):.6f}" + ) + print( + f"|shift_velocity| median [m/s]: {float(np.median(np.abs(shift_velocity_arr))):.6f}" + ) + print("\n=== Shift Distance Probes ===") + for tq in (1.0, 2.0, 2.5, 3.0, 3.5, 4.0): + if tq <= float(result.time[-1]): + print( + f"shift_distance(t={tq:.1f}s): {_shift_at_time(result.time, shift_distance_arr, tq):.9f}" + ) + + first_quasi_steady = _estimate_first_quasi_steady_time( + result.time, shift_distance_arr + ) + if first_quasi_steady is None: + print("first_quasi_steady_time: None") + else: + print(f"first_quasi_steady_time: {first_quasi_steady:.6f}s") + + near_balance = np.abs(net_axial_arr) < 1.0 + print( + f"near_balance_points (|net|<1N): {int(near_balance.sum())}/{len(near_balance)}" + ) + + if dt.size and near_balance.size > 1: + interval_mask = near_balance[:-1] + if interval_mask.any(): + dt_bal = dt[interval_mask] + print(f"near_balance dt_min: {float(dt_bal.min()):.3e}") + print(f"near_balance dt_median: {float(np.median(dt_bal)):.3e}") + + # Print the tightest-step region and corresponding state context. + if dt.size: + k = int(np.argmin(dt)) + print("\n=== Tightest Step Context ===") + print(f"t[k]: {float(result.time[k]):.9f}") + print(f"dt_min: {float(dt[k]):.3e}") + print(f"shift_distance[k]: {float(shift_distance_arr[k]):.9f}") + print(f"shift_velocity[k]: {float(shift_velocity_arr[k]):.9f}") + print(f"net_axial[k] [N]: {float(net_axial_arr[k]):.9f}") + print(f"shift_accel[k] [m/s^2]: {float(shift_accel_arr[k]):.9f}") + + if transitions: + print("\n=== Transition Log ===") + for i, tr in enumerate(transitions, start=1): + print( + f"{i}. {tr['from_mode']} -> {tr['to_mode']} at t={tr['time']:.6f}s " + f"reason={tr['reason']} shift_d={tr['shift_distance']:.9f}" + ) + + transition_times = np.asarray( + [float(tr["time"]) for tr in transitions], dtype=float + ) + if transition_times.size > 1: + transition_dt = np.diff(transition_times) + print("\n=== Transition Cadence ===") + print(f"min interval [s]: {float(np.min(transition_dt)):.6e}") + print(f"median interval [s]: {float(np.median(transition_dt)):.6e}") + print(f"intervals < 1e-2s: {int(np.sum(transition_dt < 1e-2))}") + print(f"intervals < 1e-3s: {int(np.sum(transition_dt < 1e-3))}") + + mid_shift_steady_event = get_mid_shift_steady_event(system_model) + mid_shift_wake_event = get_mid_shift_wake_event(system_model) + + print("\n=== Transition Threshold Probes ===") + for i, tr in enumerate(transitions, start=1): + t_tr = float(tr["time"]) + idx = int(np.searchsorted(result.time, t_tr, side="left")) + idx = min(max(idx, 0), len(result.states) - 1) + state = result.states[idx] + y = np.asarray(state.to_array(), dtype=float) + steady_val = float(mid_shift_steady_event(t_tr, y.copy())) + wake_val = float(mid_shift_wake_event(t_tr, y.copy())) + print( + f"{i}. t={t_tr:.6f}s reason={tr['reason']} " + f"steady_event={steady_val:+.6e} wake_event={wake_val:+.6e}" + ) + + if profiler is not None: + print("\n=== Top cProfile (cumtime) ===") + s = io.StringIO() + pstats.Stats(profiler, stream=s).sort_stats("cumtime").print_stats(25) + print(s.getvalue()) + + summary = { + "case_name": case_name, + "status": "completed", + "elapsed_s": float(elapsed), + "message": "simulation_runner completed", + "nfev_observed": None, + "steps": int(len(result.time)), + "dt_min": float(dt.min()) if dt.size else None, + "dt_median": float(np.median(dt)) if dt.size else None, + "t_end": float(result.time[-1]), + "shift_distance": ( + float(shift_distance_arr[-1]) if shift_distance_arr.size else None + ), + "shift_velocity": ( + float(shift_velocity_arr[-1]) if shift_velocity_arr.size else None + ), + "net_axial": float(net_axial_arr[-1]) if net_axial_arr.size else None, + "shift_accel": float(shift_accel_arr[-1]) if shift_accel_arr.size else None, + "transition_count": len(transitions), + "mid_shift_region": ( + bool( + (shift_distance_arr[-1] > 1e-6) + and (shift_distance_arr[-1] < float(MAX_SHIFT) - 1e-6) + ) + if shift_distance_arr.size + else None + ), + } + return summary + + +def run_diagnostic() -> None: + summary = run_single_case("user_case", build_user_case_args(), ENABLE_PROFILING) + print("\n=== Final Summary ===") + print(f"status: {summary['status']}") + print(f"elapsed_s: {summary['elapsed_s']:.3f}") + print(f"steps: {summary['steps']}") + print(f"transition_count: {summary['transition_count']}") + print(f"t_end: {summary['t_end']}") + + +if __name__ == "__main__": + run_diagnostic() diff --git a/cvtModel/src/cvt_simulator/utils/frontend_output.py b/cvtModel/src/cvt_simulator/utils/frontend_output.py index de2a172e..a7352336 100644 --- a/cvtModel/src/cvt_simulator/utils/frontend_output.py +++ b/cvtModel/src/cvt_simulator/utils/frontend_output.py @@ -1,11 +1,16 @@ -from typing import List +from typing import List, Dict from cvt_simulator.constants.car_specs import MAX_SHIFT -from cvt_simulator.models.dataTypes import SystemBreakdown +from cvt_simulator.models.dataTypes import DrivetrainBreakdown from cvt_simulator.utils.system_state import SystemState import pandas as pd from cvt_simulator.models.model_initializer import get_models from cvt_simulator.utils.simulation_args import SimulationArgs from cvt_simulator.utils.simulation_result import SimulationResult +from cvt_simulator.utils.state_computations import ( + integrate_positions_trapezoidal, + primary_pulley_angular_velocity_to_engine_angular_velocity, + secondary_pulley_angular_velocity_to_car_velocity, +) from dataclasses import is_dataclass, fields, dataclass @@ -13,16 +18,44 @@ class TimeStepData: """ Represents all the data for a single time step in the simulation. - Uses the unified SystemBreakdown for clean access to all component data. + Uses the unified DrivetrainBreakdown for clean access to all component data. """ time: float state: SystemState - system: SystemBreakdown + derived_state: "DerivedKinematicState" + drivetrain: DrivetrainBreakdown + + +@dataclass +class DerivedKinematicState: + """Derived kinematic signals that are not part of the 4 DOF solver state.""" + + car_velocity: float + car_position: float + engine_angular_velocity: float + engine_angular_position: float + + +@dataclass +class SimulationTerminationContext: + """Explains why simulation execution ended and includes final-state context.""" + + reason_code: str + reason: str + mode: str + final_time: float + reached_max_time: bool + event: str | None + event_time: float | None + transition_count: int + max_transitions: int + details: Dict[str, float | str | bool] class FormattedSimulationResult: data: List[TimeStepData] + termination: SimulationTerminationContext def __init__(self, result: SimulationResult, args: SimulationArgs): """ @@ -30,10 +63,73 @@ def __init__(self, result: SimulationResult, args: SimulationArgs): """ self.data = [] self.gather_model_states(result, args) + self.termination = self._build_termination_context(result) + + @staticmethod + def _build_termination_context( + result: SimulationResult, + ) -> SimulationTerminationContext: + context = result.termination_context or {} + details_raw = context.get("details", {}) + details: Dict[str, float | str | bool] = {} + if isinstance(details_raw, dict): + details = {str(k): v for k, v in details_raw.items()} + + return SimulationTerminationContext( + reason_code=str(context.get("reason_code", "unknown")), + reason=str( + context.get("reason", "Simulation completion reason unavailable.") + ), + mode=str(context.get("mode", "unknown")), + final_time=float( + context.get( + "final_time", result.time[-1] if len(result.time) > 0 else 0.0 + ) + ), + reached_max_time=bool(context.get("reached_max_time", False)), + event=str(context["event"]) if context.get("event") is not None else None, + event_time=( + float(context["event_time"]) + if context.get("event_time") is not None + else None + ), + transition_count=int(context.get("transition_count", 0)), + max_transitions=int(context.get("max_transitions", 0)), + details=details, + ) def gather_model_states(self, result: SimulationResult, args: SimulationArgs): system_model = get_models(args) + car_velocities = [ + secondary_pulley_angular_velocity_to_car_velocity( + state.secondary_pulley_angular_velocity + ) + for state in result.states + ] + engine_angular_velocities = [ + primary_pulley_angular_velocity_to_engine_angular_velocity( + state.primary_pulley_angular_velocity + ) + for state in result.states + ] + primary_pulley_angular_velocities = [ + state.primary_pulley_angular_velocity for state in result.states + ] + secondary_pulley_angular_velocities = [ + state.secondary_pulley_angular_velocity for state in result.states + ] + car_positions = integrate_positions_trapezoidal(result.time, car_velocities) + engine_positions = integrate_positions_trapezoidal( + result.time, engine_angular_velocities + ) + primary_pulley_angular_positions = integrate_positions_trapezoidal( + result.time, primary_pulley_angular_velocities + ) + secondary_pulley_angular_positions = integrate_positions_trapezoidal( + result.time, secondary_pulley_angular_velocities + ) + for i, (time, state) in enumerate(zip(result.time, result.states)): shift_velocity = state.shift_velocity shift_distance = state.shift_distance @@ -45,10 +141,29 @@ def gather_model_states(self, result: SimulationResult, args: SimulationArgs): state.shift_distance = MAX_SHIFT state.shift_velocity = min(0, shift_velocity) - system_breakdown = system_model.get_breakdown(state) + drivetrain_breakdown = system_model.get_breakdown(state) + + # The 4-DOF solver tracks angular velocities only; integrate them over time + # so frontend consumers can animate pulley angular position directly. + drivetrain_breakdown.cvt_dynamics.primaryPulleyState.angular_position = ( + float(primary_pulley_angular_positions[i]) + ) + drivetrain_breakdown.cvt_dynamics.secondaryPulleyState.angular_position = ( + float(secondary_pulley_angular_positions[i]) + ) + + derived_state = DerivedKinematicState( + car_velocity=car_velocities[i], + car_position=float(car_positions[i]), + engine_angular_velocity=engine_angular_velocities[i], + engine_angular_position=float(engine_positions[i]), + ) time_step_data = TimeStepData( - time=time, state=state, system=system_breakdown + time=time, + state=state, + derived_state=derived_state, + drivetrain=drivetrain_breakdown, ) self.data.append(time_step_data) diff --git a/cvtModel/src/cvt_simulator/utils/generate_graphs.py b/cvtModel/src/cvt_simulator/utils/generate_graphs.py index a775b443..62656282 100644 --- a/cvtModel/src/cvt_simulator/utils/generate_graphs.py +++ b/cvtModel/src/cvt_simulator/utils/generate_graphs.py @@ -1,353 +1,180 @@ +import argparse +from pathlib import Path + from matplotlib import pyplot as plt import numpy as np -from cvt_simulator.models.model_initializer import get_models + +from cvt_simulator.constants.car_specs import GEARBOX_RATIO, MAX_SHIFT, WHEEL_RADIUS from cvt_simulator.utils.simulation_result import SimulationResult -from cvt_simulator.constants.car_specs import ( - GEARBOX_RATIO, - FRONTAL_AREA, - DRAG_COEFFICIENT, - WHEEL_RADIUS, - MAX_SHIFT, +from cvt_simulator.utils.state_computations import ( + integrate_positions_trapezoidal, + secondary_pulley_angular_velocity_to_car_velocity, ) -from cvt_simulator.constants.constants import AIR_DENSITY -from cvt_simulator.utils.simulation_args import SimulationArgs from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm -# Parse arguments -args = SimulationArgs() - -# Initialize models with args -car_model, cvt_model = get_models(args) - -def plotVelocity(result: SimulationResult, ax=None): - vMax = (3277.6296 / (0.5 * FRONTAL_AREA * DRAG_COEFFICIENT * AIR_DENSITY)) ** ( - 1 / 3 - ) - car_velocities = [state.car_velocity for state in result.states] - if ax is None: - ax = plt.gca() - ax.plot(result.time, car_velocities, label="Car Velocity") - ax.axhline(y=vMax, color="r", linestyle="--", label="vMax") - ax.set_xlabel("Time (s)") - ax.set_ylabel("Car Velocity (m/s)") - ax.set_title("Car Velocity vs Time") - ax.legend() - ax.grid() - - -def plotPosition(result: SimulationResult, ax=None): - car_positions = [state.car_position for state in result.states] - if ax is None: - ax = plt.gca() - ax.plot(result.time, car_positions, label="Car Position") - ax.set_xlabel("Time (s)") - ax.set_ylabel("Car Position (m)") - ax.set_title("Car Position vs Time") - ax.legend() - ax.grid() - - -def plotVehicleAccel(result: SimulationResult, ax=None): - vehicle_accels = [] - for state in result.states: - car_acceleration = car_model.get_breakdown(state).acceleration - vehicle_accels.append(car_acceleration) - if ax is None: - ax = plt.gca() - ax.plot(result.time, vehicle_accels, label="Vehicle Acceleration") - ax.set_xlabel("Time (s)") - ax.set_ylabel("Vehicle Acceleration (m/s²)") - ax.set_title("Vehicle Acceleration vs Time") - ax.legend() - ax.grid() - - -def plotPrimaryClampingForce(result: SimulationResult, ax=None): - primary_clamping_forces = [] - primary_radial_forces = [] - engine_angular_velocities = [] - for state in result.states: - shift_breakdown = cvt_model.get_breakdown(state) - car_breakdown = car_model.get_breakdown(state) - - primary_force = shift_breakdown.primaryRadialForce.pulleyForce.net - primary_radial_force = shift_breakdown.primaryRadialForce.net - actual_engine_velocity = car_breakdown.engine_forces.angular_velocity - - primary_clamping_forces.append(primary_force) - primary_radial_forces.append(primary_radial_force) - engine_angular_velocities.append(actual_engine_velocity) - if ax is None: - ax = plt.gca() - ax.plot( - engine_angular_velocities, - primary_clamping_forces, - label="Primary Clamping Force", +def _build_series(result: SimulationResult) -> dict[str, np.ndarray]: + time = np.asarray(result.time) + shift_distance = np.asarray([state.shift_distance for state in result.states]) + shift_velocity = np.asarray([state.shift_velocity for state in result.states]) + primary_omega = np.asarray( + [state.primary_pulley_angular_velocity for state in result.states] ) - ax.plot( - engine_angular_velocities, primary_radial_forces, label="Primary Radial Force" + secondary_omega = np.asarray( + [state.secondary_pulley_angular_velocity for state in result.states] ) - ax.set_xlabel("Engine Angular Velocity (rad/s)") - ax.set_ylabel("Primary Clamping Force (N)") - ax.set_title("Primary Clamping Force vs Engine Angular Velocity") - ax.legend() - ax.grid() - - -def plotSecondaryClampingForce(result: SimulationResult, ax=None): - secondary_clamping_forces = [] - secondary_radial_forces = [] - engine_angular_velocities = [] - for state in result.states: - shift_breakdown = cvt_model.get_breakdown(state) - car_breakdown = car_model.get_breakdown(state) - - secondary_force = shift_breakdown.secondaryRadialForce.pulleyForce.net - secondary_radial = shift_breakdown.secondaryRadialForce.net - actual_engine_velocity = car_breakdown.engine_forces.angular_velocity - - secondary_clamping_forces.append(secondary_force) - secondary_radial_forces.append(secondary_radial) - engine_angular_velocities.append(actual_engine_velocity) - if ax is None: - ax = plt.gca() - ax.plot( - engine_angular_velocities, - secondary_clamping_forces, - label="Secondary Clamping Force", - ) - ax.plot( - engine_angular_velocities, - secondary_radial_forces, - label="Secondary Radial Force", - ) - ax.set_xlabel("Engine Angular Velocity (rad/s)") - ax.set_ylabel("Secondary Clamping Force (N)") - ax.set_title("Secondary Clamping Force vs Engine Angular Velocity") - ax.legend() - ax.grid() - - -def plotVehicleEngineSpeed(result: SimulationResult, ax=None): - cvt_ratios = [] - vehicle_speeds = [] - engine_speeds = [] - times = result.time - - for state in result.states: - car_breakdown = car_model.get_breakdown(state) - cvt_breakdown = cvt_model.get_breakdown(state) - - cvt_ratio = cvt_breakdown.cvt_ratio - - cvt_ratios.append(cvt_ratio) - vehicle_speeds.append(state.car_velocity) - engine_speeds.append(car_breakdown.engine_forces.angular_velocity) - - if ax is None: - fig, ax = plt.subplots(figsize=(12, 8)) - # Plot Vehicle Speed on the primary axis. - ax.set_xlabel("Time (s)") - ax.set_ylabel("Vehicle Speed (m/s)", color="#DDDD40") - ax.plot(times, vehicle_speeds, label="Vehicle Speed", color="#DDDD40", linewidth=4) - ax.tick_params(axis="y", labelcolor="#DDDD40") - - # Create twin axis for Engine Speed. - ax2 = ax.twinx() - ax2.set_ylabel("Engine Speed (rad/s)", color="#000000") - ax2.plot(times, engine_speeds, label="Engine Speed", color="#000000", linewidth=1.5) - ax2.tick_params(axis="y", labelcolor="#000000") - - # Create second twin axis for CVT Ratio. - ax3 = ax.twinx() - ax3.spines["right"].set_position(("outward", 60)) - ax3.set_ylabel("CVT Ratio", color="tab:green") - ax3.plot( - times, - cvt_ratios, - label="CVT Ratio", - color="tab:green", - linestyle="dashdot", - linewidth=2, - ) - ax3.tick_params(axis="y", labelcolor="tab:green") - - ax.set_title("Vehicle Speed, Engine Speed, and CVT Ratio vs Time") - ax.grid() - - -def plot_forces_over_time(result: SimulationResult, ax=None): - prim_radial = [] - sec_radial = [] - - for state in result.states: - shift_breakdown = cvt_model.get_breakdown(state) - - prim_radial.append(shift_breakdown.primaryRadialForce.net) - sec_radial.append(shift_breakdown.secondaryRadialForce.net) - - shift_distances = [state.shift_distance for state in result.states] - shift_velocities = [state.shift_velocity for state in result.states] - - if ax is None: - ax = plt.gca() - ax.plot(result.time, prim_radial, label="Primary Radial", color="tab:green") - ax.plot(result.time, sec_radial, label="Secondary Radial", color="tab:red") - ax.set_xlabel("Time (s)") - ax.set_ylabel("Force (N)") - ax.set_title("Primary and Secondary Forces Over Time") - ax.legend(loc="upper left") - ax.grid() - - # Create a twin axis for shift distance. - ax2 = ax.twinx() - ax2.plot( - result.time, - shift_distances, - label="Shift Distance", - color="tab:purple", - linestyle="dashed", + + car_velocity = np.asarray( + [ + secondary_pulley_angular_velocity_to_car_velocity( + state.secondary_pulley_angular_velocity + ) + for state in result.states + ] ) - ax2.set_ylabel("Shift Distance (units)") - ax2.legend(loc="upper right") - - # Create a third y-axis for shift velocities. - ax3 = ax.twinx() - ax3.spines["right"].set_position(("outward", 60)) - ax3.plot( - result.time, - shift_velocities, - label="Shift Velocity", - color="tab:cyan", - linestyle="dotted", + car_position = integrate_positions_trapezoidal(time, car_velocity) + cvt_ratio = np.asarray([tm.current_effective_cvt_ratio(d) for d in shift_distance]) + + return { + "time": time, + "shift_distance": shift_distance, + "shift_velocity": shift_velocity, + "primary_omega": primary_omega, + "secondary_omega": secondary_omega, + "car_velocity": car_velocity, + "car_position": car_position, + "cvt_ratio": cvt_ratio, + } + + +def plot_kinematics(series: dict[str, np.ndarray]): + fig, axes = plt.subplots(2, 2, figsize=(14, 9)) + t = series["time"] + + axes[0, 0].plot(t, series["shift_distance"], color="tab:purple") + axes[0, 0].set_title("Shift Distance vs Time") + axes[0, 0].set_xlabel("Time (s)") + axes[0, 0].set_ylabel("Shift Distance (m)") + axes[0, 0].grid(True, alpha=0.3) + + axes[0, 1].plot(t, series["shift_velocity"], color="tab:cyan") + axes[0, 1].set_title("Shift Velocity vs Time") + axes[0, 1].set_xlabel("Time (s)") + axes[0, 1].set_ylabel("Shift Velocity (m/s)") + axes[0, 1].grid(True, alpha=0.3) + + axes[1, 0].plot(t, series["car_velocity"], color="tab:blue", label="Car Velocity") + axes[1, 0].plot(t, series["car_position"], color="tab:green", label="Car Position") + axes[1, 0].set_title("Vehicle Kinematics vs Time") + axes[1, 0].set_xlabel("Time (s)") + axes[1, 0].legend() + axes[1, 0].grid(True, alpha=0.3) + + axes[1, 1].plot(t, series["primary_omega"], label="Primary Pulley") + axes[1, 1].plot(t, series["secondary_omega"], label="Secondary Pulley") + axes[1, 1].set_title("Pulley Angular Velocities vs Time") + axes[1, 1].set_xlabel("Time (s)") + axes[1, 1].set_ylabel("Angular Velocity (rad/s)") + axes[1, 1].legend() + axes[1, 1].grid(True, alpha=0.3) + + fig.tight_layout() + return fig + + +def plot_ratio_and_shift_curve(series: dict[str, np.ndarray]): + fig, axes = plt.subplots(1, 2, figsize=(14, 5.5)) + + axes[0].plot(series["time"], series["cvt_ratio"], color="tab:orange") + axes[0].set_title("Effective CVT Ratio vs Time") + axes[0].set_xlabel("Time (s)") + axes[0].set_ylabel("CVT Ratio") + axes[0].grid(True, alpha=0.3) + + vehicle_speed = series["car_velocity"] + engine_speed = series["primary_omega"] + axes[1].plot( + vehicle_speed, engine_speed, label="Simulated Shift Curve", linewidth=2 ) - ax3.set_ylabel("Shift Velocity (units/s)") - ax3.legend(loc="lower right") + min_ratio = tm.current_effective_cvt_ratio(0.0) * GEARBOX_RATIO / WHEEL_RADIUS + max_ratio = tm.current_effective_cvt_ratio(MAX_SHIFT) * GEARBOX_RATIO / WHEEL_RADIUS + x_vals = np.linspace(0, max(float(np.max(vehicle_speed)), 1e-6), 100) -def plotShiftDistance(result: SimulationResult, ax=None): - shift_distances = [] - engine_angular_velocities = [] + axes[1].plot(x_vals, min_ratio * x_vals, "--", alpha=0.8, label="Min Ratio Line") + axes[1].plot(x_vals, max_ratio * x_vals, "--", alpha=0.8, label="Max Ratio Line") + axes[1].set_title("Engine Speed vs Vehicle Speed") + axes[1].set_xlabel("Vehicle Speed (m/s)") + axes[1].set_ylabel("Engine Speed (rad/s)") + axes[1].set_xlim(left=0) + axes[1].set_ylim(bottom=0) + axes[1].legend() + axes[1].grid(True, alpha=0.3) - for state in result.states: - car_breakdown = car_model.get_breakdown(state) + fig.tight_layout() + return fig - shift_distances.append(state.shift_distance) - engine_angular_velocities.append(car_breakdown.engine_forces.angular_velocity) - if ax is None: - ax = plt.gca() - ax.plot(engine_angular_velocities, shift_distances, label="Shift Distance") - ax.set_xlabel("Engine Angular Velocity (rad/s)") - ax.set_ylabel("Shift Distance (units)") - ax.set_title("Shift Distance vs Engine Angular Velocity") - ax.legend() - ax.grid() +def generate_graphs_from_csv( + csv_path: Path, out_dir: Path, show: bool = False +) -> list[Path]: + result = SimulationResult.from_csv(str(csv_path)) + series = _build_series(result) + out_dir.mkdir(parents=True, exist_ok=True) -def plotShiftCurves(results: list[SimulationResult], ax=None): - if ax is None: - ax = plt.gca() + kinematics_fig = plot_kinematics(series) + kinematics_path = out_dir / "kinematics_overview.png" + kinematics_fig.savefig(kinematics_path, dpi=150) - # Plot each simulation's engine speed curve. - for i, result in enumerate(results): - vehicle_speeds = [] - engine_angular_velocities = [] + ratio_fig = plot_ratio_and_shift_curve(series) + ratio_path = out_dir / "ratio_and_shift_curve.png" + ratio_fig.savefig(ratio_path, dpi=150) - for state in result.states: - car_breakdown = car_model.get_breakdown(state) + if show: + plt.show() + else: + plt.close(kinematics_fig) + plt.close(ratio_fig) - vehicle_speeds.append(state.car_velocity) - engine_angular_velocities.append( - car_breakdown.engine_forces.angular_velocity - ) + return [kinematics_path, ratio_path] - ax.plot( - vehicle_speeds, - engine_angular_velocities, - label=f"Engine Speed {i}", - linewidth=2, - ) - - # Combine vehicle speeds and engine speeds from all results to determine common limits. - all_vehicle_speeds = [] - all_engine_velocities = [] - for result in results: - vehicle_speeds = [] - engine_angular_velocities = [] - - for state in result.states: - car_breakdown = car_model.get_breakdown(state) - - vehicle_speeds.append(state.car_velocity) - engine_angular_velocities.append( - car_breakdown.engine_forces.angular_velocity - ) - all_vehicle_speeds.extend(vehicle_speeds) - all_engine_velocities.extend(engine_angular_velocities) - - # Use the global maximum values for the x-range and engine speed limit. - max_x = max(all_vehicle_speeds) if all_vehicle_speeds else 0 - max_engine = max(all_engine_velocities) if all_engine_velocities else 0 - - # Compute constant ratios (they are independent of the simulation result). - min_ratio = tm.current_cvt_ratio(0) * GEARBOX_RATIO / WHEEL_RADIUS - max_ratio = tm.current_cvt_ratio(MAX_SHIFT) * GEARBOX_RATIO / WHEEL_RADIUS - - # Create a common x-axis for the dashed lines. - x_vals = np.linspace(0, max_x, 100) - y_min = min_ratio * x_vals - y_max = max_ratio * x_vals - - # Only keep the portions of the dashed lines that are below the maximum engine speed. - mask_min = y_min <= max_engine - mask_max = y_max <= max_engine - x_min = x_vals[mask_min] - y_min = y_min[mask_min] - x_max = x_vals[mask_max] - y_max = y_max[mask_max] - - # Ensure the lines start at zero. - if x_min[0] != 0: - x_min = np.insert(x_min, 0, 0) - y_min = np.insert(y_min, 0, 0) - if x_max[0] != 0: - x_max = np.insert(x_max, 0, 0) - y_max = np.insert(y_max, 0, 0) - - # Plot the min and max ratio lines once. - ax.plot(x_min, y_min, label="Min Ratio", linestyle="--", alpha=0.8) - ax.plot(x_max, y_max, label="Max Ratio", linestyle="--", alpha=0.8) - - # Set up plot labels and grid. - ax.set_xlabel("Vehicle Speed (m/s)") - ax.set_ylabel("Engine Angular Velocity (rad/s)") - ax.set_title("Engine Speed vs Vehicle Speed") - ax.legend() - ax.grid() - ax.set_xlim(left=0) - ax.set_ylim(bottom=0) +def main(): + parser = argparse.ArgumentParser( + description="Generate validation plots from an existing simulation CSV." + ) + parser.add_argument( + "--csv", + default="simulation_output.csv", + help="Path to simulation CSV (default: simulation_output.csv)", + ) + parser.add_argument( + "--out-dir", + default="generated_graphs", + help="Output directory for generated graph images", + ) + parser.add_argument( + "--show", + action="store_true", + help="Also show graphs interactively", + ) + args = parser.parse_args() + + csv_path = Path(args.csv) + if not csv_path.exists(): + raise FileNotFoundError(f"CSV file not found: {csv_path}") + + output_paths = generate_graphs_from_csv( + csv_path=csv_path, + out_dir=Path(args.out_dir), + show=args.show, + ) + + print("Generated graph files:") + for path in output_paths: + print(f" - {path}") if __name__ == "__main__": - result = SimulationResult.from_csv("simulation_output.csv") - # result2 = SimulationResult.from_csv("simulation_output_2.csv") - # Create a grid of subplots: 2 rows x 4 columns for our eight plots. - fig, axs = plt.subplots(2, 4, figsize=(24, 12)) - - # Call each plotting function with its corresponding axis. - plotVehicleEngineSpeed(result, ax=axs[0, 0]) - plotVehicleAccel(result, ax=axs[0, 1]) - plotVelocity(result, ax=axs[0, 2]) - plotPrimaryClampingForce(result, ax=axs[0, 3]) - plotSecondaryClampingForce(result, ax=axs[1, 0]) - plotPosition(result, ax=axs[1, 1]) - plotShiftDistance(result, ax=axs[1, 2]) - plotShiftCurves([result], ax=axs[1, 3]) - plt.tight_layout() - plt.show() - - plotShiftCurves([result]) - plt.show() + main() diff --git a/cvtModel/src/cvt_simulator/utils/print_progress.py b/cvtModel/src/cvt_simulator/utils/print_progress.py deleted file mode 100644 index 79d17298..00000000 --- a/cvtModel/src/cvt_simulator/utils/print_progress.py +++ /dev/null @@ -1,32 +0,0 @@ -import csv -import os -import sys - -# TODO: Remove this file - -filePath = "progress_percent.csv" -lockFile = "progress.lock" - - -def print_progress(progress): - progress_str = f"{progress:.1f}" - - with open(lockFile, "w") as f: - f.write("locked") - - while True: - try: - with open(filePath, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow(["Percent"]) - writer.writerow([progress_str]) - break - except PermissionError: - # Do not loop if progress is less than 100% - if progress < 1: - break - - os.remove(lockFile) - - sys.stdout.write(f"\rProgress: {progress_str}%") - sys.stdout.flush() diff --git a/cvtModel/src/cvt_simulator/utils/simulation_args.py b/cvtModel/src/cvt_simulator/utils/simulation_args.py index 4186f6f2..d6209009 100644 --- a/cvtModel/src/cvt_simulator/utils/simulation_args.py +++ b/cvtModel/src/cvt_simulator/utils/simulation_args.py @@ -16,21 +16,17 @@ def _get_default_primary_ramp() -> PiecewiseRampConfig: def _get_default_secondary_ramp() -> PiecewiseRampConfig: """Factory function for default secondary ramp config.""" - return create_default_helix_ramp().to_config() + return create_default_helix_ramp().angle_ramp.to_config() @dataclass(slots=True) class SimulationArgs: flyweight_mass: float = 1.0 # kg - primary_ramp_geometry: float = 1.0 # unitless (deprecated, use primary_ramp_config) primary_ramp_config: PiecewiseRampConfig = field( default_factory=_get_default_primary_ramp ) primary_spring_rate: float = 7000.0 # N/m primary_spring_pretension: float = 0.2 # m - secondary_helix_geometry: float = ( - 1.0 # unitless (deprecated, use secondary_ramp_config) - ) secondary_ramp_config: PiecewiseRampConfig = field( default_factory=_get_default_secondary_ramp ) diff --git a/cvtModel/src/cvt_simulator/utils/simulation_constraints.py b/cvtModel/src/cvt_simulator/utils/simulation_constraints.py index 377db1e4..b4615e8b 100644 --- a/cvtModel/src/cvt_simulator/utils/simulation_constraints.py +++ b/cvtModel/src/cvt_simulator/utils/simulation_constraints.py @@ -2,21 +2,12 @@ MAX_SHIFT, ) from cvt_simulator.models.system_model import SystemModel +from cvt_simulator.utils.state_computations import ( + secondary_pulley_angular_velocity_to_car_velocity, +) from cvt_simulator.utils.system_state import SystemState -import numpy as np - -def logistic_clamp(x, lower_bound, upper_bound, slope=5000.0): - """ - Returns a factor in [0..1] that is ~1 for x in (lower_bound, upper_bound) - and smoothly transitions to 0 when x is outside [lower_bound, upper_bound]. - The 'slope' controls how sharp the transition is. - """ - # Transition near the lower bound - factor_low = 1.0 / (1.0 + np.exp(-slope * (x - lower_bound))) - # Transition near the upper bound - factor_up = 1.0 / (1.0 + np.exp(slope * (x - upper_bound))) - return factor_low * factor_up +MIN_CAR_VELOCITY_MPS = -20.0 def update_y(y, state: SystemState): @@ -44,7 +35,12 @@ def shift_constraint_event(t, y): def car_velocity_constraint_event(t, y): state = SystemState.from_array(y) - return state.car_velocity + return ( + secondary_pulley_angular_velocity_to_car_velocity( + state.secondary_pulley_angular_velocity + ) + - MIN_CAR_VELOCITY_MPS + ) car_velocity_constraint_event.terminal = True @@ -123,6 +119,106 @@ def back_shift_event(t, y): return back_shift_event +def get_mid_shift_steady_event( + system_model: SystemModel, + velocity_tol: float = 1e-4, + accel_tol: float = 0.1, + wake_accel_guard_tol: float = 0.5, + boundary_margin: float = 1e-5, +): + """ + Trigger when the system is quasi-static in the shift DOF away from hard limits. + + Conditions to enter steady mode: + 1. Shift distance is not clamped against hard bounds. + 2. |shift_velocity| <= velocity_tol. + 3. |shift_accel| <= accel_tol. + 4. If shift were locked now (shift_velocity=0), the resulting + |shift_accel| must also be <= wake_accel_guard_tol. + + This is used to enter a locked mid-shift mode to avoid costly dithering. + """ + + def mid_shift_steady_event(t, y): + state = SystemState.from_array(y) + + # Only apply in the interior region, not near hard shift boundaries. + if ( + state.shift_distance <= boundary_margin + or state.shift_distance >= MAX_SHIFT - boundary_margin + ): + return 1.0 + + coupling_torque = system_model.slip_model.get_breakdown(state).coupling_torque + shift_accel = system_model.cvt_shift_model.get_breakdown( + state, coupling_torque + ).acceleration + + # Guard against immediate wake chatter: only lock if the locked-state + # acceleration would also remain below the wake threshold. + locked_state = SystemState( + shift_distance=state.shift_distance, + shift_velocity=0.0, + primary_pulley_angular_velocity=state.primary_pulley_angular_velocity, + secondary_pulley_angular_velocity=state.secondary_pulley_angular_velocity, + ) + locked_coupling_torque = system_model.slip_model.get_breakdown( + locked_state + ).coupling_torque + locked_shift_accel = system_model.cvt_shift_model.get_breakdown( + locked_state, locked_coupling_torque + ).acceleration + + # Deterministic event value: <= 0 means quasi-static and eligible to lock. + return max( + abs(state.shift_velocity) - velocity_tol, + abs(shift_accel) - accel_tol, + abs(locked_shift_accel) - wake_accel_guard_tol, + ) + + mid_shift_steady_event.terminal = True + mid_shift_steady_event.direction = ( + -1 + ) # enter steady mode when value drops below zero + return mid_shift_steady_event + + +def get_mid_shift_wake_event( + system_model: SystemModel, + wake_accel_tol: float = 1.5, + boundary_margin: float = 1e-5, +): + """ + Trigger when locked mid-shift mode should resume normal shift dynamics. + + Event value is negative while near equilibrium and becomes positive when + acceleration indicates the locked shift should move again. + + Direction logic: + - Near lower bound: only positive acceleration can move the shift, so wake on + shift_accel - wake_accel_tol. + - Near upper bound: only negative acceleration can move the shift, so wake on + -shift_accel - wake_accel_tol. + - Interior: wake on |shift_accel| - wake_accel_tol. + """ + + def mid_shift_wake_event(t, y): + state = SystemState.from_array(y) + coupling_torque = system_model.slip_model.get_breakdown(state).coupling_torque + shift_accel = system_model.cvt_shift_model.get_breakdown( + state, coupling_torque + ).acceleration + if state.shift_distance <= boundary_margin: + return shift_accel - wake_accel_tol + if state.shift_distance >= MAX_SHIFT - boundary_margin: + return -shift_accel - wake_accel_tol + return abs(shift_accel) - wake_accel_tol + + mid_shift_wake_event.terminal = True + mid_shift_wake_event.direction = 1 # wake when value rises through zero + return mid_shift_wake_event + + # Export all constraints constraints = [ shift_constraint_event, diff --git a/cvtModel/src/cvt_simulator/utils/simulation_result.py b/cvtModel/src/cvt_simulator/utils/simulation_result.py index 9affca6f..0f426b88 100644 --- a/cvtModel/src/cvt_simulator/utils/simulation_result.py +++ b/cvtModel/src/cvt_simulator/utils/simulation_result.py @@ -1,10 +1,24 @@ from cvt_simulator.utils.system_state import SystemState +from typing import Any +from cvt_simulator.utils.state_computations import ( + integrate_positions_trapezoidal, + primary_pulley_angular_velocity_to_engine_angular_velocity, + secondary_pulley_angular_velocity_to_car_velocity, +) import pandas as pd import matplotlib.pyplot as plt class SimulationResult: - def __init__(self, solution=None, time=None, states=None): + termination_context: dict[str, Any] | None + + def __init__( + self, + solution=None, + time=None, + states=None, + termination_context: dict[str, Any] | None = None, + ): """Initialize with solution from solve_ivp and parse it into states, or directly with time and states.""" if solution is not None: self.time = solution.t @@ -12,10 +26,11 @@ def __init__(self, solution=None, time=None, states=None): else: self.time = time self.states = states + self.termination_context = termination_context @staticmethod def parse_solution(solution): - """Parses the solution from solve_ivp into a list of DrivetrainState instances.""" + """Parses the solution from solve_ivp into a list of SystemState instances.""" states = [SystemState.from_array(state) for state in solution.y.T] return states @@ -26,41 +41,98 @@ def from_csv(filename="simulation_output.csv"): time = df["time"].values states = [ SystemState( - car_velocity=row["car_velocity"], - car_position=row["car_position"], - shift_velocity=row["shift_velocity"], shift_distance=row["shift_distance"], - engine_angular_velocity=row["engine_angular_velocity"], + shift_velocity=row["shift_velocity"], + primary_pulley_angular_velocity=row["primary_pulley_angular_velocity"], + secondary_pulley_angular_velocity=row[ + "secondary_pulley_angular_velocity" + ], ) for _, row in df.iterrows() ] return SimulationResult(time=time, states=states) def write_csv(self, filename="simulation_output.csv"): - """Writes the parsed solution states to a CSV file.""" + """Writes the parsed solution states to a CSV file. + + Includes 4 DOF from state plus derived quantities. + Positions (car_position, engine_angular_position) are computed via kinematic integration. + """ + # Compute positions via trapezoidal integration of velocities + car_positions = integrate_positions_trapezoidal( + self.time, + [ + secondary_pulley_angular_velocity_to_car_velocity( + s.secondary_pulley_angular_velocity + ) + for s in self.states + ], + ) + engine_positions = integrate_positions_trapezoidal( + self.time, + [ + primary_pulley_angular_velocity_to_engine_angular_velocity( + s.primary_pulley_angular_velocity + ) + for s in self.states + ], + ) + data = { "time": self.time, - "car_velocity": [state.car_velocity for state in self.states], - "car_position": [state.car_position for state in self.states], - "shift_velocity": [state.shift_velocity for state in self.states], "shift_distance": [state.shift_distance for state in self.states], + "shift_velocity": [state.shift_velocity for state in self.states], + "primary_pulley_angular_velocity": [ + state.primary_pulley_angular_velocity for state in self.states + ], + "secondary_pulley_angular_velocity": [ + state.secondary_pulley_angular_velocity for state in self.states + ], + "car_velocity": [ + secondary_pulley_angular_velocity_to_car_velocity( + s.secondary_pulley_angular_velocity + ) + for s in self.states + ], "engine_angular_velocity": [ - state.engine_angular_velocity for state in self.states + primary_pulley_angular_velocity_to_engine_angular_velocity( + s.primary_pulley_angular_velocity + ) + for s in self.states ], + "car_position": car_positions, + "engine_angular_position": engine_positions, } df = pd.DataFrame(data) df.to_csv(filename, index=False) - def plot(self, field="car_velocity"): - """Plots a selected field over time.""" + def plot(self, field="secondary_pulley_angular_velocity"): + """Plots a selected field over time. + + Available fields: shift_distance, shift_velocity, primary_pulley_angular_velocity, + secondary_pulley_angular_velocity, car_velocity, engine_angular_velocity + """ # Mapping field names to their respective data field_data = { - "car_velocity": [state.car_velocity for state in self.states], - "car_position": [state.car_position for state in self.states], - "shift_velocity": [state.shift_velocity for state in self.states], "shift_distance": [state.shift_distance for state in self.states], + "shift_velocity": [state.shift_velocity for state in self.states], + "primary_pulley_angular_velocity": [ + state.primary_pulley_angular_velocity for state in self.states + ], + "secondary_pulley_angular_velocity": [ + state.secondary_pulley_angular_velocity for state in self.states + ], + "car_velocity": [ + secondary_pulley_angular_velocity_to_car_velocity( + s.secondary_pulley_angular_velocity + ) + for s in self.states + ], "engine_angular_velocity": [ - state.engine_angular_velocity for state in self.states + primary_pulley_angular_velocity_to_engine_angular_velocity( + s.primary_pulley_angular_velocity + ) + for s in self.states ], } diff --git a/cvtModel/src/cvt_simulator/utils/state_computations.py b/cvtModel/src/cvt_simulator/utils/state_computations.py new file mode 100644 index 00000000..250067d5 --- /dev/null +++ b/cvtModel/src/cvt_simulator/utils/state_computations.py @@ -0,0 +1,42 @@ +"""Central scalar conversions used by CVT state/model code.""" + +from collections.abc import Sequence +import numpy as np +from cvt_simulator.constants.car_specs import WHEEL_RADIUS, GEARBOX_RATIO + + +def secondary_pulley_angular_velocity_to_car_velocity( + secondary_pulley_angular_velocity: float, +) -> float: + """Convert secondary pulley angular velocity ω_s [rad/s] to car velocity v [m/s].""" + wheel_angular_velocity = secondary_pulley_angular_velocity / GEARBOX_RATIO + return wheel_angular_velocity * WHEEL_RADIUS + + +def car_velocity_to_secondary_pulley_angular_velocity(car_velocity: float) -> float: + """Convert car velocity v [m/s] to secondary pulley angular velocity ω_s [rad/s].""" + wheel_angular_velocity = car_velocity / WHEEL_RADIUS + return wheel_angular_velocity * GEARBOX_RATIO + + +def primary_pulley_angular_velocity_to_engine_angular_velocity( + primary_pulley_angular_velocity: float, +) -> float: + """Convert primary pulley angular velocity ω_p [rad/s] to engine angular velocity [rad/s].""" + return primary_pulley_angular_velocity + + +def integrate_positions_trapezoidal( + time: Sequence[float], velocities: Sequence[float] +) -> np.ndarray: + """Integrate positions from velocity samples using trapezoidal integration.""" + if len(time) != len(velocities): + raise ValueError( + f"time and velocities length mismatch: {len(time)} != {len(velocities)}" + ) + + positions = np.zeros(len(time)) + for i in range(1, len(time)): + dt = time[i] - time[i - 1] + positions[i] = positions[i - 1] + (velocities[i - 1] + velocities[i]) * dt / 2.0 + return positions diff --git a/cvtModel/src/cvt_simulator/utils/system_state.py b/cvtModel/src/cvt_simulator/utils/system_state.py index ef1e2791..f7111c2e 100644 --- a/cvtModel/src/cvt_simulator/utils/system_state.py +++ b/cvtModel/src/cvt_simulator/utils/system_state.py @@ -1,34 +1,49 @@ from dataclasses import dataclass +from cvt_simulator.constants.car_specs import MAX_SHIFT @dataclass class SystemState: - car_velocity: float = 0.0 - car_position: float = 0.0 - shift_velocity: float = 0.0 + """ + State vector for CVT simulator with 4 degrees of freedom (ODE formulation). + + Contains only the 4 true degrees of freedom evolved by the ODE solver: + - shift_distance: s [m] - axial position of sheaves (0 to MAX_SHIFT) + - shift_velocity: ṡ [m/s] - rate of sheave axial movement + - primary_pulley_angular_velocity: ω_P [rad/s] - primary pulley angular velocity + - secondary_pulley_angular_velocity: ω_s [rad/s] - secondary pulley angular velocity + + All other quantities (car_velocity, engine_angular_velocity, positions, etc.) + are derived from these 4 DOF and should be computed using StateComputations utility. + """ + shift_distance: float = 0.0 - engine_angular_velocity: float = 0.0 - engine_angular_position: float = 0.0 + shift_velocity: float = 0.0 + primary_pulley_angular_velocity: float = 0.0 + secondary_pulley_angular_velocity: float = 0.0 def to_array(self): - """Converts the state to an array for solve_ivp.""" + """Converts the state to an array for solve_ivp (4 DOF only).""" return [ - self.car_velocity, - self.car_position, - self.shift_velocity, self.shift_distance, - self.engine_angular_velocity, - self.engine_angular_position, + self.shift_velocity, + self.primary_pulley_angular_velocity, + self.secondary_pulley_angular_velocity, ] @staticmethod def from_array(array): - """Creates a DrivetrainState from an array.""" + """Creates a SystemState from an array (4 DOF).""" + shift_distance = float(array[0]) + # Clamp numerical drift so downstream geometry always sees a valid shift domain. + if shift_distance < 0.0: + shift_distance = 0.0 + elif shift_distance > MAX_SHIFT: + shift_distance = float(MAX_SHIFT) + return SystemState( - car_velocity=array[0], - car_position=array[1], - shift_velocity=array[2], - shift_distance=array[3], - engine_angular_velocity=array[4], - engine_angular_position=array[5], + shift_distance=shift_distance, + shift_velocity=array[1], + primary_pulley_angular_velocity=array[2], + secondary_pulley_angular_velocity=array[3], ) diff --git a/cvtModel/src/cvt_simulator/utils/theoretical_models.py b/cvtModel/src/cvt_simulator/utils/theoretical_models.py index 8a2d5dd3..ee624f5b 100644 --- a/cvtModel/src/cvt_simulator/utils/theoretical_models.py +++ b/cvtModel/src/cvt_simulator/utils/theoretical_models.py @@ -1,8 +1,5 @@ import numpy as np -from cvt_simulator.constants.car_specs import ( - BELT_HEIGHT, - CENTER_TO_CENTER, -) +from cvt_simulator.constants.car_specs import CENTER_TO_CENTER from cvt_simulator.utils.cvt_ratio_utils import CVTGeometry # Module-level CVTGeometry instance using default constants @@ -30,6 +27,21 @@ def centrifugal_force(m: float, ω: float, r: float) -> float: def air_resistance(ρ: float, v: float, A: float, C_d: float) -> float: return 0.5 * ρ * v**2 * A * C_d + @staticmethod + def sgn(x: float) -> float: + """Sign function: returns -1, 0, or 1 based on sign of x.""" + if x > 0: + return 1.0 + elif x < 0: + return -1.0 + else: + return 0.0 + + @staticmethod + def rolling_resistance(C_rr: float, m: float, g: float, α: float) -> float: + """Calculate rolling resistance force. C_rr is coefficient, m is mass, α is incline angle.""" + return C_rr * m * g * np.cos(α) + @staticmethod def torque(P: float, ω: float) -> float: return P / ω @@ -51,20 +63,38 @@ def newtons_second_law(m: float, a: float) -> float: return m * a @staticmethod # See Enman's excel sheet - def outer_prim_radius(d: float) -> float: - return _cvt_geometry.r_primary(d) + def primary_outer_radius(d: float) -> float: + return _cvt_geometry.primary_outer_radius(d) @staticmethod # See Enman's excel sheet - def outer_sec_radius(d: float) -> float: - return _cvt_geometry.r_secondary(d) + def secondary_outer_radius(d: float) -> float: + return _cvt_geometry.secondary_outer_radius(d) + + @staticmethod + def primary_effective_radius(d: float) -> float: + return _cvt_geometry.primary_effective_radius(d) + + @staticmethod + def secondary_effective_radius(d: float) -> float: + return _cvt_geometry.secondary_effective_radius(d) + + @staticmethod + def primary_radius_rate_of_change(d: float) -> float: + """Get dr_p/dd at current shift position.""" + return _cvt_geometry._primary_outer_radius_shift_derivative(d) + + @staticmethod + def secondary_radius_rate_of_change(d: float) -> float: + """Get dr_s/dd at current shift position.""" + return _cvt_geometry._secondary_outer_radius_shift_derivative(d) @staticmethod - def current_cvt_ratio(d: float) -> float: - return _cvt_geometry.ratio_from_d(d).ratio + def current_effective_cvt_ratio(d: float) -> float: + return _cvt_geometry.effective_cvt_ratio(d) @staticmethod - def current_cvt_ratio_rate_of_change(d: float, v: float) -> float: - return _cvt_geometry.cvt_ratio_rate_of_change(d, v) + def current_effective_cvt_ratio_time_derivative(d: float, v: float) -> float: + return _cvt_geometry.effective_cvt_ratio_time_derivative(d, v) @staticmethod def wrap_angle( @@ -77,8 +107,8 @@ def wrap_angle( @staticmethod def primary_wrap_angle(d: float): - primary_radius = TheoreticalModels.outer_prim_radius(d) - BELT_HEIGHT / 2 - secondary_radius = TheoreticalModels.outer_sec_radius(d) - BELT_HEIGHT / 2 + primary_radius = TheoreticalModels.primary_effective_radius(d) + secondary_radius = TheoreticalModels.secondary_effective_radius(d) wrap_offset = TheoreticalModels.wrap_angle(primary_radius, secondary_radius) # print(f"Ratio: {secondary_radius/primary_radius}, wrap: {wrap_offset}") if primary_radius <= secondary_radius: @@ -88,8 +118,8 @@ def primary_wrap_angle(d: float): @staticmethod def secondary_wrap_angle(d: float): - primary_radius = TheoreticalModels.outer_prim_radius(d) - BELT_HEIGHT / 2 - secondary_radius = TheoreticalModels.outer_sec_radius(d) - BELT_HEIGHT / 2 + primary_radius = TheoreticalModels.primary_effective_radius(d) + secondary_radius = TheoreticalModels.secondary_effective_radius(d) wrap_offset = TheoreticalModels.wrap_angle(primary_radius, secondary_radius) if primary_radius <= secondary_radius: return np.pi + wrap_offset diff --git a/cvtModel/test/simulations/test_belt_simulator.py b/cvtModel/test/simulations/test_belt_simulator.py deleted file mode 100644 index b249a564..00000000 --- a/cvtModel/test/simulations/test_belt_simulator.py +++ /dev/null @@ -1,109 +0,0 @@ -import unittest -import numpy as np - -from simulations.belt_simulator import BeltSimulator -from cvt_simulator.utils.theoretical_models import TheoreticalModels as tm -from cvt_simulator.constants.constants import RUBBER_DENSITY - -from cvt_simulator.constants.car_specs import ( - SHEAVE_ANGLE, - BELT_CROSS_SECTIONAL_AREA, - BELT_HEIGHT, -) - - -class TestBeltSimulator(unittest.TestCase): - - def setUp(self): - self.simulator_primary = BeltSimulator( - μ_static=0.5, μ_kinetic=0.4, primary=True - ) - self.simulator_secondary = BeltSimulator( - μ_static=0.5, μ_kinetic=0.4, primary=False - ) - - def test_calculate_centrifugal_force_primary(self): - ω = 100 - shift_distance = 0.1 - wrap_angle = np.pi / 2 - expected_radius = tm.outer_prim_radius(shift_distance) - BELT_HEIGHT / 2 - expected_length = expected_radius * wrap_angle - expected_mass = RUBBER_DENSITY * BELT_CROSS_SECTIONAL_AREA * expected_length - expected_force = tm.centrifugal_force(expected_mass, ω, expected_radius) - result = self.simulator_primary.calculate_centrifugal_force( - ω, shift_distance, wrap_angle - ) - self.assertAlmostEqual(result, expected_force, places=5) - - def test_calculate_centrifugal_force_secondary(self): - ω = 100 - shift_distance = 0.05 - wrap_angle = np.pi / 2 - expected_radius = tm.outer_sec_radius(shift_distance) - BELT_HEIGHT / 2 - expected_length = expected_radius * wrap_angle - expected_mass = RUBBER_DENSITY * BELT_CROSS_SECTIONAL_AREA * expected_length - expected_force = tm.centrifugal_force(expected_mass, ω, expected_radius) - result = self.simulator_secondary.calculate_centrifugal_force( - ω, shift_distance, wrap_angle - ) - self.assertAlmostEqual(result, expected_force, places=5) - - def test_radial_force_from_clamping(self): - clamping_force = 1000 - expected_force = 2 * clamping_force * np.tan(SHEAVE_ANGLE / 2) - result = self.simulator_primary.radial_force_from_clamping(clamping_force) - self.assertAlmostEqual(result, expected_force, places=5) - - def test_calculate_net_radial_force(self): - centrifugal_force = 500 - radial_force = 1000 - wrap_angle = np.pi / 2 - expected_force = (centrifugal_force + radial_force) * 2 * np.sin(wrap_angle / 2) - result = self.simulator_primary.calculate_net_radial_force( - centrifugal_force, radial_force, wrap_angle - ) - self.assertAlmostEqual(result, expected_force, places=5) - - def test_calculate_radial_force(self): - ω = 100 - shift_distance = 0.1 - wrap_angle = np.pi / 2 - clamping_force = 1000 - centrifugal_force = self.simulator_primary.calculate_centrifugal_force( - ω, shift_distance, wrap_angle - ) - radial_force = self.simulator_primary.radial_force_from_clamping(clamping_force) - expected_force = self.simulator_primary.calculate_net_radial_force( - centrifugal_force, radial_force, wrap_angle - ) - result = self.simulator_primary.calculate_radial_force( - ω, shift_distance, wrap_angle, clamping_force - ) - self.assertAlmostEqual(result, expected_force, places=5) - - def test_calculate_slack_tension(self): - radial_force = 1000 - wrap_angle = np.pi / 2 - μ = 0.5 - θ = abs((wrap_angle - np.pi) / 2) - denominator = np.cos(θ) * (1 + np.exp(μ * wrap_angle)) - expected_tension = radial_force / denominator - result = self.simulator_primary.calculate_slack_tension( - radial_force, wrap_angle, μ - ) - self.assertAlmostEqual(result, expected_tension, places=5) - - def test_calculate_max_transferable_torque(self): - tension = 1000 - μ = 0.5 - wrap_angle = np.pi / 2 - radius = 0.1 - expected_torque = tension * radius * (np.exp(μ * wrap_angle) - 1) - result = self.simulator_primary.calculate_max_transferable_torque( - tension, μ, wrap_angle, radius - ) - self.assertAlmostEqual(result, expected_torque, places=5) - - -if __name__ == "__main__": - unittest.main() diff --git a/cvtModel/test/utils/test_ramp_config.py b/cvtModel/test/utils/test_ramp_config.py new file mode 100644 index 00000000..bdad96ea --- /dev/null +++ b/cvtModel/test/utils/test_ramp_config.py @@ -0,0 +1,44 @@ +import unittest + +from cvt_simulator.models.ramps.ramp_config import PiecewiseRampConfig + + +class TestRampConfigParsing(unittest.TestCase): + def test_from_dict_coerces_string_quadrant(self): + config = PiecewiseRampConfig.from_dict( + { + "segments": [ + {"type": "linear", "length": "0.01", "angle": "25"}, + { + "type": "circular", + "length": "0.02", + "angle_start": "30", + "angle_end": "20", + "quadrant": "2", + }, + ] + } + ) + + circular = config.segments[1] + self.assertEqual(circular.quadrant, 2) + + def test_from_dict_rejects_invalid_quadrant(self): + with self.assertRaises(ValueError): + PiecewiseRampConfig.from_dict( + { + "segments": [ + { + "type": "circular", + "length": 0.02, + "angle_start": 30, + "angle_end": 20, + "quadrant": "0", + } + ] + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/cvtModel/test/utils/test_simulation_result.py b/cvtModel/test/utils/test_simulation_result.py index 3aa1da5c..b20af52e 100644 --- a/cvtModel/test/utils/test_simulation_result.py +++ b/cvtModel/test/utils/test_simulation_result.py @@ -11,12 +11,12 @@ class TestSimulationResult(unittest.TestCase): def setUp(self): - # Define a simple ODE system for testing + # Define a simple ODE system for testing (4 DOF) def simple_ode(t, y): - return [y[1], -0.1 * y[1] - y[0]] + return [y[1], -0.1 * y[1] - y[0], 0.0, 0.0] # Solve the ODE system - y0 = [1.0, 0.0] + y0 = [1.0, 0.0, 0.0, 0.0] t_span = (0, 10) t_eval = np.linspace(*t_span, 100) self.solution = solve_ivp(simple_ode, t_span, y0, t_eval=t_eval) @@ -24,10 +24,10 @@ def simple_ode(t, y): # Mock SystemState.from_array to return a simple object self.original_from_array = SystemState.from_array SystemState.from_array = lambda arr: SystemState( - car_velocity=arr[0], - car_position=arr[1], - shift_velocity=arr[0], - shift_distance=arr[1], + shift_distance=arr[0], + shift_velocity=arr[1], + primary_pulley_angular_velocity=arr[2], + secondary_pulley_angular_velocity=arr[3], ) def tearDown(self): @@ -45,13 +45,14 @@ def test_write_csv(self): df = pd.read_csv("test_output.csv") self.assertEqual(len(df), len(self.solution.t)) self.assertIn("time", df.columns) + self.assertIn("shift_distance", df.columns) self.assertIn("car_position", df.columns) @patch.object(SimulationResult, "plot") def test_plot(self, mock_plot): result = SimulationResult(self.solution) - result.plot("car_velocity") - mock_plot.assert_called_once_with("car_velocity") + result.plot("secondary_pulley_angular_velocity") + mock_plot.assert_called_once_with("secondary_pulley_angular_velocity") if __name__ == "__main__": diff --git a/cvtModel/test/utils/test_system_state.py b/cvtModel/test/utils/test_system_state.py index a4ba39ab..50360d30 100644 --- a/cvtModel/test/utils/test_system_state.py +++ b/cvtModel/test/utils/test_system_state.py @@ -1,47 +1,41 @@ import unittest from cvt_simulator.utils.system_state import SystemState +from cvt_simulator.constants.car_specs import MAX_SHIFT class TestSystemState(unittest.TestCase): def test_initialization(self): state = SystemState( - car_velocity=30.0, - car_position=10.0, - shift_velocity=5.0, shift_distance=2.0, - engine_angular_velocity=100.0, - engine_angular_position=50.0, + shift_velocity=5.0, + primary_pulley_angular_velocity=100.0, + secondary_pulley_angular_velocity=30.0, ) - self.assertEqual(state.car_velocity, 30.0) - self.assertEqual(state.car_position, 10.0) - self.assertEqual(state.shift_velocity, 5.0) self.assertEqual(state.shift_distance, 2.0) - self.assertEqual(state.engine_angular_velocity, 100.0) - self.assertEqual(state.engine_angular_position, 50.0) + self.assertEqual(state.shift_velocity, 5.0) + self.assertEqual(state.primary_pulley_angular_velocity, 100.0) + self.assertEqual(state.secondary_pulley_angular_velocity, 30.0) def test_to_array(self): state = SystemState( - car_velocity=30.0, - car_position=10.0, - shift_velocity=5.0, shift_distance=2.0, - engine_angular_velocity=100.0, - engine_angular_position=50.0, + shift_velocity=5.0, + primary_pulley_angular_velocity=100.0, + secondary_pulley_angular_velocity=30.0, ) - expected_array = [30.0, 10.0, 5.0, 2.0, 100.0, 50.0] + expected_array = [2.0, 5.0, 100.0, 30.0] self.assertEqual(state.to_array(), expected_array) def test_from_array(self): - array = [30.0, 10.0, 5.0, 2.0, 100.0, 50.0] + array = [2.0, 5.0, 100.0, 30.0] state = SystemState.from_array(array) - self.assertEqual(state.car_velocity, 30.0) - self.assertEqual(state.car_position, 10.0) + # Shift distance is clamped when fetching it + self.assertEqual(state.shift_distance, MAX_SHIFT) self.assertEqual(state.shift_velocity, 5.0) - self.assertEqual(state.shift_distance, 2.0) - self.assertEqual(state.engine_angular_velocity, 100.0) - self.assertEqual(state.engine_angular_position, 50.0) + self.assertEqual(state.primary_pulley_angular_velocity, 100.0) + self.assertEqual(state.secondary_pulley_angular_velocity, 30.0) if __name__ == "__main__": diff --git a/cvtModel/test/utils/test_theoretical_models.py b/cvtModel/test/utils/test_theoretical_models.py index c41f940f..71c0965c 100644 --- a/cvtModel/test/utils/test_theoretical_models.py +++ b/cvtModel/test/utils/test_theoretical_models.py @@ -34,18 +34,20 @@ def test_capstan_equation(self): def test_newtons_second_law(self): self.assertAlmostEqual(TheoreticalModels.newtons_second_law(10, 9.8), 98) - def test_outer_prim_radius(self): + def test_primary_outer_radius(self): self.assertAlmostEqual( - TheoreticalModels.outer_prim_radius(0), 0.036207699999999995 + TheoreticalModels.primary_outer_radius(0), 0.036207699999999995 ) - def test_outer_sec_radius(self): + def test_secondary_outer_radius(self): self.assertAlmostEqual( - TheoreticalModels.outer_sec_radius(0), 0.10414225374493848 + TheoreticalModels.secondary_outer_radius(0), 0.10414225374493848 ) - def test_current_cvt_ratio(self): - self.assertAlmostEqual(TheoreticalModels.current_cvt_ratio(0), 3.39015972307032) + def test_current_effective_cvt_ratio(self): + self.assertAlmostEqual( + TheoreticalModels.current_effective_cvt_ratio(0), 3.39015972307032 + ) def test_wrap_angle(self): self.assertAlmostEqual( diff --git a/docs/Kai's folder of derivations/primary_max_torque.png b/docs/Kai's folder of derivations/primary_max_torque.png new file mode 100644 index 00000000..3c7f175f Binary files /dev/null and b/docs/Kai's folder of derivations/primary_max_torque.png differ diff --git a/docs/Kai's folder of derivations/secondary_max_torque.png b/docs/Kai's folder of derivations/secondary_max_torque.png new file mode 100644 index 00000000..fe5b1f05 Binary files /dev/null and b/docs/Kai's folder of derivations/secondary_max_torque.png differ diff --git a/frontend/src/components/rampBuilder/RampBuilder.module.scss b/frontend/src/components/rampBuilder/RampBuilder.module.scss index d89c508a..46d410b8 100644 --- a/frontend/src/components/rampBuilder/RampBuilder.module.scss +++ b/frontend/src/components/rampBuilder/RampBuilder.module.scss @@ -1,5 +1,40 @@ @use '@styles/_fonts'; +.rampBuilder { + position: relative; +} + +.rampStatus { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2rem; + margin-bottom: 0.5rem; +} + +.rampLabel { + color: var(--text-color); + @include fonts.title-bold; +} + +.changedBadge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--text-color); + opacity: 0.85; + font-size: 0.875rem; +} + +.changeIndicator { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + background-color: var(--warning, #ffc107); + border-radius: 50%; + flex-shrink: 0; +} + .segmentList { display: flex; flex-direction: column; diff --git a/frontend/src/components/rampBuilder/RampBuilder.tsx b/frontend/src/components/rampBuilder/RampBuilder.tsx index 17e2c18d..9587f815 100644 --- a/frontend/src/components/rampBuilder/RampBuilder.tsx +++ b/frontend/src/components/rampBuilder/RampBuilder.tsx @@ -14,6 +14,7 @@ interface RampBuilderProps { value: PiecewiseRampConfig | null; onChange: (config: PiecewiseRampConfig) => void; className?: string; + hasChanged?: boolean; } const createDefaultSegment = (type: SegmentType): RampSegment => { @@ -33,7 +34,7 @@ const preserveCommonFields = (oldSegment: RampSegment, newSegment: RampSegment): return result as RampSegment; }; -export const RampBuilder = ({ value, onChange, className }: RampBuilderProps) => { +export const RampBuilder = ({ value, onChange, className, hasChanged = false }: RampBuilderProps) => { const [segments, setSegments] = useState(() => { const initialSegments = value?.segments || [createDefaultSegment('linear')]; if (!value?.segments || value.segments.length === 0) { @@ -76,8 +77,21 @@ export const RampBuilder = ({ value, onChange, className }: RampBuilderProps) => onChange({ segments: newSegments }); }, [segments, onChange]); + const containerClassName = [styles.rampBuilder, className] + .filter(Boolean) + .join(' '); + return ( -
+
+
+ Ramp Profile + {hasChanged && ( + + + Changed + + )} +
{segments.map((segment, index) => { const fields = Object.entries(segment).filter(([key]) => key !== 'type'); diff --git a/frontend/src/components/rampBuilder/RampPreview.tsx b/frontend/src/components/rampBuilder/RampPreview.tsx index b035a462..26e2e6a4 100644 --- a/frontend/src/components/rampBuilder/RampPreview.tsx +++ b/frontend/src/components/rampBuilder/RampPreview.tsx @@ -13,8 +13,6 @@ interface RampPreviewProps { const LINE_THICKNESS = 4; const GRID = { left: 60, right: 40, top: 40, bottom: 60 } as const; -const OFFSET = 0.005; // 0.5% extra space beyond data limits for axes - export const RampPreview = ({ config }: RampPreviewProps) => { const [previewData, setPreviewData] = useState(null); const [error, setError] = useState(null); @@ -46,26 +44,39 @@ export const RampPreview = ({ config }: RampPreviewProps) => { }, []); // Compute axis limits that preserve the data's aspect ratio within the full container. - // The "tighter" dimension fills its axis exactly; the other gets extended to fill the space. - const { axisXMax, axisYMin } = useMemo(() => { - if (!previewData) return { axisXMax: 1, axisYMin: -1 }; + // Supports ramps with positive or negative heights by deriving bounds from data. + const { axisXMin, axisXMax, axisYMin, axisYMax } = useMemo(() => { + if (!previewData) { + return { axisXMin: 0, axisXMax: 1, axisYMin: -1, axisYMax: 0 }; + } + const dataXMin = Math.min(...previewData.x); const dataXMax = Math.max(...previewData.x); - const dataYMin = Math.min(...previewData.y); // negative + const dataYMinRaw = Math.min(...previewData.y); + const dataYMaxRaw = Math.max(...previewData.y); - const plotWidth = containerWidth - GRID.left - GRID.right; - const plotHeight = containerHeight - GRID.top - GRID.bottom; + // Keep zero visible for easier interpretation of ramp offsets. + const dataYMin = Math.min(dataYMinRaw, 0); + const dataYMax = Math.max(dataYMaxRaw, 0); - // pixels-per-metre if each dimension were to fill its axis exactly - const ppmX = plotWidth / dataXMax; - const ppmY = plotHeight / Math.abs(dataYMin); + const plotWidth = Math.max(1, containerWidth - GRID.left - GRID.right); + const plotHeight = Math.max(1, containerHeight - GRID.top - GRID.bottom); - // Use the smaller ppm so the data fits — then extend the other axis to fill + const xSpan = Math.max(1e-9, dataXMax - dataXMin); + const ySpan = Math.max(1e-9, dataYMax - dataYMin); + + // Pixels per meter if each dimension were to fill the plot area exactly. + const ppmX = plotWidth / xSpan; + const ppmY = plotHeight / ySpan; + + // Use the tighter scale so the data fully fits, then expand the other axis. const ppm = Math.min(ppmX, ppmY); return { - axisXMax: plotWidth / ppm, - axisYMin: -(plotHeight / ppm), + axisXMin: dataXMin, + axisXMax: dataXMin + (plotWidth / ppm), + axisYMin: dataYMin, + axisYMax: dataYMin + (plotHeight / ppm), }; }, [previewData, containerWidth, containerHeight]); @@ -96,26 +107,25 @@ export const RampPreview = ({ config }: RampPreviewProps) => { return () => clearTimeout(timeoutId); }, [configString, config]); - const getColor = (property: string, fallback: string): string => { - if (typeof window !== 'undefined') { - const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim(); - return value || fallback; - } - return fallback; - }; + const COLORS = useMemo(() => { + const getColor = (property: string, fallback: string): string => { + if (typeof window !== 'undefined') { + const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim(); + return value || fallback; + } + return fallback; + }; - const COLORS = { - BACKGROUND: getColor('--background', '#222222'), - TEXT: getColor('--text-color', '#ffffff'), - GRID: getColor('--grid-color', '#404040'), - PRIMARY: getColor('--primary', '#bb0808'), - TOOLTIP_BG: getColor('--tooltip-bg', '#2a2a2a'), - }; + return { + BACKGROUND: getColor('--background', '#222222'), + TEXT: getColor('--text-color', '#ffffff'), + GRID: getColor('--grid-color', '#404040'), + PRIMARY: getColor('--primary', '#bb0808'), + TOOLTIP_BG: getColor('--tooltip-bg', '#2a2a2a'), + }; + }, []); const chartOptions: EChartsOption = useMemo(() => { - const dataXMax = previewData ? Math.max(...previewData.x) : 1; - const dataYMin = previewData ? Math.min(...previewData.y) : -1; - return { backgroundColor: COLORS.BACKGROUND, textStyle: { color: COLORS.TEXT }, @@ -136,7 +146,7 @@ export const RampPreview = ({ config }: RampPreviewProps) => { axisTick: { lineStyle: { color: COLORS.GRID } }, axisLabel: { color: COLORS.TEXT, formatter: (val: number) => Math.round(val * 10000) / 10000 }, splitLine: { lineStyle: { color: COLORS.GRID } }, - min: 0, + min: axisXMin, max: axisXMax, scale: false, }, @@ -154,7 +164,7 @@ export const RampPreview = ({ config }: RampPreviewProps) => { lineStyle: { color: COLORS.GRID } }, min: axisYMin, - max: 0, + max: axisYMax, scale: false, }, series: [ @@ -171,26 +181,6 @@ export const RampPreview = ({ config }: RampPreviewProps) => { }, showSymbol: false, }, - { - // Bottom axis line (y = axisYMin, from x=0 to x=dataXMax) - type: 'line', - data: [[-OFFSET * dataXMax, dataYMin], [dataXMax, dataYMin]], - lineStyle: { color: COLORS.PRIMARY, width: LINE_THICKNESS }, - itemStyle: { color: COLORS.PRIMARY }, - showSymbol: false, - silent: true, - tooltip: { show: false }, - }, - { - // Left axis line (x = 0, from y=0 to y=axisYMin) - type: 'line', - data: [[0, 0], [0, dataYMin * (1 + OFFSET)]], - lineStyle: { color: COLORS.PRIMARY, width: LINE_THICKNESS }, - itemStyle: { color: COLORS.PRIMARY }, - showSymbol: false, - silent: true, - tooltip: { show: false }, - }, ], tooltip: { trigger: 'axis', @@ -221,7 +211,7 @@ export const RampPreview = ({ config }: RampPreviewProps) => { }, }, }; - }, [previewData, axisXMax, axisYMin, COLORS]); + }, [previewData, axisXMin, axisXMax, axisYMin, axisYMax, COLORS]); const renderContent = () => { if (error) { diff --git a/frontend/src/components/scene3DViewer/Scene3DViewer.module.scss b/frontend/src/components/scene3DViewer/Scene3DViewer.module.scss index 73cfd50b..4cb7e189 100644 --- a/frontend/src/components/scene3DViewer/Scene3DViewer.module.scss +++ b/frontend/src/components/scene3DViewer/Scene3DViewer.module.scss @@ -133,6 +133,34 @@ } } +.toggleSectionButton { + position: absolute; + top: 130px; + right: 10px; + padding: 8px 12px; + background-color: rgba(42, 42, 42, 0.8); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-family: inherit; + display: flex; + align-items: center; + gap: 6px; + z-index: 5; + transition: background-color 0.2s, border-color 0.2s; + + &:hover { + background-color: rgba(42, 42, 42, 0.95); + border-color: rgba(255, 255, 255, 0.4); + } + + &:active { + transform: scale(0.98); + } +} + .toggleGridsButton { position: absolute; top: 90px; diff --git a/frontend/src/components/scene3DViewer/Scene3DViewer.tsx b/frontend/src/components/scene3DViewer/Scene3DViewer.tsx index b48831c3..910dfe3d 100644 --- a/frontend/src/components/scene3DViewer/Scene3DViewer.tsx +++ b/frontend/src/components/scene3DViewer/Scene3DViewer.tsx @@ -29,10 +29,13 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp const [beltMesh, setBeltMesh] = useState(null); const [beltVisible, setBeltVisible] = useState(true); const [showAngularRotation, setShowAngularRotation] = useState(true); - const [gridsVisible, setGridsVisible] = useState(true); + const [gridsVisible, setGridsVisible] = useState(false); + const [crossSectionEnabled, setCrossSectionEnabled] = useState(false); const [gridObjects, setGridObjects] = useState([]); const [initialHelixRotation, setInitialHelixRotation] = useState(0); + const degToRad = useCallback((deg: number): number => deg * (Math.PI / 180), []); + /** * Helper to convert any distance value from BAJA units (meters) to the scene's distance unit. * Use this for all distance values used in the 3D scene. @@ -99,6 +102,7 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp const grids: THREE.Object3D[] = []; sceneController.getScene().traverse((obj) => { if (obj instanceof THREE.GridHelper || obj instanceof THREE.AxesHelper) { + obj.visible = gridsVisible; grids.push(obj); } }); @@ -109,7 +113,7 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp cleanup2(); cleanup3(); }; - }, [sceneController]); + }, [sceneController, gridsVisible]); // Setup belt mesh with initial data from replay controller useEffect(() => { @@ -120,16 +124,16 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp if (!firstDataPoint) return; // Store initial helix rotation for relative rotation calculations - const firstBreakdown = firstDataPoint.system?.cvt?.secondaryPulleyState?.breakdown; + const firstBreakdown = firstDataPoint.drivetrain?.cvt_dynamics?.secondaryPulleyState?.breakdown; const firstHelixRotation = (firstBreakdown && 'helix_force' in firstBreakdown) ? firstBreakdown.helix_force.springTorque.rotation : 0; setInitialHelixRotation(firstHelixRotation); - const primaryRadius = firstDataPoint.system?.cvt?.primaryPulleyState?.radius ?? constants.min_prim_radius; - const secondaryRadius = firstDataPoint.system?.cvt?.secondaryPulleyState?.radius ?? constants.max_sec_radius; - const primaryWrapAngleDeg = firstDataPoint.system?.cvt?.primaryPulleyState?.wrap_angle ?? 180; - const secondaryWrapAngleDeg = firstDataPoint.system?.cvt?.secondaryPulleyState?.wrap_angle ?? 180; + const primaryRadius = firstDataPoint.drivetrain?.cvt_dynamics?.primaryPulleyState?.radius ?? constants.min_prim_radius; + const secondaryRadius = firstDataPoint.drivetrain?.cvt_dynamics?.secondaryPulleyState?.radius ?? constants.max_sec_radius; + const primaryWrapAngleDeg = firstDataPoint.drivetrain?.cvt_dynamics?.primaryPulleyState?.wrap_angle ?? 180; + const secondaryWrapAngleDeg = firstDataPoint.drivetrain?.cvt_dynamics?.secondaryPulleyState?.wrap_angle ?? 180; const shiftDistance = firstDataPoint.state?.shift_distance ?? 0; const primaryWrapAngle = primaryWrapAngleDeg * (Math.PI / 180); @@ -167,27 +171,29 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp const unsubscribe = replayController.on((event) => { if (event.type === ReplayEventType.Progress) { // Extract angular positions and shift distance - const primaryAngularPosition = event.data.system?.cvt?.primaryPulleyState?.angular_position ?? 0; - const secondaryAngularPosition = event.data.system?.cvt?.secondaryPulleyState?.angular_position ?? 0; - const secondaryBreakdown = event.data.system?.cvt?.secondaryPulleyState?.breakdown; + const primaryAngularPositionDeg = event.data.drivetrain?.cvt_dynamics?.primaryPulleyState?.angular_position ?? 0; + const secondaryAngularPositionDeg = event.data.drivetrain?.cvt_dynamics?.secondaryPulleyState?.angular_position ?? 0; + const secondaryBreakdown = event.data.drivetrain?.cvt_dynamics?.secondaryPulleyState?.breakdown; const secondaryHelixRotationDeg = (secondaryBreakdown && 'helix_force' in secondaryBreakdown) ? secondaryBreakdown.helix_force.springTorque.rotation - initialHelixRotation : 0; - const secondaryHelixRotation = secondaryHelixRotationDeg * (Math.PI / 180); + const primaryAngularPosition = degToRad(primaryAngularPositionDeg); + const secondaryAngularPosition = degToRad(secondaryAngularPositionDeg); + const secondaryHelixRotation = degToRad(secondaryHelixRotationDeg); const shiftDistance = event.data.state?.shift_distance ?? 0; // Get pulley states for belt calculation - const primaryRadius = event.data.system?.cvt?.primaryPulleyState?.radius ?? constants.min_prim_radius; - const secondaryRadius = event.data.system?.cvt?.secondaryPulleyState?.radius ?? constants.max_sec_radius; - const primaryWrapAngleDeg = event.data.system?.cvt?.primaryPulleyState?.wrap_angle ?? 180; - const secondaryWrapAngleDeg = event.data.system?.cvt?.secondaryPulleyState?.wrap_angle ?? 180; + const primaryRadius = event.data.drivetrain?.cvt_dynamics?.primaryPulleyState?.radius ?? constants.min_prim_radius; + const secondaryRadius = event.data.drivetrain?.cvt_dynamics?.secondaryPulleyState?.radius ?? constants.max_sec_radius; + const primaryWrapAngleDeg = event.data.drivetrain?.cvt_dynamics?.primaryPulleyState?.wrap_angle ?? 180; + const secondaryWrapAngleDeg = event.data.drivetrain?.cvt_dynamics?.secondaryPulleyState?.wrap_angle ?? 180; // Convert wrap angles from degrees to radians for Three.js - const primaryWrapAngle = primaryWrapAngleDeg * (Math.PI / 180); - const secondaryWrapAngle = secondaryWrapAngleDeg * (Math.PI / 180); + const primaryWrapAngle = degToRad(primaryWrapAngleDeg); + const secondaryWrapAngle = degToRad(secondaryWrapAngleDeg); - // Angular positions are already in radians from backend, use directly for 3D rotation - // (Three.js rotations use radians) + // Playback data is converted to BAJA preset where angles are degrees. + // Convert to radians before applying Three.js rotations. const shiftDistanceScene = toSceneDistance(shiftDistance); const primaryRadiusScene = toSceneDistance(primaryRadius); const secondaryRadiusScene = toSceneDistance(secondaryRadius); @@ -237,7 +243,7 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp }); return unsubscribe; - }, [sceneController, replayController, constants, toSceneDistance, beltMesh, initialHelixRotation, showAngularRotation]); + }, [sceneController, replayController, constants, toSceneDistance, beltMesh, initialHelixRotation, showAngularRotation, degToRad]); // Update belt visibility when state changes useEffect(() => { @@ -253,6 +259,44 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp }); }, [gridsVisible, gridObjects]); + // Toggle per-pulley YZ cross-sections through each pulley centerline. + useEffect(() => { + if (!sceneController || !constants) return; + + const renderer = sceneController.getRenderer(); + renderer.localClippingEnabled = crossSectionEnabled; + + const primaryCenterX = -constants.center_to_center / 2; + const secondaryCenterX = constants.center_to_center / 2; + + const primaryPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -primaryCenterX); + const secondaryPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -secondaryCenterX); + + const applyModelClipping = (modelId: string, plane: THREE.Plane) => { + const model = sceneController.getModel(modelId); + if (!model) return; + + model.object3D.traverse((obj) => { + if (!(obj instanceof THREE.Mesh)) return; + + const applyClipping = (material: THREE.Material) => { + const meshMaterial = material as THREE.Material & { clippingPlanes?: THREE.Plane[] }; + meshMaterial.clippingPlanes = crossSectionEnabled ? [plane] : []; + }; + + if (Array.isArray(obj.material)) { + obj.material.forEach(applyClipping); + } else { + applyClipping(obj.material); + } + }); + }; + + // Apply to top-level pulley nodes; children inherit by traversal. + applyModelClipping('primaryFixed', primaryPlane); + applyModelClipping('secondaryFixed', secondaryPlane); + }, [sceneController, constants, crossSectionEnabled]); + return (
{isLoading && ( @@ -282,6 +326,13 @@ export const Scene3DViewer = ({ replayController, className }: Scene3DViewerProp > {gridsVisible ? '🟢' : '⚪'} Grids +
); }; diff --git a/frontend/src/components/simulationsTable/SimulationsTable.module.scss b/frontend/src/components/simulationsTable/SimulationsTable.module.scss index 9f3fb42a..43dc8e36 100644 --- a/frontend/src/components/simulationsTable/SimulationsTable.module.scss +++ b/frontend/src/components/simulationsTable/SimulationsTable.module.scss @@ -12,7 +12,9 @@ $recent-row-bg: rgba(136, 136, 136, 0.1); $recent-row-hover: rgba(136, 136, 136, 0.15); .tableContainer { - flex-grow: 1; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; @include mixins.scrollbar; padding: 2rem; } diff --git a/frontend/src/constants/defaultSimulations.ts b/frontend/src/constants/defaultSimulations.ts index 1bd08470..a76ea05f 100644 --- a/frontend/src/constants/defaultSimulations.ts +++ b/frontend/src/constants/defaultSimulations.ts @@ -14,14 +14,14 @@ export const DEFAULT_SIMULATIONS = [ FlyweightMass: 0.5, PrimaryRampConfig: { segments: [ - { type: 'circular' as const, length: 0.024, angle_start: 60, angle_end: 40, quadrant: 3 }, + { type: 'circular' as const, length: 0.024, angle_start: 40, angle_end: 15, quadrant: 2 }, ], } as RampConfig, PrimarySpringRate: 12784, PrimarySpringPretension: 0.1, SecondaryRampConfig: { segments: [ - { type: 'linear' as const, length: 1, angle: -10 }, + { type: 'linear' as const, length: 1, angle: 50 }, ], } as RampConfig, SecondaryTorsionSpringRate: 3.476, @@ -41,14 +41,14 @@ export const DEFAULT_SIMULATIONS = [ FlyweightMass: 0.5, PrimaryRampConfig: { segments: [ - { type: 'circular' as const, length: 0.024, angle_start: 60, angle_end: 40, quadrant: 3 }, + { type: 'circular' as const, length: 0.024, angle_start: 40, angle_end: 15, quadrant: 2 }, ], } as RampConfig, PrimarySpringRate: 12784, PrimarySpringPretension: 0.1, SecondaryRampConfig: { segments: [ - { type: 'linear' as const, length: 1, angle: -10 }, + { type: 'linear' as const, length: 1, angle: 50 }, ], } as RampConfig, SecondaryTorsionSpringRate: 3.476, @@ -68,14 +68,14 @@ export const DEFAULT_SIMULATIONS = [ FlyweightMass: 0.5, PrimaryRampConfig: { segments: [ - { type: 'circular' as const, length: 0.024, angle_start: 60, angle_end: 40, quadrant: 3 }, + { type: 'circular' as const, length: 0.024, angle_start: 40, angle_end: 15, quadrant: 2 }, ], } as RampConfig, PrimarySpringRate: 12784, PrimarySpringPretension: 0.1, SecondaryRampConfig: { segments: [ - { type: 'linear' as const, length: 1, angle: -10 }, + { type: 'linear' as const, length: 1, angle: 50 }, ], } as RampConfig, SecondaryTorsionSpringRate: 3.476, @@ -89,6 +89,34 @@ export const DEFAULT_SIMULATIONS = [ TotalDistance: 200, }, }, + { + name: 'Best shift curve', + parameters: { + FlyweightMass: 0.5, + PrimaryRampConfig: { + segments: [ + { type: 'circular' as const, length: 0.002, angle_start: 75, angle_end: 50, quadrant: 2 }, + { type: 'circular' as const, length: 0.022, angle_start: 50, angle_end: 35, quadrant: 2 }, + ], + } as RampConfig, + PrimarySpringRate: 12784, + PrimarySpringPretension: 0.1, + SecondaryRampConfig: { + segments: [ + { type: 'linear' as const, length: 1, angle: 50 }, + ], + } as RampConfig, + SecondaryTorsionSpringRate: 3.476, + SecondaryCompressionSpringRate: 7000, + SecondaryRotationalSpringPretension: 800, + SecondaryLinearSpringPretension: 0.1, + VehicleWeight: 225, + DriverWeight: 75, + Traction: 100, + AngleOfIncline: 0, + TotalDistance: 200, + }, + }, ]; /** diff --git a/frontend/src/contexts/ParameterContext.tsx b/frontend/src/contexts/ParameterContext.tsx index 5cf55a62..a47b2960 100644 --- a/frontend/src/contexts/ParameterContext.tsx +++ b/frontend/src/contexts/ParameterContext.tsx @@ -1,11 +1,10 @@ -import React, { createContext, useReducer, useContext, useEffect } from 'react'; +import React, { createContext, useReducer, useContext } from 'react'; import { PARAMETERS, type Parameter, type ParameterState } from '@types'; type ParameterAction = | { type: 'SET_PARAMETER'; parameter: Parameter; value: ParameterState[Parameter] } | { type: 'SET_MULTIPLE_PARAMETERS'; parameters: Partial } - | { type: 'RESET_TO_DEFAULTS' } - | { type: 'LOAD_FROM_STORAGE'; parameters: ParameterState }; + | { type: 'RESET_TO_DEFAULTS' }; const ParameterContext = createContext<{ parameters: ParameterState; @@ -66,29 +65,17 @@ const parameterReducer = (state: ParameterState, action: ParameterAction): Param case 'RESET_TO_DEFAULTS': newState = getInitialState(); break; - case 'LOAD_FROM_STORAGE': - newState = action.parameters; - break; default: return state; } - // Save to localStorage for all actions except LOAD_FROM_STORAGE - if (action.type !== 'LOAD_FROM_STORAGE') { - saveToStorage(newState); - } + saveToStorage(newState); return newState; }; export const ParameterProvider = ({ children }: { children: React.ReactNode }) => { - const [parameters, dispatch] = useReducer(parameterReducer, getInitialState()); - - // Load from localStorage on mount - useEffect(() => { - const storedParameters = loadFromStorage(); - dispatch({ type: 'LOAD_FROM_STORAGE', parameters: storedParameters }); - }, []); + const [parameters, dispatch] = useReducer(parameterReducer, undefined, () => loadFromStorage()); const setParameter = (parameter: Parameter, value: ParameterState[Parameter]) => { dispatch({ type: 'SET_PARAMETER', parameter, value }); diff --git a/frontend/src/hooks/useFormState.ts b/frontend/src/hooks/useFormState.ts index 9b5e8426..d9ca438c 100644 --- a/frontend/src/hooks/useFormState.ts +++ b/frontend/src/hooks/useFormState.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useLayoutEffect } from 'react'; import { PARAMETERS, type Parameter, type ParameterState, type ParameterValue } from '@types'; export interface FormState { @@ -9,17 +9,19 @@ export interface FormState { } export const useFormState = (contextValues?: ParameterState) => { - // Initialize form values - strings for simple types, objects for complex types - const getInitialValues = useCallback((): Record => { + const formatValues = useCallback((sourceValues?: ParameterState): Record => { return Object.entries(PARAMETERS).reduce((acc, [key, config]) => { const paramKey = key as Parameter; - // Use context value if available, otherwise use default - const value = contextValues?.[paramKey] ?? config.defaultValue; - // Keep complex types as-is, convert primitives to strings for input fields + const value = sourceValues?.[paramKey] ?? config.defaultValue; acc[paramKey] = config.type === 'ramp' ? value : String(value); return acc; }, {} as Record); - }, [contextValues]); + }, []); + + // Initialize form values - strings for simple types, objects for complex types + const getInitialValues = useCallback((): Record => { + return formatValues(contextValues); + }, [contextValues, formatValues]); const getInitialErrors = useCallback((): Record => { return Object.keys(PARAMETERS).reduce((acc, key) => { @@ -43,6 +45,22 @@ export const useFormState = (contextValues?: ParameterState) => { // Keep track of initial values for change detection const initialValuesRef = useRef>(getInitialValues()); + // Keep form in sync with external context updates (e.g., reload/session restore, New/Edit actions) + useLayoutEffect(() => { + if (!contextValues) return; + + const formattedContextValues = formatValues(contextValues); + if (JSON.stringify(values) === JSON.stringify(formattedContextValues)) { + return; + } + + setValues(formattedContextValues); + setErrors(getInitialErrors()); + setTouched(getInitialTouched()); + setHasChanges(false); + initialValuesRef.current = formattedContextValues; + }, [contextValues, values, formatValues, getInitialErrors, getInitialTouched]); + // Update a field value and validate it const updateField = useCallback((parameter: Parameter, value: ParameterValue) => { setValues(prev => ({ ...prev, [parameter]: value })); diff --git a/frontend/src/hooks/useSessionPersistence.ts b/frontend/src/hooks/useSessionPersistence.ts index 87897c11..8bb77c07 100644 --- a/frontend/src/hooks/useSessionPersistence.ts +++ b/frontend/src/hooks/useSessionPersistence.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useMemo } from 'react'; +import { useEffect, useCallback, useMemo, useLayoutEffect, useRef } from 'react'; import { useParameter } from '@contexts/ParameterContext'; import type { ParameterState } from '@types'; import { @@ -8,8 +8,10 @@ import { setLoadedSimulationId, getLoadedSimulationId, getSimulation, + getRecentRuns, } from '@utils/localStorage'; import { getDefaultSimulations } from '@constants/defaultSimulations'; +import { PARAMETERS, type Parameter } from '@types'; /** * Hook for managing session persistence and tracking parameter changes @@ -22,38 +24,65 @@ import { getDefaultSimulations } from '@constants/defaultSimulations'; */ export const useSessionPersistence = () => { const { parameters, setMultipleParameters } = useParameter(); + const isHydratedRef = useRef(false); + const hasSkippedInitialSaveRef = useRef(false); /** * Initialize: Load session parameters or check for loaded simulation */ - useEffect(() => { + useLayoutEffect(() => { const sessionParams = getSessionParameters(); const loadedId = getLoadedSimulationId(); + const defaults = getDefaultSimulations(); - if (sessionParams && loadedId) { - // Both exist - restore session and keep baseline reference + if (sessionParams) { + // Session always has highest priority on reload/navigation setMultipleParameters(sessionParams); - } else if (sessionParams && !loadedId) { - // Session exists but no baseline - this is a page reload - // Set the session state and mark it as the baseline by storing it with a special ID - setMultipleParameters(sessionParams); - setLoadedSimulationId('session_baseline'); - } else if (!sessionParams && !loadedId) { - // Fresh start - load first default and set it as baseline - const defaults = getDefaultSimulations(); - if (defaults[0]) { - setMultipleParameters(defaults[0].parameters); - setLoadedSimulationId(defaults[0].id); + if (!loadedId) { + // Keep a baseline marker so changed detection has a stable reference + setLoadedSimulationId('session_baseline'); + } + } else if (loadedId) { + // No session state available; restore from the selected simulation ID + const savedSimulation = getSimulation(loadedId); + if (savedSimulation) { + setMultipleParameters(savedSimulation.parameters); + } else { + const recentRun = getRecentRuns().find(sim => sim.id === loadedId); + if (recentRun) { + setMultipleParameters(recentRun.parameters); + } else { + const defaultSimulation = defaults.find(sim => sim.id === loadedId); + if (defaultSimulation) { + setMultipleParameters(defaultSimulation.parameters); + } else if (defaults[0]) { + // Loaded ID is stale; fall back to first default + setMultipleParameters(defaults[0].parameters); + setLoadedSimulationId(defaults[0].id); + } else { + setLoadedSimulationId(null); + } + } } + } else if (defaults[0]) { + // Fresh start fallback + setMultipleParameters(defaults[0].parameters); + setLoadedSimulationId(defaults[0].id); } - // else: loadedId exists without session (from dashboard), parameters already set + + isHydratedRef.current = true; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run on mount + }, []); /** * Auto-save parameters to session on change */ useEffect(() => { + if (!isHydratedRef.current) return; + if (!hasSkippedInitialSaveRef.current) { + hasSkippedInitialSaveRef.current = true; + return; + } saveSessionParameters(parameters); }, [parameters]); @@ -89,6 +118,12 @@ export const useSessionPersistence = () => { if (defaultSim) { return defaultSim.parameters; } + + // Check if it's a recent run + const recentRun = getRecentRuns().find(s => s.id === loadedId); + if (recentRun) { + return recentRun.parameters; + } // Fallback return parameters; @@ -98,7 +133,11 @@ export const useSessionPersistence = () => { * Check if current parameters differ from baseline */ const hasChanges = useCallback((): boolean => { - return JSON.stringify(parameters) !== JSON.stringify(baselineParameters); + return (Object.keys(PARAMETERS) as Parameter[]).some((field) => { + const currentStr = JSON.stringify(parameters[field]); + const baselineStr = JSON.stringify(baselineParameters[field]); + return currentStr !== baselineStr; + }); }, [parameters, baselineParameters]); /** @@ -115,18 +154,7 @@ export const useSessionPersistence = () => { const currentStr = JSON.stringify(parameters[field]); const baselineStr = JSON.stringify(baselineParameters[field]); - const changed = currentStr !== baselineStr; - - // Only log if actually changed (reduces noise) - if (changed) { - console.log(`[Field Check] ${String(field)}: CHANGED`, { - loadedId, - current: parameters[field], - baseline: baselineParameters[field] - }); - } - - return changed; + return currentStr !== baselineStr; }, [parameters, baselineParameters] ); diff --git a/frontend/src/hooks/useSimulations.ts b/frontend/src/hooks/useSimulations.ts index 20d3a252..5b5b501f 100644 --- a/frontend/src/hooks/useSimulations.ts +++ b/frontend/src/hooks/useSimulations.ts @@ -105,10 +105,11 @@ export const useSimulations = (): UseSimulationsReturn => { const handleEdit = useCallback(() => { if (!selectedSimulation) return; + clearSessionParameters(); + setLoadedSimulationId(selectedSimulation.id); setMultipleParameters(selectedSimulation.parameters); - setLoadedSimulationId(selectedId); navigate('/input'); - }, [selectedSimulation, selectedId, setMultipleParameters, navigate]); + }, [selectedSimulation, setMultipleParameters, navigate]); const handleDelete = useCallback(() => { if (!selectedSimulation || !selectedId) return; diff --git a/frontend/src/pages/dashboard/Dashboard.module.scss b/frontend/src/pages/dashboard/Dashboard.module.scss index b07adb1a..c0e2c2a1 100644 --- a/frontend/src/pages/dashboard/Dashboard.module.scss +++ b/frontend/src/pages/dashboard/Dashboard.module.scss @@ -9,6 +9,7 @@ width: 100vw; padding: 0.625rem; box-sizing: border-box; + overflow: hidden; .header { display: flex; @@ -16,6 +17,7 @@ align-items: center; height: fit-content; margin: 1rem; + flex-shrink: 0; .logo { height: 7rem; @@ -36,5 +38,7 @@ flex-direction: row; justify-content: end; gap: 1rem; + flex-shrink: 0; + margin-top: 0.5rem; } } \ No newline at end of file diff --git a/frontend/src/pages/input/Input.module.scss b/frontend/src/pages/input/Input.module.scss index 59736484..dbf3bbfd 100644 --- a/frontend/src/pages/input/Input.module.scss +++ b/frontend/src/pages/input/Input.module.scss @@ -10,9 +10,56 @@ height: 100vh; width: 100vw; + .topBar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 0.5rem 1rem 0 0.5rem; + z-index: 5; + + .sessionInfo { + display: inline-flex; + align-items: center; + gap: 0.75rem; + max-width: 65vw; + white-space: nowrap; + overflow: hidden; + color: var(--text-color); + + .selectedSetName { + @include fonts.title-bold; + overflow: hidden; + text-overflow: ellipsis; + } + + .changesBadge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + @include fonts.text-small; + flex-shrink: 0; + + .changeIndicator { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + background-color: var(--warning, #ffc107); + border-radius: 50%; + flex-shrink: 0; + } + } + } + + .topBarSpacer { + justify-self: end; + width: 0; + height: 0; + } + } + .backButton { - margin-left: 0.5rem; - margin-top: 0.5rem; + margin: 0; + justify-self: start; } .solverResultsPosition { diff --git a/frontend/src/pages/input/Input.tsx b/frontend/src/pages/input/Input.tsx index 9c2c5425..dd277b93 100644 --- a/frontend/src/pages/input/Input.tsx +++ b/frontend/src/pages/input/Input.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '@components/button/Button'; import { ParameterAccordion } from '@components/parameterAccordion/ParameterAccordion'; @@ -15,6 +15,12 @@ import { useLoading } from '@contexts/LoadingContext'; import { useFormState } from '@hooks/useFormState'; import { useRunSimulation } from '@hooks/useRunSimulation'; import { useSessionPersistence } from '@hooks/useSessionPersistence'; +import { + getLoadedSimulationId, + getSimulation, + getRecentRuns, +} from '@utils/localStorage'; +import { getDefaultSimulations } from '@constants/defaultSimulations'; import Home from '@assets/icons/home.svg?react'; import ArrowUpCircle from '@assets/icons/arrow_up_circle.svg?react'; import ArrowDownCircle from '@assets/icons/arrow_down_circle.svg?react'; @@ -38,6 +44,23 @@ export const Input = () => { const navigate = useNavigate(); const { runSimulation } = useRunSimulation(); const { isFieldChanged, hasChanges, resetToBaseline, baselineParameters } = useSessionPersistence(); + const hasSessionChanges = hasChanges(); + const selectedSetName = useMemo(() => { + const loadedId = getLoadedSimulationId(); + if (!loadedId) return 'New Parameter Set'; + if (loadedId === 'session_baseline') return 'Session Parameters'; + + const savedSimulation = getSimulation(loadedId); + if (savedSimulation) return savedSimulation.name; + + const defaultSimulation = getDefaultSimulations().find((simulation) => simulation.id === loadedId); + if (defaultSimulation) return defaultSimulation.name; + + const recentRun = getRecentRuns().find((simulation) => simulation.id === loadedId); + if (recentRun) return recentRun.name; + + return 'Selected Parameter Set'; + }, [parameters]); // State to manage which accordions are expanded const [expanded, setExpanded] = useState>(expandedState); @@ -140,12 +163,24 @@ export const Input = () => { parameters={formState.getParsedValues()} onSave={handleSaveComplete} /> -
); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 74860e25..5b203d95 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -133,16 +133,6 @@ export interface components { primary_engagement: components["schemas"]["SolverResultModel"]; shift_initiation: components["schemas"]["SolverResultModel"]; }; - /** CarForceBreakdownModel */ - CarForceBreakdownModel: { - /** Coupling Torque At Wheel */ - coupling_torque_at_wheel: number; - /** Load Torque At Wheel */ - load_torque_at_wheel: number; - external_forces: components["schemas"]["ExternalLoadForceBreakdownModel"]; - /** Acceleration */ - acceleration: number; - }; /** * CarSpecs * @description Configuration class for CVT simulator car specifications. @@ -157,6 +147,24 @@ export interface components { * @default 0.1 */ engine_inertia: number; + /** + * Secondary Inertia + * @description Secondary CVT pulley inertia in kg*m^2 + * @default 0.1 + */ + secondary_inertia: number; + /** + * Gearbox Inertia + * @description Gearbox inertia in kg*m^2 + * @default 0.05 + */ + gearbox_inertia: number; + /** + * Wheel Inertia + * @description Wheel inertia in kg*m^2 (all wheels) + * @default 0.2 + */ + wheel_inertia: number; /** * Driveline Inertia * @description Driveline inertia in kg*m^2 (includes sec CVT, gearbox, axles, wheels, hubs, etc) @@ -187,6 +195,12 @@ export interface components { * @default 0.6 */ drag_coefficient: number; + /** + * Rolling Resistance Coefficient + * @description Rolling resistance coefficient (unitless) + * @default 0.015 + */ + rolling_resistance_coefficient: number; /** * Sheave Angle * @description Sheave angle in radians @@ -196,7 +210,7 @@ export interface components { /** * Initial Flyweight Radius * @description Initial flyweight radius in meters - * @default 0.05 + * @default 0.04878 */ initial_flyweight_radius: number; /** @@ -205,12 +219,6 @@ export interface components { * @default 0.04445 */ helix_radius: number; - /** - * Belt Angle - * @description Belt angle in radians - * @default 0.24434609527920614 - */ - belt_angle: number; /** * Belt Height * @description Belt height in meters @@ -226,13 +234,19 @@ export interface components { /** * Belt Width Top * @description Belt width at top in meters - * @default 0.021589999999999998 + * @default 0.021335999999999997 */ belt_width_top: number; + /** + * Belt Width Bottom + * @description Belt width at bottom in meters + * @default 0.0168148 + */ + belt_width_bottom: number; /** * Min Prim Radius * @description Minimum primary pulley radius in meters - * @default 0.019049999999999997 + * @default 0.0206375 */ min_prim_radius: number; /** @@ -244,14 +258,14 @@ export interface components { /** * Initial Sheave Displacement * @description Initial sheave displacement in meters - * @default 0.0063754 + * @default 0.0024891999999999996 */ initial_sheave_displacement: number; /** - * Belt Width Bottom + * Belt Angle * @description Belt width at bottom in meters, calculated from top width, height and angle. */ - readonly belt_width_bottom: number; + readonly belt_angle: number; /** * Belt Cross Sectional Area * @description Belt cross-sectional area in m^2. @@ -284,8 +298,8 @@ export interface components { */ type: "circular"; }; - /** CvtSystemForceBreakdownModel */ - CvtSystemForceBreakdownModel: { + /** CvtDynamicsBreakdownModel */ + CvtDynamicsBreakdownModel: { primaryPulleyState: components["schemas"]["PulleyStateModel"]; secondaryPulleyState: components["schemas"]["PulleyStateModel"]; /** Friction */ @@ -297,32 +311,48 @@ export interface components { /** Net */ net: number; }; - /** EngineForceBreakdownModel */ - EngineForceBreakdownModel: { - /** Torque */ - torque: number; - /** Coupling Torque At Engine */ - coupling_torque_at_engine: number; - /** Power */ - power: number; - /** Angular Velocity */ - angular_velocity: number; - /** Angular Acceleration */ - angular_acceleration: number; + /** DerivedKinematicStateModel */ + DerivedKinematicStateModel: { + /** Car Velocity */ + car_velocity: number; + /** Car Position */ + car_position: number; + /** Engine Angular Velocity */ + engine_angular_velocity: number; + /** Engine Angular Position */ + engine_angular_position: number; + }; + /** DrivetrainBreakdownModel */ + DrivetrainBreakdownModel: { + belt_slip: components["schemas"]["SlipBreakdownModel"]; + primary_pulley: components["schemas"]["PrimaryPulleyDynamicsBreakdownModel"]; + secondary_pulley: components["schemas"]["SecondaryPulleyDynamicsBreakdownModel"]; + cvt_dynamics: components["schemas"]["CvtDynamicsBreakdownModel"]; }; /** ExternalLoadForceBreakdownModel */ ExternalLoadForceBreakdownModel: { + /** Rolling Resistance Force */ + rolling_resistance_force: number; /** Incline Force */ incline_force: number; /** Drag Force */ drag_force: number; - /** Net */ - net: number; + /** Net Force At Car */ + net_force_at_car: number; + /** Rolling Resistance Torque At Secondary */ + rolling_resistance_torque_at_secondary: number; + /** Incline Torque At Secondary */ + incline_torque_at_secondary: number; + /** Drag Torque At Secondary */ + drag_torque_at_secondary: number; + /** Net Torque At Secondary */ + net_torque_at_secondary: number; }; /** FormattedSimulationResultModel */ FormattedSimulationResultModel: { /** Data */ data: components["schemas"]["TimeStepDataModel"][]; + termination: components["schemas"]["SimulationTerminationContextModel"]; }; /** HTTPValidationError */ HTTPValidationError: { @@ -367,14 +397,57 @@ export interface components { /** Net */ net: number; }; + /** PrimaryPulleyDynamicsBreakdownModel */ + PrimaryPulleyDynamicsBreakdownModel: { + /** Primary Pulley Drive Torque */ + primary_pulley_drive_torque: number; + /** Coupling Torque At Primary Pulley */ + coupling_torque_at_primary_pulley: number; + /** Power */ + power: number; + /** Primary Pulley Angular Velocity */ + primary_pulley_angular_velocity: number; + /** Primary Pulley Angular Acceleration */ + primary_pulley_angular_acceleration: number; + }; + /** PrimaryTorqueBoundsBreakdownModel */ + PrimaryTorqueBoundsBreakdownModel: { + /** Tau Lower */ + tau_lower: number; + /** Tau Upper */ + tau_upper: number; + numerator: components["schemas"]["PrimaryTorqueNumeratorBreakdownModel"]; + denominator_upper: components["schemas"]["PrimaryTorqueDenominatorBreakdownModel"]; + denominator_lower: components["schemas"]["PrimaryTorqueDenominatorBreakdownModel"]; + }; + /** PrimaryTorqueDenominatorBreakdownModel */ + PrimaryTorqueDenominatorBreakdownModel: { + /** Inverse Radius Term */ + inverse_radius_term: number; + /** Inertial Feedback Term */ + inertial_feedback_term: number; + /** Net */ + net: number; + }; + /** PrimaryTorqueNumeratorBreakdownModel */ + PrimaryTorqueNumeratorBreakdownModel: { + /** Clamping Term */ + clamping_term: number; + /** Load Term */ + load_term: number; + /** Shift Term */ + shift_term: number; + /** Net */ + net: number; + }; /** PulleyForcesModel */ PulleyForcesModel: { - /** Clamping Force */ - clamping_force: number; - /** Radial Force */ - radial_force: number; - /** Max Torque */ - max_torque: number; + /** Axial Clamping Force */ + axial_clamping_force: number; + /** Axial Centrifugal From Belt */ + axial_centrifugal_from_belt: number; + /** Axial Force Total */ + axial_force_total: number; }; /** PulleyStateModel */ PulleyStateModel: { @@ -387,10 +460,6 @@ export interface components { angular_velocity: number; /** Angular Position */ angular_position: number; - /** Radial From Clamping */ - radial_from_clamping: number; - /** Radial From Centrifugal */ - radial_from_centrifugal: number; /** Breakdown */ breakdown: components["schemas"]["PrimaryForceBreakdownModel"] | components["schemas"]["SecondaryForceBreakdownModel"]; }; @@ -414,19 +483,57 @@ export interface components { /** Net */ net: number; }; + /** SecondaryPulleyDynamicsBreakdownModel */ + SecondaryPulleyDynamicsBreakdownModel: { + /** Coupling Torque At Secondary Pulley */ + coupling_torque_at_secondary_pulley: number; + /** External Load Torque At Secondary Pulley */ + external_load_torque_at_secondary_pulley: number; + external_forces: components["schemas"]["ExternalLoadForceBreakdownModel"]; + /** Secondary Pulley Angular Acceleration */ + secondary_pulley_angular_acceleration: number; + }; + /** SecondaryTorqueBoundsBreakdownModel */ + SecondaryTorqueBoundsBreakdownModel: { + /** Tau Negative */ + tau_negative: number; + /** Tau Positive */ + tau_positive: number; + numerator: components["schemas"]["SecondaryTorqueNumeratorBreakdownModel"]; + denominator_positive: components["schemas"]["SecondaryTorqueDenominatorBreakdownModel"]; + denominator_negative: components["schemas"]["SecondaryTorqueDenominatorBreakdownModel"]; + }; + /** SecondaryTorqueDenominatorBreakdownModel */ + SecondaryTorqueDenominatorBreakdownModel: { + /** Inverse Radius Term */ + inverse_radius_term: number; + /** Helix Feedback Term */ + helix_feedback_term: number; + /** Inertial Feedback Term */ + inertial_feedback_term: number; + /** Net */ + net: number; + }; + /** SecondaryTorqueNumeratorBreakdownModel */ + SecondaryTorqueNumeratorBreakdownModel: { + /** Spring Term */ + spring_term: number; + /** Load Term */ + load_term: number; + /** Shift Term */ + shift_term: number; + /** Net */ + net: number; + }; /** SimulationArgsInput */ SimulationArgsInput: { /** Flyweight Mass */ flyweight_mass?: number | null; - /** Primary Ramp Geometry */ - primary_ramp_geometry?: number | null; primary_ramp_config?: components["schemas"]["PiecewiseRampConfigModel"] | null; /** Primary Spring Rate */ primary_spring_rate?: number | null; /** Primary Spring Pretension */ primary_spring_pretension?: number | null; - /** Secondary Helix Geometry */ - secondary_helix_geometry?: number | null; secondary_ramp_config?: components["schemas"]["PiecewiseRampConfigModel"] | null; /** Secondary Torsion Spring Rate */ secondary_torsion_spring_rate?: number | null; @@ -447,18 +554,47 @@ export interface components { /** Total Distance */ total_distance?: number | null; }; + /** SimulationTerminationContextModel */ + SimulationTerminationContextModel: { + /** Reason Code */ + reason_code: string; + /** Reason */ + reason: string; + /** Mode */ + mode: string; + /** Final Time */ + final_time: number; + /** Reached Max Time */ + reached_max_time: boolean; + /** Event */ + event: string | null; + /** Event Time */ + event_time: number | null; + /** Transition Count */ + transition_count: number; + /** Max Transitions */ + max_transitions: number; + /** Details */ + details: { + [key: string]: number | string | boolean; + }; + }; /** SlipBreakdownModel */ SlipBreakdownModel: { /** Coupling Torque */ coupling_torque: number; /** Torque Demand */ torque_demand: number; - /** T Max Prim */ - t_max_prim: number; - /** T Max Sec */ - t_max_sec: number; - /** Cvt Ratio Derivative */ - cvt_ratio_derivative: number; + /** Relative Velocity */ + relative_velocity: number; + /** Tau Upper */ + tau_upper: number; + /** Tau Lower */ + tau_lower: number; + primary_tau_bounds: components["schemas"]["PrimaryTorqueBoundsBreakdownModel"]; + secondary_tau_bounds: components["schemas"]["SecondaryTorqueBoundsBreakdownModel"]; + /** Effective Cvt Ratio Time Derivative */ + effective_cvt_ratio_time_derivative: number; /** Is Slipping */ is_slipping: boolean; }; @@ -511,34 +647,24 @@ export interface components { /** Percent */ percent: number; }; - /** SystemBreakdownModel */ - SystemBreakdownModel: { - slip: components["schemas"]["SlipBreakdownModel"]; - engine: components["schemas"]["EngineForceBreakdownModel"]; - car: components["schemas"]["CarForceBreakdownModel"]; - cvt: components["schemas"]["CvtSystemForceBreakdownModel"]; - }; /** SystemStateModel */ SystemStateModel: { - /** Car Velocity */ - car_velocity: number; - /** Car Position */ - car_position: number; - /** Shift Velocity */ - shift_velocity: number; /** Shift Distance */ shift_distance: number; - /** Engine Angular Velocity */ - engine_angular_velocity: number; - /** Engine Angular Position */ - engine_angular_position: number; + /** Shift Velocity */ + shift_velocity: number; + /** Primary Pulley Angular Velocity */ + primary_pulley_angular_velocity: number; + /** Secondary Pulley Angular Velocity */ + secondary_pulley_angular_velocity: number; }; /** TimeStepDataModel */ TimeStepDataModel: { /** Time */ time: number; state: components["schemas"]["SystemStateModel"]; - system: components["schemas"]["SystemBreakdownModel"]; + derived_state: components["schemas"]["DerivedKinematicStateModel"]; + drivetrain: components["schemas"]["DrivetrainBreakdownModel"]; }; /** ValidationError */ ValidationError: { diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index e1975694..22aad124 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -19,54 +19,63 @@ export type GraphCategory = { }; export const timeAccessor: AccessorStrategy = (point) => point.time; -const positionAccessor: AccessorStrategy = (point) => point.state.car_position; -const velocityAccessor: AccessorStrategy = (point) => point.state.car_velocity; -const accelerationAccessor: AccessorStrategy = (point) => point.system.car.acceleration; +const positionAccessor: AccessorStrategy = (point) => point.derived_state.car_position; +const velocityAccessor: AccessorStrategy = (point) => point.derived_state.car_velocity; +const accelerationAccessor: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.secondary_pulley_angular_acceleration; // Temp -const couplingTorqueAtWheels: AccessorStrategy = (point) => point.system.car.coupling_torque_at_wheel; -const loadTorqueAtWheels: AccessorStrategy = (point) => point.system.car.load_torque_at_wheel; +const couplingTorqueAtWheels: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.coupling_torque_at_secondary_pulley; +const loadTorqueAtWheels: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.external_load_torque_at_secondary_pulley; -const couplingTorqueAtEngine: AccessorStrategy = (point) => point.system.engine.coupling_torque_at_engine; +const couplingTorqueAtEngine: AccessorStrategy = (point) => point.drivetrain.primary_pulley.coupling_torque_at_primary_pulley; // Engine and CVT stuff -const cvtRatioAccessor: AccessorStrategy = (point) => point.system.cvt.cvt_ratio; -const engineRpmAccessor: AccessorStrategy = (point) => point.system.engine.angular_velocity; -const engineTorqueAccessor: AccessorStrategy = (point) => point.system.engine.torque; -const cvtRatioRateOfChangeAccessor: AccessorStrategy = (point) => point.system.slip.cvt_ratio_derivative; -const enginePowerAccessor: AccessorStrategy = (point) => point.system.engine.power; -const cvtAccelerationAccessor: AccessorStrategy = (point) => point.system.cvt.acceleration; +const cvtRatioAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.cvt_ratio; +const engineRpmAccessor: AccessorStrategy = (point) => point.drivetrain.primary_pulley.primary_pulley_angular_velocity; +const engineTorqueAccessor: AccessorStrategy = (point) => point.drivetrain.primary_pulley.primary_pulley_drive_torque; +const cvtRatioRateOfChangeAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.effective_cvt_ratio_time_derivative; +const enginePowerAccessor: AccessorStrategy = (point) => point.drivetrain.primary_pulley.power; +const cvtAccelerationAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.acceleration; // Slip model accessors -const t_max_primAccessor: AccessorStrategy = (point) => point.system.slip.t_max_prim; -const t_max_secAccessor: AccessorStrategy = (point) => point.system.slip.t_max_sec; -const coupling_torqueAccessor: AccessorStrategy = (point) => point.system.slip.coupling_torque; -const torque_demandAccessor: AccessorStrategy = (point) => point.system.slip.torque_demand; -const isSlippingAccessor: AccessorStrategy = (point) => point.system.slip.is_slipping ? 1 : 0; +const coupling_torqueAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.coupling_torque; +const torque_demandAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.torque_demand; +const tau_upperAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.tau_upper; +const tau_lowerAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.tau_lower; +const primary_tau_upperAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.primary_tau_bounds.tau_upper; +const primary_tau_lowerAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.primary_tau_bounds.tau_lower; +const secondary_tau_positiveAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.secondary_tau_bounds.tau_positive; +const secondary_tau_negativeAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.secondary_tau_bounds.tau_negative; +const relativeVelocityAccessor: AccessorStrategy = (point) => (point.drivetrain.belt_slip as DataPoint['drivetrain']['belt_slip'] & { relative_velocity: number }).relative_velocity; +const isSlippingAccessor: AccessorStrategy = (point) => point.drivetrain.belt_slip.is_slipping ? 1 : 0; // External load -const inclineForceAccessor: AccessorStrategy = (point) => point.system.car.external_forces.incline_force; -const dragForceAccessor: AccessorStrategy = (point) => point.system.car.external_forces.drag_force; -const totalExternalLoadAccessor: AccessorStrategy = (point) => point.system.car.external_forces.net; - -// Overall pulley radial force (combined) -const primaryRadialForceAccessor: AccessorStrategy = (point) => point.system.cvt.primaryPulleyState.forces.radial_force; -const secondaryRadialForceAccessor: AccessorStrategy = (point) => point.system.cvt.secondaryPulleyState.forces.radial_force; - -// Components of radial force prior to 2sin(phi/2) multiplication -const primaryRadialFromCentrifugalAccessor: AccessorStrategy = (point) => point.system.cvt.primaryPulleyState.radial_from_centrifugal; -const primaryRadialFromClampingAccessor: AccessorStrategy = (point) => point.system.cvt.primaryPulleyState.radial_from_clamping; -const secondaryRadialFromCentrifugalAccessor: AccessorStrategy = (point) => point.system.cvt.secondaryPulleyState.radial_from_centrifugal; -const secondaryRadialFromClampingAccessor: AccessorStrategy = (point) => point.system.cvt.secondaryPulleyState.radial_from_clamping; +const rollingResistanceForceAccessor: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.external_forces.rolling_resistance_force; +const inclineForceAccessor: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.external_forces.incline_force; +const dragForceAccessor: AccessorStrategy = (point) => point.drivetrain.secondary_pulley.external_forces.drag_force; +const totalExternalLoadForceAtCarAccessor: AccessorStrategy = (point) => + (point.drivetrain.secondary_pulley.external_forces as DataPoint['drivetrain']['secondary_pulley']['external_forces'] & { net_force_at_car: number }).net_force_at_car; +const rollingResistanceTorqueAtSecondaryAccessor: AccessorStrategy = (point) => + (point.drivetrain.secondary_pulley.external_forces as DataPoint['drivetrain']['secondary_pulley']['external_forces'] & { rolling_resistance_torque_at_secondary: number }).rolling_resistance_torque_at_secondary; +const inclineTorqueAtSecondaryAccessor: AccessorStrategy = (point) => + (point.drivetrain.secondary_pulley.external_forces as DataPoint['drivetrain']['secondary_pulley']['external_forces'] & { incline_torque_at_secondary: number }).incline_torque_at_secondary; +const dragTorqueAtSecondaryAccessor: AccessorStrategy = (point) => + (point.drivetrain.secondary_pulley.external_forces as DataPoint['drivetrain']['secondary_pulley']['external_forces'] & { drag_torque_at_secondary: number }).drag_torque_at_secondary; +const totalExternalLoadTorqueAtSecondaryAccessor: AccessorStrategy = (point) => + (point.drivetrain.secondary_pulley.external_forces as DataPoint['drivetrain']['secondary_pulley']['external_forces'] & { net_torque_at_secondary: number }).net_torque_at_secondary; // Overall pulley clamping force (Axial) -const primaryClampingForceAccessor: AccessorStrategy = (point) => point.system.cvt.primaryPulleyState.forces.clamping_force; -const secondaryClampingForceAccessor: AccessorStrategy = (point) => point.system.cvt.secondaryPulleyState.forces.clamping_force; +const primaryAxialClampingForceAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_clamping_force; +const secondaryAxialClampingForceAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_clamping_force; +const primaryAxialCentrifugalFromBeltAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_centrifugal_from_belt; +const secondaryAxialCentrifugalFromBeltAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_centrifugal_from_belt; +const primaryAxialForceTotalAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_force_total; +const secondaryAxialForceTotalAccessor: AccessorStrategy = (point) => point.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_force_total; // Helper function to extract values from breakdown with proper error handling function getBreakdownValue( - breakdown: DataPoint['system']['cvt']['primaryPulleyState']['breakdown'] | - DataPoint['system']['cvt']['secondaryPulleyState']['breakdown'], + breakdown: DataPoint['drivetrain']['cvt_dynamics']['primaryPulleyState']['breakdown'] | + DataPoint['drivetrain']['cvt_dynamics']['secondaryPulleyState']['breakdown'], propertyPath: string[], contextName: string = "breakdown" ): T { @@ -88,42 +97,42 @@ function getBreakdownValue( } const primaryFlyweightForceAccessor: AccessorStrategy = (point) => { - const prf = point.system.cvt.primaryPulleyState.breakdown; + const prf = point.drivetrain.cvt_dynamics.primaryPulleyState.breakdown; return getBreakdownValue(prf, ['flyweightForce', 'net'], "primary pulley"); }; const rawFlyweightCentrifugalForce: AccessorStrategy = (point) => { - const prf = point.system.cvt.primaryPulleyState.breakdown; + const prf = point.drivetrain.cvt_dynamics.primaryPulleyState.breakdown; return getBreakdownValue(prf, ['flyweightForce', 'centrifugal_force'], "primary pulley"); }; const primarySpringForceAccessor: AccessorStrategy = (point) => { - const prf = point.system.cvt.primaryPulleyState.breakdown; + const prf = point.drivetrain.cvt_dynamics.primaryPulleyState.breakdown; return getBreakdownValue(prf, ['springForce', 'net'], "primary pulley"); }; const primaryRampAngleAccessor: AccessorStrategy = (point) => { - const prf = point.system.cvt.primaryPulleyState.breakdown; + const prf = point.drivetrain.cvt_dynamics.primaryPulleyState.breakdown; return getBreakdownValue(prf, ['flyweightForce', 'angle'], "primary pulley"); }; const secondaryHelixFeedbackTorqueAccessor: AccessorStrategy = (point) => { - const srf = point.system.cvt.secondaryPulleyState.breakdown; + const srf = point.drivetrain.cvt_dynamics.secondaryPulleyState.breakdown; return getBreakdownValue(srf, ['helix_force', 'feedbackTorque'], "secondary pulley"); }; const secondaryHelixSpringTorqueAccessor: AccessorStrategy = (point) => { - const srf = point.system.cvt.secondaryPulleyState.breakdown; + const srf = point.drivetrain.cvt_dynamics.secondaryPulleyState.breakdown; return getBreakdownValue(srf, ['helix_force', 'springTorque', 'net'], "secondary pulley"); }; const secondaryHelixForceAccessor: AccessorStrategy = (point) => { - const srf = point.system.cvt.secondaryPulleyState.breakdown; + const srf = point.drivetrain.cvt_dynamics.secondaryPulleyState.breakdown; return getBreakdownValue(srf, ['helix_force', 'net'], "secondary pulley"); }; const secondarySpringCompForceAccessor: AccessorStrategy = (point) => { - const srf = point.system.cvt.secondaryPulleyState.breakdown; + const srf = point.drivetrain.cvt_dynamics.secondaryPulleyState.breakdown; return getBreakdownValue(srf, ['springCompForce', 'net'], "secondary pulley"); }; @@ -132,18 +141,21 @@ export const accessorToUnit = new Map([ [timeAccessor, 'time'], [positionAccessor, 'distance'], [velocityAccessor, 'velocity'], - [accelerationAccessor, 'acceleration'], + [accelerationAccessor, 'angular_acceleration'], [cvtRatioAccessor, 'dimensionless'], [engineRpmAccessor, 'angular_velocity'], [engineTorqueAccessor, 'torque'], [cvtRatioRateOfChangeAccessor, 'dimensionless_rate'], [enginePowerAccessor, 'power'], [coupling_torqueAccessor, 'torque'], - [t_max_primAccessor, 'torque'], - [t_max_secAccessor, 'torque'], + [tau_upperAccessor, 'torque'], + [tau_lowerAccessor, 'torque'], + [primary_tau_upperAccessor, 'torque'], + [primary_tau_lowerAccessor, 'torque'], + [secondary_tau_positiveAccessor, 'torque'], + [secondary_tau_negativeAccessor, 'torque'], + [relativeVelocityAccessor, 'angular_velocity'], [torque_demandAccessor, 'torque'], - [primaryRadialForceAccessor, 'force'], - [secondaryRadialForceAccessor, 'force'], [primaryFlyweightForceAccessor, 'force'], [rawFlyweightCentrifugalForce, 'force'], [primarySpringForceAccessor, 'force'], @@ -151,15 +163,20 @@ export const accessorToUnit = new Map([ [secondaryHelixSpringTorqueAccessor, 'torque'], [secondaryHelixForceAccessor, 'force'], [secondarySpringCompForceAccessor, 'force'], - [primaryClampingForceAccessor, 'force'], - [secondaryClampingForceAccessor, 'force'], + [primaryAxialClampingForceAccessor, 'force'], + [secondaryAxialClampingForceAccessor, 'force'], + [primaryAxialCentrifugalFromBeltAccessor, 'force'], + [secondaryAxialCentrifugalFromBeltAccessor, 'force'], + [primaryAxialForceTotalAccessor, 'force'], + [secondaryAxialForceTotalAccessor, 'force'], + [rollingResistanceForceAccessor, 'force'], [inclineForceAccessor, 'force'], [dragForceAccessor, 'force'], - [totalExternalLoadAccessor, 'force'], - [primaryRadialFromCentrifugalAccessor, 'force'], - [primaryRadialFromClampingAccessor, 'force'], - [secondaryRadialFromCentrifugalAccessor, 'force'], - [secondaryRadialFromClampingAccessor, 'force'], + [totalExternalLoadForceAtCarAccessor, 'force'], + [rollingResistanceTorqueAtSecondaryAccessor, 'torque'], + [inclineTorqueAtSecondaryAccessor, 'torque'], + [dragTorqueAtSecondaryAccessor, 'torque'], + [totalExternalLoadTorqueAtSecondaryAccessor, 'torque'], [isSlippingAccessor, 'dimensionless'], [couplingTorqueAtWheels, 'torque'], [loadTorqueAtWheels, 'torque'], @@ -225,7 +242,7 @@ export const graphCategories: GraphCategory[] = [ title: "Torques at Wheels vs Time", xAxis: { name: "Time", type: "value", unit: getAxisUnit(timeAccessor) }, yAxis: { name: "Torque", type: "value", unit: getAxisUnit(couplingTorqueAtWheels) }, - seriesNames: ["Coupling Torque at Wheels", "Load Torque at Wheels"], + seriesNames: ["Coupling Torque at Secondary", "Load Torque at Secondary"], showXLine: true, showYLine: false } @@ -250,12 +267,24 @@ export const graphCategories: GraphCategory[] = [ /** EXTERNAL LOAD */ { xAccessor: velocityAccessor, - yAccessor: [totalExternalLoadAccessor, inclineForceAccessor, dragForceAccessor], + yAccessor: [totalExternalLoadForceAtCarAccessor, rollingResistanceForceAccessor, inclineForceAccessor, dragForceAccessor], config: { - title: "External Load Forces vs Vehicle Speed", + title: "External Load Forces at Car vs Vehicle Speed", xAxis: { name: "Vehicle Speed", type: "value", unit: getAxisUnit(velocityAccessor) }, yAxis: { name: "Force", type: "value", unit: getAxisUnit(inclineForceAccessor) }, - seriesNames: ["Total External Load", "Incline Force", "Air Resistance"], + seriesNames: ["Total (Car)", "Rolling Resistance", "Incline Force", "Air Resistance"], + showXLine: true, + showYLine: false + } + }, + { + xAccessor: velocityAccessor, + yAccessor: [totalExternalLoadTorqueAtSecondaryAccessor, rollingResistanceTorqueAtSecondaryAccessor, inclineTorqueAtSecondaryAccessor, dragTorqueAtSecondaryAccessor], + config: { + title: "External Load Torques at Secondary vs Vehicle Speed", + xAxis: { name: "Vehicle Speed", type: "value", unit: getAxisUnit(velocityAccessor) }, + yAxis: { name: "Torque", type: "value", unit: getAxisUnit(totalExternalLoadTorqueAtSecondaryAccessor) }, + seriesNames: ["Total (Secondary)", "Rolling Resistance", "Incline Torque", "Air Resistance Torque"], showXLine: true, showYLine: false } @@ -343,24 +372,24 @@ export const graphCategories: GraphCategory[] = [ /** PRIM AND SEC OVERALL GRAPHS */ { // Shows overall direction of shift xAccessor: timeAccessor, - yAccessor: [primaryRadialForceAccessor, secondaryRadialForceAccessor], + yAccessor: [primaryAxialForceTotalAccessor, secondaryAxialForceTotalAccessor], config: { - title: "Pulley Net Radial Forces vs Time", + title: "Pulley Total Axial Forces vs Time", xAxis: { name: "Time", type: "value", unit: "s" }, - yAxis: { name: "Radial Force", type: "value", unit: "N" }, - seriesNames: ["Primary Net", "Secondary Net"], + yAxis: { name: "Axial Force", type: "value", unit: "N" }, + seriesNames: ["Primary Total", "Secondary Total"], showXLine: true, showYLine: false } }, - { // Breakdown of radial force in belt centrifugal and clamping + { // Breakdown of axial clamping and belt centrifugal contribution xAccessor: timeAccessor, - yAccessor: [ primaryRadialFromClampingAccessor, secondaryRadialFromClampingAccessor, primaryRadialFromCentrifugalAccessor, secondaryRadialFromCentrifugalAccessor], + yAccessor: [primaryAxialClampingForceAccessor, secondaryAxialClampingForceAccessor, primaryAxialCentrifugalFromBeltAccessor, secondaryAxialCentrifugalFromBeltAccessor], config: { - title: "Radial Breakdown vs Time", + title: "Axial Force Breakdown vs Time", xAxis: { name: "Time", type: "value", unit: "s" }, yAxis: { name: "Force", type: "value", unit: "N" }, - seriesNames: ["Primary Pulley", "Secondary Pulley", "Primary Centrifugal", "Secondary Centrifugal"], + seriesNames: ["Primary Clamping", "Secondary Clamping", "Primary Belt Centrifugal", "Secondary Belt Centrifugal"], showXLine: true, showYLine: false } @@ -384,7 +413,7 @@ export const graphCategories: GraphCategory[] = [ /** PRIMARY GRAPHS */ { // Primary axial force breakdown into components xAccessor: timeAccessor, - yAccessor: [primaryClampingForceAccessor, primaryFlyweightForceAccessor, primarySpringForceAccessor], + yAccessor: [primaryAxialClampingForceAccessor, primaryFlyweightForceAccessor, primarySpringForceAccessor], config: { title: "Primary Axial Forces vs Time", xAxis: { name: "Time", type: "value", unit: "s" }, @@ -425,7 +454,7 @@ export const graphCategories: GraphCategory[] = [ /** SECONDARY GRAPHS */ { // Top level breakdown of axial from helix and axial from spring xAccessor: timeAccessor, - yAccessor: [secondaryClampingForceAccessor, secondaryHelixForceAccessor, secondarySpringCompForceAccessor], + yAccessor: [secondaryAxialClampingForceAccessor, secondaryHelixForceAccessor, secondarySpringCompForceAccessor], config: { title: "Secondary Axial Forces vs Time", xAxis: { name: "Time", type: "value", unit: "s" }, @@ -449,7 +478,7 @@ export const graphCategories: GraphCategory[] = [ }, { // Same graph as 2 above, but vs CVT ratio xAccessor: cvtRatioAccessor, - yAccessor: [secondaryClampingForceAccessor, secondaryHelixForceAccessor, secondarySpringCompForceAccessor], + yAccessor: [secondaryAxialClampingForceAccessor, secondaryHelixForceAccessor, secondarySpringCompForceAccessor], config: { title: "Secondary Axial Forces vs CVT RATIO", xAxis: { name: "CVT RATIO", type: "value", unit: getAxisUnit(cvtRatioAccessor) }, @@ -476,37 +505,47 @@ export const graphCategories: GraphCategory[] = [ title: "Slip Model", graphs: [ /** SLIP MODEL GRAPHS */ - { // Slip model torques vs time + { // Per-pulley torque bounds xAccessor: timeAccessor, - yAccessor: [coupling_torqueAccessor, torque_demandAccessor, t_max_primAccessor, t_max_secAccessor], + yAccessor: [ + primary_tau_upperAccessor, + primary_tau_lowerAccessor, + secondary_tau_positiveAccessor, + secondary_tau_negativeAccessor + ], config: { - title: "Slip Model Torques vs Time", + title: "Primary and Secondary Torque Bounds vs Time", xAxis: { name: "Time", type: "value", unit: getAxisUnit(timeAccessor) }, yAxis: { name: "Torque", type: "value", unit: getAxisUnit(coupling_torqueAccessor) }, - seriesNames: ["Coupling", "Demand", "T_max (Primary)", "T_max (Secondary)"], + seriesNames: [ + "Primary Upper Bound", + "Primary Lower Bound", + "Secondary Upper Bound", + "Secondary Lower Bound" + ], showXLine: true, showYLine: false } }, - { // Same graph but vs Engine RPM (is this useful?) - xAccessor: engineRpmAccessor, - yAccessor: [coupling_torqueAccessor, t_max_primAccessor, t_max_secAccessor], + { // Overall bounds and torque tracking + xAccessor: timeAccessor, + yAccessor: [tau_upperAccessor, tau_lowerAccessor, coupling_torqueAccessor, torque_demandAccessor], config: { - title: "Slip Model Torques vs Engine RPM", - xAxis: { name: "Engine RPM", type: "value", unit: getAxisUnit(engineRpmAccessor) }, + title: "Overall Bounds, Coupling, and No-Slip Torque vs Time", + xAxis: { name: "Time", type: "value", unit: getAxisUnit(timeAccessor) }, yAxis: { name: "Torque", type: "value", unit: "N·m" }, - seriesNames: ["Coupling", "T_max (Primary)", "T_max (Secondary)"], + seriesNames: ["Overall Upper Bound", "Overall Lower Bound", "Coupling (Final)", "No-Slip Torque"], showXLine: true, showYLine: false } }, - { // Whether you are slipping or not vs time + { xAccessor: timeAccessor, - yAccessor: [isSlippingAccessor], + yAccessor: [relativeVelocityAccessor], config: { - title: "Is Slipping vs Time", + title: "Relative Velocity vs Time", xAxis: { name: "Time", type: "value", unit: getAxisUnit(timeAccessor) }, - yAxis: { name: "Is Slipping", type: "value", unit: "dimensionless" }, + yAxis: { name: "Relative Velocity", type: "value", unit: getAxisUnit(relativeVelocityAccessor) }, showXLine: true, showYLine: false } diff --git a/frontend/src/types/parameter.ts b/frontend/src/types/parameter.ts index bf5b0bab..6b86aac9 100644 --- a/frontend/src/types/parameter.ts +++ b/frontend/src/types/parameter.ts @@ -99,9 +99,9 @@ const PARAMETERS_IMPL = { { type: 'circular' as const, length: 0.024, - angle_start: 60, - angle_end: 40, - quadrant: 3, + angle_start: 40, + angle_end: 15, + quadrant: 2, }, ], } as components['schemas']['PiecewiseRampConfigModel'], @@ -162,7 +162,7 @@ const PARAMETERS_IMPL = { { type: 'linear' as const, length: 1, - angle: -10, + angle: 50, }, ], } as components['schemas']['PiecewiseRampConfigModel'], diff --git a/frontend/src/types/ramp.ts b/frontend/src/types/ramp.ts index 4aa78b87..6a457c00 100644 --- a/frontend/src/types/ramp.ts +++ b/frontend/src/types/ramp.ts @@ -6,8 +6,8 @@ export type SegmentType = RampSegment['type']; // Default values for creating new segments export const SEGMENT_DEFAULTS: Record> = { - linear: { type: 'linear', length: 0.05, angle: -15 }, - circular: { type: 'circular', length: 0.05, angle_start: 45, angle_end: 15, quadrant: 3 }, + linear: { type: 'linear', length: 0.05, angle: 15 }, + circular: { type: 'circular', length: 0.05, angle_start: 45, angle_end: 15, quadrant: 2 }, }; type FieldMetadata = { diff --git a/frontend/src/utils/conversion/constantsConversion.ts b/frontend/src/utils/conversion/constantsConversion.ts index cd0bf2e8..bc00cdfa 100644 --- a/frontend/src/utils/conversion/constantsConversion.ts +++ b/frontend/src/utils/conversion/constantsConversion.ts @@ -21,6 +21,9 @@ export function convertConstants( return { // Inertia values engine_inertia: conv(constants.engine_inertia, 'inertia'), + secondary_inertia: conv(constants.secondary_inertia, 'inertia'), + gearbox_inertia: conv(constants.gearbox_inertia, 'inertia'), + wheel_inertia: conv(constants.wheel_inertia, 'inertia'), driveline_inertia: conv(constants.driveline_inertia, 'inertia'), // Drivetrain @@ -30,6 +33,7 @@ export function convertConstants( // Aerodynamics frontal_area: conv(constants.frontal_area, 'area'), drag_coefficient: conv(constants.drag_coefficient, 'dimensionless'), + rolling_resistance_coefficient: conv(constants.rolling_resistance_coefficient, 'dimensionless'), // Pulley geometry sheave_angle: conv(constants.sheave_angle, 'angle'), diff --git a/frontend/src/utils/conversion/timeStepDataConversion.ts b/frontend/src/utils/conversion/timeStepDataConversion.ts index 4dd9bf0d..7befc02e 100644 --- a/frontend/src/utils/conversion/timeStepDataConversion.ts +++ b/frontend/src/utils/conversion/timeStepDataConversion.ts @@ -21,76 +21,124 @@ function convertTimeStepData( time: conv(timeStep.time, 'time'), state: { - car_velocity: conv(timeStep.state.car_velocity, 'velocity'), - car_position: conv(timeStep.state.car_position, 'distance'), - shift_velocity: conv(timeStep.state.shift_velocity, 'velocity'), shift_distance: conv(timeStep.state.shift_distance, 'distance'), - engine_angular_velocity: conv(timeStep.state.engine_angular_velocity, 'angular_velocity'), - engine_angular_position: conv(timeStep.state.engine_angular_position, 'angle'), + shift_velocity: conv(timeStep.state.shift_velocity, 'velocity'), + primary_pulley_angular_velocity: conv(timeStep.state.primary_pulley_angular_velocity, 'angular_velocity'), + secondary_pulley_angular_velocity: conv(timeStep.state.secondary_pulley_angular_velocity, 'angular_velocity'), }, - system: { - slip: { - coupling_torque: conv(timeStep.system.slip.coupling_torque, 'torque'), - torque_demand: conv(timeStep.system.slip.torque_demand, 'torque'), - t_max_prim: conv(timeStep.system.slip.t_max_prim, 'torque'), - t_max_sec: conv(timeStep.system.slip.t_max_sec, 'torque'), - cvt_ratio_derivative: conv(timeStep.system.slip.cvt_ratio_derivative, 'dimensionless_rate'), - is_slipping: timeStep.system.slip.is_slipping - }, - engine: { - torque: conv(timeStep.system.engine.torque, 'torque'), - coupling_torque_at_engine: conv(timeStep.system.engine.coupling_torque_at_engine, 'torque'), - power: conv(timeStep.system.engine.power, 'power'), - angular_velocity: conv(timeStep.system.engine.angular_velocity, 'angular_velocity'), - angular_acceleration: conv(timeStep.system.engine.angular_acceleration, 'angular_acceleration') + derived_state: { + car_velocity: conv(timeStep.derived_state.car_velocity, 'velocity'), + car_position: conv(timeStep.derived_state.car_position, 'distance'), + engine_angular_velocity: conv(timeStep.derived_state.engine_angular_velocity, 'angular_velocity'), + engine_angular_position: conv(timeStep.derived_state.engine_angular_position, 'angle'), + }, + drivetrain: { + belt_slip: ({ + coupling_torque: conv(timeStep.drivetrain.belt_slip.coupling_torque, 'torque'), + torque_demand: conv(timeStep.drivetrain.belt_slip.torque_demand, 'torque'), + relative_velocity: conv((timeStep.drivetrain.belt_slip as TimeStepDataModel['drivetrain']['belt_slip'] & { relative_velocity: number }).relative_velocity, 'angular_velocity'), + tau_upper: conv(timeStep.drivetrain.belt_slip.tau_upper, 'torque'), + tau_lower: conv(timeStep.drivetrain.belt_slip.tau_lower, 'torque'), + primary_tau_bounds: { + tau_lower: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.tau_lower, 'torque'), + tau_upper: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.tau_upper, 'torque'), + numerator: { + clamping_term: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.numerator.clamping_term, 'torque'), + load_term: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.numerator.load_term, 'torque'), + shift_term: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.numerator.shift_term, 'torque'), + net: conv(timeStep.drivetrain.belt_slip.primary_tau_bounds.numerator.net, 'torque'), + }, + denominator_upper: { + inverse_radius_term: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_upper.inverse_radius_term, + inertial_feedback_term: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_upper.inertial_feedback_term, + net: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_upper.net, + }, + denominator_lower: { + inverse_radius_term: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_lower.inverse_radius_term, + inertial_feedback_term: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_lower.inertial_feedback_term, + net: timeStep.drivetrain.belt_slip.primary_tau_bounds.denominator_lower.net, + }, + }, + secondary_tau_bounds: { + tau_negative: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.tau_negative, 'torque'), + tau_positive: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.tau_positive, 'torque'), + numerator: { + spring_term: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.numerator.spring_term, 'torque'), + load_term: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.numerator.load_term, 'torque'), + shift_term: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.numerator.shift_term, 'torque'), + net: conv(timeStep.drivetrain.belt_slip.secondary_tau_bounds.numerator.net, 'torque'), + }, + denominator_positive: { + inverse_radius_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_positive.inverse_radius_term, + helix_feedback_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_positive.helix_feedback_term, + inertial_feedback_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_positive.inertial_feedback_term, + net: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_positive.net, + }, + denominator_negative: { + inverse_radius_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_negative.inverse_radius_term, + helix_feedback_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_negative.helix_feedback_term, + inertial_feedback_term: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_negative.inertial_feedback_term, + net: timeStep.drivetrain.belt_slip.secondary_tau_bounds.denominator_negative.net, + }, + }, + effective_cvt_ratio_time_derivative: conv(timeStep.drivetrain.belt_slip.effective_cvt_ratio_time_derivative, 'dimensionless_rate'), + is_slipping: timeStep.drivetrain.belt_slip.is_slipping + } as TimeStepDataModel['drivetrain']['belt_slip']), + primary_pulley: { + primary_pulley_drive_torque: conv(timeStep.drivetrain.primary_pulley.primary_pulley_drive_torque, 'torque'), + coupling_torque_at_primary_pulley: conv(timeStep.drivetrain.primary_pulley.coupling_torque_at_primary_pulley, 'torque'), + power: conv(timeStep.drivetrain.primary_pulley.power, 'power'), + primary_pulley_angular_velocity: conv(timeStep.drivetrain.primary_pulley.primary_pulley_angular_velocity, 'angular_velocity'), + primary_pulley_angular_acceleration: conv(timeStep.drivetrain.primary_pulley.primary_pulley_angular_acceleration, 'angular_acceleration') }, - car: { - coupling_torque_at_wheel: conv(timeStep.system.car.coupling_torque_at_wheel, 'torque'), - load_torque_at_wheel: conv(timeStep.system.car.load_torque_at_wheel, 'torque'), + secondary_pulley: { + coupling_torque_at_secondary_pulley: conv(timeStep.drivetrain.secondary_pulley.coupling_torque_at_secondary_pulley, 'torque'), + external_load_torque_at_secondary_pulley: conv(timeStep.drivetrain.secondary_pulley.external_load_torque_at_secondary_pulley, 'torque'), external_forces: { - incline_force: conv(timeStep.system.car.external_forces.incline_force, 'force'), - drag_force: conv(timeStep.system.car.external_forces.drag_force, 'force'), - net: conv(timeStep.system.car.external_forces.net, 'force') + rolling_resistance_force: conv(timeStep.drivetrain.secondary_pulley.external_forces.rolling_resistance_force, 'force'), + incline_force: conv(timeStep.drivetrain.secondary_pulley.external_forces.incline_force, 'force'), + drag_force: conv(timeStep.drivetrain.secondary_pulley.external_forces.drag_force, 'force'), + net_force_at_car: conv(timeStep.drivetrain.secondary_pulley.external_forces.net_force_at_car, 'force'), + rolling_resistance_torque_at_secondary: conv(timeStep.drivetrain.secondary_pulley.external_forces.rolling_resistance_torque_at_secondary, 'torque'), + incline_torque_at_secondary: conv(timeStep.drivetrain.secondary_pulley.external_forces.incline_torque_at_secondary, 'torque'), + drag_torque_at_secondary: conv(timeStep.drivetrain.secondary_pulley.external_forces.drag_torque_at_secondary, 'torque'), + net_torque_at_secondary: conv(timeStep.drivetrain.secondary_pulley.external_forces.net_torque_at_secondary, 'torque') }, - acceleration: conv(timeStep.system.car.acceleration, 'acceleration') + secondary_pulley_angular_acceleration: conv(timeStep.drivetrain.secondary_pulley.secondary_pulley_angular_acceleration, 'angular_acceleration') }, - cvt: { + cvt_dynamics: { primaryPulleyState: { forces: { - radial_force: conv(timeStep.system.cvt.primaryPulleyState.forces.radial_force, 'force'), - clamping_force: conv(timeStep.system.cvt.primaryPulleyState.forces.clamping_force, 'force'), - max_torque: conv(timeStep.system.cvt.primaryPulleyState.forces.max_torque, 'torque'), + axial_clamping_force: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_clamping_force, 'force'), + axial_centrifugal_from_belt: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_centrifugal_from_belt, 'force'), + axial_force_total: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.forces.axial_force_total, 'force'), }, - wrap_angle: conv(timeStep.system.cvt.primaryPulleyState.wrap_angle, 'angle'), - radius: conv(timeStep.system.cvt.primaryPulleyState.radius, 'distance'), - angular_velocity: conv(timeStep.system.cvt.primaryPulleyState.angular_velocity, 'angular_velocity'), - angular_position: conv(timeStep.system.cvt.primaryPulleyState.angular_position, 'angle'), - radial_from_centrifugal: conv(timeStep.system.cvt.primaryPulleyState.radial_from_centrifugal, 'force'), - radial_from_clamping: conv(timeStep.system.cvt.primaryPulleyState.radial_from_clamping, 'force'), + wrap_angle: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.wrap_angle, 'angle'), + radius: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.radius, 'distance'), + angular_velocity: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.angular_velocity, 'angular_velocity'), + angular_position: conv(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.angular_position, 'angle'), breakdown: { - ...convertPulleyForce(timeStep.system.cvt.primaryPulleyState.breakdown, config) + ...convertPulleyForce(timeStep.drivetrain.cvt_dynamics.primaryPulleyState.breakdown, config) } }, secondaryPulleyState: { forces: { - radial_force: conv(timeStep.system.cvt.secondaryPulleyState.forces.radial_force, 'force'), - clamping_force: conv(timeStep.system.cvt.secondaryPulleyState.forces.clamping_force, 'force'), - max_torque: conv(timeStep.system.cvt.secondaryPulleyState.forces.max_torque, 'torque'), + axial_clamping_force: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_clamping_force, 'force'), + axial_centrifugal_from_belt: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_centrifugal_from_belt, 'force'), + axial_force_total: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.forces.axial_force_total, 'force'), }, - wrap_angle: conv(timeStep.system.cvt.secondaryPulleyState.wrap_angle, 'angle'), - radius: conv(timeStep.system.cvt.secondaryPulleyState.radius, 'distance'), - angular_velocity: conv(timeStep.system.cvt.secondaryPulleyState.angular_velocity, 'angular_velocity'), - angular_position: conv(timeStep.system.cvt.secondaryPulleyState.angular_position, 'angle'), - radial_from_centrifugal: conv(timeStep.system.cvt.secondaryPulleyState.radial_from_centrifugal, 'force'), - radial_from_clamping: conv(timeStep.system.cvt.secondaryPulleyState.radial_from_clamping, 'force'), + wrap_angle: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.wrap_angle, 'angle'), + radius: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.radius, 'distance'), + angular_velocity: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.angular_velocity, 'angular_velocity'), + angular_position: conv(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.angular_position, 'angle'), breakdown: { - ...convertPulleyForce(timeStep.system.cvt.secondaryPulleyState.breakdown, config) + ...convertPulleyForce(timeStep.drivetrain.cvt_dynamics.secondaryPulleyState.breakdown, config) } }, - friction: conv(timeStep.system.cvt.friction, 'dimensionless'), - acceleration: conv(timeStep.system.cvt.acceleration, 'acceleration'), - cvt_ratio: conv(timeStep.system.cvt.cvt_ratio, 'dimensionless'), - net: conv(timeStep.system.cvt.net, 'force') + friction: conv(timeStep.drivetrain.cvt_dynamics.friction, 'dimensionless'), + acceleration: conv(timeStep.drivetrain.cvt_dynamics.acceleration, 'acceleration'), + cvt_ratio: conv(timeStep.drivetrain.cvt_dynamics.cvt_ratio, 'dimensionless'), + net: conv(timeStep.drivetrain.cvt_dynamics.net, 'force') } } }; @@ -149,7 +197,18 @@ export function convertSimulationData( data: FormattedSimulationResultModel, config: UnitConfiguration = DEFAULT_UNIT_CONFIG ): FormattedSimulationResultModel { + const conv = (value: number, type: T) => + convertValue(value, type, getTargetUnit(type, config)); + return { data: data.data.map(timeStep => convertTimeStepData(timeStep, config)), + termination: { + ...data.termination, + final_time: conv(data.termination.final_time, 'time'), + event_time: + data.termination.event_time == null + ? null + : conv(data.termination.event_time, 'time'), + }, }; }