diff --git a/.pylintrc b/.pylintrc index 3b059ee2c..feb85e897 100644 --- a/.pylintrc +++ b/.pylintrc @@ -231,6 +231,22 @@ good-names=FlightPhases, R_uncanted, R_body_to_fin, Re, # Reynolds number + cL_alpha, + cQ_beta, + cN_alpha, + cY_beta, + cL, + cD, + cQ, + cN, + cY, + cA, + cNf, + cNd, + cYf, + cYd, + cAf, + cAd, # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted diff --git a/data/monte_carlo/stability_dispersion.errors.txt b/data/monte_carlo/stability_dispersion.errors.txt new file mode 100644 index 000000000..e69de29bb diff --git a/data/monte_carlo/stability_dispersion.inputs.txt b/data/monte_carlo/stability_dispersion.inputs.txt new file mode 100644 index 000000000..29f758544 --- /dev/null +++ b/data/monte_carlo/stability_dispersion.inputs.txt @@ -0,0 +1,100 @@ +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.521507153174057, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.822363841581048, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009075430781169808, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6351.369486474051, "burn_start_time": 0, "burn_out_time": 4.12503921194419, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 1} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5101556242274552, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.013242161800042, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004636885474406456, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5983.494032079531, "burn_start_time": 0, "burn_out_time": 3.945911628888069, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 2} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.8860073805903463, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.96150876992343, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.015720218955994004, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6162.926243427371, "burn_start_time": 0, "burn_out_time": 3.82953677131187, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 3} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2431228373091128, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.429987350290446, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.023425570557600188, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6240.047946584141, "burn_start_time": 0, "burn_out_time": 3.8479921589454285, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 4} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8926107317108068, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.909315780860616, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03313420098540572, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5898.780443757008, "burn_start_time": 0, "burn_out_time": 3.5504898781516396, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 5} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.254135896199279, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.73459606153309, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0465851431774179, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5859.83559479326, "burn_start_time": 0, "burn_out_time": 4.071184289884895, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 6} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.46171044281572615, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.97626609539867, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0036886485744965693, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5902.798595639467, "burn_start_time": 0, "burn_out_time": 3.7758921947544137, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 7} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.55414505653343, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.993967505335391, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.015453909077576865, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5871.598134248473, "burn_start_time": 0, "burn_out_time": 3.9627164054022304, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 8} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.7088223255245034, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.648851358355651, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03796465369699778, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5865.428226638956, "burn_start_time": 0, "burn_out_time": 3.858357488909442, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 9} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1025774678540878, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.207537580418032, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.026502636458545295, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6080.884653022599, "burn_start_time": 0, "burn_out_time": 3.9166936295605166, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 10} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0607750200071973, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.867283874921611, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.014070821764981069, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5913.870418610246, "burn_start_time": 0, "burn_out_time": 3.795666925127526, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 11} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9941527130896458, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.812450062594092, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.037195032384504306, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6029.23942711598, "burn_start_time": 0, "burn_out_time": 4.042021691795649, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 12} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.46697295282380413, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.549898786101538, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04142063457938412, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6072.075417218228, "burn_start_time": 0, "burn_out_time": 3.7317099503587103, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 13} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1040404742345593, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.393044684018475, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016789306703718795, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6055.03282805908, "burn_start_time": 0, "burn_out_time": 3.93846869900211, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 14} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5874177070288262, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.648465635332474, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.06073837606411138, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6094.522067960042, "burn_start_time": 0, "burn_out_time": 3.8996269564143557, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 15} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5378823500699033, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.800129553482858, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.017046253278285635, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5757.311036017226, "burn_start_time": 0, "burn_out_time": 3.8178832949370203, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 16} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7284185586893085, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.726131791473186, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016883248625913692, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5942.124888379175, "burn_start_time": 0, "burn_out_time": 3.996403159641596, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 17} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8175942187227592, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.20152500283439, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.027534072203136414, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6259.565732212167, "burn_start_time": 0, "burn_out_time": 3.7138081473887206, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 18} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.182223283464123, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.16214884924183, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.06702705968258874, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5866.122801560027, "burn_start_time": 0, "burn_out_time": 3.9095582858310367, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 19} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0576006868772183, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.908316473087542, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0020487835079548013, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5715.863945166125, "burn_start_time": 0, "burn_out_time": 3.8879546467766133, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 20} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1443098910854685, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.426686716582134, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.03076383150584279, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6132.172664640067, "burn_start_time": 0, "burn_out_time": 4.1610413858966755, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 21} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 2.39999788029899, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.369548756752208, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.009270854202445727, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6238.992928485764, "burn_start_time": 0, "burn_out_time": 3.90111241815629, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 22} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.293822694225872, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.019396008421712, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02570113710478727, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5784.398808839861, "burn_start_time": 0, "burn_out_time": 3.8158631584599236, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 23} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.3696123040255518, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.319897184503134, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0067417896740935, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6025.818253677406, "burn_start_time": 0, "burn_out_time": 3.9664257312961833, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 24} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2795214625994893, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.640737192956678, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02424482969599957, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5584.243936627794, "burn_start_time": 0, "burn_out_time": 3.8694447429507153, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 25} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2054186202923738, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.316554346919487, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02423275857658752, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5844.0664752939965, "burn_start_time": 0, "burn_out_time": 3.823183841031567, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 26} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.936358889318001, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.09734876707819, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02175727081984967, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6160.027005816634, "burn_start_time": 0, "burn_out_time": 3.670503736564817, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 27} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8914548477758035, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.86849579830338, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.031989757028450055, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5835.549869239558, "burn_start_time": 0, "burn_out_time": 3.9543773917109073, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 28} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0553928717948868, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.896803649679448, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.03566275146481105, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5882.704323776219, "burn_start_time": 0, "burn_out_time": 3.569840304601262, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 29} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.207516947004454, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.351766404064886, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.025392739753787856, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.51239692753, "burn_start_time": 0, "burn_out_time": 3.798500859164062, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 30} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5682653051852451, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.01597603827496, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02042918984075148, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6045.494568639416, "burn_start_time": 0, "burn_out_time": 3.817798778488695, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 31} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9175796846096277, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.691140822923222, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.006394804012495172, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5985.006088195862, "burn_start_time": 0, "burn_out_time": 4.078608312248814, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 32} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6123885409030086, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.462521029606126, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04072157962131368, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5891.4504055681955, "burn_start_time": 0, "burn_out_time": 4.06406339414674, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 33} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3275365266684764, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.102685448648783, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.026436721281435635, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5873.239513595609, "burn_start_time": 0, "burn_out_time": 3.9167907338105947, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 34} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5094400871361682, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.456487188673387, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019746563706290392, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6249.26984689557, "burn_start_time": 0, "burn_out_time": 3.9106300099876163, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 35} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5576953122535993, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.262613068044427, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.09904871755174288, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5523.178363080369, "burn_start_time": 0, "burn_out_time": 3.785049044431886, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 36} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5951710875788105, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.307258749830067, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02712461956013373, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5859.122857763266, "burn_start_time": 0, "burn_out_time": 3.997255266703495, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 37} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6042359768386178, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.396046500655638, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.012537707606567899, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6182.455189093834, "burn_start_time": 0, "burn_out_time": 3.8092829493009672, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 38} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.537286587820295, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.65609753034603, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009368037234779858, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5974.225982881899, "burn_start_time": 0, "burn_out_time": 4.042183750887245, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 39} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8991522374654737, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.0246729579954, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019635003344855777, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6116.434480715165, "burn_start_time": 0, "burn_out_time": 4.039795635951713, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 40} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6262644799866924, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.740086052609113, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.059654899753242896, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5875.4025378143315, "burn_start_time": 0, "burn_out_time": 3.803868195081801, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 41} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3002166986540749, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.326751348183375, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.013501633642283342, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5705.098185346435, "burn_start_time": 0, "burn_out_time": 3.995731621890093, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 42} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1756277800770079, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.368392660064638, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0017475555005954622, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6189.217860975409, "burn_start_time": 0, "burn_out_time": 3.902670054509228, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 43} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2264357481438815, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.507963833339327, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.024072971929202085, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5566.4401831124715, "burn_start_time": 0, "burn_out_time": 3.8104288499991212, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 44} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7386724525231617, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.870729806595604, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.006292712294764745, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6264.747749775479, "burn_start_time": 0, "burn_out_time": 3.7704312887018943, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 45} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7724596974683736, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.999672753172847, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004663092153944251, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5972.978051890591, "burn_start_time": 0, "burn_out_time": 4.092376386260005, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 46} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9203388845149081, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.086227593906028, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0020316024888011033, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6122.174947456201, "burn_start_time": 0, "burn_out_time": 3.639430487024025, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 47} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9057076484860059, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.195050595784386, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.010979306129075314, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6183.99044808285, "burn_start_time": 0, "burn_out_time": 3.7869769431052394, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 48} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.944015615328221, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.667504678312683, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.016707499844365828, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5716.794045225628, "burn_start_time": 0, "burn_out_time": 3.7606244323855726, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 49} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2615864841275002, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.035418400789801, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.009812389838004726, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6133.307007528654, "burn_start_time": 0, "burn_out_time": 4.087879106827424, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 50} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6463695176146074, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.542959130559593, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.010273686526313015, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6015.209766382824, "burn_start_time": 0, "burn_out_time": 3.8703268054917888, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 51} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5135563974711828, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.303402199529199, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.013530486519409041, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6115.245210948932, "burn_start_time": 0, "burn_out_time": 4.132944036078017, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 52} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9950124966474333, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.973205637367604, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.02118627177585532, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6439.623772817847, "burn_start_time": 0, "burn_out_time": 3.790244011471183, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 53} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8952534389366245, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.20136908420973, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.028202427971494873, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6357.586210966299, "burn_start_time": 0, "burn_out_time": 3.9098894495830683, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 54} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.8119021747133472, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.401883634429852, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.002746598433826927, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6121.916776050539, "burn_start_time": 0, "burn_out_time": 3.94827370999028, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 55} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3565978706444604, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.446310311738237, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.025894748215433683, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6310.233721346618, "burn_start_time": 0, "burn_out_time": 3.6951702164329947, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 56} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.687232953077063, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.532365798315034, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05428992670716373, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6174.638985492842, "burn_start_time": 0, "burn_out_time": 3.6145371851491293, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 57} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0425250781016568, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.354923966362001, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.006373972867909677, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5920.667688712692, "burn_start_time": 0, "burn_out_time": 3.9242006196990986, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 58} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.719249443748984, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.729912766150282, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05161241337215586, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6064.957536270543, "burn_start_time": 0, "burn_out_time": 3.8506612045014617, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 59} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3615077513889289, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.908469054240625, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.08184890736311479, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5987.77381956212, "burn_start_time": 0, "burn_out_time": 3.9174830063606754, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 60} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7296967439091322, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.907292761046216, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.007481289303339345, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6157.929433080301, "burn_start_time": 0, "burn_out_time": 4.1990572604783845, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 61} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8170566698395048, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.498580985730609, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.012294525829345546, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6022.11800221605, "burn_start_time": 0, "burn_out_time": 4.0125637353043855, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 62} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9125305207120032, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.521548781832555, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.045109402273743916, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6243.94197224416, "burn_start_time": 0, "burn_out_time": 3.946254684544609, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 63} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2589251167875195, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.27557318272878, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04159207767593187, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6202.391712399047, "burn_start_time": 0, "burn_out_time": 3.8286369437045558, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 64} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4558048826919037, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.8688875499481, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.003760506582064991, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6288.907582638557, "burn_start_time": 0, "burn_out_time": 3.7717572955847314, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 65} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5342419823403632, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.432782489983293, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.01110808191311571, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5798.13280897443, "burn_start_time": 0, "burn_out_time": 3.833237972920794, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 66} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9282502375421586, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.841841386049571, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.013028115070091575, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5807.51370398012, "burn_start_time": 0, "burn_out_time": 4.054554444037106, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 67} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1746583599849303, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.230468992233321, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.01957833897886917, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5904.789675660768, "burn_start_time": 0, "burn_out_time": 3.7668353891076993, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 68} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0566482097887433, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.34698723599429, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.024339364380413283, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5918.695527443453, "burn_start_time": 0, "burn_out_time": 3.909454972167619, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 69} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9160490054703169, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.697383279594456, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0035034024542409882, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6176.448020136504, "burn_start_time": 0, "burn_out_time": 3.9357019038950476, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 70} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4728794970707417, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.400658743131533, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.011498337870130942, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5940.5398503802435, "burn_start_time": 0, "burn_out_time": 3.7743514260251443, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 71} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9551785926079897, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.673284533471286, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.002265113898276123, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6136.547455572391, "burn_start_time": 0, "burn_out_time": 4.019126904768468, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 72} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6747479175273858, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.338383644151882, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.050748580125890695, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5918.984184895319, "burn_start_time": 0, "burn_out_time": 3.8432044351883303, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 73} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.20930783895083, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.025666731308315, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.033771052729587685, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.783539016162, "burn_start_time": 0, "burn_out_time": 3.9296585219950386, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 74} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.5127824544803135, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.321336172817102, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.002123495677305162, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6141.030408134489, "burn_start_time": 0, "burn_out_time": 3.865639146123638, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 75} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9707156297310905, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.9814366636166, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04296958102248286, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5843.492024855827, "burn_start_time": 0, "burn_out_time": 3.6696276440798714, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 76} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8805447482432478, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.301052853428859, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.013392687542252075, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6213.415672590566, "burn_start_time": 0, "burn_out_time": 3.9801881318186956, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 77} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4437365459566989, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.085246711792577, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.020114198713927584, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5897.848664284219, "burn_start_time": 0, "burn_out_time": 3.8668279463451256, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 78} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.1255112592418468, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.717762886257601, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.004694373695493757, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6038.768048346616, "burn_start_time": 0, "burn_out_time": 4.059209455837512, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 79} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6584161847708666, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.146583403511169, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.03658997791311692, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5922.217491962746, "burn_start_time": 0, "burn_out_time": 3.889764929702553, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 80} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.493056798892862, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.211092341519155, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.07472732670491032, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5642.947335059451, "burn_start_time": 0, "burn_out_time": 3.810941867812397, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 81} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9466845221896786, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.83649395356574, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.045962645546592774, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6089.837469678587, "burn_start_time": 0, "burn_out_time": 4.108948943442876, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 82} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5873184088958892, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.45296065327586, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.039833682969637185, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5870.432062012816, "burn_start_time": 0, "burn_out_time": 4.078352974580726, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 83} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.504207431858531, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.49806030718616, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.022580806254653748, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6146.017070588164, "burn_start_time": 0, "burn_out_time": 3.9402205721711714, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 84} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.0255030912059533, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.00897763805044, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.019836567711375014, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5813.458476313082, "burn_start_time": 0, "burn_out_time": 3.88867826491503, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 85} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.6390632196895947, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.594597985644617, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.02260159354554305, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6081.372272714397, "burn_start_time": 0, "burn_out_time": 3.96510856973297, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 86} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5113588097444572, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.624391446002114, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.034001386215105234, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6139.575197727543, "burn_start_time": 0, "burn_out_time": 3.991457881237835, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 87} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.243510943209721, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.976075370949667, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.004847739923519672, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6171.794407727714, "burn_start_time": 0, "burn_out_time": 3.9198447264490057, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 88} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5055627982087956, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.947326187342245, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.05175050585857492, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5599.951690575628, "burn_start_time": 0, "burn_out_time": 3.919343321576347, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 89} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3406846359067714, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.77097079101815, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.009379489909334264, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6008.39094811574, "burn_start_time": 0, "burn_out_time": 3.892738655532904, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 90} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.7690176739612495, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.437087496321743, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.0022677989336368077, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5753.755185770554, "burn_start_time": 0, "burn_out_time": 3.543805339463317, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 91} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.7234523543403084, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.668031297859383, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.01267243863095468, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5907.183481136588, "burn_start_time": 0, "burn_out_time": 3.99830081031704, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 92} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.8979368903909795, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.145400151980878, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.0028027144959191248, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5605.18983942218, "burn_start_time": 0, "burn_out_time": 3.851160445753806, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 93} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.5644393984521076, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.368414654819071, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.012667904026666075, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5953.662553791602, "burn_start_time": 0, "burn_out_time": 3.930065229489668, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 94} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3960659404942042, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.128520301417089, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.04009907540013144, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6129.468922973201, "burn_start_time": 0, "burn_out_time": 3.980673380053079, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 95} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.4368577379881373, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.4490630583561, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.012006442834018378, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6227.537736602414, "burn_start_time": 0, "burn_out_time": 4.182803626540288, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 96} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.6186593834110099, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.92993878871148, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": 0.04550398951057217, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 5744.841798853582, "burn_start_time": 0, "burn_out_time": 4.115253821341056, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 97} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 0.9374292052792661, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 14.177883544279485, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.005518442012212102, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6038.054475762916, "burn_start_time": 0, "burn_out_time": 4.0014644012448795, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 98} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.2490520218025916, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 13.965331899250637, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.04594684816415277, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6176.670635311683, "burn_start_time": 0, "burn_out_time": 3.851633650387373, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 99} +{"elevation": 1400, "gravity": "Function from R1 to R1 : (height (m)) \u2192 (gravity (m/s\u00b2))", "latitude": 32.990254, "longitude": -106.974998, "wind_velocity_x_factor": 1.3143667461743567, "wind_velocity_y_factor": 1.0, "datum": "SIRGAS2000", "timezone": null, "air_brakes": [], "parachutes": [], "radius": 0.0635, "mass": 15.153955882317693, "I_11_without_motor": 6.321, "I_22_without_motor": 6.321, "I_33_without_motor": 0.034, "I_12_without_motor": 0, "I_13_without_motor": 0, "I_23_without_motor": 0, "power_off_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power Off)", "power_on_drag": "Function from R1 to R1 : (Mach Number) \u2192 (Drag Coefficient with Power On)", "power_off_drag_factor": 1.0, "power_on_drag_factor": 1.0, "center_of_mass_without_motor": -0.01017422126766747, "coordinate_system_orientation": "tail_to_nose", "motors": [{"thrust_source": [[0, 0], [0.055, 100.0], [0.092, 1500.0], [0.1, 2000.0], [0.15, 2200.0], [0.2, 1800.0], [0.5, 1950.0], [1.0, 2034.0], [1.5, 2000.0], [2.0, 1900.0], [2.5, 1760.0], [2.9, 1700.0], [3.0, 1650.0], [3.3, 530.0], [3.4, 350.0], [3.9, 0.0]], "total_impulse": 6056.5531267118, "burn_start_time": 0, "burn_out_time": 3.9547655913184028, "dry_mass": 1.815, "dry_I_11": 0.125, "dry_I_22": 0.125, "dry_I_33": 0.002, "dry_I_12": 0, "dry_I_13": 0, "dry_I_23": 0, "nozzle_radius": 0.033, "grain_number": 5, "grain_density": 1815, "grain_outer_radius": 0.033, "grain_initial_inner_radius": 0.015, "grain_initial_height": 0.12, "grain_separation": 0.005, "grains_center_of_mass_position": 0.397, "center_of_dry_mass_position": 0.317, "nozzle_position": 0, "throat_radius": 0.011, "interpolate": "linear", "coordinate_system_orientation": "nozzle_to_combustion_chamber", "position": -1.255}], "aerodynamic_surfaces": [{"length": 0.55829, "kind": "vonKarman", "base_radius": 0.0635, "bluffness": 0, "rocket_radius": 0.0635, "power": null, "name": "Nose Cone", "position": [0, 0, 1.278]}, {"n": 4, "root_chord": 0.12, "tip_chord": 0.06, "span": 0.11, "rocket_radius": 0.0635, "cant_angle": -0.0, "sweep_length": 0.06, "sweep_angle": null, "airfoil": ["../data/airfoils/NACA0012-radians.txt", "radians"], "name": "Fins", "position": [0, 0, -1.04956]}, {"top_radius": 0.0635, "bottom_radius": 0.0435, "length": 0.06, "rocket_radius": 0.0635, "name": "Tail", "position": [0, 0, -1.194656]}], "rail_buttons": [], "rail_length": 5.2, "inclination": 85, "heading": 0, "index": 100} diff --git a/data/monte_carlo/stability_dispersion.outputs.txt b/data/monte_carlo/stability_dispersion.outputs.txt new file mode 100644 index 000000000..5d5330263 --- /dev/null +++ b/data/monte_carlo/stability_dispersion.outputs.txt @@ -0,0 +1,100 @@ +{"apogee": 4861.059128669208, "y_impact": 0, "out_of_rail_time": 0.423769074499993, "apogee_x": -146.93079438169238, "apogee_time": 26.51594339163099, "frontal_surface_wind": 0.0, "apogee_y": 616.1470208873146, "out_of_rail_stability_margin": 2.265281592202937, "t_final": 26.51594339163099, "x_impact": 0, "initial_stability_margin": 2.1798501057629904, "out_of_rail_velocity": 30.0308932593615, "lateral_surface_wind": -1.564521459522171, "max_mach_number": 0.881174939443053, "impact_velocity": 0, "index": 1, "rail_exit_aoa": 2.9822459742928653} +{"apogee": 4745.968264912956, "y_impact": 0, "out_of_rail_time": 0.41529467918564494, "apogee_x": -134.73394030108742, "apogee_time": 25.985672171421474, "frontal_surface_wind": 0.0, "apogee_y": 589.5509953139139, "out_of_rail_stability_margin": 2.2795408162857553, "t_final": 25.985672171421474, "x_impact": 0, "initial_stability_margin": 2.18777343525097, "out_of_rail_velocity": 30.518799341367785, "lateral_surface_wind": -1.5304668726823656, "max_mach_number": 0.8722481605013893, "impact_velocity": 0, "index": 2, "rail_exit_aoa": 2.8708827501670675} +{"apogee": 4675.6302173772065, "y_impact": 0, "out_of_rail_time": 0.41184706108715086, "apogee_x": -473.2569592832319, "apogee_time": 25.77461921098828, "frontal_surface_wind": 0.0, "apogee_y": 579.840763939317, "out_of_rail_stability_margin": 2.4323707012514926, "t_final": 25.77461921098828, "x_impact": 0, "initial_stability_margin": 2.3399806052007586, "out_of_rail_velocity": 30.690244962642193, "lateral_surface_wind": -5.658022141771039, "max_mach_number": 0.8578729590647751, "impact_velocity": 0, "index": 3, "rail_exit_aoa": 10.445701536637845} +{"apogee": 4844.720752041134, "y_impact": 0, "out_of_rail_time": 0.40498203713935343, "apogee_x": -321.57511052657213, "apogee_time": 26.277584110699063, "frontal_surface_wind": 0.0, "apogee_y": 602.9921974620992, "out_of_rail_stability_margin": 2.43051917307307, "t_final": 26.277584110699063, "x_impact": 0, "initial_stability_margin": 2.3378016945566022, "out_of_rail_velocity": 31.287670930209153, "lateral_surface_wind": -3.7293685119273383, "max_mach_number": 0.8932516873842207, "impact_velocity": 0, "index": 4, "rail_exit_aoa": 6.797363163210099} +{"apogee": 4705.623905928367, "y_impact": 0, "out_of_rail_time": 0.3899231535612529, "apogee_x": -204.96055806873392, "apogee_time": 25.6632827362864, "frontal_surface_wind": 0.0, "apogee_y": 570.458338750699, "out_of_rail_stability_margin": 2.049885389822403, "t_final": 25.6632827362864, "x_impact": 0, "initial_stability_margin": 1.956418604948673, "out_of_rail_velocity": 32.234321027351704, "lateral_surface_wind": -2.6778321951324204, "max_mach_number": 0.8777734145497106, "impact_velocity": 0, "index": 5, "rail_exit_aoa": 4.748881983000292} +{"apogee": 4505.63794973302, "y_impact": 0, "out_of_rail_time": 0.4354086022554508, "apogee_x": -315.9021992486424, "apogee_time": 25.33527859806564, "frontal_surface_wind": 0.0, "apogee_y": 560.5182031936844, "out_of_rail_stability_margin": 2.03484745543855, "t_final": 25.33527859806564, "x_impact": 0, "initial_stability_margin": 1.9492341899016072, "out_of_rail_velocity": 29.036946263016052, "lateral_surface_wind": -3.762407688597837, "max_mach_number": 0.816386227218562, "impact_velocity": 0, "index": 6, "rail_exit_aoa": 7.382858806844491} +{"apogee": 4514.684938293628, "y_impact": 0, "out_of_rail_time": 0.415858181860322, "apogee_x": -115.25999912212619, "apogee_time": 25.262931916005, "frontal_surface_wind": 0.0, "apogee_y": 555.0276869750553, "out_of_rail_stability_margin": 2.362712584704993, "t_final": 25.262931916005, "x_impact": 0, "initial_stability_margin": 2.2692091584391627, "out_of_rail_velocity": 30.215458624713378, "lateral_surface_wind": -1.3851313284471785, "max_mach_number": 0.8206718067451596, "impact_velocity": 0, "index": 7, "rail_exit_aoa": 2.624704715290225} +{"apogee": 4461.511715946739, "y_impact": 0, "out_of_rail_time": 0.4301351127938697, "apogee_x": -384.8153238877181, "apogee_time": 25.15007667756139, "frontal_surface_wind": 0.0, "apogee_y": 552.0633447681942, "out_of_rail_stability_margin": 2.2460940294107123, "t_final": 25.15007667756139, "x_impact": 0, "initial_stability_margin": 2.1565216396910722, "out_of_rail_velocity": 29.299220993866616, "lateral_surface_wind": -4.66243516960029, "max_mach_number": 0.8100562060710047, "impact_velocity": 0, "index": 8, "rail_exit_aoa": 9.041762861336235} +{"apogee": 4521.329605693292, "y_impact": 0, "out_of_rail_time": 0.41960262862325637, "apogee_x": -409.73060076401305, "apogee_time": 25.267384943094115, "frontal_surface_wind": 0.0, "apogee_y": 555.7828525911232, "out_of_rail_stability_margin": 2.082160667638206, "t_final": 25.267384943094115, "x_impact": 0, "initial_stability_margin": 1.9933619027006095, "out_of_rail_velocity": 30.02148157022414, "lateral_surface_wind": -5.126466976573511, "max_mach_number": 0.8281321990113074, "impact_velocity": 0, "index": 9, "rail_exit_aoa": 9.69035943448747} +{"apogee": 4772.744314445873, "y_impact": 0, "out_of_rail_time": 0.4122332901026502, "apogee_x": -286.85147207454594, "apogee_time": 26.06538048760921, "frontal_surface_wind": 0.0, "apogee_y": 593.7241019671629, "out_of_rail_stability_margin": 2.4284681091114444, "t_final": 26.06538048760921, "x_impact": 0, "initial_stability_margin": 2.335008088501943, "out_of_rail_velocity": 30.742413840466778, "lateral_surface_wind": -3.3077324035622633, "max_mach_number": 0.8784261910919092, "impact_velocity": 0, "index": 10, "rail_exit_aoa": 6.141118598409435} +{"apogee": 4534.280778616781, "y_impact": 0, "out_of_rail_time": 0.41584552879937836, "apogee_x": -260.43098622442227, "apogee_time": 25.31717701287329, "frontal_surface_wind": 0.0, "apogee_y": 558.1170224814891, "out_of_rail_stability_margin": 2.416062309674137, "t_final": 25.31717701287329, "x_impact": 0, "initial_stability_margin": 2.321737157905933, "out_of_rail_velocity": 30.250540044982795, "lateral_surface_wind": -3.1823250600215918, "max_mach_number": 0.8268248081273232, "impact_velocity": 0, "index": 11, "rail_exit_aoa": 6.005367427267921} +{"apogee": 4806.16029727606, "y_impact": 0, "out_of_rail_time": 0.41813169403487865, "apogee_x": -267.07214906306194, "apogee_time": 26.18124821703252, "frontal_surface_wind": 0.0, "apogee_y": 600.4931103282491, "out_of_rail_stability_margin": 2.452801116100952, "t_final": 26.18124821703252, "x_impact": 0, "initial_stability_margin": 2.3590680519526543, "out_of_rail_velocity": 30.40956172265108, "lateral_surface_wind": -2.9824581392689375, "max_mach_number": 0.8860281878611809, "impact_velocity": 0, "index": 12, "rail_exit_aoa": 5.601445444617184} +{"apogee": 4717.960348204425, "y_impact": 0, "out_of_rail_time": 0.40328160824349857, "apogee_x": -120.2480903453828, "apogee_time": 25.85710853308616, "frontal_surface_wind": 0.0, "apogee_y": 582.2411469138832, "out_of_rail_stability_margin": 2.552498553363377, "t_final": 25.85710853308616, "x_impact": 0, "initial_stability_margin": 2.4554958036662318, "out_of_rail_velocity": 31.257346444584027, "lateral_surface_wind": -1.4009188584714125, "max_mach_number": 0.8665078059126148, "impact_velocity": 0, "index": 13, "rail_exit_aoa": 2.5662145728716816} +{"apogee": 4720.60965406142, "y_impact": 0, "out_of_rail_time": 0.4163237446868821, "apogee_x": -282.3060533858383, "apogee_time": 25.93646857427243, "frontal_surface_wind": 0.0, "apogee_y": 587.1203531711153, "out_of_rail_stability_margin": 2.1854355532060943, "t_final": 25.93646857427243, "x_impact": 0, "initial_stability_margin": 2.0965209560065703, "out_of_rail_velocity": 30.412346045302314, "lateral_surface_wind": -3.3121214227036777, "max_mach_number": 0.8650015455286694, "impact_velocity": 0, "index": 14, "rail_exit_aoa": 6.215423114329094} +{"apogee": 4690.372378630645, "y_impact": 0, "out_of_rail_time": 0.4149160638767922, "apogee_x": -411.9646326773905, "apogee_time": 25.828842814319742, "frontal_surface_wind": 0.0, "apogee_y": 583.6316955677842, "out_of_rail_stability_margin": 2.676120911911297, "t_final": 25.828842814319742, "x_impact": 0, "initial_stability_margin": 2.5795551586065315, "out_of_rail_velocity": 30.45847269774805, "lateral_surface_wind": -4.762253121086479, "max_mach_number": 0.8602583432598059, "impact_velocity": 0, "index": 15, "rail_exit_aoa": 8.886382309997451} +{"apogee": 4435.543472272095, "y_impact": 0, "out_of_rail_time": 0.4218049372404265, "apogee_x": -133.16166566528594, "apogee_time": 24.999123854743758, "frontal_surface_wind": 0.0, "apogee_y": 543.9001318951339, "out_of_rail_stability_margin": 2.4290455683959316, "t_final": 24.999123854743758, "x_impact": 0, "initial_stability_margin": 2.3334782467534, "out_of_rail_velocity": 29.76069187636971, "lateral_surface_wind": -1.6136470502097098, "max_mach_number": 0.8059685810484515, "impact_velocity": 0, "index": 16, "rail_exit_aoa": 3.103581153319485} +{"apogee": 4580.983920037511, "y_impact": 0, "out_of_rail_time": 0.42737224293462184, "apogee_x": -187.77260599162355, "apogee_time": 25.557122627129065, "frontal_surface_wind": 0.0, "apogee_y": 570.1945043985857, "out_of_rail_stability_margin": 2.2138894542875613, "t_final": 25.557122627129065, "x_impact": 0, "initial_stability_margin": 2.1251096377952323, "out_of_rail_velocity": 29.57466127317569, "lateral_surface_wind": -2.1852556760679254, "max_mach_number": 0.8308676925273584, "impact_velocity": 0, "index": 17, "rail_exit_aoa": 4.225874626409653} +{"apogee": 4908.954583565018, "y_impact": 0, "out_of_rail_time": 0.39322693163194655, "apogee_x": -203.43530922032105, "apogee_time": 26.39765923785646, "frontal_surface_wind": 0.0, "apogee_y": 606.0631718651757, "out_of_rail_stability_margin": 2.105244675806875, "t_final": 26.39765923785646, "x_impact": 0, "initial_stability_margin": 2.015908978426879, "out_of_rail_velocity": 32.17646928545603, "lateral_surface_wind": -2.4527826561682775, "max_mach_number": 0.9115885873107927, "impact_velocity": 0, "index": 18, "rail_exit_aoa": 4.359174077854781} +{"apogee": 4623.763416388843, "y_impact": 0, "out_of_rail_time": 0.4181429613480069, "apogee_x": -286.81972736127693, "apogee_time": 25.593630056893282, "frontal_surface_wind": 0.0, "apogee_y": 569.8656994173409, "out_of_rail_stability_margin": 1.8654396405111937, "t_final": 25.593630056893282, "x_impact": 0, "initial_stability_margin": 1.779801479263598, "out_of_rail_velocity": 30.216226714958534, "lateral_surface_wind": -3.5466698503923695, "max_mach_number": 0.8489731284834018, "impact_velocity": 0, "index": 19, "rail_exit_aoa": 6.694536568715267} +{"apogee": 4559.72313483547, "y_impact": 0, "out_of_rail_time": 0.4197371455646918, "apogee_x": -260.8213497777298, "apogee_time": 25.347276386566193, "frontal_surface_wind": 0.0, "apogee_y": 559.9242574702647, "out_of_rail_stability_margin": 2.257016257736069, "t_final": 25.347276386566193, "x_impact": 0, "initial_stability_margin": 2.1626207823210555, "out_of_rail_velocity": 30.103065712790247, "lateral_surface_wind": -3.172802060631655, "max_mach_number": 0.8389665706124879, "impact_velocity": 0, "index": 20, "rail_exit_aoa": 6.016645448819411} +{"apogee": 4763.604600054928, "y_impact": 0, "out_of_rail_time": 0.42906626832874717, "apogee_x": -315.7210268624283, "apogee_time": 26.171689584114688, "frontal_surface_wind": 0.0, "apogee_y": 601.4546940348298, "out_of_rail_stability_margin": 2.4714847285355415, "t_final": 26.171689584114688, "x_impact": 0, "initial_stability_margin": 2.3809021220880098, "out_of_rail_velocity": 29.654821926881137, "lateral_surface_wind": -3.432929673256406, "max_mach_number": 0.8675469410697194, "impact_velocity": 0, "index": 21, "rail_exit_aoa": 6.603335912410592} +{"apogee": 4814.344778240577, "y_impact": 0, "out_of_rail_time": 0.4079855886238799, "apogee_x": -613.7051036149487, "apogee_time": 26.15281282379752, "frontal_surface_wind": 0.0, "apogee_y": 598.3296060405976, "out_of_rail_stability_margin": 2.3395167578893528, "t_final": 26.15281282379752, "x_impact": 0, "initial_stability_margin": 2.248758754715839, "out_of_rail_velocity": 31.095775615493523, "lateral_surface_wind": -7.19999364089697, "max_mach_number": 0.8952663645356832, "impact_velocity": 0, "index": 22, "rail_exit_aoa": 13.03667460546709} +{"apogee": 4402.26325146706, "y_impact": 0, "out_of_rail_time": 0.4235899094528802, "apogee_x": -305.77277878980004, "apogee_time": 24.89434324683989, "frontal_surface_wind": 0.0, "apogee_y": 538.7682594418363, "out_of_rail_stability_margin": 2.188325393472822, "t_final": 24.89434324683989, "x_impact": 0, "initial_stability_margin": 2.097371751661811, "out_of_rail_velocity": 29.661724458528745, "lateral_surface_wind": -3.881468082677616, "max_mach_number": 0.8004083556666952, "impact_velocity": 0, "index": 23, "rail_exit_aoa": 7.455238478582467} +{"apogee": 4722.023710925485, "y_impact": 0, "out_of_rail_time": 0.4183920385908822, "apogee_x": -100.0037420622863, "apogee_time": 25.955904587395935, "frontal_surface_wind": 0.0, "apogee_y": 588.4233113760358, "out_of_rail_stability_margin": 2.320090763102421, "t_final": 25.955904587395935, "x_impact": 0, "initial_stability_margin": 2.22888694952396, "out_of_rail_velocity": 30.274374603777034, "lateral_surface_wind": -1.1088369120766552, "max_mach_number": 0.8630101752151198, "impact_velocity": 0, "index": 24, "rail_exit_aoa": 2.0975921065325234} +{"apogee": 4316.204283039229, "y_impact": 0, "out_of_rail_time": 0.4307595751953714, "apogee_x": -306.2403239867814, "apogee_time": 24.58557269432186, "frontal_surface_wind": 0.0, "apogee_y": 526.564683638577, "out_of_rail_stability_margin": 2.4594700406977354, "t_final": 24.58557269432186, "x_impact": 0, "initial_stability_margin": 2.3620419244868227, "out_of_rail_velocity": 29.18734562854441, "lateral_surface_wind": -3.838564387798468, "max_mach_number": 0.7858294261787138, "impact_velocity": 0, "index": 25, "rail_exit_aoa": 7.492237963702455} +{"apogee": 4580.421775577209, "y_impact": 0, "out_of_rail_time": 0.41452788908703814, "apogee_x": -291.6034901465998, "apogee_time": 25.42603518144242, "frontal_surface_wind": 0.0, "apogee_y": 562.5044590752298, "out_of_rail_stability_margin": 2.136741369325951, "t_final": 25.42603518144242, "x_impact": 0, "initial_stability_margin": 2.0457281334462416, "out_of_rail_velocity": 30.40391182341143, "lateral_surface_wind": -3.6162558608771214, "max_mach_number": 0.841173554128255, "impact_velocity": 0, "index": 26, "rail_exit_aoa": 6.782921656055069} +{"apogee": 5009.929189116323, "y_impact": 0, "out_of_rail_time": 0.382551293938355, "apogee_x": -226.6763234833904, "apogee_time": 26.51906703408914, "frontal_surface_wind": 0.0, "apogee_y": 611.6404487702985, "out_of_rail_stability_margin": 2.0359327287972446, "t_final": 26.51906703408914, "x_impact": 0, "initial_stability_margin": 1.9439258635295085, "out_of_rail_velocity": 33.16807109100089, "lateral_surface_wind": -2.8090766679540033, "max_mach_number": 0.9540120327620064, "impact_velocity": 0, "index": 27, "rail_exit_aoa": 4.840952911426418} +{"apogee": 4473.174697512416, "y_impact": 0, "out_of_rail_time": 0.4295268081476819, "apogee_x": -221.0967314085801, "apogee_time": 25.192909588840973, "frontal_surface_wind": 0.0, "apogee_y": 553.0840187822552, "out_of_rail_stability_margin": 2.135617818120419, "t_final": 25.192909588840973, "x_impact": 0, "initial_stability_margin": 2.047245890651631, "out_of_rail_velocity": 29.341399159410663, "lateral_surface_wind": -2.6743645433274104, "max_mach_number": 0.810226085483311, "impact_velocity": 0, "index": 28, "rail_exit_aoa": 5.207917153162725} +{"apogee": 4513.400587084696, "y_impact": 0, "out_of_rail_time": 0.40119146931290356, "apogee_x": -238.96706447262517, "apogee_time": 25.151820119787804, "frontal_surface_wind": 0.0, "apogee_y": 547.5979726767164, "out_of_rail_stability_margin": 2.1199326600159676, "t_final": 25.151820119787804, "x_impact": 0, "initial_stability_margin": 2.0277181282296333, "out_of_rail_velocity": 31.201704719621958, "lateral_surface_wind": -3.1661786153846605, "max_mach_number": 0.8281155376802564, "impact_velocity": 0, "index": 29, "rail_exit_aoa": 5.794229268903359} +{"apogee": 4593.730036373843, "y_impact": 0, "out_of_rail_time": 0.41220339242964765, "apogee_x": -297.59356861774745, "apogee_time": 25.457527846077085, "frontal_surface_wind": 0.0, "apogee_y": 564.7085995136297, "out_of_rail_stability_margin": 2.4382884629087833, "t_final": 25.457527846077085, "x_impact": 0, "initial_stability_margin": 2.3421236989073533, "out_of_rail_velocity": 30.548953646337875, "lateral_surface_wind": -3.622550841013362, "max_mach_number": 0.8442808621017847, "impact_velocity": 0, "index": 30, "rail_exit_aoa": 6.762658357211761} +{"apogee": 4610.972500697442, "y_impact": 0, "out_of_rail_time": 0.41457464090964125, "apogee_x": -145.46287630480748, "apogee_time": 25.600868682592193, "frontal_surface_wind": 0.0, "apogee_y": 571.0929093436595, "out_of_rail_stability_margin": 2.4662707994282553, "t_final": 25.600868682592193, "x_impact": 0, "initial_stability_margin": 2.372654654829949, "out_of_rail_velocity": 30.385901195466825, "lateral_surface_wind": -1.7047959155557353, "max_mach_number": 0.8382622597182194, "impact_velocity": 0, "index": 31, "rail_exit_aoa": 3.2112036133885673} +{"apogee": 4614.00127765572, "y_impact": 0, "out_of_rail_time": 0.43109722574198783, "apogee_x": -241.74457954699653, "apogee_time": 25.694662462849475, "frontal_surface_wind": 0.0, "apogee_y": 577.6693917895657, "out_of_rail_stability_margin": 2.2731060581830125, "t_final": 25.694662462849475, "x_impact": 0, "initial_stability_margin": 2.1844510238080797, "out_of_rail_velocity": 29.371383111827484, "lateral_surface_wind": -2.752739053828883, "max_mach_number": 0.8362318345518877, "impact_velocity": 0, "index": 32, "rail_exit_aoa": 5.3542236843905116} +{"apogee": 4591.543256316378, "y_impact": 0, "out_of_rail_time": 0.43098596986779963, "apogee_x": -164.59926204025038, "apogee_time": 25.594430559955267, "frontal_surface_wind": 0.0, "apogee_y": 573.643069690264, "out_of_rail_stability_margin": 2.537495895088278, "t_final": 25.594430559955267, "x_impact": 0, "initial_stability_margin": 2.4431530739383054, "out_of_rail_velocity": 29.381549668277213, "lateral_surface_wind": -1.8371656227090258, "max_mach_number": 0.833449675548987, "impact_velocity": 0, "index": 33, "rail_exit_aoa": 3.577924807043246} +{"apogee": 4449.513528515032, "y_impact": 0, "out_of_rail_time": 0.42799236956007586, "apogee_x": -331.81268598403165, "apogee_time": 25.102353387705485, "frontal_surface_wind": 0.0, "apogee_y": 550.1555357605414, "out_of_rail_stability_margin": 2.510459179419721, "t_final": 25.102353387705485, "x_impact": 0, "initial_stability_margin": 2.4162209096674974, "out_of_rail_velocity": 29.4112174318883, "lateral_surface_wind": -3.9826095800054295, "max_mach_number": 0.8067164076455079, "impact_velocity": 0, "index": 34, "rail_exit_aoa": 7.711587387887622} +{"apogee": 4837.302581146948, "y_impact": 0, "out_of_rail_time": 0.4099002308566802, "apogee_x": -393.78373338391094, "apogee_time": 26.27823016170023, "frontal_surface_wind": 0.0, "apogee_y": 603.7385909500493, "out_of_rail_stability_margin": 2.4102424977764154, "t_final": 26.27823016170023, "x_impact": 0, "initial_stability_margin": 2.3185258597748932, "out_of_rail_velocity": 30.99890312578654, "lateral_surface_wind": -4.5283202614085045, "max_mach_number": 0.891228096198128, "impact_velocity": 0, "index": 35, "rail_exit_aoa": 8.310984021386785} +{"apogee": 4356.597991535161, "y_impact": 0, "out_of_rail_time": 0.42217434135183507, "apogee_x": -137.58720792643825, "apogee_time": 24.66169650142705, "frontal_surface_wind": 0.0, "apogee_y": 529.8583710629215, "out_of_rail_stability_margin": 2.8752846707153, "t_final": 24.66169650142705, "x_impact": 0, "initial_stability_margin": 2.768237541940302, "out_of_rail_velocity": 29.699461942848057, "lateral_surface_wind": -1.6730859367607978, "max_mach_number": 0.7962629958085592, "impact_velocity": 0, "index": 36, "rail_exit_aoa": 3.224285708190031} +{"apogee": 4598.266674651531, "y_impact": 0, "out_of_rail_time": 0.42580511455812403, "apogee_x": -156.729228344003, "apogee_time": 25.570601629646877, "frontal_surface_wind": 0.0, "apogee_y": 571.5526539577704, "out_of_rail_stability_margin": 2.4421817675859767, "t_final": 25.570601629646877, "x_impact": 0, "initial_stability_margin": 2.3480931876285953, "out_of_rail_velocity": 29.690807522168654, "lateral_surface_wind": -1.7855132627364316, "max_mach_number": 0.8380206772749137, "impact_velocity": 0, "index": 37, "rail_exit_aoa": 3.44144622320467} +{"apogee": 4821.608332744584, "y_impact": 0, "out_of_rail_time": 0.40375643896057467, "apogee_x": -156.90138555787485, "apogee_time": 26.19892412377759, "frontal_surface_wind": 0.0, "apogee_y": 598.3704961418855, "out_of_rail_stability_margin": 2.3626903957238223, "t_final": 26.19892412377759, "x_impact": 0, "initial_stability_margin": 2.2701770453326153, "out_of_rail_velocity": 31.337259824861118, "lateral_surface_wind": -1.8127079305158535, "max_mach_number": 0.8874734747460986, "impact_velocity": 0, "index": 38, "rail_exit_aoa": 3.310592737039639} +{"apogee": 4618.980492319256, "y_impact": 0, "out_of_rail_time": 0.42867255071850185, "apogee_x": -142.64667755769077, "apogee_time": 25.696828135311875, "frontal_surface_wind": 0.0, "apogee_y": 577.11178470503, "out_of_rail_stability_margin": 2.2525603116473687, "t_final": 25.696828135311875, "x_impact": 0, "initial_stability_margin": 2.1636833521040995, "out_of_rail_velocity": 29.52867074997482, "lateral_surface_wind": -1.611859763460885, "max_mach_number": 0.8372754658894587, "impact_velocity": 0, "index": 39, "rail_exit_aoa": 3.1244616266080314} +{"apogee": 4650.496111505198, "y_impact": 0, "out_of_rail_time": 0.42753561706282917, "apogee_x": -240.21161401212666, "apogee_time": 25.82628456437363, "frontal_surface_wind": 0.0, "apogee_y": 583.9062659207591, "out_of_rail_stability_margin": 2.459141876442102, "t_final": 25.82628456437363, "x_impact": 0, "initial_stability_margin": 2.3686968396864088, "out_of_rail_velocity": 29.604512966326972, "lateral_surface_wind": -2.697456712396421, "max_mach_number": 0.8411371034332941, "impact_velocity": 0, "index": 40, "rail_exit_aoa": 5.206209392098499} +{"apogee": 4535.808409485871, "y_impact": 0, "out_of_rail_time": 0.4163973196401238, "apogee_x": -150.78785823473393, "apogee_time": 25.326036945625642, "frontal_surface_wind": 0.0, "apogee_y": 556.7760481670839, "out_of_rail_stability_margin": 1.95893017261229, "t_final": 25.326036945625642, "x_impact": 0, "initial_stability_margin": 1.8718693459619884, "out_of_rail_velocity": 30.21312137369453, "lateral_surface_wind": -1.8787934399600772, "max_mach_number": 0.8265311516058946, "impact_velocity": 0, "index": 41, "rail_exit_aoa": 3.558338108611282} +{"apogee": 4651.700159024707, "y_impact": 0, "out_of_rail_time": 0.42058202479977963, "apogee_x": -326.00725118805383, "apogee_time": 25.61847706229021, "frontal_surface_wind": 0.0, "apogee_y": 573.0573834859069, "out_of_rail_stability_margin": 2.1067889595707645, "t_final": 25.61847706229021, "x_impact": 0, "initial_stability_margin": 2.0149418257826, "out_of_rail_velocity": 30.13932316016667, "lateral_surface_wind": -3.9006500959622246, "max_mach_number": 0.8627006855789121, "impact_velocity": 0, "index": 42, "rail_exit_aoa": 7.374265760083016} +{"apogee": 4820.427023692357, "y_impact": 0, "out_of_rail_time": 0.40940344348556396, "apogee_x": -304.34668684167235, "apogee_time": 26.22448299415647, "frontal_surface_wind": 0.0, "apogee_y": 600.5937075088995, "out_of_rail_stability_margin": 2.2941767687917687, "t_final": 26.22448299415647, "x_impact": 0, "initial_stability_margin": 2.2039061563899076, "out_of_rail_velocity": 30.945775997719643, "lateral_surface_wind": -3.5268833402310236, "max_mach_number": 0.8870072925176842, "impact_velocity": 0, "index": 43, "rail_exit_aoa": 6.501932902035678} +{"apogee": 4145.980316117054, "y_impact": 0, "out_of_rail_time": 0.4351646694359077, "apogee_x": -283.35178652388095, "apogee_time": 24.024712250021157, "frontal_surface_wind": 0.0, "apogee_y": 501.96989068169586, "out_of_rail_stability_margin": 2.5338710192713543, "t_final": 24.024712250021157, "x_impact": 0, "initial_stability_margin": 2.436532690864459, "out_of_rail_velocity": 28.6816459719639, "lateral_surface_wind": -3.6793072444316444, "max_mach_number": 0.7479171944195236, "impact_velocity": 0, "index": 44, "rail_exit_aoa": 7.310030468017832} +{"apogee": 4793.961326808117, "y_impact": 0, "out_of_rail_time": 0.40388666340163726, "apogee_x": -189.07197691957515, "apogee_time": 26.14652935388459, "frontal_surface_wind": 0.0, "apogee_y": 595.4960741128258, "out_of_rail_stability_margin": 2.3672350918302367, "t_final": 26.14652935388459, "x_impact": 0, "initial_stability_margin": 2.2756350541494528, "out_of_rail_velocity": 31.317628430454743, "lateral_surface_wind": -2.216017357569485, "max_mach_number": 0.877891492507276, "impact_velocity": 0, "index": 45, "rail_exit_aoa": 4.047470279923347} +{"apogee": 4734.834341213674, "y_impact": 0, "out_of_rail_time": 0.4252702284189577, "apogee_x": -207.25899340639012, "apogee_time": 26.01325502727093, "frontal_surface_wind": 0.0, "apogee_y": 592.4608398392875, "out_of_rail_stability_margin": 2.2768527861625047, "t_final": 26.01325502727093, "x_impact": 0, "initial_stability_margin": 2.1866572611148523, "out_of_rail_velocity": 29.883502807775972, "lateral_surface_wind": -2.3173790924051207, "max_mach_number": 0.8667068705868156, "impact_velocity": 0, "index": 46, "rail_exit_aoa": 4.434247439145768} +{"apogee": 4831.760779870034, "y_impact": 0, "out_of_rail_time": 0.39116658072268107, "apogee_x": -223.81636204542218, "apogee_time": 26.112006687331036, "frontal_surface_wind": 0.0, "apogee_y": 592.5853218524821, "out_of_rail_stability_margin": 2.2493673180086216, "t_final": 26.112006687331036, "x_impact": 0, "initial_stability_margin": 2.1554998006899475, "out_of_rail_velocity": 32.27573207259573, "lateral_surface_wind": -2.7610166535447243, "max_mach_number": 0.8995490800678625, "impact_velocity": 0, "index": 47, "rail_exit_aoa": 4.88944458409614} +{"apogee": 4854.274626263077, "y_impact": 0, "out_of_rail_time": 0.400143490712648, "apogee_x": -230.85863632785288, "apogee_time": 26.26051976221902, "frontal_surface_wind": 0.0, "apogee_y": 601.1487800070748, "out_of_rail_stability_margin": 2.3350562700336814, "t_final": 26.26051976221902, "x_impact": 0, "initial_stability_margin": 2.242307362208066, "out_of_rail_velocity": 31.614990474421965, "lateral_surface_wind": -2.717122945458018, "max_mach_number": 0.8983745766588944, "impact_velocity": 0, "index": 48, "rail_exit_aoa": 4.9121654278566504} +{"apogee": 4426.351166491034, "y_impact": 0, "out_of_rail_time": 0.41798068690434903, "apogee_x": -222.35607119861976, "apogee_time": 24.92629207757687, "frontal_surface_wind": 0.0, "apogee_y": 539.5956021778271, "out_of_rail_stability_margin": 2.2143121403783472, "t_final": 24.92629207757687, "x_impact": 0, "initial_stability_margin": 2.121111651753317, "out_of_rail_velocity": 30.02777527296275, "lateral_surface_wind": -2.832046845984663, "max_mach_number": 0.8079022513603901, "impact_velocity": 0, "index": 49, "rail_exit_aoa": 5.387870347379748} +{"apogee": 4834.18322015885, "y_impact": 0, "out_of_rail_time": 0.4201856597315243, "apogee_x": -339.6623452127056, "apogee_time": 26.309076728423424, "frontal_surface_wind": 0.0, "apogee_y": 606.6368998238219, "out_of_rail_stability_margin": 2.3100083745056326, "t_final": 26.309076728423424, "x_impact": 0, "initial_stability_margin": 2.2203766635608004, "out_of_rail_velocity": 30.29977649571531, "lateral_surface_wind": -3.7847594523825006, "max_mach_number": 0.8890621593973166, "impact_velocity": 0, "index": 50, "rail_exit_aoa": 7.119965761390784} +{"apogee": 4652.297118427853, "y_impact": 0, "out_of_rail_time": 0.41447849608671916, "apogee_x": -409.15871273199605, "apogee_time": 25.68724527526533, "frontal_surface_wind": 0.0, "apogee_y": 575.5126455501345, "out_of_rail_stability_margin": 2.2390014933050937, "t_final": 25.68724527526533, "x_impact": 0, "initial_stability_margin": 2.148507857782118, "out_of_rail_velocity": 30.472037913352366, "lateral_surface_wind": -4.939108552843822, "max_mach_number": 0.8546050082718321, "impact_velocity": 0, "index": 51, "rail_exit_aoa": 9.20680717182765} +{"apogee": 4785.6085061120375, "y_impact": 0, "out_of_rail_time": 0.4265447728716474, "apogee_x": -143.45191695185548, "apogee_time": 26.22778212794182, "frontal_surface_wind": 0.0, "apogee_y": 603.0194413390082, "out_of_rail_stability_margin": 2.3569193556387438, "t_final": 26.22778212794182, "x_impact": 0, "initial_stability_margin": 2.267455802566362, "out_of_rail_velocity": 29.834304247010564, "lateral_surface_wind": -1.5406691924135483, "max_mach_number": 0.8716143978545347, "impact_velocity": 0, "index": 52, "rail_exit_aoa": 2.9561774930409843} +{"apogee": 4895.201173604007, "y_impact": 0, "out_of_rail_time": 0.40034537297433637, "apogee_x": -259.42811771167015, "apogee_time": 26.473134542228383, "frontal_surface_wind": 0.0, "apogee_y": 611.4715351677291, "out_of_rail_stability_margin": 2.4648887952276755, "t_final": 26.473134542228383, "x_impact": 0, "initial_stability_margin": 2.373479368804944, "out_of_rail_velocity": 31.59741635744488, "lateral_surface_wind": -2.9850374899423, "max_mach_number": 0.897621569036305, "impact_velocity": 0, "index": 53, "rail_exit_aoa": 5.396768984292379} +{"apogee": 4796.2173874866985, "y_impact": 0, "out_of_rail_time": 0.4130777831480599, "apogee_x": -238.88689560386788, "apogee_time": 26.247503228177905, "frontal_surface_wind": 0.0, "apogee_y": 602.2077938554493, "out_of_rail_stability_margin": 2.5263333790505773, "t_final": 26.247503228177905, "x_impact": 0, "initial_stability_margin": 2.435287522972921, "out_of_rail_velocity": 30.658022083187014, "lateral_surface_wind": -2.6857603168098736, "max_mach_number": 0.8714549217765806, "impact_velocity": 0, "index": 54, "rail_exit_aoa": 5.006548664608852} +{"apogee": 4747.272931864136, "y_impact": 0, "out_of_rail_time": 0.4149034831696583, "apogee_x": -465.69195587359496, "apogee_time": 25.999423722427927, "frontal_surface_wind": 0.0, "apogee_y": 591.0752499098882, "out_of_rail_stability_margin": 2.303246005416549, "t_final": 25.999423722427927, "x_impact": 0, "initial_stability_margin": 2.2129862616242537, "out_of_rail_velocity": 30.532962019937187, "lateral_surface_wind": -5.435706524140041, "max_mach_number": 0.8745303584339066, "impact_velocity": 0, "index": 55, "rail_exit_aoa": 10.094466393075681} +{"apogee": 4890.265738231196, "y_impact": 0, "out_of_rail_time": 0.3934965627109948, "apogee_x": -340.20014581604767, "apogee_time": 26.345716470342122, "frontal_surface_wind": 0.0, "apogee_y": 604.792102619259, "out_of_rail_stability_margin": 2.4485178734045547, "t_final": 26.345716470342122, "x_impact": 0, "initial_stability_margin": 2.353951060390061, "out_of_rail_velocity": 32.178944462208264, "lateral_surface_wind": -4.069793611933381, "max_mach_number": 0.9076105768097836, "impact_velocity": 0, "index": 56, "rail_exit_aoa": 7.208145011749926} +{"apogee": 4773.569579806192, "y_impact": 0, "out_of_rail_time": 0.3920298780503988, "apogee_x": -411.6075817749728, "apogee_time": 25.94702346229824, "frontal_surface_wind": 0.0, "apogee_y": 586.2062979704664, "out_of_rail_stability_margin": 2.6294660348157595, "t_final": 25.94702346229824, "x_impact": 0, "initial_stability_margin": 2.5303062689301443, "out_of_rail_velocity": 32.12482903001046, "lateral_surface_wind": -5.061698859231189, "max_mach_number": 0.88676563496281, "impact_velocity": 0, "index": 57, "rail_exit_aoa": 8.954105982558708} +{"apogee": 4630.486020788806, "y_impact": 0, "out_of_rail_time": 0.4193704267896021, "apogee_x": -263.45204783393723, "apogee_time": 25.640021204113303, "frontal_surface_wind": 0.0, "apogee_y": 573.5328440313372, "out_of_rail_stability_margin": 2.2457477644033665, "t_final": 25.640021204113303, "x_impact": 0, "initial_stability_margin": 2.1546601949778905, "out_of_rail_velocity": 30.143711318474217, "lateral_surface_wind": -3.1275752343049703, "max_mach_number": 0.8474238599054356, "impact_velocity": 0, "index": 58, "rail_exit_aoa": 5.923555692638457} +{"apogee": 4673.66223038333, "y_impact": 0, "out_of_rail_time": 0.4140490308301805, "apogee_x": -187.15759704616318, "apogee_time": 25.786384470771388, "frontal_surface_wind": 0.0, "apogee_y": 580.4408109132213, "out_of_rail_stability_margin": 2.629405398425624, "t_final": 25.786384470771388, "x_impact": 0, "initial_stability_margin": 2.532820812526416, "out_of_rail_velocity": 30.522791562709155, "lateral_surface_wind": -2.1577483312469523, "max_mach_number": 0.8530406191170431, "impact_velocity": 0, "index": 59, "rail_exit_aoa": 4.043684563443348} +{"apogee": 4571.445447960524, "y_impact": 0, "out_of_rail_time": 0.4229648772939671, "apogee_x": -328.673984572755, "apogee_time": 25.500249273026764, "frontal_surface_wind": 0.0, "apogee_y": 565.5477108725754, "out_of_rail_stability_margin": 1.836103990258003, "t_final": 25.500249273026764, "x_impact": 0, "initial_stability_margin": 1.7532248457586754, "out_of_rail_velocity": 29.87231578453399, "lateral_surface_wind": -4.084523254166786, "max_mach_number": 0.8318082464447516, "impact_velocity": 0, "index": 60, "rail_exit_aoa": 7.7859263744226785} +{"apogee": 4883.912678698289, "y_impact": 0, "out_of_rail_time": 0.42527539018494753, "apogee_x": -203.4951845654452, "apogee_time": 26.51001216457813, "frontal_surface_wind": 0.0, "apogee_y": 616.5678121483355, "out_of_rail_stability_margin": 2.19332166218488, "t_final": 26.51001216457813, "x_impact": 0, "initial_stability_margin": 2.1066131884949004, "out_of_rail_velocity": 30.01565862367333, "lateral_surface_wind": -2.1890902317273966, "max_mach_number": 0.8951580822818812, "impact_velocity": 0, "index": 61, "rail_exit_aoa": 4.171288039672103} +{"apogee": 4680.6386194684555, "y_impact": 0, "out_of_rail_time": 0.42346561989111414, "apogee_x": -213.6757461592623, "apogee_time": 25.860933961062617, "frontal_surface_wind": 0.0, "apogee_y": 584.3596747441185, "out_of_rail_stability_margin": 2.2211586250631683, "t_final": 25.860933961062617, "x_impact": 0, "initial_stability_margin": 2.1324737629214217, "out_of_rail_velocity": 29.9164865795034, "lateral_surface_wind": -2.4511700095185143, "max_mach_number": 0.8526650753097205, "impact_velocity": 0, "index": 62, "rail_exit_aoa": 4.683995481620223} +{"apogee": 4654.584073881658, "y_impact": 0, "out_of_rail_time": 0.42197158910714094, "apogee_x": -242.91858792349254, "apogee_time": 25.842188309592387, "frontal_surface_wind": 0.0, "apogee_y": 584.2072994623683, "out_of_rail_stability_margin": 2.6570192174367238, "t_final": 25.842188309592387, "x_impact": 0, "initial_stability_margin": 2.564319977889451, "out_of_rail_velocity": 29.924337750361353, "lateral_surface_wind": -2.73759156213601, "max_mach_number": 0.8398496515983702, "impact_velocity": 0, "index": 63, "rail_exit_aoa": 5.22708463433912} +{"apogee": 4845.202488170467, "y_impact": 0, "out_of_rail_time": 0.4032842298385252, "apogee_x": -325.41542847765544, "apogee_time": 26.250542551627863, "frontal_surface_wind": 0.0, "apogee_y": 601.8619038045018, "out_of_rail_stability_margin": 2.52566497801074, "t_final": 26.250542551627863, "x_impact": 0, "initial_stability_margin": 2.4305533119715874, "out_of_rail_velocity": 31.414486355880033, "lateral_surface_wind": -3.7767753503625583, "max_mach_number": 0.8959361870111318, "impact_velocity": 0, "index": 64, "rail_exit_aoa": 6.85542539863223} +{"apogee": 4797.3365243670805, "y_impact": 0, "out_of_rail_time": 0.40315471484650056, "apogee_x": -365.0617250055372, "apogee_time": 26.141353092076642, "frontal_surface_wind": 0.0, "apogee_y": 595.5059413526708, "out_of_rail_stability_margin": 2.3060375941495908, "t_final": 26.141353092076642, "x_impact": 0, "initial_stability_margin": 2.2156432867982105, "out_of_rail_velocity": 31.365784232551214, "lateral_surface_wind": -4.367414648075711, "max_mach_number": 0.8817536650911723, "impact_velocity": 0, "index": 65, "rail_exit_aoa": 7.926975011924114} +{"apogee": 4515.86055823126, "y_impact": 0, "out_of_rail_time": 0.41784592504968693, "apogee_x": -373.02492327064255, "apogee_time": 25.218258677427528, "frontal_surface_wind": 0.0, "apogee_y": 554.2082237592166, "out_of_rail_stability_margin": 2.359953441607795, "t_final": 25.218258677427528, "x_impact": 0, "initial_stability_margin": 2.2652193518916537, "out_of_rail_velocity": 30.12217094433833, "lateral_surface_wind": -4.6027259470210895, "max_mach_number": 0.8288215099613113, "impact_velocity": 0, "index": 66, "rail_exit_aoa": 8.687707002679995} +{"apogee": 4452.264141932459, "y_impact": 0, "out_of_rail_time": 0.4371560102491331, "apogee_x": -239.14973306732995, "apogee_time": 25.16208646969738, "frontal_surface_wind": 0.0, "apogee_y": 553.734456462753, "out_of_rail_stability_margin": 2.4052770636242715, "t_final": 25.16208646969738, "x_impact": 0, "initial_stability_margin": 2.3132679791084514, "out_of_rail_velocity": 28.869450128251874, "lateral_surface_wind": -2.784750712626476, "max_mach_number": 0.8042176316339115, "impact_velocity": 0, "index": 67, "rail_exit_aoa": 5.509711666184054} +{"apogee": 4642.672254397237, "y_impact": 0, "out_of_rail_time": 0.407821875544885, "apogee_x": -288.3492828926142, "apogee_time": 25.588976410139377, "frontal_surface_wind": 0.0, "apogee_y": 570.22524212112, "out_of_rail_stability_margin": 2.3921951200472824, "t_final": 25.588976410139377, "x_impact": 0, "initial_stability_margin": 2.296371476600632, "out_of_rail_velocity": 30.909947527965034, "lateral_surface_wind": -3.523975079954791, "max_mach_number": 0.85611783776149, "impact_velocity": 0, "index": 68, "rail_exit_aoa": 6.504083174627569} +{"apogee": 4630.274269834718, "y_impact": 0, "out_of_rail_time": 0.4183217849446811, "apogee_x": -269.47718722636114, "apogee_time": 25.629226711321788, "frontal_surface_wind": 0.0, "apogee_y": 573.5078448858428, "out_of_rail_stability_margin": 2.4298052677914375, "t_final": 25.629226711321788, "x_impact": 0, "initial_stability_margin": 2.3354291001308796, "out_of_rail_velocity": 30.203800243693497, "lateral_surface_wind": -3.16994462936623, "max_mach_number": 0.8479809857113503, "impact_velocity": 0, "index": 69, "rail_exit_aoa": 5.991364071501602} +{"apogee": 4933.910213975454, "y_impact": 0, "out_of_rail_time": 0.4054478490919374, "apogee_x": -240.12805552280278, "apogee_time": 26.507184943005182, "frontal_surface_wind": 0.0, "apogee_y": 613.8735825180578, "out_of_rail_stability_margin": 2.199772334343518, "t_final": 26.507184943005182, "x_impact": 0, "initial_stability_margin": 2.1097247467011564, "out_of_rail_velocity": 31.386709285550797, "lateral_surface_wind": -2.7481470164109507, "max_mach_number": 0.9172556018114032, "impact_velocity": 0, "index": 70, "rail_exit_aoa": 5.003923509767985} +{"apogee": 4630.839535985905, "y_impact": 0, "out_of_rail_time": 0.40876709876568135, "apogee_x": -359.83616410495495, "apogee_time": 25.564511720298324, "frontal_surface_wind": 0.0, "apogee_y": 569.2604063136346, "out_of_rail_stability_margin": 2.3590455611977377, "t_final": 25.564511720298324, "x_impact": 0, "initial_stability_margin": 2.264567467019022, "out_of_rail_velocity": 30.81322711586669, "lateral_surface_wind": -4.418638491212225, "max_mach_number": 0.8532734775983278, "impact_velocity": 0, "index": 71, "rail_exit_aoa": 8.160620968235335} +{"apogee": 4729.559503369839, "y_impact": 0, "out_of_rail_time": 0.42191355602718666, "apogee_x": -252.87565029620322, "apogee_time": 26.033834316521737, "frontal_surface_wind": 0.0, "apogee_y": 592.7627969295646, "out_of_rail_stability_margin": 2.2961130562222243, "t_final": 26.033834316521737, "x_impact": 0, "initial_stability_margin": 2.2074306050782435, "out_of_rail_velocity": 30.03697381624711, "lateral_surface_wind": -2.865535777823969, "max_mach_number": 0.8609517205056275, "impact_velocity": 0, "index": 72, "rail_exit_aoa": 5.4495409878393755} +{"apogee": 4619.104576895596, "y_impact": 0, "out_of_rail_time": 0.41433170801236874, "apogee_x": -401.75287006430256, "apogee_time": 25.551093250080996, "frontal_surface_wind": 0.0, "apogee_y": 568.0408496625219, "out_of_rail_stability_margin": 1.9787943156296002, "t_final": 25.551093250080996, "x_impact": 0, "initial_stability_margin": 1.8910961740396202, "out_of_rail_velocity": 30.49713671268091, "lateral_surface_wind": -5.024243752582158, "max_mach_number": 0.8507830468054751, "impact_velocity": 0, "index": 73, "rail_exit_aoa": 9.355148449069723} +{"apogee": 4464.449626198876, "y_impact": 0, "out_of_rail_time": 0.4282509838541082, "apogee_x": -305.00217370204007, "apogee_time": 25.154589506471474, "frontal_surface_wind": 0.0, "apogee_y": 552.6614038028171, "out_of_rail_stability_margin": 2.5482215690667944, "t_final": 25.154589506471474, "x_impact": 0, "initial_stability_margin": 2.4533070290347396, "out_of_rail_velocity": 29.41817161946509, "lateral_surface_wind": -3.62792351685249, "max_mach_number": 0.8093198990804412, "impact_velocity": 0, "index": 74, "rail_exit_aoa": 7.030363903424994} +{"apogee": 4786.889043450874, "y_impact": 0, "out_of_rail_time": 0.4081305046542291, "apogee_x": -383.40517145771287, "apogee_time": 26.088740850713556, "frontal_surface_wind": 0.0, "apogee_y": 594.0216279765633, "out_of_rail_stability_margin": 2.267542200060266, "t_final": 26.088740850713556, "x_impact": 0, "initial_stability_margin": 2.176792564767675, "out_of_rail_velocity": 31.039962212121225, "lateral_surface_wind": -4.53834736344094, "max_mach_number": 0.8836581758956217, "impact_velocity": 0, "index": 75, "rail_exit_aoa": 8.318265860212493} +{"apogee": 4648.514242662916, "y_impact": 0, "out_of_rail_time": 0.40057573090379445, "apogee_x": -235.06514960809167, "apogee_time": 25.539103391785982, "frontal_surface_wind": 0.0, "apogee_y": 567.3186085303394, "out_of_rail_stability_margin": 2.51003431882791, "t_final": 25.539103391785982, "x_impact": 0, "initial_stability_margin": 2.409827933242639, "out_of_rail_velocity": 31.413859735578118, "lateral_surface_wind": -2.9121468891932714, "max_mach_number": 0.86198407110919, "impact_velocity": 0, "index": 76, "rail_exit_aoa": 5.2963312756975} +{"apogee": 4854.052779032079, "y_impact": 0, "out_of_rail_time": 0.4131119899597909, "apogee_x": -232.95865758870437, "apogee_time": 26.363295069325037, "frontal_surface_wind": 0.0, "apogee_y": 607.3815113485223, "out_of_rail_stability_margin": 2.19609360646836, "t_final": 26.363295069325037, "x_impact": 0, "initial_stability_margin": 2.1083063628990484, "out_of_rail_velocity": 30.72555897465883, "lateral_surface_wind": -2.6416342447297434, "max_mach_number": 0.8911180907889562, "impact_velocity": 0, "index": 77, "rail_exit_aoa": 4.913928987475554} +{"apogee": 4655.630981100524, "y_impact": 0, "out_of_rail_time": 0.413441706653819, "apogee_x": -354.95116914102425, "apogee_time": 25.655538075963552, "frontal_surface_wind": 0.0, "apogee_y": 573.5775376982995, "out_of_rail_stability_margin": 2.1398671348728024, "t_final": 25.655538075963552, "x_impact": 0, "initial_stability_margin": 2.049127887006423, "out_of_rail_velocity": 30.565934616971663, "lateral_surface_wind": -4.331209637870097, "max_mach_number": 0.8589731874910751, "impact_velocity": 0, "index": 78, "rail_exit_aoa": 8.065149133447521} +{"apogee": 4645.057159870632, "y_impact": 0, "out_of_rail_time": 0.4283040827485579, "apogee_x": -296.82297503290675, "apogee_time": 25.78281127913727, "frontal_surface_wind": 0.0, "apogee_y": 581.8693490013206, "out_of_rail_stability_margin": 2.342315702950087, "t_final": 25.78281127913727, "x_impact": 0, "initial_stability_margin": 2.25274427983248, "out_of_rail_velocity": 29.578015595798007, "lateral_surface_wind": -3.37653377772554, "max_mach_number": 0.8435286096897919, "impact_velocity": 0, "index": 79, "rail_exit_aoa": 6.512514774385579} +{"apogee": 4676.31975657416, "y_impact": 0, "out_of_rail_time": 0.414745721355066, "apogee_x": -170.78928890419735, "apogee_time": 25.752324254472025, "frontal_surface_wind": 0.0, "apogee_y": 578.9315661014036, "out_of_rail_stability_margin": 2.4844817515235142, "t_final": 25.752324254472025, "x_impact": 0, "initial_stability_margin": 2.3885202525905047, "out_of_rail_velocity": 30.46923731517052, "lateral_surface_wind": -1.9752485543126, "max_mach_number": 0.8585290555805316, "impact_velocity": 0, "index": 80, "rail_exit_aoa": 3.7091595492216594} +{"apogee": 4443.227568486534, "y_impact": 0, "out_of_rail_time": 0.41934599670728845, "apogee_x": -340.6474575960493, "apogee_time": 24.955585818303675, "frontal_surface_wind": 0.0, "apogee_y": 540.2141581711332, "out_of_rail_stability_margin": 1.8260805464483307, "t_final": 24.955585818303675, "x_impact": 0, "initial_stability_margin": 1.738663017332515, "out_of_rail_velocity": 29.97506883273719, "lateral_surface_wind": -4.479170396678586, "max_mach_number": 0.8164846525252969, "impact_velocity": 0, "index": 81, "rail_exit_aoa": 8.498815258035464} +{"apogee": 4663.511074111039, "y_impact": 0, "out_of_rail_time": 0.4312201878917248, "apogee_x": -248.79773283137757, "apogee_time": 25.885237636254082, "frontal_surface_wind": 0.0, "apogee_y": 585.8733313409764, "out_of_rail_stability_margin": 2.04501562865208, "t_final": 25.885237636254082, "x_impact": 0, "initial_stability_margin": 1.9613266844377832, "out_of_rail_velocity": 29.4102349205895, "lateral_surface_wind": -2.840053566569036, "max_mach_number": 0.8437600711798128, "impact_velocity": 0, "index": 82, "rail_exit_aoa": 5.515769891033272} +{"apogee": 4761.477268827097, "y_impact": 0, "out_of_rail_time": 0.4218730746715751, "apogee_x": -159.6636353582206, "apogee_time": 26.0246989253644, "frontal_surface_wind": 0.0, "apogee_y": 593.3422274466111, "out_of_rail_stability_margin": 2.432448256679382, "t_final": 26.0246989253644, "x_impact": 0, "initial_stability_margin": 2.3374542731583414, "out_of_rail_velocity": 30.138809095326163, "lateral_surface_wind": -1.7619552266876675, "max_mach_number": 0.878696446451948, "impact_velocity": 0, "index": 83, "rail_exit_aoa": 3.3457799744341457} +{"apogee": 4756.780011823031, "y_impact": 0, "out_of_rail_time": 0.41463209318284705, "apogee_x": -391.6103808349012, "apogee_time": 26.04655575335157, "frontal_surface_wind": 0.0, "apogee_y": 593.3468530093837, "out_of_rail_stability_margin": 2.4312433448433732, "t_final": 26.04655575335157, "x_impact": 0, "initial_stability_margin": 2.339144463832044, "out_of_rail_velocity": 30.557445295324, "lateral_surface_wind": -4.512622295575593, "max_mach_number": 0.8734807276171523, "impact_velocity": 0, "index": 84, "rail_exit_aoa": 8.400534642442093} +{"apogee": 4429.459505186919, "y_impact": 0, "out_of_rail_time": 0.4272035964383403, "apogee_x": -253.98505545147447, "apogee_time": 25.021660183751564, "frontal_surface_wind": 0.0, "apogee_y": 545.9298899996932, "out_of_rail_stability_margin": 2.4629938949733345, "t_final": 25.021660183751564, "x_impact": 0, "initial_stability_margin": 2.3685489783687186, "out_of_rail_velocity": 29.452574052939138, "lateral_surface_wind": -3.0765092736178596, "max_mach_number": 0.8028549321466105, "impact_velocity": 0, "index": 85, "rail_exit_aoa": 5.963283745711275} +{"apogee": 4688.733827024327, "y_impact": 0, "out_of_rail_time": 0.4193393510392021, "apogee_x": -416.8090169401243, "apogee_time": 25.853007890697175, "frontal_surface_wind": 0.0, "apogee_y": 583.8238387629679, "out_of_rail_stability_margin": 2.16764044911626, "t_final": 25.853007890697175, "x_impact": 0, "initial_stability_margin": 2.0799051415437715, "out_of_rail_velocity": 30.195243344584497, "lateral_surface_wind": -4.917189659068784, "max_mach_number": 0.858814023217045, "impact_velocity": 0, "index": 86, "rail_exit_aoa": 9.24922734677589} +{"apogee": 4747.7344106012115, "y_impact": 0, "out_of_rail_time": 0.41959495387868145, "apogee_x": -135.66498848642044, "apogee_time": 26.082485411340826, "frontal_surface_wind": 0.0, "apogee_y": 593.7767050883701, "out_of_rail_stability_margin": 2.1005835753869038, "t_final": 26.082485411340826, "x_impact": 0, "initial_stability_margin": 2.0146511219153527, "out_of_rail_velocity": 30.2134380158144, "lateral_surface_wind": -1.5340764292333717, "max_mach_number": 0.8643832802677351, "impact_velocity": 0, "index": 87, "rail_exit_aoa": 2.9066764098384854} +{"apogee": 4889.618418430485, "y_impact": 0, "out_of_rail_time": 0.4072382747651859, "apogee_x": -68.20537337908166, "apogee_time": 26.41155582469309, "frontal_surface_wind": 0.0, "apogee_y": 608.8890741298794, "out_of_rail_stability_margin": 2.2183906286609774, "t_final": 26.41155582469309, "x_impact": 0, "initial_stability_margin": 2.1285436536978732, "out_of_rail_velocity": 31.17668583031003, "lateral_surface_wind": -0.730532829629163, "max_mach_number": 0.9028334064122084, "impact_velocity": 0, "index": 88, "rail_exit_aoa": 1.3423104085146087} +{"apogee": 4472.197617047766, "y_impact": 0, "out_of_rail_time": 0.42558749217289993, "apogee_x": -129.2416707397595, "apogee_time": 25.08473823250677, "frontal_surface_wind": 0.0, "apogee_y": 549.1430596772685, "out_of_rail_stability_margin": 2.557759315419103, "t_final": 25.08473823250677, "x_impact": 0, "initial_stability_margin": 2.457903161928682, "out_of_rail_velocity": 29.60199583617497, "lateral_surface_wind": -1.5166883946263867, "max_mach_number": 0.8184019625613047, "impact_velocity": 0, "index": 89, "rail_exit_aoa": 2.9330428515556783} +{"apogee": 4793.976685935666, "y_impact": 0, "out_of_rail_time": 0.4083968521446543, "apogee_x": -338.32525713683754, "apogee_time": 26.064603853402183, "frontal_surface_wind": 0.0, "apogee_y": 592.8314476978112, "out_of_rail_stability_margin": 2.1735535612528336, "t_final": 26.064603853402183, "x_impact": 0, "initial_stability_margin": 2.082565248472076, "out_of_rail_velocity": 31.039201817991586, "lateral_surface_wind": -4.022053907720315, "max_mach_number": 0.890055150609898, "impact_velocity": 0, "index": 90, "rail_exit_aoa": 7.383235913021106} +{"apogee": 4506.059541269961, "y_impact": 0, "out_of_rail_time": 0.3989202535288424, "apogee_x": -175.9870880218369, "apogee_time": 25.073386540897808, "frontal_surface_wind": 0.0, "apogee_y": 544.4417340423548, "out_of_rail_stability_margin": 2.311089031798174, "t_final": 25.073386540897808, "x_impact": 0, "initial_stability_margin": 2.213184310897127, "out_of_rail_velocity": 31.347122256144516, "lateral_surface_wind": -2.307053021883749, "max_mach_number": 0.8308621759869218, "impact_velocity": 0, "index": 91, "rail_exit_aoa": 4.209206615810742} +{"apogee": 4542.574031475685, "y_impact": 0, "out_of_rail_time": 0.4280751114841993, "apogee_x": -433.7461007739593, "apogee_time": 25.3986161545091, "frontal_surface_wind": 0.0, "apogee_y": 563.7262483408686, "out_of_rail_stability_margin": 2.2347389134335, "t_final": 25.3986161545091, "x_impact": 0, "initial_stability_margin": 2.1452617762886614, "out_of_rail_velocity": 29.518523144283357, "lateral_surface_wind": -5.1703570630209255, "max_mach_number": 0.8290011565159854, "impact_velocity": 0, "index": 92, "rail_exit_aoa": 9.934937827680258} +{"apogee": 4436.31865658282, "y_impact": 0, "out_of_rail_time": 0.4227964863859661, "apogee_x": -216.31902752858005, "apogee_time": 24.951481544263846, "frontal_surface_wind": 0.0, "apogee_y": 541.7342231588866, "out_of_rail_stability_margin": 2.2513147461829437, "t_final": 24.951481544263846, "x_impact": 0, "initial_stability_margin": 2.1564806530154996, "out_of_rail_velocity": 29.747077995369374, "lateral_surface_wind": -2.6938106711729386, "max_mach_number": 0.8122060668266222, "impact_velocity": 0, "index": 93, "rail_exit_aoa": 5.17442895799046} +{"apogee": 4659.360055321377, "y_impact": 0, "out_of_rail_time": 0.4188550270751765, "apogee_x": -145.82130169233386, "apogee_time": 25.745112073084943, "frontal_surface_wind": 0.0, "apogee_y": 578.0255510963478, "out_of_rail_stability_margin": 2.208801488371183, "t_final": 25.745112073084943, "x_impact": 0, "initial_stability_margin": 2.1186283458897055, "out_of_rail_velocity": 30.1986116012157, "lateral_surface_wind": -1.6933181953563228, "max_mach_number": 0.851260418988824, "impact_velocity": 0, "index": 94, "rail_exit_aoa": 3.2093692536625387} +{"apogee": 4814.353149917056, "y_impact": 0, "out_of_rail_time": 0.41415801524290713, "apogee_x": -357.61666771012887, "apogee_time": 26.210984717724337, "frontal_surface_wind": 0.0, "apogee_y": 599.9051789053665, "out_of_rail_stability_margin": 2.021343837636815, "t_final": 26.210984717724337, "x_impact": 0, "initial_stability_margin": 1.9354428822403793, "out_of_rail_velocity": 30.664927116715944, "lateral_surface_wind": -4.188197821482612, "max_mach_number": 0.8875065880773171, "impact_velocity": 0, "index": 95, "rail_exit_aoa": 7.777303202614337} +{"apogee": 4820.155625462501, "y_impact": 0, "out_of_rail_time": 0.42765936134071747, "apogee_x": -396.81742922898417, "apogee_time": 26.350901542916535, "frontal_surface_wind": 0.0, "apogee_y": 609.9280644485187, "out_of_rail_stability_margin": 2.3599032022126702, "t_final": 26.350901542916535, "x_impact": 0, "initial_stability_margin": 2.272009622906703, "out_of_rail_velocity": 29.794042165357798, "lateral_surface_wind": -4.310573213964412, "max_mach_number": 0.8797907750376329, "impact_velocity": 0, "index": 96, "rail_exit_aoa": 8.232375051102547} +{"apogee": 4387.605007375576, "y_impact": 0, "out_of_rail_time": 0.445267353606082, "apogee_x": -163.66242657244558, "apogee_time": 24.981355133668707, "frontal_surface_wind": 0.0, "apogee_y": 546.7530770605564, "out_of_rail_stability_margin": 2.609939848517891, "t_final": 24.981355133668707, "x_impact": 0, "initial_stability_margin": 2.514748662699979, "out_of_rail_velocity": 28.386865620185336, "lateral_surface_wind": -1.8559781502330297, "max_mach_number": 0.7888298933488584, "impact_velocity": 0, "index": 97, "rail_exit_aoa": 3.7407644254544694} +{"apogee": 4748.853222703051, "y_impact": 0, "out_of_rail_time": 0.4189806478219249, "apogee_x": -245.3940428582747, "apogee_time": 26.032866106266557, "frontal_surface_wind": 0.0, "apogee_y": 592.3312016696564, "out_of_rail_stability_margin": 2.2331166516236287, "t_final": 26.032866106266557, "x_impact": 0, "initial_stability_margin": 2.143500523700404, "out_of_rail_velocity": 30.284134036215082, "lateral_surface_wind": -2.812287615837798, "max_mach_number": 0.8706871062299387, "impact_velocity": 0, "index": 98, "rail_exit_aoa": 5.305464980025932} +{"apogee": 4882.446414812261, "y_impact": 0, "out_of_rail_time": 0.4025432629153109, "apogee_x": -312.0971572085366, "apogee_time": 26.3444702601522, "frontal_surface_wind": 0.0, "apogee_y": 604.6416705409873, "out_of_rail_stability_margin": 1.9729934597236711, "t_final": 26.3444702601522, "x_impact": 0, "initial_stability_margin": 1.8864112128893116, "out_of_rail_velocity": 31.51424342853964, "lateral_surface_wind": -3.7471560654077747, "max_mach_number": 0.9067071035078418, "impact_velocity": 0, "index": 99, "rail_exit_aoa": 6.780836398344449} +{"apogee": 4575.521046801354, "y_impact": 0, "out_of_rail_time": 0.4258092526029767, "apogee_x": -333.7826668054668, "apogee_time": 25.54783967052688, "frontal_surface_wind": 0.0, "apogee_y": 569.8781411643731, "out_of_rail_stability_margin": 2.290392314150821, "t_final": 25.54783967052688, "x_impact": 0, "initial_stability_margin": 2.201419586472164, "out_of_rail_velocity": 29.695445810354602, "lateral_surface_wind": -3.94310023852307, "max_mach_number": 0.8294619360772141, "impact_velocity": 0, "index": 100, "rail_exit_aoa": 7.563754664560585} diff --git a/docs/reference/classes/Function.rst b/docs/reference/classes/Function.rst index 423123945..88a1bc769 100644 --- a/docs/reference/classes/Function.rst +++ b/docs/reference/classes/Function.rst @@ -4,4 +4,7 @@ Function Classes .. seealso:: :doc:`Function Class Usage ` .. autoclass:: rocketpy.Function + :members: + +.. autoclass:: rocketpy.PiecewiseFunction :members: \ No newline at end of file diff --git a/docs/reference/classes/PointMassRocket.rst b/docs/reference/classes/PointMassRocket.rst new file mode 100644 index 000000000..f77a8ca54 --- /dev/null +++ b/docs/reference/classes/PointMassRocket.rst @@ -0,0 +1,5 @@ +PointMassRocket Class +--------------------- + +.. autoclass:: rocketpy.PointMassRocket + :members: diff --git a/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst b/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst new file mode 100644 index 000000000..94e9b7814 --- /dev/null +++ b/docs/reference/classes/aero_surfaces/ControllableGenericSurface.rst @@ -0,0 +1,5 @@ +Controllable Generic Surface Class +---------------------------------- + +.. autoclass:: rocketpy.ControllableGenericSurface + :members: diff --git a/docs/reference/classes/aero_surfaces/index.rst b/docs/reference/classes/aero_surfaces/index.rst index a3dad0417..d4b67e68a 100644 --- a/docs/reference/classes/aero_surfaces/index.rst +++ b/docs/reference/classes/aero_surfaces/index.rst @@ -19,4 +19,5 @@ AeroSurface Classes RailButtons AirBrakes GenericSurface - LinearGenericSurface \ No newline at end of file + LinearGenericSurface + ControllableGenericSurface \ No newline at end of file diff --git a/docs/reference/classes/monte_carlo/stochastic_models/index.rst b/docs/reference/classes/monte_carlo/stochastic_models/index.rst index ca8b2b1e2..028a48246 100644 --- a/docs/reference/classes/monte_carlo/stochastic_models/index.rst +++ b/docs/reference/classes/monte_carlo/stochastic_models/index.rst @@ -20,6 +20,7 @@ input parameters, enabling robust Monte Carlo simulations. stochastic_trapezoidal_fins stochastic_elliptical_fins stochastic_tail + stochastic_air_brakes stochastic_rail_buttons stochastic_rocket stochastic_parachute diff --git a/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst b/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst new file mode 100644 index 000000000..7f37852c0 --- /dev/null +++ b/docs/reference/classes/monte_carlo/stochastic_models/stochastic_air_brakes.rst @@ -0,0 +1,5 @@ +Stochastic Air Brakes +--------------------- + +.. autoclass:: rocketpy.stochastic.StochasticAirBrakes + :members: diff --git a/docs/reference/classes/motors/EmptyMotor.rst b/docs/reference/classes/motors/EmptyMotor.rst new file mode 100644 index 000000000..757c04937 --- /dev/null +++ b/docs/reference/classes/motors/EmptyMotor.rst @@ -0,0 +1,5 @@ +EmptyMotor Class +---------------- + +.. autoclass:: rocketpy.EmptyMotor + :members: diff --git a/docs/reference/classes/motors/PointMassMotor.rst b/docs/reference/classes/motors/PointMassMotor.rst new file mode 100644 index 000000000..4635ca274 --- /dev/null +++ b/docs/reference/classes/motors/PointMassMotor.rst @@ -0,0 +1,5 @@ +PointMassMotor Class +-------------------- + +.. autoclass:: rocketpy.PointMassMotor + :members: diff --git a/docs/reference/classes/motors/index.rst b/docs/reference/classes/motors/index.rst index 3bd6e2d96..99d905295 100644 --- a/docs/reference/classes/motors/index.rst +++ b/docs/reference/classes/motors/index.rst @@ -10,6 +10,8 @@ Motor Classes HybridMotor LiquidMotor GenericMotor + PointMassMotor + EmptyMotor Fluid Tank Classes Tank Geometry Classes diff --git a/docs/reference/index.rst b/docs/reference/index.rst index e86240dd9..f5503e12e 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -13,6 +13,7 @@ This reference manual details functions, modules, methods and attributes include AeroSurface Classes classes/Components classes/Rocket + classes/PointMassRocket classes/Parachute classes/sensors/index.rst classes/Flight diff --git a/docs/static/rocket/aeroframe.png b/docs/static/rocket/aeroframe.png index c48c86e1a..235c15eb9 100644 Binary files a/docs/static/rocket/aeroframe.png and b/docs/static/rocket/aeroframe.png differ diff --git a/docs/static/rocket/cal-per-length.png b/docs/static/rocket/cal-per-length.png new file mode 100644 index 000000000..1d3a66ab3 Binary files /dev/null and b/docs/static/rocket/cal-per-length.png differ diff --git a/docs/static/rocket/damped-oscillation.png b/docs/static/rocket/damped-oscillation.png new file mode 100644 index 000000000..80acab979 Binary files /dev/null and b/docs/static/rocket/damped-oscillation.png differ diff --git a/docs/static/rocket/stable-unstable.png b/docs/static/rocket/stable-unstable.png new file mode 100644 index 000000000..7757d1399 Binary files /dev/null and b/docs/static/rocket/stable-unstable.png differ diff --git a/docs/technical/aerodynamics/elliptical_fins.rst b/docs/technical/aerodynamics/elliptical_fins.rst index 5ff5c4ee9..d856c60d3 100644 --- a/docs/technical/aerodynamics/elliptical_fins.rst +++ b/docs/technical/aerodynamics/elliptical_fins.rst @@ -2,13 +2,6 @@ Elliptical Fins Equations ========================= -:Author: Mateus Stano Junqueira, -:Author: Franz Masatoshi Yuri, -:Author: Kaleb Ramos Wanderley Santos, -:Author: Matheus Gonçalvez Doretto, -:Date: February 2022 - - Nomenclature ============ diff --git a/docs/technical/aerodynamics/individual_fins.rst b/docs/technical/aerodynamics/individual_fins.rst index 408352e16..d8cb7a100 100644 --- a/docs/technical/aerodynamics/individual_fins.rst +++ b/docs/technical/aerodynamics/individual_fins.rst @@ -4,9 +4,6 @@ Individual Fin Model ==================== -:Author: Mateus Stano Junqueira -:Date: March 2025 - Introduction ============ @@ -491,8 +488,3 @@ rocket: # Angle of sideslip test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) - - - - - diff --git a/docs/technical/aerodynamics/roll_equations.rst b/docs/technical/aerodynamics/roll_equations.rst index 8ad106b60..394d6ea02 100644 --- a/docs/technical/aerodynamics/roll_equations.rst +++ b/docs/technical/aerodynamics/roll_equations.rst @@ -2,10 +2,6 @@ Roll equations for high-powered rockets ======================================= -:Author: Bruno Abdulklech Sorban, -:Author: Mateus Stano Junqueira -:Date: February 2022 - Nomenclature ============ @@ -236,25 +232,3 @@ For the damping moment lift coefficient derivative: .. math:: (C_{lf\delta})_{K_{f}} = K_{f} \cdot C_{lf\delta} .. math:: (C_{ld\omega})_{K_{d}} = K_{d} \cdot C_{ld\omega} - -Comments -======== - -Roll moment is expected to increase linearly with velocity. This -relationship can be verified in the rotation frequency equilibrium -equation, described by [Niskanen]_ in equation -(3.73), and again stated below: - -.. math:: f_{eq} = \frac{\omega}{2\pi} = \frac{A_{ref}\beta \overline{Y_t} (C_{N\alpha})_1 }{4\pi^2 \sum_{i} c_i \xi^2 \Delta \xi} \, \delta V_0 - -The auxiliary value :math:`\beta` is defined as: -:math:`\beta = \sqrt{|1-M|}`, where M is the speed of the rocket in -Mach. - -.. .. math:: k = 1 + \frac{\frac{\sqrt{s^2-r_{t}^2}\Bigl(2C_{r}r_{t}^2\ln\Bigl(\frac{2s\sqrt{s^2-r_{t}^2}+2s^2}{r_{t}}\Bigr)-2C_{r}r_{t}^2\ln\Bigl(2s\Bigr)\Bigr)+2C_{r}s^3-{\pi}C_{r}r_{t}s^2-2C_{r}r_{t}^2s+{\pi}C_{r}r_{t}^3}{2r_{t}s^3-2r_{t}^3s}}{C_{r}\cdot\Bigl(\dfrac{s^2}{3}+\dfrac{{\pi}r_{t}s}{4}\Bigr)} - -.. .. math:: - -.. k = 1 + \frac{\sqrt{s^2-r_{t}^2}\Bigl(2r_{t}^2\ln\Bigl(\frac{2s\sqrt{s^2-r_{t}^2}+2s^2}{r_{t}}\Bigr)-2r_{t}^2\ln\Bigl(2s\Bigr)\Bigr)+2s^3-{\pi}r_{t}s^2-2r_{t}^2s+{\pi}r_{t}^3} -.. {(2r_{t}s^3-2r_{t}^3s) \cdot\Bigl(\dfrac{s^2}{3}+\dfrac{{\pi}r_{t}s}{4}\Bigr)} - diff --git a/docs/user/center_of_pressure_and_stability.rst b/docs/user/center_of_pressure_and_stability.rst new file mode 100644 index 000000000..69962b581 --- /dev/null +++ b/docs/user/center_of_pressure_and_stability.rst @@ -0,0 +1,1064 @@ +.. _aero_cp_stability: + +================================ +Center of Pressure and Stability +================================ + +Introduction +============ + +Stability is a central consideration in rocket design. Two rules of thumb are +widely repeated in amateur and student rocketry: the center of pressure must be +behind the center of gravity, and a static margin of one to two calibers is +generally desirable. This document explains the reasoning behind those rules +and distinguishes between three related but distinct quantities: **static +margin**, **stability margin**, and **dynamic stability**. + +The presentation proceeds from physical principles to the exact definitions and +formulas used by RocketPy, illustrated throughout with the **Calisto** reference +rocket, introduced in the :ref:`firstsimulation` guide. + +.. contents:: On this page + :local: + :depth: 2 + +.. jupyter-execute:: + :hide-code: + :hide-output: + + # The Calisto reference rocket from the First Simulation guide, reused for + # every worked example below. See the firstsimulation guide for the full + # build. IMPORTANT: adjust the data paths below to match your own system. + from rocketpy import Environment, SolidMotor, Rocket, Flight + + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + env.set_atmospheric_model(type="standard_atmosphere") + + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + + rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(Pro75M1670, position=-1.255) + rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + test_flight = Flight( + rocket=rocket, environment=env, rail_length=5.2, inclination=85, heading=0 + ) + + +Part 1: Physical principles +============================ + +Static stability +----------------- + +.. admonition:: Static stability + :class: note + + A rocket is **statically stable** if, when a disturbance pushes its nose + away from the flight direction, the aerodynamic forces generate a + restoring moment that returns it toward that direction. + + An **unstable** rocket exhibits the opposite behavior: a disturbance is + never corrected, and the rocket tumbles. + +Whether a restoring moment exists depends on the relative position of two +points along the rocket's axis: the **center of mass** and the **center of +pressure**: + +- **Center of mass (CM), also called center of gravity (CG).** The point about + which the rocket's mass is balanced, and about which the rocket rotates in + flight. In RocketPy this quantity is ``Rocket.center_of_mass``, a function of + time since propellant consumption shifts it. + +- **Center of pressure (CP).** The point at which the net aerodynamic force can + be considered to act. At a small angle to the airflow, a sideways ("normal") + force is generated, and the CP is its effective point of application. In + RocketPy this quantity is ``Rocket.cp_position``, an alias of + ``Rocket.aerodynamic_center``. + +The relative position of these two points determines stability: + +.. admonition:: The stability rule + :class: important + + If the **center of pressure is located behind the center of mass** (toward + the tail), the rocket is stable. + + If the center of pressure is located ahead of the center of mass the rocket + is unstable. + +.. figure:: ../../static/rocket/stable-unstable.png + :align: center + :width: 80% + + The aerodynamic force acting at the CP creates a torque about the CM. + When the CP is aft of the CM the torque is restoring (left); when the CP + is forward of the CM the torque grows the disturbance (right). + +.. Role of the fins +.. ----------------- + +.. Fins are the primary means of shifting the CP toward the tail. As large +.. lifting surfaces positioned far aft, they move the rocket's aggregate center +.. of pressure behind the center of mass, establishing the restoring lever arm. +.. Nose cones and outward-flaring transitions tend to move the CP forward and are +.. destabilizing in isolation; the fins must more than compensate for this +.. effect. + +.. Increasing fin size, moving the fins aft, or adding mass to the nose (which +.. moves the CM forward) are the standard corrective measures for an unstable +.. design, since each increases the distance by which the CP trails the CM. + + +Part 2: Static margin +===================== + +Definition +---------- + +.. admonition:: Static margin + :class: note + + The **static margin** is the distance between the **CM** and the **CP**, + expressed in **calibers**, where one caliber equals the body diameter. + + .. math:: + :label: static_margin_intro + + \text{static margin} = + \frac{(\text{CP position}) - (\text{CM position})}{\text{diameter}} + \;\;[\text{calibers}] + + - A **positive** static margin indicates that the CP is behind the CM, and + therefore that the rocket is stable. A margin of two calibers indicates + that the two points are separated by two body diameters. + - A **negative** static margin indicates that the CP is ahead of the CM, and + therefore that the rocket is unstable. + + The diameter is used as the reference length because it is the natural + length scale of the aerodynamics: the normal force generated by a body + scales with its cross-sectional area. This convention is widely used in + rocketry. + +Here is a plot of the static margin of the Calisto rocket over time. The +variation of the static margin here is due to the forward shift of the center of +mass as the motor burns and propellant is consumed. The right-hand axis +expresses the same margin as a percentage of body length (discussed in +:ref:`percent_of_length`): + +.. jupyter-execute:: + + rocket.plots.static_margin() + +Recommended margin range +------------------------ + +The following ranges are widely used design rules of thumb, not precise or +authoritative thresholds: + +- **Below approximately 1 caliber:** marginal. Manufacturing tolerances, added + nose-cone mass, or a shifted CG can be enough to make the rocket unstable. +- **Approximately 1 to 2 calibers:** the standard target range for most + rockets, and the most common recommendation in hobby and student rocketry. +- **Approximately 2 to 3 calibers:** another common target range for high-power + rockets, providing extra margin for the CG shift that occurs as the motor + burns. +- **Above approximately 4 calibers (over-stable):** also undesirable, for the + reasons described below. + +**These figures are rules of thumb, not objective truths**: the transition +between ranges is gradual and depends on the specific rocket, so a margin +just outside one of them does not mean the rocket will not fly safely. Falling +within a target range also does not by itself guarantee good flight behavior. + +.. admonition:: Excessive static margin + :class: warning + + An over-stable rocket has a strong restoring moment, so in a crosswind it + turns into the wind and drifts further downwind ("weathercocking"). Aim for + enough margin to keep the rocket reliably stable, rather than the largest + margin achievable. The flight studies in :ref:`stability_in_flight` examine + how much this actually affects a real flight, and the + :ref:`practical studies ` show that the altitude usually + blamed on over-stability is really the cost of the added nose weight. + +.. _percent_of_length: + +Margin as a percentage of length +-------------------------------- + +An alternative convention expresses the margin as a **percentage of the +rocket's overall length** rather than in calibers: + +.. math:: + + \text{margin (\% of length)} = + \frac{(\text{CP position}) - (\text{CM position})}{\text{body length}} + \times 100 + +.. figure:: ../../static/rocket/cal-per-length.png + :align: center + :width: 80% + + The same CM-to-CP distance expressed against two reference lengths: the + body diameter (one caliber) and the overall body length. Both numbers + describe the identical physical gap. + +This measures the identical physical distance as the caliber margin, +normalized by the total length instead of the diameter, as illustrated +above. RocketPy exposes the overall length as +:attr:`rocketpy.Rocket.length`, defined as the axial span from the nose tip +to the aft-most point of the rocket, whether that is an aerodynamic surface +or the motor nozzle if it extends further aft. + +For Calisto (from :ref:`firstsimulation`), with a length of **2.53 m** and a +fineness ratio of approximately 20, the two conventions yield: + +.. list-table:: + :header-rows: 1 + :widths: 40 30 30 + + * - Condition + - Calibers + - % of length + * - Lift-off (``t = 0``) + - ``2.20 c`` + - ``11.0 %`` + * - Burnout (``t = 3.9 s``) + - ``3.11 c`` + - ``15.6 %`` + +As a rule of thumb, a percent-of-length margin of roughly **8 to 15%** is a +commonly cited target, with the lower bound the firmer of the two. For a +typical rocket with a fineness ratio near 10, one caliber is about 10% of the +length, so this range corresponds to the familiar 1 to 2 calibers. The two +conventions only diverge for unusually short or slender rockets, which is +precisely where the percentage form is meant to help. Like the caliber ranges, +these are **informal guidelines rather than authoritative thresholds**. + +.. admonition:: Why express margin as a percent of length? + :class: note + + The percent-of-length convention fixes a **gap in the caliber measure**: the + caliber says nothing about the rocket's overall length. Two rockets with + a static margin of 2 calibers, one short and one long and slender, don't + necessarily behave the same in flight. + + The slender rocket has + **substantially greater** rotational inertia (which scales with length + squared) and a longer aerodynamic damping arm. Expressing the margin as + a fraction of length scales the required + physical margin with rocket size. This is a rough correction for + slenderness. + + +.. _stability_margin_part: + +Part 3: Stability margin +======================== + +The **stability margin** is the same center-of-mass-to-center-of-pressure +distance, in the same calibers, but with the center of pressure taken at the +rocket's **actual flight condition**, its Mach number and angle of attack, +rather than at rest. + +Writing :math:`z_\text{cm}(t)` for the center of mass, :math:`2R` for the body +diameter (one caliber), the two margins have the same form and differ only in +the center-of-pressure reference they subtract: + +.. math:: + + \text{static margin}(t) = c\,\frac{z_\text{cm}(t) - x_\text{AC}(0)}{2R}, + \qquad + \text{stability margin}(\alpha, M, t) + = c\,\frac{z_\text{cm}(t) - x_\text{NP}(\alpha, M)}{2R}. + +- The static margin uses the **aerodynamic center** :math:`x_\text{AC}(0)`: the + center of pressure linearized about zero angle of attack and evaluated at zero + airspeed (:math:`M = 0`). It is a fixed reference, so the static margin varies + only through the center of mass, that is, with time. +- The stability margin uses the **neutral point** :math:`x_\text{NP}(\alpha, M)`, + the local center of pressure at the actual Mach number and angle of attack. + Because that reference moves with the flow, the stability margin depends on + angle of attack and Mach number as well as time. + +The static margin and the stability margin are the same underlying +quantity, evaluated under different +conditions: + +.. list-table:: + :header-rows: 1 + :widths: 24 24 26 + + * - + - **Static margin** + - **Stability margin** + * - Depends on + - Time only + - Angle of attack, Mach number **and** time + * - Evaluated at + - Zero incidence, zero airspeed (``M = 0``) + - *Any* angle of attack and Mach number (an aerodynamic map) + * - Answers + - Stability at rest + - Stability at any chosen flow state + * - RocketPy + - ``Rocket.static_margin`` (function of ``t``) + - ``Rocket.stability_margin`` (function of ``alpha, M, t``) + +The margin varies for up to three independent reasons: + +**1. Center-of-mass displacement (time).** As the motor consumes propellant the +CM moves. This changes the CM-to-CP distance. + +**2. Center-of-pressure displacement (Mach number).** Aerodynamic surfaces +effectivenss (e.g. fins) varies with speed, causing the CP +to shift with Mach number. + +**3. Center-of-pressure displacement (angle of attack).** For a rocket +carrying a surface that generates lift nonlinearly with incidence, the center +of pressure also migrates with the angle of attack. RocketPy's pre-set +geometric surfaces (``NoseCone``, ``TrapezoidalFins``, +``EllipticalFins``, ``FreeFormFins``, ``Tail``) are all linear in +incidence, so a rocket built only from them never sees this effect. It shows up +when a surface is added as a :class:`rocketpy.GenericSurface` with a nonlinear +coefficient, for example a Galejs body-lift term. + +The flight stability margin +---------------------------- + +``Flight.stability_margin`` samples the rocket's ``stability_margin`` map at +the angle of attack, Mach number and time realized during the simulated flight: + +.. jupyter-execute:: + + test_flight.prints.stability_margin() + +``Flight.plots.stability_margin_data()`` plots this curve for the pitch and yaw +planes, with a percent-of-length axis on the right: + +.. jupyter-execute:: + + test_flight.plots.stability_margin_data() + +.. tip:: + + The **out-of-rail stability margin** is frequently the most significant + single value: it is the margin at the instant of rail departure, when the + rocket is slowest and most susceptible to wind disturbance. A recommended + design practice is to ensure this value is suficcient. The out-of-rail + instant is examined in detail in :ref:`stability_in_flight`. + + +.. _part_dynamic: + +Part 4: Dynamic stability +========================= + +The static margin has a fundamental limitation: it indicates only the *sign* +of the restoring moment, not the rocket's actual dynamic behavior. A positive +margin guarantees the existence of a restoring tendency but provides no +information regarding the rate of return, the presence of overshoot, or +whether resulting oscillations decay or persist. These characteristics are +described by **dynamic stability**. + +.. jupyter-execute:: + :hide-code: + :hide-output: + + import numpy as np + import matplotlib.pyplot as plt + + def build_rocket(center_of_mass_without_motor=0.0, lateral_inertia=6.321, + mass=14.426, motor=Pro75M1670): + """Calisto with four adjustable parameters. Shifting the dry center of + mass toward the nose (positive) raises the static margin; toward the tail + (negative) lowers it. The lateral moment of inertia is adjustable + separately, which changes the dynamic response without changing the + static margin. The dry mass (in kilograms) and the motor can also be + swapped, for the mass and thrust studies later in this document. + Everything else, including the aerodynamics, is fixed. + """ + rocket = Rocket( + radius=127 / 2000, + mass=mass, + inertia=(lateral_inertia, lateral_inertia, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=center_of_mass_without_motor, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(motor, position=-1.255) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.060, span=0.110, + cant_angle=0.0, position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + return rocket + + def windy_site(wind_speed): + """A standard atmosphere with a constant eastward crosswind, in m/s.""" + site = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + site.set_atmospheric_model( + type="custom_atmosphere", wind_u=wind_speed, wind_v=0 + ) + return site + +The attitude oscillator +----------------------- + +A stable rocket disturbed by angle :math:`\theta` behaves as a damped +spring-mass oscillator: + +.. math:: + + I_L\,\ddot\theta + C_2\,\dot\theta + C_1\,\theta = 0 + +with three governing parameters: + +- :math:`C_1`, the **corrective (restoring) moment coefficient**, analogous + to a spring constant. It is proportional to the static margin: + :math:`C_1 = \bar q\, A\, C_{N,\alpha}\, (z_\text{cm} - x_\text{cp})`, where + :math:`\bar q` is the dynamic pressure. A larger margin or higher airspeed + produces a stiffer restoring moment. +- :math:`C_2`, the **damping moment coefficient**, analogous to a damping + coefficient. It arises from aerodynamic resistance of the fins to rotation, + together with **jet damping** resulting from mass ejection through the + nozzle. +- :math:`I_L`, the **lateral moment of inertia** about the center of mass, + representing the rotational inertia opposing angular acceleration. + +These parameters determine the two quantities that characterize the dynamic +response: + +.. math:: + + \omega_n = \sqrt{\frac{C_1}{I_L}} + \quad(\text{natural frequency}), + \qquad + \zeta = \frac{C_2}{2\sqrt{C_1\,I_L}} + \quad(\text{damping ratio}). + +- The **natural frequency** :math:`\omega_n` sets the oscillation rate, in + rad/s (divide by :math:`2\pi` for Hz), and increases with static margin and + airspeed. +- The **damping ratio** :math:`\zeta` sets how quickly the oscillation + decays. Rockets are normally *underdamped* (:math:`\zeta < 1`), oscillating + with the amplitude shrinking over several cycles. + +.. figure:: ../../static/rocket/damped-oscillation.png + :align: center + :width: 90% + + Attitude response to a single disturbance. The spacing between peaks is set + by the natural frequency; the rate at which the peaks shrink toward zero, the + dashed envelope, is set by the damping ratio. A larger damping ratio decays + faster for the same natural frequency. + +RocketPy exposes each of these quantities on the ``Flight`` object: +``corrective_moment_coefficient`` (:math:`C_1`), +``damping_moment_coefficient`` (:math:`C_2`), +``pitch_natural_frequency``, ``pitch_damping_ratio``, and the corresponding +``yaw_*`` quantities. ``Flight.prints.dynamic_stability()`` summarizes them at +the key ascent instants, rail departure and burnout, together with the roll +rate at burnout: + +.. jupyter-execute:: + + test_flight.prints.dynamic_stability() + +``Flight.plots.dynamic_stability_data()`` plots natural frequency and damping +ratio as functions of time, with roll rate overlaid. + +.. jupyter-execute:: + + test_flight.plots.dynamic_stability_data() + +.. _stability_in_flight: + +Part 5: Stability in a real flight +================================== + +Parts 1 to 4 defined each stability quantity on a single nominal flight. This +part watches those quantities at work: the wind disturbance at rail exit, the +margin and damping correcting it, and how stable the rocket stays across the +spread of conditions a real launch day brings. + +.. admonition:: The out-of-rail instant + :class: note + + Rail departure is the most critical moment of a rocket's flight. The rail + buttons hold its orientation until the last one clears the rail. From then + on, aerodynamics takes over, right as the rocket is at its slowest and + least able to resist a disturbance. + +Stall is not modeled +-------------------- + +**RocketPy does not model stall.** On a real fin, lift grows with angle of +attack only up to a point. Past roughly 10 to 15 degrees the flow +**separates** from the surface, the lift collapses, and the drag climbs +sharply: the fin *stalls*. A stalled fin **stops correcting**, and the rocket +can tumble out of controlled flight. + +RocketPy's Barrowman fins never do this. A fin takes its lift *slope* at zero +angle of attack and applies that **same slope at every angle**, so its normal +force keeps growing without limit: + +.. jupyter-execute:: + :hide-code: + + # Left: the measured NACA 0012 lift curve (the file stores angle in radians). + # Right: the RocketPy fin built from that same airfoil. RocketPy keeps only + # the curve's slope at 0 degrees, so the fin's coefficient never stalls. + airfoil = np.loadtxt("../data/airfoils/NACA0012-radians.txt", delimiter=",") + aoa_deg, cl = np.degrees(airfoil[:, 0]), airfoil[:, 1] + + fin = build_rocket().fins[0] + clalpha = fin.clalpha(0.1) # normal-force slope at low Mach + aoa = np.linspace(0, 15, 200) + cn_fin = clalpha * np.radians(aoa) + peak = np.argmax(cl[aoa_deg <= 20]) # airfoil peak, just before stall + + fig, (axL, axR) = plt.subplots(1, 2, figsize=(10, 3.8)) + + shown = aoa_deg <= 15 + axL.plot(aoa_deg[shown], cl[shown], "-o", color="#c0392b", lw=2, ms=4) + axL.annotate("stall", xy=(aoa_deg[peak], cl[peak]), + xytext=(aoa_deg[peak] + 1.5, cl[peak] + 0.03), + fontsize=11, fontweight="bold", color="#c0392b", + arrowprops=dict(arrowstyle="->", color="#c0392b")) + axL.set_title("Real airfoil (NACA 0012 data)", fontweight="bold") + axL.set_ylabel(r"lift coefficient $C_L$") + + axR.plot(aoa, cn_fin, color="#2980b9", lw=2.4) + axR.text(0.05, 0.9, "linear: never stalls", transform=axR.transAxes, + fontsize=11, fontweight="bold", color="#2980b9") + axR.set_title("RocketPy fin (Barrowman)", fontweight="bold") + axR.set_ylabel(r"normal-force coefficient $C_N$") + + for ax in (axL, axR): + ax.set_xlabel("angle of attack (deg)") + ax.set_xlim(0, 15) + ax.set_ylim(0, 0.9) + ax.grid(alpha=0.25) + ax.spines[["top", "right"]].set_visible(False) + fig.tight_layout() + plt.show() + +The airfoil on the left carries the whole story of the flow: lift rises, +**peaks near 9 degrees, then falls off a cliff** as the flow separates. The +RocketPy fin on the right, built from that very airfoil, **keeps the same +initial slope forever**. + +So **a high computed rail-exit angle of attack marks a failure, not a +survivable condition.** The simulation shows the rocket swinging back into +line even past the angle where a real fin would have stalled. Keep the +out-of-rail velocity high relative to the wind and the rocket stays below the +stall range; the recovery the simulation shows past it **would not happen in +reality**. In the sweeps below, read a large computed angle of attack as a +warning sign, not a number the simulation can be trusted to reproduce. + +To actually simulate stall, or any other measured nonlinear aerodynamics, +provide the coefficients directly with a generic surface (see +:ref:`genericsurfaces`). + +The angle of attack at rail exit +-------------------------------- + +At rail departure the body points along the rail. But the air it meets is the +vector sum of the rocket's own velocity and the wind. That gives an angle of +attack of approximately + +.. math:: + + \alpha_\text{exit} \approx \arctan\!\left(\frac{V_\text{wind}}{V_\text{exit}}\right), + +where :math:`V_\text{wind}` is the crosswind and :math:`V_\text{exit}` is the +out-of-rail velocity. Only their *ratio* matters: a stronger wind and a +slower exit velocity push the angle up the same way. The flight below, the +nominal Calisto in an 8 m/s crosswind, checks the estimate against a real +simulation: + +.. jupyter-execute:: + + import numpy as np + + windy_flight = Flight( + rocket=build_rocket(), environment=windy_site(5), + rail_length=5.2, inclination=85, heading=0, terminate_on_apogee=True, + ) + + v_exit = windy_flight.out_of_rail_velocity + aoa_exit = windy_flight.angle_of_attack(windy_flight.out_of_rail_time) + estimate = np.degrees(np.arctan(5 / v_exit)) + + print(f"out-of-rail velocity : {v_exit:5.1f} m/s") + print(f"rail-exit AoA : {aoa_exit:5.2f} deg (simulated)") + print(f"arctan(wind / V_exit): {estimate:5.2f} deg (geometric estimate)") + +The simulated angle tracks the arctangent estimate closely. This is the +disturbance set the instant the rail lets go, and the rest of the flight has +to correct it. Keeping it well below the stall range of the fins is the +first stability requirement of any launch. + +The disturbance, corrected +-------------------------- + +The damped oscillation from :ref:`part_dynamic` can be represented in a flight +simulation. Tracking the angle of +attack after rail exit shows the rocket swinging back toward the relative +wind, and the swing dying away: + +.. jupyter-execute:: + + t0 = windy_flight.out_of_rail_time + t = np.linspace(t0, t0 + 4, 400) + aoa = [windy_flight.angle_of_attack(ti) for ti in t] + + fig, ax = plt.subplots(figsize=(8, 4)) + ax.plot(t, aoa) + ax.axvline(t0, color="0.6", ls="--", lw=1, label="rail exit") + ax.set_xlabel("Time (s)") + ax.set_ylabel("Angle of attack (deg)") + ax.set_title("Response to the rail-exit disturbance (8 m/s crosswind)") + ax.legend() + ax.grid(True) + plt.show() + +Every stability quantity from earlier parts shows up here. The rocket returns +toward zero at all because its **stability margin** +(:ref:`stability_margin_part`) is positive. The center of pressure sits +behind the center of mass, so the aerodynamic force restores rather than +diverges. The *rate* of the wobble is the **natural frequency**. The *speed* +it settles at is the **damping ratio** (:ref:`part_dynamic`). +``windy_flight.prints.dynamic_stability()`` reports both for this flight. A +positive margin only guarantees the curve trends back to zero. It says +nothing about how fast or how smoothly, which is exactly the distinction +Part 4 draws. Here the angle of attack swings through several cycles before it +settles, a sign that this rocket is only lightly damped. It recovers either +way, but for a cleaner flight, one that settles after an overshoot or two, it +could do with more damping. + +Stability across a launch day +----------------------------- + +**A single nominal flight is not what a launch actually delivers.** The wind +shifts from minute to minute. The finished mass differs from the design +value. The center of mass is never exactly where the drawing puts it. That +spread of conditions moves two quantities that matter at rail exit: the +stability margin and the angle of attack. + +RocketPy's Monte Carlo tooling measures exactly that. The ``Stochastic*`` +classes wrap the nominal environment, rocket, motor and flight, and attach a +spread to each uncertain input. :class:`rocketpy.MonteCarlo` then runs the +flight many times, each with a fresh draw, and saves the results. Here the +balance (dry mass and center of mass), the motor's total impulse and burn +time, and the crosswind are dispersed: + +.. code-block:: python + + from rocketpy import MonteCarlo, NoseCone, TrapezoidalFins, Tail + from rocketpy.stochastic import ( + StochasticEnvironment, + StochasticRocket, + StochasticSolidMotor, + StochasticFlight, + StochasticNoseCone, + StochasticTrapezoidalFins, + StochasticTail, + ) + + # Environment: 3 m/s mean crosswind, scaled by a normal factor so the wind + # spans roughly 3 +/- 2.5 m/s from flight to flight. + stochastic_env = StochasticEnvironment( + environment=windy_site(3), + wind_velocity_x_factor=(1.0, 0.42, "normal"), + ) + + # Motor: total impulse and burn time vary together, the way two motors from + # the same production lot differ. Roughly +/- 3%, a typical manufacturing + # tolerance; reshapes the whole thrust curve to match each draw. + stochastic_motor = StochasticSolidMotor( + solid_motor=Pro75M1670, + total_impulse=(6026, 180, "normal"), # newton-seconds + burn_out_time=(3.9, 0.12, "normal"), # seconds + ) + + # Rocket: same Calisto, but the dry mass and the balance point vary too. + # The surfaces are added back with no spread (fixed geometry). + stochastic_rocket = StochasticRocket( + rocket=rocket, + mass=(14.426, 0.4, "normal"), # dry mass, kg + center_of_mass_without_motor=(0.0, 0.03, "normal"), # balance, m + ) + stochastic_rocket.add_motor(stochastic_motor, position=(-1.255, 0)) + fixed_surface = { + NoseCone: (StochasticNoseCone, stochastic_rocket.add_nose), + TrapezoidalFins: (StochasticTrapezoidalFins, + stochastic_rocket.add_trapezoidal_fins), + Tail: (StochasticTail, stochastic_rocket.add_tail), + } + # Re-add each surface with no spread; a (value, 0) position means "fixed". + for surface, position in rocket.aerodynamic_surfaces: + stochastic_surface, add = fixed_surface[type(surface)] + add(stochastic_surface(surface), (position.z, 0)) + + # Flight: fixed rail and launch angles; stop each run at apogee to save time. + base_flight = Flight( + rocket=rocket, environment=windy_site(5), + rail_length=5.2, inclination=85, heading=0, terminate_on_apogee=True, + ) + stochastic_flight = StochasticFlight(flight=base_flight, terminate_on_apogee=True) + + # A data_collector callback receives each finished flight and returns a value, + # here the rail-exit angle of attack (not one of the standard exports). + analysis = MonteCarlo( + filename="../data/monte_carlo/stability_dispersion", + environment=stochastic_env, + rocket=stochastic_rocket, + flight=stochastic_flight, + data_collector={ + "rail_exit_aoa": lambda f: f.angle_of_attack(f.out_of_rail_time), + }, + ) + analysis.simulate(number_of_simulations=100, include_function_data=False) + + analysis.set_results() + margins = np.array(analysis.results["out_of_rail_stability_margin"]) + aoa_exits = np.array(analysis.results["rail_exit_aoa"]) + +.. jupyter-execute:: + :hide-code: + :hide-output: + + # The docs build does not run the Monte Carlo above; it loads the committed + # output below instead. Regenerate it by running the code above (which writes + # to the same path) after changing any distribution. + import json + + results_file = "../data/monte_carlo/stability_dispersion.outputs.txt" + with open(results_file, encoding="utf-8") as f: + records = [json.loads(line) for line in f] + margins = np.array([r["out_of_rail_stability_margin"] for r in records]) + aoa_exits = np.array([r["rail_exit_aoa"] for r in records]) + +The two quantities are now distributions over a simulated launch day: + +.. jupyter-execute:: + + fig, (axm, axa) = plt.subplots(1, 2, figsize=(10, 4)) + axm.hist(margins, bins=20, color="#4c72b0") + axm.axvspan(2.0, 3.0, color="green", alpha=0.12, label="2-3 cal target") + axm.set_xlabel("Out-of-rail stability margin (cal)") + axm.set_ylabel("Simulations") + axm.legend() + axa.hist(aoa_exits, bins=20, color="#c44e52") + axa.axvline(10, color="k", ls="--", label="stall onset (~10 deg)") + axa.set_xlabel("Rail-exit angle of attack (deg)") + axa.legend() + fig.suptitle(f"Launch-day dispersion ({margins.size} simulations)") + fig.tight_layout() + plt.show() + + print(f"out-of-rail margin : {np.percentile(margins, 5):.2f} to " + f"{np.percentile(margins, 95):.2f} cal (5th-95th percentile)") + print(f"rail-exit AoA : 95th pct {np.percentile(aoa_exits, 95):.1f} deg, " + f"worst {aoa_exits.max():.1f} deg") + print(f"flights above stall: {100 * np.mean(aoa_exits > 10):.0f}%") + +This is a concrete deliverable of a stability analysis. Not a single +margin, but a *distribution* read against the design thresholds. If a meaningful +fraction of flights cross either line, the rail, the mass or the margin needs +another look before flying. + +.. seealso:: + + A full dispersion study varies every input, not just five, and runs the + flights in parallel. ``analysis.simulate(..., parallel=True)`` does that, + and the ``Stochastic*`` classes cover parachutes and rail buttons too. See + :ref:`stochastic_usage` for the class walkthrough, and :ref:`MRS` for + weighting a finished sample toward measured launch-day conditions. + +Part 6: How much does the static margin matter? +=============================================== + +The dispersion above shows a Calisto-class rocket that is comfortably stable, +and yet a great deal of design effort in rocketry goes into chasing static +margin. The simulation lets us weigh that margin against the other things a +builder can change, and see **how much it really decides**, following +Thomas Fetter's flight-data study *How Far Does a Rocket Turn Into the +Wind?* (NARCON-2024). + +A rocket launched straight up into a crosswind turns as it climbs, and by the +time the motor burns out its flight path has tilted some degrees away from +vertical. This tilt is the *turn*, and because the launch was vertical it is +**entirely the rocket's response to the wind**, the weathercocking that +stability is meant to hold in check. Sweeping each design parameter on its own across a +realistic range, with the others left at their nominal values, shows how much +each one moves the turn: + +.. jupyter-execute:: + + def turn_at_burnout(flight): + """Flight-path tilt away from vertical at motor burnout, in degrees.""" + return 90 - flight.path_angle(flight.rocket.motor.burn_out_time) + + def vertical_flight(rocket, wind, rail=5.2): + return Flight( + rocket=rocket, environment=windy_site(wind), + rail_length=rail, inclination=90, heading=0, terminate_on_apogee=True, + ) + + def sweep(values, make_flight): + flights = [make_flight(v) for v in values] + return np.array([turn_at_burnout(f) for f in flights]), flights + + # Each lever swept alone across a realistic range; others nominal, 5 m/s wind. + winds = np.linspace(0, 14, 9) # crosswind, m/s + rails = np.linspace(1.2, 9.0, 8) # rail length sets the exit velocity + cgs = np.linspace(-0.25, 0.9, 10) # sets the static margin + masses = np.linspace(9, 30, 8) # dry mass, kg + + turn_wind, _ = sweep(winds, lambda w: vertical_flight(build_rocket(), w)) + turn_rail, rail_f = sweep(rails, lambda r: vertical_flight(build_rocket(), 5, rail=r)) + turn_cg, cg_f = sweep(cgs, lambda c: vertical_flight(build_rocket(c), 5)) + turn_mass, _ = sweep(masses, lambda m: vertical_flight(build_rocket(mass=m), 5)) + + exit_v = np.array([f.out_of_rail_velocity for f in rail_f]) + margins = np.array([f.rocket.static_margin(0) for f in cg_f]) + + panels = [ + (winds, turn_wind, "crosswind (m/s)", "wind speed", "#c0392b"), + (exit_v, turn_rail, "exit velocity (m/s)", "exit velocity (rail length)", "#e67e22"), + (margins, turn_cg, "static margin (cal)", "static margin", "#2980b9"), + (masses, turn_mass, "dry mass (kg)", "mass", "#27ae60"), + ] + ymax = max(y.max() for _, y, *_ in panels) + + fig, axs = plt.subplots(2, 2, figsize=(9.5, 7), sharey=True) + for ax, (x, y, xlabel, title, color) in zip(axs.flat, panels): + ax.fill_between(x, 0, y, color=color, alpha=0.12) + ax.plot(x, y, "-o", color=color, lw=2.4, ms=6, mfc=color, mec="white", mew=0.8) + ax.annotate(f"swing {y[-1] - y[0]:+.1f}°", # signed: low end -> high end + xy=(0.04, 0.92), xycoords="axes fraction", ha="left", va="top", + fontsize=11, fontweight="bold", color=color) + ax.set_title(title, fontweight="bold") + ax.set_xlabel(xlabel) + ax.grid(True, alpha=0.3) + ax.set_ylim(0, ymax * 1.12) + axs[0, 0].set_ylabel("turn at burnout (deg)") + axs[1, 0].set_ylabel("turn at burnout (deg)") + fig.suptitle("What moves the turn into the wind? (each lever alone; others nominal, " + "5 m/s wind)", fontsize=12, fontweight="bold") + fig.tight_layout() + plt.show() + +Each panel is labeled with its *swing*, meaning how many degrees the turn +changes as that parameter goes from the low end of its range to the high end. + +The wind dominates, mass comes next, and a higher exit velocity has a +moderate effect the other way, lowering the turn. + +**The static margin is the weakest of the four**: across a change in margin +the turn barely moves, and it levels off at high margins, holding steady well +past the over-stable range. For a Calisto-class rocket the margin is simply +not what decides how far it weathercocks. A rocket that turns hard into the +wind is easy to **misjudge as over- or super-stable**. + +What static margin it does instead is *correction*. +Keeping the center of pressure behind the center of mass is what lets a +disturbance correct itself at all (:ref:`stability_margin_part` and +:ref:`part_dynamic`), and the rail-exit angle of attack still has to stay +below the stall range. Beyond that, **a larger margin does little for a +flight like this one**. + +Part 7: Pitch and yaw planes +============================ + +A rocket with evenly spaced fins, like Calisto, is **axisymmetric**. Its +geometry does not change under rotation about the body axis, so its +stability is the same in every plane, and a single margin describes it +fully. For these rockets, ``Rocket.is_axisymmetric`` returns ``True``, and +the rest of this section does not apply. + +Some configurations are **not** axisymmetric: canards on a single axis, +off-center payloads, fins arranged asymmetrically. For these, stability +differs between the **pitch** plane and the **yaw** plane, and RocketPy +computes each one independently: + +- pitch: ``aerodynamic_center``, ``static_margin``, ``stability_margin``; +- yaw: ``aerodynamic_center_yaw``, ``static_margin_yaw``, ``stability_margin_yaw``. + +For an axisymmetric rocket, the two planes coincide. When they do not, +RocketPy issues a warning, because the unqualified ``static_margin`` then +describes the pitch plane only. In that case, the plotting and print +methods report both planes. + +Helper code +=========== + +Every worked example on this page is built on the same Calisto reference +rocket and a handful of small helper functions. Most of that code runs behind +the scenes so the examples above can stay focused on one idea at a time. It is +gathered here so it can be read and reused. The data-file paths are written +relative to the RocketPy ``docs`` directory; adjust them to your own setup. + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + from rocketpy import Environment, SolidMotor, Rocket, Flight + + # The Calisto reference rocket from the First Simulation guide, reused for + # every worked example on this page. + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + env.set_atmospheric_model(type="standard_atmosphere") + + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + + rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(Pro75M1670, position=-1.255) + rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + test_flight = Flight( + rocket=rocket, environment=env, rail_length=5.2, inclination=85, heading=0 + ) + +The design studies build fresh rockets and windy environments through two +small factories: + +.. code-block:: python + + def build_rocket(center_of_mass_without_motor=0.0, lateral_inertia=6.321, + mass=14.426, motor=Pro75M1670): + """Calisto with four adjustable parameters. Shifting the dry center of + mass toward the nose (positive) raises the static margin; toward the tail + (negative) lowers it. The lateral moment of inertia is adjustable + separately, which changes the dynamic response without changing the + static margin. The dry mass (in kilograms) and the motor can also be + swapped, for the mass and thrust studies later in this document. + Everything else, including the aerodynamics, is fixed. + """ + rocket = Rocket( + radius=127 / 2000, + mass=mass, + inertia=(lateral_inertia, lateral_inertia, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=center_of_mass_without_motor, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(motor, position=-1.255) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.060, span=0.110, + cant_angle=0.0, position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + return rocket + + def windy_site(wind_speed): + """A standard atmosphere with a constant eastward crosswind, in m/s.""" + site = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + site.set_atmospheric_model( + type="custom_atmosphere", wind_u=wind_speed, wind_v=0 + ) + return site + +The three helpers that measure the turn and sweep one parameter at a time +(``turn_at_burnout``, ``vertical_flight`` and ``sweep``) are shown inline where +they are used, in Part 6 above. + diff --git a/docs/user/first_simulation.rst b/docs/user/first_simulation.rst index 18e4b9882..85b418705 100644 --- a/docs/user/first_simulation.rst +++ b/docs/user/first_simulation.rst @@ -310,6 +310,12 @@ We can then see if the rocket is stable by plotting the static margin: If it is unreasonably **high**, your rocket is **super stable** and the simulation will most likely **fail**. +.. seealso:: + + For a full treatment of static margin, stability margin and dynamic + stability, including what these numbers mean, what values to aim for, and + how they play out over a real flight, see :ref:`aero_cp_stability`. + To guarantee that the rocket is stable, the positions of all added components must be correct. The ``Rocket`` class can help you with the ``draw`` method: @@ -580,15 +586,21 @@ following method: test_flight.plots.fluid_mechanics_data() -Stability Margin and Frequency Response +Stability Margin and Dynamic Stability ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The Stability margin can be checked along with the frequency response of the -rocket: +The stability margin over the flight: + +.. jupyter-execute:: + + test_flight.plots.stability_margin_data() + +The dynamic-stability quantities (natural frequency, damping ratio and the +attitude frequency response): .. jupyter-execute:: - test_flight.plots.stability_and_control_data() + test_flight.plots.dynamic_stability_data() Visualizing the Trajectory in Google Earth diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 31e7ab588..a356c7dfe 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -474,7 +474,8 @@ Energy Analysis flight.plots.energy_data() # Stability analysis - flight.plots.stability_and_control_data() + flight.plots.stability_margin_data() + flight.plots.dynamic_stability_data() Comprehensive Analysis ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/index.rst b/docs/user/index.rst index c44527670..218fb9e4f 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -47,4 +47,5 @@ RocketPy's User Guide :caption: Further Analysis Function - Utilities \ No newline at end of file + Utilities + Center of Pressure and Stability \ No newline at end of file diff --git a/docs/user/rocket/generic_surface.rst b/docs/user/rocket/generic_surface.rst index 997f5a178..ffe210be8 100644 --- a/docs/user/rocket/generic_surface.rst +++ b/docs/user/rocket/generic_surface.rst @@ -3,7 +3,7 @@ Generic Surfaces and Custom Aerodynamic Coefficients ==================================================== -Generic aerodynamic surfaces can be used to model aerodynamic forces based on +Generic aerodynamic surfaces can be used to model aerodynamic forces based on force and moment coefficients. The :class:`rocketpy.GenericSurface` receives the coefficients as functions of the angle of attack, side slip angle, Mach number, Reynolds number, pitch rate, yaw rate, and roll rate. @@ -16,114 +16,140 @@ slip angle, Mach number, Reynolds number, pitch rate, yaw rate, and roll rate. These classes allows the user to be less dependent on the built-in aerodynamic surfaces and to define their own aerodynamic coefficients. -Both classes base their coefficient on the definition of the aerodynamic frame -of reference. +Wind Frame and Body Frame +------------------------- -Aerodynamic Frame ------------------ - -The aerodynamic frame of reference of the rocket is defined as follows: - -- The origin is at the rocket's center of dry mass (``center_of_dry_mass_position``). -- The ``z`` axis is defined along the rocket's centerline, pointing from the center of dry mass towards the nose. -- The ``x`` and ``y`` axes are perpendicular. -- The partial angle of attack (``alpha``) is defined as the angle, in the y-z - plane, from the velocity vector to the z axis. -- The partial side slip angle (``beta``) is defined as the angle, in the x-z - plane, from the velocity vector to the z axis. +A surface's aerodynamic force is the same physical vector written in two +coordinate frames: the **body frame**, fixed to the rocket, and the **wind +frame**, aligned with the airflow. You can provide the coefficients in either +frame; the two are related by the angle of attack and sideslip defined below. -The following figure shows the aerodynamic frame of reference: +The following figure shows the body frame (subscript :math:`B`) and the wind +frame (subscript :math:`W`): .. figure:: ../../static/rocket/aeroframe.png :align: center - :alt: Aerodynamic frame of reference + :alt: Wind frame of reference In the figure we define: - :math:`\mathbf{\vec{V}}` as rocket velocity vector. - :math:`x_B`, :math:`y_B`, and :math:`z_B` as the body axes. -- :math:`x_A`, :math:`y_A`, and :math:`z_A` as the aerodynamic axes. +- :math:`x_W`, :math:`y_W`, and :math:`z_W` as the wind-frame axes. - :math:`\alpha` as the partial angle of attack. - :math:`\beta` as the side slip angle. - :math:`L` as the lift force. - :math:`D` as the drag force. -- :math:`Q` as the side force. +- :math:`Q` as the wind frame side force. +- :math:`N` as the normal force. +- :math:`A` as the axial force. +- :math:`Y` as the body frame side force. + +Body frame +~~~~~~~~~~ + +The body frame is fixed to the rocket: + +- The origin is at the rocket's center of dry mass (``center_of_dry_mass_position``). +- The :math:`z_B` axis lies along the rocket's centerline, pointing from the center of dry mass towards the nose. +- The :math:`x_B` and :math:`y_B` axes are perpendicular to it. -Here we define the aerodynamic forces in the aerodynamic frame of reference as: +In this frame the aerodynamic force is made up of the normal force :math:`N`, +the side force :math:`Y` and the axial force :math:`A`. As in the wind frame, the +side force is the :math:`x_B` component, while the normal and axial forces enter +the :math:`y_B` and :math:`z_B` components with a negative sign: .. math:: - \vec{\mathbf{F}}_A=\begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_A=\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_A + \vec{\mathbf{F}}_B=\begin{bmatrix}X_B\\Y_B\\Z_B\end{bmatrix}_B=\begin{bmatrix}Y\\-N\\-A\end{bmatrix}_B + +Wind frame +~~~~~~~~~~ -The aerodynamic forces in the body axes coordinate system are defined as -:math:`\vec{\mathbf{F}}_B`. +The wind frame is aligned with the airflow: its :math:`z_W` axis runs along the +velocity vector. In this frame the aerodynamic force is the lift :math:`L`, the +drag :math:`D` and the (wind-frame) side force :math:`Q`: .. math:: - \vec{\mathbf{F}}_B=\begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_B=\mathbf{M}_{BA}\cdot\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_A + \vec{\mathbf{F}}_W=\begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix}_W=\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_W -Where the transformation matrix :math:`\mathbf{M}_{BA}`, which transforms the -aerodynamic forces from the aerodynamic frame of reference to the body axes -coordinate system, is defined as: +Relating the two frames +~~~~~~~~~~~~~~~~~~~~~~~~ + +The two are the same force, related by the angle-of-attack/sideslip rotation +:math:`\mathbf{M}_{BW}`, which transforms the wind frame into the body frame: .. math:: - \mathbf{M}_{BA} = \begin{bmatrix} + \vec{\mathbf{F}}_B=\mathbf{M}_{BW}\cdot\begin{bmatrix}Q\\-L\\-D\end{bmatrix}_W + +where + +.. math:: + \mathbf{M}_{BW} = \begin{bmatrix} 1 & 0 & 0 \\ - 0 & \cos(\alpha) & -\sin(\alpha) \\ - 0 & \sin(\alpha) & \cos(\alpha) + 0 & \cos(\alpha) & \sin(\alpha) \\ + 0 & -\sin(\alpha) & \cos(\alpha) \end{bmatrix} \begin{bmatrix} - \cos(\beta) & 0 & -\sin(\beta) \\ + \cos(\beta) & 0 & \sin(\beta) \\ 0 & 1 & 0 \\ - \sin(\beta) & 0 & \cos(\beta) + -\sin(\beta) & 0 & \cos(\beta) \end{bmatrix} +The force coefficients follow the same rotation. In the wind frame they are the +lift :math:`C_L`, side :math:`C_Q` and drag :math:`C_D`; in the body frame the +normal :math:`C_N`, side :math:`C_Y` and axial :math:`C_A`: -The forces coefficients can finally be defined as: +.. math:: + \begin{aligned} + C_N &= \cos\alpha\, C_L + \sin\alpha\,(\sin\beta\, C_Q + \cos\beta\, C_D) \\ + C_Y &= \cos\beta\, C_Q - \sin\beta\, C_D \\ + C_A &= -\sin\alpha\, C_L + \cos\alpha\,(\sin\beta\, C_Q + \cos\beta\, C_D) + \end{aligned} -- :math:`C_L` as the lift coefficient. -- :math:`C_Q` as the side force coefficient (or cross stream force coefficient). -- :math:`C_D` as the drag coefficient. +At small angles these reduce to :math:`C_N \approx C_L`, :math:`C_Y \approx C_Q` +and :math:`C_A \approx C_D`. -And the forces from the coefficients are defined as: +The force itself is recovered from the coefficients with the dynamic pressure +:math:`\bar q` and the reference area :math:`A_{ref}`. From **body-frame** +coefficients the force is obtained directly, with no rotation: .. math:: - \begin{bmatrix}X_A\\Y_A\\Z_A\end{bmatrix}_B =\mathbf{M}_{BA}\cdot\overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Q\\-C_L\\-C_D\end{bmatrix}_A + \vec{\mathbf{F}}_B =\begin{bmatrix}Y\\-N\\-A\end{bmatrix}_B= \overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Y\\-C_N\\-C_A\end{bmatrix}_B -Where: +while **wind-frame** coefficients are rotated into the body frame first: -- :math:`\bar{q}` is the dynamic pressure. -- :math:`A_{ref}` is the reference area used to calculate the coefficients. - Commonly the rocket's cross-sectional area is used as the reference area. +.. math:: + \vec{\mathbf{F}}_B =\mathbf{M}_{BW}\cdot\overline{q}\cdot A_{ref}\cdot\begin{bmatrix}C_Q\\-C_L\\-C_D\end{bmatrix}_W -The moment coefficients can be defined as: +where :math:`\bar{q}` is the dynamic pressure and :math:`A_{ref}` the reference +area (commonly the rocket's cross-sectional area). -- :math:`C_l` as the rolling moment coefficient. -- :math:`C_m` as the pitching moment coefficient. -- :math:`C_n` as the yawing moment coefficient. +Moments +~~~~~~~ -And the moments from the coefficients are defined as: +The moment coefficients are the same in both frames: the rolling moment +:math:`C_l`, the pitching moment :math:`C_m` and the yawing moment :math:`C_n`. +The moments about the body axes follow with the reference area and the reference +length :math:`L_{ref}`: .. math:: - \vec{\mathbf{M}}_B=\begin{bmatrix}M_{x_A}\\M_{y_A}\\M_{z_A}\end{bmatrix}_B =\overline{q}\cdot A_{ref}\cdot L_{ref}\cdot\begin{bmatrix}C_m\\C_n\\C_l\end{bmatrix} - -Where: + \vec{\mathbf{M}}_B=\begin{bmatrix}M_{x}\\M_{y}\\M_{z}\end{bmatrix}_B =\overline{q}\cdot A_{ref}\cdot L_{ref}\cdot\begin{bmatrix}C_m\\C_n\\C_l\end{bmatrix} -- :math:`L_{ref}` is the reference length used to calculate the coefficients. - Commonly the rocket's diameter is used as the reference length. +where :math:`L_{ref}` is the reference length (commonly the rocket's diameter). +Angles of attack and sideslip +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Aerodynamic angles -~~~~~~~~~~~~~~~~~~ +RocketPy uses the flow angles two ways: -The aerodynamic angles are defined in two different ways in RocketPy: +- the partial **angle of attack** :math:`\alpha` and **sideslip angle** + :math:`\beta` (shown in the figure above), used by the generic-surface forces + and moments; +- the **total angle of attack** :math:`\alpha_{\text{tot}}`, the angle between + the total velocity vector and the rocket's centerline, used by the standard + (Barrowman) surfaces. -- As the angle of attack (:math:`\alpha`) and the side slip \ - angle (:math:`\beta`), which are defined in the image above. These are used \ - in the calculation of the generic surface forces and moments. -- As the total angle of attack (:math:`\alpha_{\text{tot}}`), defined as the \ - angle between the total velocity vector and the rocket's centerline. This is \ - used in the calculation of the standard aerodynamic surface forces and moments. - -The partial angles are calculated as: +The partial angles are .. math:: \begin{aligned} @@ -131,101 +157,155 @@ The partial angles are calculated as: \beta &= \arctan\left(\frac{V_x}{V_z}\right) \end{aligned} -The total angle of attack is calculated as: +and the total angle of attack is .. math:: \alpha_{\text{tot}} = \arccos\left(\frac{\mathbf{\vec{V}}\cdot\mathbf{z_B}}{||\mathbf{\vec{V}}||\cdot||\mathbf{z_B}||}\right) .. note:: When the simulation is done, the total angle of attack is accessed through - the :attr:`rocketpy.Flight.angle_of_attack` attribute. - The partial angles of attack and side slip are accessed through the - :attr:`rocketpy.Flight.partial_angle_of_attack` and + the :attr:`rocketpy.Flight.angle_of_attack` attribute. The partial angles of + attack and sideslip are accessed through the + :attr:`rocketpy.Flight.partial_angle_of_attack` and :attr:`rocketpy.Flight.angle_of_sideslip` attributes, respectively. + .. _genericsurface: Generic Surface Class --------------------- -The :class:`rocketpy.GenericSurface` class is used to define an aerodynamic -surface based on force and moment coefficients. A generic surface is defined -as follows: +The :class:`rocketpy.GenericSurface` class defines an aerodynamic surface +directly from its force and moment coefficients. A surface is created by giving +it a reference area and length, the coefficients, and a few optional settings: .. seealso:: - For more information on class initialization, see - :class:`rocketpy.GenericSurface.__init__` + For more information on class initialization, see + :class:`rocketpy.GenericSurface.__init__` .. code-block:: python + import numpy as np from rocketpy import GenericSurface - + radius = 0.0635 - + generic_surface = GenericSurface( reference_area=np.pi * radius**2, reference_length=2 * radius, coefficients={ - "cL": "cL.csv", - "cQ": "cQ.csv", - "cD": "cD.csv", + "cN": "cN.csv", + "cY": "cY.csv", + "cA": "cA.csv", "cm": "cm.csv", "cn": "cn.csv", "cl": "cl.csv", }, + center_of_pressure=(0, 0, 0), name="Generic Surface", + reynolds_length=2 * radius, + interpolation="linear", + extrapolation="constant", + force_convention="body", + active_during="always", ) -The ``coefficients`` argument is a dictionary containing the coefficients of the -generic surface. The keys of the dictionary are the coefficient names, and the -values are the coefficients. The possible coefficient names are: - -- ``cL``: Lift coefficient. -- ``cQ``: Side force coefficient. -- ``cD``: Drag coefficient. +Constructor parameters +~~~~~~~~~~~~~~~~~~~~~~~~ + +:class:`rocketpy.GenericSurface` takes the following parameters: + +- ``reference_area`` (int or float): reference area used to non-dimensionalize + the coefficients, in :math:`m^2`. Commonly the rocket's cross-sectional area. +- ``reference_length`` (int or float): reference length, in meters, used to + non-dimensionalize the moment coefficients and the reduced rotation rates. + Commonly the rocket's diameter. +- ``coefficients`` (dict): the force and moment coefficients, by name (detailed + in `Coefficients`_ below). +- ``center_of_pressure`` (tuple, optional): the point where the surface's forces + and moments are applied, in the surface's local frame. Default ``(0, 0, 0)``. + See `Moment reference point`_. +- ``name`` (str, optional): a name for the surface. Default + ``"Generic Surface"``. +- ``reynolds_length`` (int or float, optional): length scale, in meters, of the + Reynolds number fed to the coefficients. Default ``None`` (uses + ``reference_length``). +- ``interpolation`` (str or dict, optional): how tabulated coefficients are + interpolated between their data points. Default ``None``. See + :ref:`generic_surface_interpolation`. +- ``extrapolation`` (str or dict, optional): how tabulated coefficients behave + outside their tabulated range. Default ``None``. See + :ref:`generic_surface_interpolation`. +- ``force_convention`` (str, optional): the frame the force coefficients are + given in, ``"body"`` or ``"wind"``. Default ``None`` (inferred from the + coefficient names). +- ``active_during`` (str or callable, optional): when the surface produces + aerodynamic force during the flight. Default ``"always"``. See + :ref:`active_during`. + +Coefficients +~~~~~~~~~~~~~ + +The ``coefficients`` argument is a dictionary mapping each coefficient's name to +its value. The body-frame coefficient names are: + +- ``cN``: Normal force coefficient (perpendicular to the body axis). +- ``cY``: Side force coefficient. +- ``cA``: Axial force coefficient (along the body axis). - ``cm``: Pitching moment coefficient. - ``cn``: Yawing moment coefficient. - ``cl``: Rolling moment coefficient. -Only one of the coefficients is required to be provided, but any combination of -the coefficients can be used. The coefficient values can be provided as a -single value, a callable function of seven arguments, or a path to a ``.csv`` -file containing the values. +Alternatively, you can supply the force coefficients in the **wind frame** as +``cL`` (lift), ``cQ`` (side) and ``cD`` (drag) in place of ``cN``/``cY``/``cA`` +(the moment coefficients ``cm``/``cn``/``cl`` are shared by both frames). By +default the frame is inferred from the names you pass; set ``force_convention`` +(``"body"`` or ``"wind"``) to state it explicitly. Whichever frame you choose, +all nine coefficients remain available as attributes (``surface.cN``, +``surface.cL``, ...), converted on demand from the ones you provided using the +rotation described in `Relating the two frames`_ above. + +Only one coefficient is required, and any combination can be provided; the ones +you omit are treated as zero. -The coefficients are all functions of: +Each coefficient is a function of the same seven independent variables: - Angle of attack (:math:`\alpha`) in radians. - Side slip angle (:math:`\beta`) in radians. - Mach number (:math:`Ma`). - Reynolds number (:math:`Re`). -- Pitch rate (:math:`q`) in radians per second. -- Yaw rate (:math:`r`) in radians per second. -- Roll rate (:math:`p`) in radians per second. +- Pitch rate (:math:`q^{*}`), non-dimensional (reduced). +- Yaw rate (:math:`r^{*}`), non-dimensional (reduced). +- Roll rate (:math:`p^{*}`), non-dimensional (reduced). -.. math:: - \begin{aligned} - C_L &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_Q &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_D &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_m &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_n &= f(\alpha, \beta, Ma, Re, q, r, p) \\ - C_l &= f(\alpha, \beta, Ma, Re, q, r, p) - \end{aligned} +.. important:: + The angular rates are the conventional **non-dimensional reduced rates**, not + the raw body rates in rad/s: -From the coefficients, the forces and moments are calculated with + .. math:: + q^{*} = \frac{q \, L_{ref}}{2 V}, \quad + r^{*} = \frac{r \, L_{ref}}{2 V}, \quad + p^{*} = \frac{p \, L_{ref}}{2 V} -.. math:: - \begin{aligned} - L &= \overline{q}\cdot A_{ref}\cdot C_L \\ - Q &= \overline{q}\cdot A_{ref}\cdot C_Q \\ - D &= \overline{q}\cdot A_{ref}\cdot C_D \\ - M_{m} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_m \\ - M_{n} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_n \\ - M_{l} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_l - \end{aligned} + where :math:`L_{ref}` is the surface reference length and :math:`V` the + freestream speed. RocketPy non-dimensionalizes the body rates internally + before evaluating the coefficients (the factor is 0 at zero airspeed). + Define your tables against the reduced rates. + +Once evaluated, the coefficients are turned into body-frame forces and moments +exactly as described in `Wind Frame and Body Frame`_ above (wind-frame inputs +are rotated into the body frame first). + +Each coefficient value can be given three ways: a single number for a constant, +a callable function of the seven variables, or a path to a ``.csv`` file of +tabulated data. + +Defining a coefficient as a callable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -These coefficients can be defined as a callable such as: +A coefficient can be any callable that takes the seven independent variables and +returns the value: .. code-block:: python @@ -233,11 +313,14 @@ These coefficients can be defined as a callable such as: ... return value -In which any algorithm can be implemented to calculate the coefficient values. +Any algorithm can be implemented inside to compute the coefficient. -Otherwise, the coefficients can be defined as a ``.csv`` file. The file must -contain a header with at least one of the following columns representing the -independent variables: +Defining a coefficient from a CSV file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A coefficient can also be tabulated in a ``.csv`` file. The file must have a +header naming its columns. The independent-variable columns are optional, but +those present must use these exact names: - ``alpha``: Angle of attack. - ``beta``: Side slip angle. @@ -247,17 +330,16 @@ independent variables: - ``yaw_rate``: Yaw rate. - ``roll_rate``: Roll rate. -The last column must be the coefficient value, and must contain a header, -though the header name can be anything. +The **last** column holds the coefficient value; it **must** have a header, but +the header name can be anything. .. important:: - Not all columns need to be present in the file, but the columns that are - present must be correctly named as described above. Independent variable - columns can be in any order. + Not all independent-variable columns need to be present, but the columns that + are present must be named exactly as above. They can be in any order. -An example of a ``.csv`` file is shown below: +An example ``.csv`` file, tabulated against angle of attack and Mach: -.. code-block:: +.. code-block:: "alpha", "mach", "coefficient" -0.017, 0, -0.11 @@ -273,12 +355,22 @@ An example of a ``.csv`` file is shown below: 0.017, 2, 0.084 0.017, 3, 0.061 -After the definition of the ``GenericSurface`` object, it must be added to the -rocket's configuration: +.. note:: + The ``reynolds`` axis is by default based on the reference length (the + rocket diameter). Published rocket data often bases the Reynolds number on + the **body length** instead, which for a slender rocket is much larger. If + your table uses a different length, pass it as ``reynolds_length`` when + creating the surface so the Reynolds number the simulation feeds your table + matches the one it was built against. + +Adding the surface to the rocket +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once defined, the surface is added to the rocket like any other: .. seealso:: For more information on how to add a generic surface to the rocket, see - :class:`rocketpy.Rocket.add_generic_surface` + :class:`rocketpy.Rocket.add_surfaces` .. code-block:: python :emphasize-lines: 5 @@ -289,20 +381,41 @@ rocket's configuration: ) rocket.add_surfaces(generic_surface, position=(0,0,0)) -The position of the generic surface is defined in the User Defined coordinate -System, see :ref:`rocket_axes` for more information. +The position is given in the User Defined Coordinate System; see +:ref:`rocket_axes` for more information. + +.. attention:: + If the generic surface is positioned **not** at the center of dry mass, the + forces generated by the force coefficients (``cN``, ``cY``, ``cA``) generate + a moment about the center of dry mass. This moment is computed and added to + the moment generated by the moment coefficients (``cm``, ``cn``, ``cl``). .. tip:: - If defining the coefficients of the entire rocket is desired, only a single - generic surface can be added to the rocket, positioned at the center of dry - mass. This will be equivalent to defining the coefficients of the entire - rocket. + To describe the whole vehicle with a single set of coefficients (rather than + one surface per component), use + :meth:`rocketpy.Rocket.add_full_body_aerodynamics`. See + :ref:`fullbodyaerodynamics`. -.. attention:: - If there generic surface is positioned **not** at the center of dry mass, the - forces generated by the force coefficients (cL, cQ, cD) will generate a - moment around the center of dry mass. This moment will be calculated and - added to the moment generated by the moment coefficients (cm, cn, cl). +Moment reference point +~~~~~~~~~~~~~~~~~~~~~~~~ + +The moment coefficients :math:`C_m`, :math:`C_n` and :math:`C_l` are taken about +the surface's own reference point (its ``center_of_pressure``). When the rocket +assembles the total aerodynamic moment it transports each surface's force from +that point to the rocket's **center of dry mass**, adding the +:math:`\vec{r}_{\text{cp} \to \text{cdm}} \times \vec{F}` term, so the rocket's +reported pitch/yaw moment and static margin are about the center of dry mass. + +This matters when your coefficients come from a source that uses a different +reference. A pitch-moment coefficient referenced to a point a distance :math:`d` +ahead of the surface's center of pressure must be shifted before use: + +.. math:: + C_{m,\,\text{cp}} = C_{m,\,\text{ref}} + \frac{d}{L_{ref}}\, C_N + +Provide the coefficient about the surface's center of pressure (or set +``center_of_pressure`` so the transport lands the moment at the intended point), +otherwise the static margin will be off by the reference-point offset. .. _lineargenericsurface: @@ -310,102 +423,122 @@ System, see :ref:`rocket_axes` for more information. Linear Generic Surface Class ---------------------------- -The :class:`rocketpy.LinearGenericSurface` class is used to define a aerodynamic -surface based on the forces and moments coefficient derivatives. A linear generic -surface will receive the derivatives of each coefficient with respect to the -independent variables. The derivatives are defined as: +The :class:`rocketpy.LinearGenericSurface` class defines an aerodynamic surface +from the **derivatives** of its force and moment coefficients, instead of the +coefficients themselves. This is convenient when you have stability-derivative +data rather than full tables: the surface builds each coefficient by summing its +derivatives times the independent variables. + +For every one of the six coefficients (``cN``, ``cY``, ``cA``, ``cm``, ``cn``, +``cl``), you provide a constant term and one derivative per independent variable: -- :math:`C_{\alpha}=\frac{dC}{d\alpha}`: Coefficient derivative with respect to angle of attack. -- :math:`C_{\beta}=\frac{dC}{d\beta}`: Coefficient derivative with respect to side slip angle. -- :math:`C_{Ma}=\frac{dC}{dMa}`: Coefficient derivative with respect to Mach number. -- :math:`C_{Re}=\frac{dC}{dRe}`: Coefficient derivative with respect to Reynolds number. -- :math:`C_{q}=\frac{dC}{dq}`: Coefficient derivative with respect to pitch rate. -- :math:`C_{r}=\frac{dC}{dr}`: Coefficient derivative with respect to yaw rate. -- :math:`C_{p}=\frac{dC}{dp}`: Coefficient derivative with respect to roll rate. +- :math:`C_{0}`: the coefficient value at the reference condition. +- :math:`C_{\alpha}=\frac{dC}{d\alpha}`: derivative with respect to angle of attack. +- :math:`C_{\beta}=\frac{dC}{d\beta}`: derivative with respect to side slip angle. +- :math:`C_{Ma}=\frac{dC}{dMa}`: derivative with respect to Mach number. +- :math:`C_{Re}=\frac{dC}{dRe}`: derivative with respect to Reynolds number. +- :math:`C_{q}=\frac{dC}{dq}`: derivative with respect to pitch rate. +- :math:`C_{r}=\frac{dC}{dr}`: derivative with respect to yaw rate. +- :math:`C_{p}=\frac{dC}{dp}`: derivative with respect to roll rate. -A non derivative coefficient :math:`C_{0}` is also included. +Just like the plain generic surface, each of these terms is itself a function of +all seven independent variables, and may be a constant, a callable, or a +tabulated ``.csv`` file. -Each coefficient derivative is defined as a function of all the seven -independent variables. +How the coefficients are assembled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The coefficients are then grouped into **forcing** coefficients: +The derivatives are first combined into **forcing** coefficients, which depend on +the steady flow state (angles, Mach, Reynolds): .. math:: \begin{aligned} - C_{Lf} &= C_{L0} + C_{L\alpha}\cdot\alpha + C_{L\beta}\cdot\beta + C_{LMa}\cdot Ma + C_{LRe}\cdot Re \\ - C_{Qf} &= C_{Q0} + C_{Q\alpha}\cdot\alpha + C_{Q\beta}\cdot\beta + C_{QMa}\cdot Ma + C_{QRe}\cdot Re \\ - C_{Df} &= C_{D0} + C_{D\alpha}\cdot\alpha + C_{D\beta}\cdot\beta + C_{DMa}\cdot Ma + C_{DRe}\cdot Re \\ + C_{Nf} &= C_{N0} + C_{N\alpha}\cdot\alpha + C_{N\beta}\cdot\beta + C_{NMa}\cdot Ma + C_{NRe}\cdot Re \\ + C_{Yf} &= C_{Y0} + C_{Y\alpha}\cdot\alpha + C_{Y\beta}\cdot\beta + C_{YMa}\cdot Ma + C_{YRe}\cdot Re \\ + C_{Af} &= C_{A0} + C_{A\alpha}\cdot\alpha + C_{A\beta}\cdot\beta + C_{AMa}\cdot Ma + C_{ARe}\cdot Re \\ C_{mf} &= C_{m0} + C_{m\alpha}\cdot\alpha + C_{m\beta}\cdot\beta + C_{mMa}\cdot Ma + C_{mRe}\cdot Re \\ C_{nf} &= C_{n0} + C_{n\alpha}\cdot\alpha + C_{n\beta}\cdot\beta + C_{nMa}\cdot Ma + C_{nRe}\cdot Re \\ - C_{lf} &= C_{l0} + C_{l\alpha}\cdot\alpha + C_{l\beta}\cdot\beta + C_{lMa}\cdot Ma + C_{lRe}\cdot Re + C_{lf} &= C_{l0} + C_{l\alpha}\cdot\alpha + C_{l\beta}\cdot\beta + C_{lMa}\cdot Ma + C_{lRe}\cdot Re \end{aligned} -And **damping** coefficients: +and **damping** coefficients, which depend on the rotation rates: .. math:: \begin{aligned} - C_{Ld} &= C_{L_{q}}\cdot q + C_{L_{r}}\cdot r + C_{L_{p}}\cdot p \\ - C_{Qd} &= C_{Q_{q}}\cdot q + C_{Q_{r}}\cdot r + C_{Q_{p}}\cdot p \\ - C_{Dd} &= C_{D_{q}}\cdot q + C_{D_{r}}\cdot r + C_{D_{p}}\cdot p \\ + C_{Nd} &= C_{N_{q}}\cdot q + C_{N_{r}}\cdot r + C_{N_{p}}\cdot p \\ + C_{Yd} &= C_{Y_{q}}\cdot q + C_{Y_{r}}\cdot r + C_{Y_{p}}\cdot p \\ + C_{Ad} &= C_{A_{q}}\cdot q + C_{A_{r}}\cdot r + C_{A_{p}}\cdot p \\ C_{md} &= C_{m_{q}}\cdot q + C_{m_{r}}\cdot r + C_{m_{p}}\cdot p \\ C_{nd} &= C_{n_{q}}\cdot q + C_{n_{r}}\cdot r + C_{n_{p}}\cdot p \\ - C_{ld} &= C_{l_{q}}\cdot q + C_{l_{r}}\cdot r + C_{l_{p}}\cdot p + C_{ld} &= C_{l_{q}}\cdot q + C_{l_{r}}\cdot r + C_{l_{p}}\cdot p \end{aligned} -The forces and moments are then calculated as: +The body-frame forces and moments then follow, the damping terms scaled by the +reduced-rate factor :math:`\frac{L_{ref}}{2V}`: .. math:: \begin{aligned} - L &= \overline{q}\cdot A_{ref}\cdot C_{Lf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Ld} \\ - Q &= \overline{q}\cdot A_{ref}\cdot C_{Qf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Qd} \\ - D &= \overline{q}\cdot A_{ref}\cdot C_{Df} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Dd} \\ + N &= \overline{q}\cdot A_{ref}\cdot C_{Nf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Nd} \\ + Y &= \overline{q}\cdot A_{ref}\cdot C_{Yf} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Yd} \\ + A &= \overline{q}\cdot A_{ref}\cdot C_{Af} + \overline{q}\cdot A_{ref}\cdot \frac{L_{ref}}{2V} C_{Ad} \\ M_{m} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{mf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{md} \\ M_{n} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{nf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{nd} \\ M_{l} &= \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot C_{lf} + \overline{q}\cdot A_{ref}\cdot L_{ref}\cdot \frac{L_{ref}}{2V} C_{ld} \end{aligned} -The linear generic surface is defined very similarly to the generic surface. -The coefficients are defined in the same way, but with the addition of the -derivative values. +Defining a linear generic surface +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -An example of a linear generic surface defined with **all** the coefficients is -shown below: +A linear generic surface takes the **same parameters** as +:class:`rocketpy.GenericSurface`, only the +``coefficients`` dictionary is different. Each key follows the pattern +``_``. For example ``cN_alpha`` is +:math:`C_{N\alpha}`, ``cm_q`` is :math:`C_{m_q}`, and ``cN_0`` is the constant +term :math:`C_{N0}`. Any term you omit is zero. + +.. note:: + The derivative names carry the force frame: ``cN_alpha``, ``cY_beta``, ... + in the body frame versus ``cL_alpha``, ``cQ_beta``, ... in the wind frame. + ``force_convention`` selects the frame just as it does for + :class:`rocketpy.GenericSurface`. .. seealso:: - For more information on class initialization, see + For more information on class initialization, see :class:`rocketpy.LinearGenericSurface.__init__` +An example defining **all** the coefficient derivatives: + .. code-block:: python - + from rocketpy import LinearGenericSurface linear_generic_surface = LinearGenericSurface( reference_area=np.pi * 0.0635**2, reference_length=2 * 0.0635, coefficients={ - "cL_0": "cL_0.csv", - "cL_alpha": "cL_alpha.csv", - "cL_beta": "cL_beta.csv", - "cL_Ma": "cL_Ma.csv", - "cL_Re": "cL_Re.csv", - "cL_q": "cL_q.csv", - "cL_r": "cL_r.csv", - "cL_p": "cL_p.csv", - "cQ_0": "cQ_0.csv", - "cQ_alpha": "cQ_alpha.csv", - "cQ_beta": "cQ_beta.csv", - "cQ_Ma": "cQ_Ma.csv", - "cQ_Re": "cQ_Re.csv", - "cQ_q": "cQ_q.csv", - "cQ_r": "cQ_r.csv", - "cQ_p": "cQ_p.csv", - "cD_0": "cD_0.csv", - "cD_alpha": "cD_alpha.csv", - "cD_beta": "cD_beta.csv", - "cD_Ma": "cD_Ma.csv", - "cD_Re": "cD_Re.csv", - "cD_q": "cD_q.csv", - "cD_r": "cD_r.csv", - "cD_p": "cD_p.csv", + "cN_0": "cN_0.csv", + "cN_alpha": "cN_alpha.csv", + "cN_beta": "cN_beta.csv", + "cN_Ma": "cN_Ma.csv", + "cN_Re": "cN_Re.csv", + "cN_q": "cN_q.csv", + "cN_r": "cN_r.csv", + "cN_p": "cN_p.csv", + "cY_0": "cY_0.csv", + "cY_alpha": "cY_alpha.csv", + "cY_beta": "cY_beta.csv", + "cY_Ma": "cY_Ma.csv", + "cY_Re": "cY_Re.csv", + "cY_q": "cY_q.csv", + "cY_r": "cY_r.csv", + "cY_p": "cY_p.csv", + "cA_0": "cA_0.csv", + "cA_alpha": "cA_alpha.csv", + "cA_beta": "cA_beta.csv", + "cA_Ma": "cA_Ma.csv", + "cA_Re": "cA_Re.csv", + "cA_q": "cA_q.csv", + "cA_r": "cA_r.csv", + "cA_p": "cA_p.csv", "cm_0": "cm_0.csv", "cm_alpha": "cm_alpha.csv", "cm_beta": "cm_beta.csv", @@ -435,3 +568,209 @@ shown below: rocket.add_surfaces(linear_generic_surface, position=(0,0,0)) +.. _generic_surface_interpolation: + +Interpolation and Extrapolation of Tabulated Coefficients +--------------------------------------------------------- + +When a coefficient is provided as tabulated data (a ``.csv`` file or a list of +points), RocketPy stores it as a :class:`rocketpy.Function` and must decide two +things: how to **interpolate** *between* the tabulated points, and how to +**extrapolate** *outside* the tabulated range. Both :class:`rocketpy.GenericSurface` +and :class:`rocketpy.LinearGenericSurface` expose these as the ``interpolation`` +and ``extrapolation`` arguments. + +.. note:: + Interpolation and extrapolation only apply to **tabulated** coefficients. + A coefficient given as a constant or a callable is evaluated directly, so + these settings have no effect on it (a callable is assumed valid over its + whole domain). + +Each argument accepts either: + +- a **single string**, applied to every coefficient of the surface; or +- a **dictionary** keyed by coefficient name, setting the method per + coefficient. Coefficients omitted from the dictionary keep the default. + +.. code-block:: python + + from rocketpy import GenericSurface + + radius = 0.0635 + generic_surface = GenericSurface( + reference_area=np.pi * radius**2, + reference_length=2 * radius, + coefficients={ + "cA": "cA.csv", + "cN": "cN.csv", + }, + # A single method applied to every coefficient: + extrapolation="constant", + # ... or per coefficient (unlisted ones keep the default): + interpolation={"cA": "linear", "cN": "akima"}, + ) + +Choosing an interpolation method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Interpolation controls the behavior *between* tabulated points. For 1-D tables +the options are ``"linear"``, ``"akima"``, ``"spline"`` and ``"polynomial"``. + +- ``"linear"`` (**default**) is the safe choice. It never overshoots and + introduces no spurious oscillations, which matters most across the + **transonic drag rise** (:math:`Ma \approx 0.8`--:math:`1.2`), where a spline + will oscillate and invent non-physical wiggles in the axial coefficient + :math:`C_A`. Prefer it for coarse tables and for anything with a sharp feature. +- ``"akima"`` gives continuous first derivatives (smoother + :math:`C_{m_\alpha}`, cleaner stability curves) while resisting the overshoot + of a natural cubic spline near kinks. It is the best "smooth" option for + **dense, smooth** data. +- ``"spline"`` produces the smoothest derivatives but overshoots near sharp + features (stall, :math:`Ma = 1`). Use it only for genuinely smooth, + well-resolved data. + +A practical rule of thumb: use ``"linear"`` against Mach (transonic kinks) and +``"akima"`` against angle of attack / sideslip when you have fine data and care +about smooth derivatives. + +.. note:: + Multi-dimensional CSV tables that form a strict Cartesian grid are read with + a :class:`scipy.interpolate.RegularGridInterpolator`. The ``interpolation`` + argument still applies: it is mapped onto the interpolator's method, with + ``"spline"`` becoming ``"cubic"`` and ``"akima"`` becoming the + shape-preserving ``"pchip"`` (``"linear"`` stays linear). Smooth methods need + enough samples per axis (``"cubic"`` needs at least 4), otherwise SciPy + raises. + +Choosing an extrapolation method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Extrapolation controls the behavior *outside* the tabulated range. The options +are ``"constant"``, ``"natural"`` and ``"zero"``. This choice matters more than +interpolation, because a bad one fails silently, precisely when the rocket is at +an extreme condition beyond your data. + +- ``"constant"`` holds the value at the nearest edge of the data. This is the + **default for tabulated coefficients**, and the right choice for essentially + all of them: a rocket can briefly exceed your tabulated Mach/angle range, and + holding the last value is bounded and physically conservative. +- ``"zero"`` returns 0 outside the range. Occasionally reasonable for force or + moment *slopes* if you want contributions to vanish past the modeled envelope, + but it introduces a discontinuity at the edge. +- ``"natural"`` continues the fitted curve past the data. **Avoid this for + tabulated coefficients**: extrapolating a linear or spline fit can send + :math:`C_A` or a moment slope to large, non-physical values right when the + rocket is at an extreme condition. + +.. tip:: + Tabulated coefficients default to ``extrapolation="constant"`` so they never + run to non-physical values past the tabulated envelope. Override it only when + you have a specific reason (e.g. ``"zero"`` to make a contribution vanish + outside the modeled range). + +.. seealso:: + These arguments are forwarded to each :class:`rocketpy.Function`; see + :meth:`rocketpy.Function.set_interpolation` and + :meth:`rocketpy.Function.set_extrapolation` for the full list of methods. + + +.. _active_during: + +Activation Window +----------------- + +By default a surface produces aerodynamic force throughout the flight. The +``active_during`` argument restricts it to part of the flight. This is useful +for a surface that only exists (or only matters) during a phase. It is accepted +by both :class:`rocketpy.GenericSurface` and +:class:`rocketpy.LinearGenericSurface`, and accepts: + +- ``"always"`` (default): the surface always contributes force. +- ``"power_on"``: only while the motor is burning (up to burnout). +- ``"power_off"``: only after the motor has burned out. +- a callable ``active_during(t, flight)`` returning ``True`` when the surface is + active at time ``t`` (in seconds) of the given :class:`rocketpy.Flight`, for + any custom window. + +.. code-block:: python + + # base drag that only applies after burnout + base_drag = GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={"cA": 0.4}, # axial (drag) coefficient + active_during="power_off", + ) + +This is also how a full-vehicle model captures the powered/coasting drag +difference: build one ``"power_on"`` and one ``"power_off"`` surface and add them +together (see :ref:`fullbodyaerodynamics`). + + +.. _fullbodyaerodynamics: + +Whole-vehicle aerodynamics +-------------------------- + +Instead of (or in addition to) modelling each component, you can describe the +**entire rocket** with a single generic surface that already carries the whole +vehicle's coefficients, for example a set exported from CFD, a wind tunnel or +OpenRocket. + +Adding a prebuilt full-vehicle model +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`rocketpy.Rocket.add_full_body_aerodynamics` adds such a surface (or a list +of them). Reference its coefficients to the rocket cross-section area and diameter +so it sums consistently with the rest of the vehicle: + +.. code-block:: python + + surface = GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={...}, + ) + rocket.add_full_body_aerodynamics(surface) + +Because it is just another aerodynamic surface, a full-vehicle model can be +**mixed** with modelled add-on surfaces (e.g. a measured body plus modelled +canards). Pass ``overwrite=True`` to make it the rocket's **only** aerodynamics: +every existing aerodynamic surface is removed and both built-in drag curves are +cleared, so the supplied surface(s) provide the complete force set. + +A rocket's drag differs between powered and coasting flight (the motor plume +lowers the base drag). To capture this, build two surfaces, set each one's +``active_during`` to ``"power_on"`` and ``"power_off"``, and pass them as a list; +each then produces force only during its phase. + +Extracting a rocket's coefficients +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can also collapses an assembled rocket into a single +stability-derivative model about its center of dry mass: + +- :meth:`rocketpy.Rocket.to_coefficients` returns the coefficient curves as a + dict split into ``"power_off"`` and ``"power_on"`` sets (only the drag differs + between them), each mapping a coefficient name to a :class:`rocketpy.Function` + of Mach. +- :meth:`rocketpy.Rocket.to_surface` wraps those into a ready-to-use pair of + :class:`rocketpy.LinearGenericSurface` objects, one gated to each motor phase -- + the inverse of :meth:`~rocketpy.Rocket.add_full_body_aerodynamics`. + +.. code-block:: python + + coefficients = rocket.to_coefficients() # {"power_off": {...}, "power_on": {...}} + surfaces = rocket.to_surface() # [power_off surface, power_on surface] + + # a bare rocket carrying only this pair flies the same as the full model + bare.add_full_body_aerodynamics(surfaces, overwrite=True) + +.. important:: + The extracted model is a **linear summary tabulated only against Mach**: the + derivatives are taken at zero angle of attack, zero sideslip and zero rates, + so incidence/rate nonlinearity, Reynolds dependence and control-surface + dependence are dropped. These are exactly the assumptions of the built-in + Barrowman surfaces (nose cones, fins, and tails), so a rocket built only from + those is reproduced exactly. + See :ref:`aero_cp_stability` for the extraction math and its limitations. diff --git a/docs/user/rocket/rocket.rst b/docs/user/rocket/rocket.rst index 956175409..7a7636815 100644 --- a/docs/user/rocket/rocket.rst +++ b/docs/user/rocket/rocket.rst @@ -16,6 +16,5 @@ Rocket Usage .. toctree:: :maxdepth: 3 :caption: Generic Surfaces and Custom Aerodynamic Coefficients - + Generic Surfaces and Custom Aerodynamic Coefficients - \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 61a594320..4206c8c15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ numpy>=1.13 -scipy>=1.0 +scipy>=1.13.0 # RegularGridInterpolator "pchip"/spline methods (Apr 2024) matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 requests diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index ae602d784..42024fb9d 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -30,6 +30,7 @@ AeroSurface, AirBrakes, Components, + ControllableGenericSurface, EllipticalFin, EllipticalFins, Fin, diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 33a82ec01..2787f569e 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -40,6 +40,32 @@ "regular_grid": 6, } EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} +# Maps a requested interpolation name onto a scipy ``RegularGridInterpolator`` +# ``method`` for gridded (N-D Cartesian) data. The 1-D-only names ``spline`` and +# ``akima`` fall back to their closest grid analogs (``cubic`` and the +# shape-preserving ``pchip``); anything unrecognized defaults to ``linear``. +REGULAR_GRID_METHODS = { + "linear": "linear", + "nearest": "nearest", + "slinear": "slinear", + "cubic": "cubic", + "quintic": "quintic", + "pchip": "pchip", + "spline": "cubic", + "akima": "pchip", + "polynomial": "cubic", +} +# Minimum points per axis required by each ``RegularGridInterpolator`` method. +# A grid with fewer samples on any axis cannot use the higher-order methods, so +# the caller falls back to linear rather than letting SciPy raise mid-build. +REGULAR_GRID_MIN_POINTS = { + "nearest": 1, + "linear": 2, + "slinear": 2, + "pchip": 2, + "cubic": 4, + "quintic": 6, +} class SourceType(Enum): @@ -157,7 +183,12 @@ def __init__( @classmethod def from_regular_grid_csv( - cls, csv_source, variable_names, coeff_name, extrapolation + cls, + csv_source, + variable_names, + coeff_name, + extrapolation, + interpolation="linear", ): """Create a regular-grid Function from CSV samples when possible. @@ -171,6 +202,14 @@ def from_regular_grid_csv( Name of the output coefficient. extrapolation : str Extrapolation method passed to the Function constructor. + interpolation : str, optional + Requested interpolation. Mapped onto a + :class:`scipy.interpolate.RegularGridInterpolator` ``method`` via + :data:`REGULAR_GRID_METHODS` (e.g. ``"spline"`` -> ``"cubic"``, + ``"akima"`` -> ``"pchip"``); unrecognized names fall back to + ``"linear"``. Smooth methods require enough points per axis + (``"cubic"`` needs at least 4), otherwise SciPy raises. Default + ``"linear"``. Returns ------- @@ -215,13 +254,33 @@ def from_regular_grid_csv( return None grid_data = sorted_values.reshape(tuple(axis.size for axis in axes)) - return cls( + grid_function = cls( (axes, grid_data), inputs=variable_names, outputs=[coeff_name], interpolation="regular_grid", extrapolation=extrapolation, ) + # Honor the requested interpolation on the grid by rebuilding the + # interpolator/extrapolator with the mapped scipy ``method``. The + # constructor above always builds the default ("linear"); only rebuild + # when a different method was asked for. + grid_method = REGULAR_GRID_METHODS.get(interpolation, "linear") + smallest_axis = min(axis.size for axis in axes) + if smallest_axis < REGULAR_GRID_MIN_POINTS.get(grid_method, 2): + warnings.warn( + f"Grid interpolation method '{grid_method}' needs at least " + f"{REGULAR_GRID_MIN_POINTS[grid_method]} points per axis, but the " + f"coarsest axis of '{coeff_name}' has {smallest_axis}; falling " + "back to 'linear'.", + UserWarning, + ) + grid_method = "linear" + if grid_method != "linear": + grid_function._grid_method = grid_method + grid_function.set_interpolation("regular_grid") + grid_function.set_extrapolation(grid_function.get_extrapolation_method()) + return grid_function # Define all set methods def set_inputs(self, inputs): @@ -318,6 +377,10 @@ def set_source(self, source): # pylint: disable=too-many-statements self.__dom_dim__ = source.shape[1] - 1 self._domain = source[:, :-1] self._image = source[:, -1] + # Cache per-dimension domain bounds so the N-D hot evaluation path + # (``__get_value_opt_nd``) does not recompute them on every call. + self._domain_min = self._domain.min(axis=0) + self._domain_max = self._domain.max(axis=0) # set x and y. If Function is 2D, also set z if self.__dom_dim__ == 1: @@ -488,11 +551,20 @@ def __process_grid_source(self, source): f"{grid_data.shape[i]} points." ) if not np.all(np.diff(ax) > 0): - warnings.warn( - f"Axis {i} is not strictly sorted in ascending order. " - "RegularGridInterpolator requires sorted axes.", - UserWarning, - ) + # RegularGridInterpolator requires strictly ascending axes. Sort + # this axis (and reorder the grid data along it) so descending or + # shuffled inputs are accepted; repeated coordinates cannot form + # a regular grid and are rejected with a clear error rather than + # a cryptic SciPy failure. + order = np.argsort(ax, kind="stable") + ax = ax[order] + grid_data = np.take(grid_data, order, axis=i) + axes[i] = ax + if not np.all(np.diff(ax) > 0): + raise ValueError( + f"Axis {i} has repeated coordinates; a regular grid " + "requires strictly increasing values along each axis." + ) self._grid_axes = axes self._grid_data = grid_data @@ -596,7 +668,7 @@ def rbf_interpolation(x, x_min, x_max, x_data, y_data, coeffs): # pylint: disab grid_interpolator = RegularGridInterpolator( self._grid_axes, self._grid_data, - method="linear", + method=getattr(self, "_grid_method", "linear"), bounds_error=True, ) # Store so extrapolation funcs can reuse it @@ -720,9 +792,9 @@ def natural_extrapolation( # pylint: disable=function-redefined grid_extrapolator = RegularGridInterpolator( self._grid_axes, self._grid_data, - method="linear", + method=getattr(self, "_grid_method", "linear"), bounds_error=False, - fill_value=None, # linear extrapolation beyond edges + fill_value=None, # extrapolation beyond edges ) def natural_extrapolation( # pylint: disable=function-redefined @@ -824,8 +896,15 @@ def __get_value_opt_nd(self, *args): arg_qty = len(args) result = np.empty(arg_qty) - min_domain = self._domain.T.min(axis=1) - max_domain = self._domain.T.max(axis=1) + # Domain bounds are fixed once the source is set, so they are cached in + # ``set_source`` (this hot path runs per integration step); fall back to + # computing them for any Function built without going through it. + min_domain = getattr(self, "_domain_min", None) + if min_domain is None: + min_domain = self._domain.min(axis=0) + max_domain = self._domain.max(axis=0) + else: + max_domain = self._domain_max lower, upper = args < min_domain, args > max_domain extrap = np.logical_or(lower.any(axis=1), upper.any(axis=1)) @@ -4162,7 +4241,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument else: source = source.__name__ - return { + function_dict = { "source": source, "title": self.title, "inputs": self.__inputs__, @@ -4171,6 +4250,20 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument "extrapolation": self.__extrapolation__, } + # A regular-grid Function cannot be rebuilt from its flat scatter + # ``source``; persist the ``(axes, grid_data)`` structure (and the mapped + # scipy method) instead, so it round-trips through ``from_dict``. + if self.__interpolation__ == "regular_grid": + function_dict["source"] = [ + [np.asarray(axis).tolist() for axis in self._grid_axes], + np.asarray(self._grid_data).tolist(), + ] + grid_method = getattr(self, "_grid_method", "linear") + if grid_method != "linear": + function_dict["grid_method"] = grid_method + + return function_dict + @classmethod def from_dict(cls, func_dict): """Creates a Function instance from a dictionary. @@ -4184,7 +4277,7 @@ def from_dict(cls, func_dict): if func_dict["interpolation"] is None and func_dict["extrapolation"] is None: source = from_hex_decode(source) - return cls( + function = cls( source=source, interpolation=func_dict["interpolation"], extrapolation=func_dict["extrapolation"], @@ -4193,6 +4286,16 @@ def from_dict(cls, func_dict): title=func_dict["title"], ) + # Restore a non-default regular-grid method (the constructor above builds + # the "linear" default); rebuild the interpolator/extrapolator with it. + grid_method = func_dict.get("grid_method") + if grid_method and grid_method != "linear": + function._grid_method = grid_method + function.set_interpolation("regular_grid") + function.set_extrapolation(function.get_extrapolation_method()) + + return function + @staticmethod def __make_arith_lambda( operator, func, other, func_dim, other_dim=0, reverse=False diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index e9b30da45..17d2e3a87 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -1,6 +1,4 @@ # pylint: disable=too-many-statements -from abc import ABC, abstractmethod - import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Ellipse @@ -8,16 +6,16 @@ from .plot_helpers import show_or_save_plot -class _AeroSurfacePlots(ABC): - """Abstract class that contains all aero surface plots.""" +class _GenericSurfacePlots: + """Base plots for a generic aerodynamic surface.""" def __init__(self, aero_surface): """Initialize the class Parameters ---------- - aero_surface : rocketpy.AeroSurface - AeroSurface object to be plotted + aero_surface : rocketpy.GenericSurface + Aerodynamic surface object to be plotted Returns ------- @@ -25,35 +23,117 @@ def __init__(self, aero_surface): """ self.aero_surface = aero_surface - @abstractmethod def draw(self, *, filename=None): - pass - - def lift(self): - """Plots the lift coefficient of the aero surface as a function of Mach - and the angle of attack. A 3D plot is expected. See the rocketpy.Function - class for more information on how this plot is made. + """A plain generic surface has no geometry to draw.""" + + # Coefficients swept against their most relevant incidence angle: pitch-plane + # coefficients vs. angle of attack, yaw-plane ones vs. sideslip. + _COEFFICIENT_SWEEP = [ + ("cL", "alpha"), + ("cQ", "beta"), + ("cD", "alpha"), + ("cm", "alpha"), + ("cn", "beta"), + ] + + def coefficients(self, *, mach=0.3, angle_range_deg=15.0, filename=None): + """Plot the surface's main aerodynamic coefficients. + + Each available, non-zero coefficient (``cL, cQ, cD, cm, cn``) is swept + against its most relevant incidence angle (pitch-plane coefficients vs. + angle of attack, yaw-plane vs. sideslip) at a representative Mach, + skipping coefficients that are identically zero or flat. Works + uniformly across surface types because every generic, linear and + Barrowman surface now exposes these coefficients as callables over the + standard argument tuple. - Returns - ------- - None + Parameters + ---------- + mach : float, optional + Mach number at which to sample the coefficients. Default 0.3. + angle_range_deg : float, optional + Half-range of the incidence sweep, in degrees. Default 15. + filename : str | None, optional + Path to save the figure; if None the figure is shown. """ - self.aero_surface.cl() + surface = self.aero_surface + independent_vars = getattr(surface, "independent_vars", None) + if independent_vars is None: + return + index = {name: i for i, name in enumerate(independent_vars)} + if "mach" not in index: + return + n_args = len(independent_vars) + + angles = np.linspace( + np.deg2rad(-angle_range_deg), np.deg2rad(angle_range_deg), 61 + ) + entries = [] + for name, var in self._COEFFICIENT_SWEEP: + coeff = getattr(surface, name, None) + if coeff is None or getattr(coeff, "is_zero", False): + continue + if var not in index: + continue + values = np.empty_like(angles) + for i, angle in enumerate(angles): + args = [0.0] * n_args + args[index["mach"]] = mach + args[index[var]] = angle + values[i] = coeff(*args) + if np.allclose(values, 0.0): + continue + entries.append((name, var, values)) + + if not entries: + return + + fig, axes = plt.subplots( + len(entries), 1, figsize=(7, 2.3 * len(entries)), squeeze=False + ) + for ax, (name, var, values) in zip(axes[:, 0], entries): + ax.plot(np.rad2deg(angles), values) + ax.set_xlabel(f"{var.replace('_', ' ').title()} (°)") + ax.set_ylabel(name) + ax.grid(True) + axes[0, 0].set_title(f"{surface.name} coefficients (Mach {mach})") + plt.tight_layout() + show_or_save_plot(filename) def all(self): - """Plots all aero surface plots. + """Plots the generic surface's aerodynamic coefficients.""" + self.coefficients() + + +class _LinearGenericSurfacePlots(_GenericSurfacePlots): + """Plots for a linear generic surface; same plots as the generic base.""" + + +class _BarrowmanSurfacePlots(_LinearGenericSurfacePlots): + """Plots shared by the geometry-defined (Barrowman) surfaces: adds the + geometry drawing and the lift-coefficient surface plot.""" + + def lift(self): + """Plots the lift-curve slope (``clalpha``) of the aero surface as a + function of Mach number. Returns ------- None """ + self.aero_surface.clalpha() + + def all(self): + """Plots the surface geometry, the lift coefficient and the + aerodynamic coefficients.""" self.draw() self.lift() + self.coefficients() -class _NoseConePlots(_AeroSurfacePlots): +class _NoseConePlots(_BarrowmanSurfacePlots): """Class that contains all nosecone plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def draw(self, *, filename=None): """Draw the nosecone shape along with some important information, @@ -136,9 +216,9 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _FinsPlots(_AeroSurfacePlots): +class _FinsPlots(_BarrowmanSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If @@ -193,8 +273,7 @@ class for more information on how this plot is made. Also, this method ------- None """ - print("Lift coefficient:") - self.aero_surface.cl(filename=filename) + print("Lift coefficient derivative:") self.aero_surface.clalpha_single_fin(filename=filename) self.aero_surface.clalpha_multiple_fins(filename=filename) @@ -215,11 +294,12 @@ def all(self, *, filename=None): self.airfoil(filename=filename) self.roll(filename=filename) self.lift(filename=filename) + self.coefficients(filename=filename) -class _FinPlots(_AeroSurfacePlots): +class _FinPlots(_BarrowmanSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the - _AeroSurfacePlots class.""" + _BarrowmanSurfacePlots class.""" def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If @@ -274,8 +354,7 @@ class for more information on how this plot is made. Also, this method ------- None """ - print("Lift coefficient:") - self.aero_surface.cl(filename=filename) + print("Lift coefficient derivative:") self.aero_surface.clalpha_single_fin(filename=filename) def all(self, *, filename=None): @@ -295,6 +374,7 @@ def all(self, *, filename=None): self.airfoil(filename=filename) self.roll(filename=filename) self.lift(filename=filename) + self.coefficients(filename=filename) class _TrapezoidalFinsPlots(_FinsPlots): @@ -841,7 +921,7 @@ def draw(self, *, filename=None): show_or_save_plot(filename) -class _TailPlots(_AeroSurfacePlots): +class _TailPlots(_BarrowmanSurfacePlots): """Class that contains all tail plots.""" def draw(self, *, filename=None): @@ -849,7 +929,7 @@ def draw(self, *, filename=None): pass -class _AirBrakesPlots(_AeroSurfacePlots): +class _AirBrakesPlots(_GenericSurfacePlots): """Class that contains all air brakes plots.""" def drag_coefficient_curve(self): @@ -870,17 +950,3 @@ def all(self): None """ self.drag_coefficient_curve() - - -class _GenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all generic surface plots.""" - - def draw(self, *, filename=None): - pass - - -class _LinearGenericSurfacePlots(_AeroSurfacePlots): - """Class that contains all linear generic surface plots.""" - - def draw(self, *, filename=None): - pass diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index e792acc9f..99e4d2fa8 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -59,8 +59,9 @@ def first_parachute_event_time_index(self): # Consistent red used for the rocket trajectory line across all plots. _TRAJECTORY_COLOR = "#e63946" - # Burnout vertical/drop-line color -- kept separate from the orange dot marker so - # the dashed line stays readable against typical orange and blue plot lines. + # Dark drop-line/vertical-line color shared by the Burnout and Out Of Rail + # events -- kept separate from their (orange / dark-red) dot markers so the + # dashed line stays readable against typical orange and blue plot lines. _BURNOUT_LINE_COLOR = "#4a4a4a" _EVENT_LINE_WIDTH = 1.2 @@ -69,7 +70,7 @@ def first_parachute_event_time_index(self): "Impact": "#ff1f1f", "Apogee": "#46daff", "Burnout": "#ff8121", - "Out Of Rail": "#8b0000", + "Out Of Rail": "#e6c000", } _COLOR_CYCLE = [ "#7de07a", @@ -110,8 +111,9 @@ def _collect_events(self): (t_ev, "Apogee", "o", self._RESERVED_COLORS["Apogee"], 40) ) elif name == "Out Of Rail": + # Same dot shape and size as Burnout; distinguished by color. events.append( - (t_ev, "Out Of Rail", "^", self._RESERVED_COLORS["Out Of Rail"], 30) + (t_ev, "Out Of Rail", "o", self._RESERVED_COLORS["Out Of Rail"], 40) ) elif "Parachute" in name: if name not in parachute_color_map: @@ -186,18 +188,21 @@ def _add_event_markers(self, ax, legend=True): def _add_event_markers_dropline(self, ax, legend=True, y_bottom=None, labels=None): """Event markers on the plotted curve with drop-lines from the y-axis bottom. - For each trigger-once event (excluding Out Of Rail and Landing), draws an - unlabelled dashed vertical line from the axis bottom to the curve value at - that time, and a labelled scatter marker on the curve itself. Apogee is - drawn last so it renders on top of coincident markers. + For each trigger-once event, draws an unlabelled dashed vertical line from + the axis bottom to the curve value at that time, and a labelled scatter + marker on the curve itself. Apogee is drawn last so it renders on top of + coincident markers. By default Out Of Rail and Landing are omitted, but a + caller can draw them by naming them explicitly in ``labels``. Parameters ---------- y_bottom : float or None - Y coordinate for the bottom of drop-lines. When None (default) the + Y coordinate for the bottom of drop-lines. When None (default) the bottom is derived from the minimum of the visible plotted data. labels : set or None - If given, only events whose label is in this set are drawn. + If given, only events whose label is in this set are drawn; an + explicit set also overrides the default omission of Out Of Rail and + Landing (so e.g. ``labels={"Out Of Rail"}`` draws that marker). """ lines = [ln for ln in ax.lines if len(ln.get_xdata()) > 1] if not lines: @@ -217,14 +222,22 @@ def _add_event_markers_dropline(self, ax, legend=True, y_bottom=None, labels=Non deferred_apogee = None for t_ev, label, marker, color, size in self._collect_events(): - if label in ("Out Of Rail", "Landing"): - continue - if labels is not None and label not in labels: + if labels is not None: + # An explicit label set is an opt-in: draw exactly those events, + # including Out Of Rail / Landing when named. + if label not in labels: + continue + elif label in ("Out Of Rail", "Landing"): + # Omitted from the default (unfiltered) set of drop-line markers. continue if not xlim[0] <= t_ev <= xlim[1]: continue y_ev = float(np.interp(t_ev, xdata, ydata)) - line_color = self._BURNOUT_LINE_COLOR if label == "Burnout" else color + line_color = ( + self._BURNOUT_LINE_COLOR + if label in ("Burnout", "Out Of Rail") + else color + ) lw = ( self._EVENT_LINE_WIDTH if label == "Burnout" else self._EVENT_LINE_WIDTH ) @@ -1245,9 +1258,16 @@ def fluid_mechanics_data(self, *, filename=None): # pylint: disable=too-many-st plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) - def stability_and_control_data(self, *, filename=None): # pylint: disable=too-many-statements - """Prints out Rocket Stability and Control parameters graphs available - about the Flight + def stability_margin_data(self, *, filename=None): + """Plots the rocket's stability margin over the flight, in calibers. + + The stability margin is one of the most important results of a + simulation: it is the distance from the center of mass to the center of + pressure that must stay positive (center of pressure behind the center + of mass) for the rocket to correct disturbances. A secondary axis reads + the same margin as a percentage of the rocket's overall length, the + convention often used in hobby rocketry. For a non-axisymmetric rocket + the pitch and yaw margins are drawn separately. Parameters ---------- @@ -1261,55 +1281,162 @@ def stability_and_control_data(self, *, filename=None): # pylint: disable=too-m ------- None """ + asymmetric = not self.flight.rocket.is_axisymmetric - plt.figure(figsize=(9, 6)) - - ax1 = plt.subplot(211) - ax1.plot(self.flight.stability_margin[:, 0], self.flight.stability_margin[:, 1]) + plt.figure(figsize=(9, 4.5)) + ax1 = plt.subplot(111) + ax1.plot( + self.flight.stability_margin[:, 0], + self.flight.stability_margin[:, 1], + label="Pitch" if asymmetric else "Stability margin", + ) + if asymmetric: + ax1.plot( + self.flight.stability_margin_yaw[:, 0], + self.flight.stability_margin_yaw[:, 1], + label="Yaw", + ) ax1.set_title("Stability Margin") ax1.set_xlabel("Time (s)") ax1.set_ylabel("Stability Margin (c)") ax1.set_xlim(0, self.first_parachute_event_time) + ax1.legend() ax1.grid() - self._add_event_markers_dropline(ax1, labels={"Burnout"}) + # Secondary y-axis reading the same margin as a percentage of the + # rocket's overall length (see Rocket.length), the convention often used + # in hobby rocketry. A margin in calibers and the same margin as a + # fraction of body length differ only by the constant factor below, so + # the second scale is a plain rescaling of the caliber axis. + # A rocket with no aerodynamic surfaces has no defined length, so the + # percentage-of-length scale can't be drawn; skip it in that case. + rocket = self.flight.rocket + rocket_length = rocket.length if rocket.aerodynamic_surfaces else 0 + if rocket_length > 0: + factor = 2 * rocket.radius / rocket_length * 100 + secondary_axis = ax1.secondary_yaxis( + "right", + functions=(lambda c: c * factor, lambda p: p / factor), + ) + secondary_axis.set_ylabel("Stability Margin (% of length)") + self._add_event_markers_dropline(ax1, labels={"Out Of Rail", "Burnout"}) - ax2 = plt.subplot(212) - x_axis = np.arange(0, 5, 0.01) - max_attitude = self.flight.attitude_frequency_response.max - max_attitude = max_attitude if max_attitude != 0 else 1 - ax2.plot( - x_axis, - self.flight.attitude_frequency_response(x_axis) / max_attitude, - label="Attitude Angle", - ) - max_omega1 = self.flight.omega1_frequency_response.max - max_omega1 = max_omega1 if max_omega1 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega1_frequency_response(x_axis) / max_omega1, - label=r"$\omega_1$", + show_or_save_plot(filename) + + def stability_and_control_data(self, *, filename=None): + """Deprecated. Stability and the frequency response are now separate + plots. + + Use :meth:`stability_margin_data` for the stability margin, and + :meth:`dynamic_stability_data` for the natural frequency, damping ratio + and attitude frequency response. + + Parameters + ---------- + filename : str | None, optional + Passed through to both replacement plots. + """ + warnings.warn( + "stability_and_control_data() is deprecated and will be removed in " + "v1.13. Stability is now its own plot: use stability_margin_data() " + "for the stability margin, and dynamic_stability_data() for the " + "natural frequency, damping ratio and attitude frequency response.", + DeprecationWarning, + stacklevel=2, ) - max_omega2 = self.flight.omega2_frequency_response.max - max_omega2 = max_omega2 if max_omega2 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega2_frequency_response(x_axis) / max_omega2, - label=r"$\omega_2$", + self.stability_margin_data(filename=filename) + self.dynamic_stability_data(filename=filename) + + def dynamic_stability_data(self, *, filename=None): # pylint: disable=too-many-statements + """Plots the rocket's dynamic-stability quantities over the flight: the + pitch (and, for non-axisymmetric rockets, yaw) natural frequency and + damping ratio of the linearized attitude oscillation, together with the + attitude frequency response (the FFT of the simulated oscillation), which + independently verifies the predicted natural frequency. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + asymmetric = not self.flight.rocket.is_axisymmetric + # Cap the time axis at apogee: the attitude oscillation is only + # meaningful during ascent. Fall back to the first parachute event (or + # flight end) when there is no apogee, e.g. a flight cut short before it. + upper = ( + self.flight.apogee_time + if self.flight.apogee_time != 0 + else self.first_parachute_event_time ) - max_omega3 = self.flight.omega3_frequency_response.max - max_omega3 = max_omega3 if max_omega3 != 0 else 1 - ax2.plot( - x_axis, - self.flight.omega3_frequency_response(x_axis) / max_omega3, - label=r"$\omega_3$", + + plt.figure(figsize=(9, 9)) + + ax1 = plt.subplot(311) + freq = self.flight.pitch_natural_frequency + ax1.plot(freq[:, 0], freq[:, 1] / (2 * np.pi), label="Pitch natural freq.") + if asymmetric: + yaw_freq = self.flight.yaw_natural_frequency + ax1.plot( + yaw_freq[:, 0], + yaw_freq[:, 1] / (2 * np.pi), + "--", + label="Yaw natural freq.", + ) + roll_rate = self.flight.w3 + ax1.plot( + roll_rate[:, 0], + np.abs(roll_rate[:, 1]) / (2 * np.pi), + ":", + color="tab:red", + label="Roll rate", ) - ax2.set_title("Frequency Response") - ax2.set_xlabel("Frequency (Hz)") - ax2.set_ylabel("Amplitude Magnitude Normalized") - ax2.set_xlim(0, 5) + ax1.set_title("Natural Frequency & Roll Rate") + ax1.set_xlabel("Time (s)") + ax1.set_ylabel("Frequency (Hz)") + ax1.set_xlim(0, upper) + ax1.legend() + ax1.grid() + self._add_event_markers_dropline(ax1, labels={"Out Of Rail", "Burnout"}) + + ax2 = plt.subplot(312) + ratio = self.flight.pitch_damping_ratio + ax2.plot(ratio[:, 0], ratio[:, 1], label="Pitch") + if asymmetric: + yaw_ratio = self.flight.yaw_damping_ratio + ax2.plot(yaw_ratio[:, 0], yaw_ratio[:, 1], "--", label="Yaw") + ax2.set_title("Damping Ratio") + ax2.set_xlabel("Time (s)") + ax2.set_ylabel("Damping Ratio (ζ)") + ax2.set_xlim(0, upper) ax2.legend() ax2.grid() + # Frequency response: the FFT spectrum of the simulated attitude and body + # rates. Its peak should fall at the natural frequency plotted above, + # giving an independent check of the linearized prediction. + ax3 = plt.subplot(313) + x_axis = np.arange(0, 5, 0.01) + for response, label in ( + (self.flight.attitude_frequency_response, "Attitude Angle"), + (self.flight.omega1_frequency_response, r"$\omega_1$"), + (self.flight.omega2_frequency_response, r"$\omega_2$"), + (self.flight.omega3_frequency_response, r"$\omega_3$"), + ): + peak = response.max if response.max != 0 else 1 + ax3.plot(x_axis, response(x_axis) / peak, label=label) + ax3.set_title("Attitude Frequency Response") + ax3.set_xlabel("Frequency (Hz)") + ax3.set_ylabel("Amplitude Magnitude Normalized") + ax3.set_xlim(0, 5) + ax3.legend() + ax3.grid() + plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) @@ -1428,7 +1555,11 @@ def altitude_data(self, *, filename=None): if not xlim[0] <= t_ev <= xlim[1]: continue alt_ev = float(np.interp(t_ev, z_times, z_agl)) - line_color = self._BURNOUT_LINE_COLOR if label == "Burnout" else color + line_color = ( + self._BURNOUT_LINE_COLOR + if label in ("Burnout", "Out Of Rail") + else color + ) lw = ( self._EVENT_LINE_WIDTH if label == "Burnout" else self._EVENT_LINE_WIDTH ) @@ -1662,6 +1793,19 @@ def _ylim_in_range(arr): top = float(np.percentile(vals, 95)) * 1.5 return max(top, 1.0) + def _ylim_signed(arr): + # Symmetric y-limits for signed quantities (partial angle of attack, + # sideslip): these are arctan2-based and routinely go negative, so a + # 0 lower bound would clip half the signal. Scale by the 95th + # percentile of the magnitude to ignore the runaway rise near apogee. + mask = (arr[:, 0] >= t_lower) & (arr[:, 0] <= t_upper) + vals = arr[mask, 1] + if len(vals) == 0: + return (-10.0, 10.0) + top = float(np.percentile(np.abs(vals), 95)) * 1.5 + top = max(top, 1.0) + return (-top, top) + plt.figure(figsize=(9, 9)) ax1 = plt.subplot(311) @@ -1679,7 +1823,8 @@ def _ylim_in_range(arr): self.flight.partial_angle_of_attack[:, 1], ) ax2.set_xlim(t_lower, t_upper) - ax2.set_ylim(0, _ylim_in_range(self.flight.partial_angle_of_attack[:, :])) + ax2.set_ylim(*_ylim_signed(self.flight.partial_angle_of_attack[:, :])) + ax2.axhline(0, color="0.6", linewidth=0.8) ax2.set_title("Partial Angle of Attack") ax2.set_xlabel("Time (s)") ax2.set_ylabel("Partial Angle of Attack (°)") @@ -1690,7 +1835,8 @@ def _ylim_in_range(arr): self.flight.angle_of_sideslip[:, 0], self.flight.angle_of_sideslip[:, 1] ) ax3.set_xlim(t_lower, t_upper) - ax3.set_ylim(0, _ylim_in_range(self.flight.angle_of_sideslip[:, :])) + ax3.set_ylim(*_ylim_signed(self.flight.angle_of_sideslip[:, :])) + ax3.axhline(0, color="0.6", linewidth=0.8) ax3.set_title("Angle of Sideslip") ax3.set_xlabel("Time (s)") ax3.set_ylabel("Angle of Sideslip (°)") @@ -1725,6 +1871,12 @@ def all(self): # pylint: disable=too-many-statements print("\n\nTrajectory Angular Velocity and Acceleration Plots\n") self.angular_kinematics_data() + print("\n\nStability Margin Plot\n") + self.stability_margin_data() + + print("\n\nDynamic Stability Plots\n") + self.dynamic_stability_data() + print("\n\nAngle of Attack Plots\n") self.angle_of_attack_data() @@ -1749,9 +1901,6 @@ def all(self): # pylint: disable=too-many-statements print("\n\nTrajectory Fluid Mechanics Plots\n") self.fluid_mechanics_data() - print("\n\nTrajectory Stability and Control Plots\n") - self.stability_and_control_data() - if self.flight.sensors: print("\n\nSensor Data Plots\n") self.sensor_data() diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 47da8a78b..ed7c6d3bb 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -1,12 +1,15 @@ +import os + import matplotlib.pyplot as plt import numpy as np +from rocketpy.mathutils.function import Function from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors import EmptyMotor, HybridMotor, LiquidMotor, SolidMotor from rocketpy.rocket.aero_surface import Fin, Fins, NoseCone, Tail from rocketpy.rocket.aero_surface.generic_surface import GenericSurface -from .plot_helpers import show_or_save_plot +from .plot_helpers import show_or_save_fig, show_or_save_plot class _RocketPlots: @@ -53,9 +56,28 @@ def reduced_mass(self): self.rocket.reduced_mass() + def _caliber_to_length_percent(self): + """Return the (forward, inverse) pair converting a margin in calibers to + a percentage of the rocket's overall aerodynamic length. + + A margin in calibers is ``distance / (2 * radius)``; the same distance as + a fraction of the body length is ``distance / length``. The two therefore + differ only by the constant factor ``2 * radius / length`` (times 100 for + a percentage), so the length-percentage scale is a plain rescaling of the + caliber scale and can be drawn as a secondary axis. See + :attr:`rocketpy.Rocket.length`. + """ + factor = 2 * self.rocket.radius / self.rocket.length * 100 + return (lambda calibers: calibers * factor, lambda percent: percent / factor) + def static_margin(self, *, filename=None): """Plots static margin of the rocket as a function of time. + A secondary y-axis on the right expresses the same margin as a + percentage of the rocket's overall length (see + :attr:`rocketpy.Rocket.length`), the convention often used in hobby + rocketry, alongside the primary caliber (diameter) axis. + Parameters ---------- filename : str | None, optional @@ -68,23 +90,107 @@ def static_margin(self, *, filename=None): ------- None """ + self._plot_static_margin(self.rocket.static_margin, "Static Margin", filename) + + def _plot_static_margin(self, margin, title, filename): + """Draw a static-margin-vs-time line plot with a caliber primary y-axis + and a length-percentage secondary y-axis.""" + time = np.linspace(0, self.rocket.motor.burn_out_time, 200) + values = margin.get_value(time) + + fig, ax = plt.subplots() + ax.plot(time, values) + ax.set_xlabel("Time (s)") + ax.set_ylabel("Static Margin (calibers)") + ax.set_title(title) + ax.grid(True) + + secondary_axis = ax.secondary_yaxis( + "right", functions=self._caliber_to_length_percent() + ) + secondary_axis.set_ylabel("Static Margin (% of length)") - self.rocket.static_margin(filename=filename) + show_or_save_fig(fig, filename) - def stability_margin(self): - """Plots static margin of the rocket as a function of time. + def stability_margin(self, *, filename=None): + """Plots the stability margin of the rocket as a function of Mach number + and time, at zero angle of attack (the design surface). + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. + + Returns + ------- + None + """ + self._design_stability_margin(self.rocket.stability_margin).plot_2d( + lower=0, + upper=[2, self.rocket.motor.burn_out_time], # Mach 2 and burnout + samples=[20, 20], + disp_type="surface", + alpha=1, + filename=filename, + ) + + def static_margin_yaw(self, *, filename=None): + """Plots the yaw-plane static margin of the rocket as a function of + time. Only meaningful for non-axisymmetric rockets; for an axisymmetric + rocket it is identical to :meth:`static_margin`. + + A secondary y-axis expresses the margin as a percentage of the rocket's + overall length (see :attr:`rocketpy.Rocket.length`). + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). Returns ------- None """ + self._plot_static_margin( + self.rocket.static_margin_yaw, "Static Margin (Yaw Plane)", filename + ) + + def stability_margin_yaw(self, *, filename=None): + """Plots the yaw-plane stability margin of the rocket as a function of + Mach number and time. Only meaningful for non-axisymmetric rockets; for + an axisymmetric rocket it is identical to :meth:`stability_margin`. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. - self.rocket.stability_margin.plot_2d( + Returns + ------- + None + """ + self._design_stability_margin(self.rocket.stability_margin_yaw).plot_2d( lower=0, upper=[2, self.rocket.motor.burn_out_time], # Mach 2 and burnout samples=[20, 20], disp_type="surface", alpha=1, + filename=filename, + ) + + @staticmethod + def _design_stability_margin(margin): + """The zero-incidence (Mach, time) slice of an angle-of-attack-aware + stability margin, as a 2-D Function for surface plotting.""" + return Function( + lambda mach, time: margin.get_value_opt(0.0, mach, time), + inputs=["Mach", "Time (s)"], + outputs=margin.__outputs__[0], ) # pylint: disable=too-many-statements @@ -198,7 +304,34 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): "line_width": 1.0, } - _, ax = plt.subplots(figsize=(8, 6), facecolor=vis_args["background"]) + # A non-axisymmetric rocket looks different in the xz and yz planes + # (e.g. canards or fins present in one plane only), so draw both for + # comparison; an axisymmetric rocket looks the same in either plane. + planes = [plane] if self.rocket.is_axisymmetric else ["xz", "yz"] + + # Each plane is drawn in its own figure so the projections can be read + # and saved independently. When saving multiple planes, the plane name + # is appended to the filename (e.g. ``rocket_xz.png``). + for draw_plane in planes: + _, ax = plt.subplots( + figsize=(8, 6), + facecolor=vis_args["background"], + ) + self._draw_on_plane(ax, vis_args, draw_plane) + plt.tight_layout() + show_or_save_plot(self.__plane_filename(filename, draw_plane, planes)) + + @staticmethod + def __plane_filename(filename, plane, planes): + """Inserts the plane name before the extension when more than one plane + is drawn so each figure is saved to a distinct file.""" + if filename is None or len(planes) == 1: + return filename + root, ext = os.path.splitext(filename) + return f"{root}_{plane}{ext}" + + def _draw_on_plane(self, ax, vis_args, plane): + """Draws the rocket onto a single axis for the given projection plane.""" ax.set_aspect("equal") ax.grid(True, linestyle="--", linewidth=0.5) @@ -210,17 +343,17 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): last_radius, last_x = self._draw_tubes(ax, drawn_surfaces, vis_args) self._draw_motor(last_radius, last_x, ax, vis_args) self._draw_rail_buttons(ax, vis_args) - self._draw_center_of_mass_and_pressure(ax) + self._draw_center_of_mass_and_pressure(ax, plane) self._draw_sensors(ax, self.rocket.sensors, plane) - plt.title("Rocket Representation") - plt.xlim() - plt.ylim([-self.rocket.radius * 4, self.rocket.radius * 6]) - plt.xlabel("Position (m)") - plt.ylabel("Radius (m)") - plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") - plt.tight_layout() - show_or_save_plot(filename) + title = "Rocket Representation" + if not self.rocket.is_axisymmetric: + title += f" ({plane} plane)" + ax.set_title(title) + ax.set_ylim([-self.rocket.radius * 4, self.rocket.radius * 6]) + ax.set_xlabel("Position (m)") + ax.set_ylabel("Radius (m)") + ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left") def __validate_aerodynamic_surfaces(self, plane): if not self.rocket.aerodynamic_surfaces: @@ -632,16 +765,18 @@ def _draw_rail_buttons(self, ax, vis_args): except IndexError: pass - def _draw_center_of_mass_and_pressure(self, ax): - """Draws the center of mass and center of pressure of the rocket.""" + def _draw_center_of_mass_and_pressure(self, ax, plane="xz"): + """Draws the center of mass and center of pressure of the rocket. + + The red dot is the (linear) aerodynamic center, conventionally labeled + the center of pressure. + """ # Draw center of mass and center of pressure cm = self.rocket.center_of_mass(0) ax.scatter(cm, 0, color="#1565c0", label="Center of Mass", s=10) - cp = self.rocket.cp_position(0) - ax.scatter( - cp, 0, label="Static Center of Pressure", color="red", s=10, zorder=10 - ) + cp = self.rocket.aerodynamic_center(0) + ax.scatter(cp, 0, label="Center of Pressure", color="red", s=10, zorder=10) def _draw_sensors(self, ax, sensors, plane): """Draw the sensor as a small thick line at the position of the sensor, @@ -730,6 +865,11 @@ def all(self): print("-" * 20) # Separator for Stability Plots self.static_margin() self.stability_margin() + # Non-axisymmetric rockets: the above describe the pitch plane only, so + # also show the yaw-plane margins. + if not self.rocket.is_axisymmetric: + self.static_margin_yaw() + self.stability_margin_yaw() # Thrust-to-Weight Plot print("\nThrust-to-Weight Plot") diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py index cc36f1b01..12a2ba5c5 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -1,11 +1,58 @@ -from abc import ABC, abstractmethod +import numpy as np -# TODO: the rocketpy/prints/aero_surface_prints.py file could be separated into different, smaller files. -class _AeroSurfacePrints(ABC): +# The print classes mirror the aerodynamic-surface class hierarchy: +# GenericSurface -> _GenericSurfacePrints (root) +# LinearGenericSurface -> _LinearGenericSurfacePrints +# _BarrowmanSurface -> _BarrowmanSurfacePrints (adds clalpha/CP lift) +# NoseCone / Tail / Fins/Fin -> the leaf print classes below +# ControllableGenericSurface / AirBrakes / RailButtons -> generic-rooted leaves +# TODO: this file could be separated into different, smaller files. +class _GenericSurfacePrints: + """Base prints for a generic aerodynamic surface.""" + def __init__(self, aero_surface): self.aero_surface = aero_surface + def coefficients(self): + """Prints a summary of the surface's main aerodynamic coefficients. + + For every non-zero coefficient (``cL, cQ, cD, cm, cn``) reports its + value at a reference condition (5° angle of attack and sideslip, Mach + 0.3) and, when available, the variables it depends on. Works across all + surface types that expose the uniform coefficient accessors. + """ + surface = self.aero_surface + independent_vars = getattr(surface, "independent_vars", None) + if independent_vars is None: + return + index = {name: i for i, name in enumerate(independent_vars)} + if "mach" not in index: + return + n_args = len(independent_vars) + + print("Aerodynamic coefficients (AoA 5°, sideslip 5°, Mach 0.3):") + print("---------------------------------------------------------") + args = [0.0] * n_args + args[index["mach"]] = 0.3 + if "alpha" in index: + args[index["alpha"]] = np.deg2rad(5) + if "beta" in index: + args[index["beta"]] = np.deg2rad(5) + printed = False + for name in ("cL", "cQ", "cD", "cm", "cn"): + coeff = getattr(surface, name, None) + if coeff is None or getattr(coeff, "is_zero", False): + continue + value = coeff(*args) + depends = getattr(coeff, "depends_on", None) + suffix = f" [depends on {', '.join(depends)}]" if depends else "" + print(f" {name} = {value:.4f}{suffix}") + printed = True + if not printed: + print(" (all zero)") + print() + def identity(self): """Prints the identity of the aero surface. @@ -18,9 +65,33 @@ def identity(self): print(f"Name: {self.aero_surface.name}") print(f"Python Class: {str(self.aero_surface.__class__)}\n") - @abstractmethod def geometry(self): - pass + """Prints the reference geometry of the generic surface.""" + print("Geometric information of the Surface:") + print("----------------------------------") + print(f"Reference Area: {self.aero_surface.reference_area:.3f} m^2") + print(f"Reference length: {self.aero_surface.reference_length:.3f} m\n") + + def all(self): + """Prints all information of the generic surface. + + Returns + ------- + None + """ + self.identity() + self.geometry() + self.coefficients() + + +class _LinearGenericSurfacePrints(_GenericSurfacePrints): + """Prints for a linear generic surface; same reporting as the generic + base.""" + + +class _BarrowmanSurfacePrints(_LinearGenericSurfacePrints): + """Prints shared by the geometry-defined (Barrowman) surfaces: adds the + center-of-pressure / lift-curve-slope report on top of the generic base.""" def lift(self): """Prints the lift information of the aero surface. @@ -51,9 +122,10 @@ def all(self): self.identity() self.geometry() self.lift() + self.coefficients() -class _NoseConePrints(_AeroSurfacePrints): +class _NoseConePrints(_BarrowmanSurfacePrints): """Class that contains all nosecone prints.""" def geometry(self): @@ -72,7 +144,7 @@ def geometry(self): print(f"Reference radius ratio: {self.aero_surface.radius_ratio:.3f}\n") -class _FinsPrints(_AeroSurfacePrints): +class _FinsPrints(_BarrowmanSurfacePrints): def geometry(self): print("Geometric information of the fin set:") print("-------------------------------------") @@ -170,7 +242,7 @@ def all(self): self.lift() -class _FinPrints(_AeroSurfacePrints): +class _FinPrints(_BarrowmanSurfacePrints): def geometry(self): print("Geometric information of the fin set:") print("-------------------------------------") @@ -291,7 +363,7 @@ class _FreeFormFinPrints(_FinPrints): """Class that contains all free form fins prints.""" -class _TailPrints(_AeroSurfacePrints): +class _TailPrints(_BarrowmanSurfacePrints): """Class that contains all tail prints.""" def geometry(self): @@ -311,7 +383,7 @@ def geometry(self): print(f"Surface area: {self.aero_surface.surface_area:.6f} m²\n") -class _RailButtonsPrints(_AeroSurfacePrints): +class _RailButtonsPrints(_GenericSurfacePrints): """Class that contains all rail buttons prints.""" def geometry(self): @@ -327,7 +399,7 @@ def geometry(self): ) -class _AirBrakesPrints(_AeroSurfacePrints): +class _AirBrakesPrints(_GenericSurfacePrints): """Class that contains all air_brakes prints. Not yet implemented.""" def geometry(self): @@ -335,45 +407,3 @@ def geometry(self): def all(self): pass - - -class _GenericSurfacePrints(_AeroSurfacePrints): - """Class that contains all generic surface prints.""" - - def geometry(self): - print("Geometric information of the Surface:") - print("----------------------------------") - print(f"Reference Area: {self.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") - - def all(self): - """Prints all information of the generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.lift() - - -class _LinearGenericSurfacePrints(_AeroSurfacePrints): - """Class that contains all linear generic surface prints.""" - - def geometry(self): - print("Geometric information of the Surface:") - print("----------------------------------") - print(f"Reference Area: {self.generic_surface.reference_area:.3f} m") - print(f"Reference length: {2 * self.generic_surface.rocket_radius:.3f} m") - - def all(self): - """Prints all information of the linear generic surface. - - Returns - ------- - None - """ - self.identity() - self.geometry() - self.lift() diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index e28ed2a12..34e63a22b 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -612,25 +612,98 @@ def stability_margin(self): The stability margin is typically measured in calibers (c), where 1 caliber is the diameter of the rocket. """ + # The stability margin is reported in calibers and, when the rocket has + # a defined overall length, also as a percentage of that length (the + # convention often used in hobby rocketry). See Rocket.length. + rocket = self.flight.rocket + length = rocket.length if rocket.aerodynamic_surfaces else 0 + to_percent = 2 * rocket.radius / length * 100 if length > 0 else None + + def _margin(value): + """Format a margin in calibers, appending the length percentage when + the rocket length is known.""" + if to_percent is None: + return f"{value:.3f} c" + return f"{value:.3f} c ({value * to_percent:.2f}% of length)" + print("\nStability Margin\n") print( - f"Initial Stability Margin: {self.flight.initial_stability_margin:.3f} c " + f"Initial Stability Margin: {_margin(self.flight.initial_stability_margin)} " f"at {self.flight.time[0]:.2f} s" ) print( "Out of Rail Stability Margin: " - f"{self.flight.out_of_rail_stability_margin:.3f} c " + f"{_margin(self.flight.out_of_rail_stability_margin)} " f"at {self.flight.out_of_rail_time:.2f} s" ) print( - f"Maximum Stability Margin: {self.flight.max_stability_margin:.3f} c " + f"Maximum Stability Margin: {_margin(self.flight.max_stability_margin)} " f"at {self.flight.max_stability_margin_time:.2f} s" ) print( - f"Minimum Stability Margin: {self.flight.min_stability_margin:.3f} c " + f"Minimum Stability Margin: {_margin(self.flight.min_stability_margin)} " f"at {self.flight.min_stability_margin_time:.2f} s" ) + out_of_rail_time = self.flight.out_of_rail_time + # The margins above describe the pitch plane. For a non-axisymmetric + # rocket, also report the yaw-plane margin at rail departure. + if not self.flight.rocket.is_axisymmetric: + print( + "Out of Rail Stability Margin - yaw: " + f"{_margin(self.flight.stability_margin_yaw.get_value_opt(out_of_rail_time))}" + ) + + def dynamic_stability(self): + """Prints the rocket's dynamic-stability quantities at the key instants + of the ascent. + + For rail departure and motor burnout it reports the attitude + oscillation's natural frequency and damping ratio (pitch, and yaw as + well for a non-axisymmetric rocket), and it reports the roll rate at + burnout. Companion summary to ``Flight.plots.dynamic_stability_data``. + + Notes + ----- + The natural frequency sets how fast the rocket oscillates after a + disturbance; the damping ratio sets how quickly that oscillation decays + (below 1 is underdamped, the usual case). Roll resonance is a concern + where the roll rate crosses the natural frequency: the + ``dynamic_stability_data`` plot overlays the two so those crossings can + be read off directly. Frequencies are given in Hz. + """ + two_pi = 6.283185307179586 + flight = self.flight + asymmetric = not flight.rocket.is_axisymmetric + + def report(label, time): + natural_frequency = ( + flight.pitch_natural_frequency.get_value_opt(time) / two_pi + ) + damping_ratio = flight.pitch_damping_ratio.get_value_opt(time) + plane = "Pitch " if asymmetric else "" + print( + f"{label} (t = {time:.2f} s): {plane}natural frequency = " + f"{natural_frequency:.2f} Hz, damping ratio = {damping_ratio:.3f}" + ) + if asymmetric: + yaw_frequency = ( + flight.yaw_natural_frequency.get_value_opt(time) / two_pi + ) + yaw_damping = flight.yaw_damping_ratio.get_value_opt(time) + print( + f" Yaw natural frequency = {yaw_frequency:.2f} Hz, " + f"damping ratio = {yaw_damping:.3f}" + ) + + burn_out_time = flight.rocket.motor.burn_out_time + + print("\nDynamic Stability\n") + report("Out of Rail", flight.out_of_rail_time) + report("Burnout", burn_out_time) + roll_rate = abs(flight.w3.get_value_opt(burn_out_time)) / two_pi + print(f"Roll Rate at Burnout: {roll_rate:.2f} Hz") + def all(self): """Prints out all data available about the Flight. This method invokes all other print methods in the class. @@ -668,6 +741,9 @@ def all(self): self.stability_margin() print() + self.dynamic_stability() + print() + self.maximum_values() print() diff --git a/rocketpy/prints/rocket_prints.py b/rocketpy/prints/rocket_prints.py index 7b768ea2f..d9021b6e6 100644 --- a/rocketpy/prints/rocket_prints.py +++ b/rocketpy/prints/rocket_prints.py @@ -126,21 +126,56 @@ def rocket_aerodynamics_quantities(self): f"Center of Mass position (time=0): {self.rocket.center_of_mass(0):.3f} m" ) print( - f"Center of Pressure position (time=0): {self.rocket.cp_position(0):.3f} m" - ) + f"Aerodynamic Center position (Mach=0): " + f"{self.rocket.aerodynamic_center(0):.3f} m" + ) + # The static margin is reported in calibers and, when the rocket has a + # defined overall length, also as a percentage of that length (the + # convention often used in hobby rocketry). See Rocket.length. + burn_out_time = self.rocket.motor.burn_out_time + length = self.rocket.length if self.rocket.aerodynamic_surfaces else 0 + to_percent = 2 * self.rocket.radius / length * 100 if length > 0 else None + + def _margin(value): + """Format a margin in calibers, appending the length percentage when + the rocket length is known.""" + if to_percent is None: + return f"{value:.3f} c" + return f"{value:.3f} c ({value * to_percent:.2f}% of length)" + + if length > 0: + print(f"Rocket Length: {length:.3f} m") print( f"Initial Static Margin (mach=0, time=0): " - f"{self.rocket.static_margin(0):.3f} c" + f"{_margin(self.rocket.static_margin(0))}" ) print( f"Final Static Margin (mach=0, time=burn_out): " - f"{self.rocket.static_margin(self.rocket.motor.burn_out_time):.3f} c" + f"{_margin(self.rocket.static_margin(burn_out_time))}" ) print( - f"Rocket Center of Mass (time=0) - Center of Pressure (mach=0): " - f"{abs(self.rocket.center_of_mass(0) - self.rocket.cp_position(0)):.3f} m\n" + f"Rocket Center of Mass (time=0) - Aerodynamic Center (Mach=0): " + f"{abs(self.rocket.center_of_mass(0) - self.rocket.aerodynamic_center(0)):.3f} m\n" ) + if not self.rocket.is_axisymmetric: + print( + "The rocket is NOT axisymmetric: the values above describe the " + "PITCH plane. Yaw plane:\n" + ) + print( + f"Aerodynamic Center position - yaw (Mach=0): " + f"{self.rocket.aerodynamic_center_yaw(0):.3f} m" + ) + print( + f"Initial Static Margin - yaw (mach=0, time=0): " + f"{_margin(self.rocket.static_margin_yaw(0))}" + ) + print( + f"Final Static Margin - yaw (mach=0, time=burn_out): " + f"{_margin(self.rocket.static_margin_yaw(burn_out_time))}\n" + ) + def parachute_data(self): """Print parachute data. diff --git a/rocketpy/rocket/__init__.py b/rocketpy/rocket/__init__.py index afb7f0bb6..94ad5e8ee 100644 --- a/rocketpy/rocket/__init__.py +++ b/rocketpy/rocket/__init__.py @@ -2,6 +2,7 @@ from rocketpy.rocket.aero_surface import ( AeroSurface, AirBrakes, + ControllableGenericSurface, EllipticalFin, EllipticalFins, Fin, diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py index 7634d3500..7a6e7ac2d 100644 --- a/rocketpy/rocket/aero_surface/__init__.py +++ b/rocketpy/rocket/aero_surface/__init__.py @@ -1,5 +1,8 @@ from rocketpy.rocket.aero_surface.aero_surface import AeroSurface from rocketpy.rocket.aero_surface.air_brakes import AirBrakes +from rocketpy.rocket.aero_surface.controllable_generic_surface import ( + ControllableGenericSurface, +) from rocketpy.rocket.aero_surface.fins import ( EllipticalFin, EllipticalFins, diff --git a/rocketpy/rocket/aero_surface/_barrowman_surface.py b/rocketpy/rocket/aero_surface/_barrowman_surface.py new file mode 100644 index 000000000..dc161b9e6 --- /dev/null +++ b/rocketpy/rocket/aero_surface/_barrowman_surface.py @@ -0,0 +1,226 @@ +import numpy as np + +from rocketpy.mathutils.vector_matrix import Matrix, Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient +from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface + + +class _BarrowmanSurface(LinearGenericSurface): + """Intermediate base for Barrowman-defined aerodynamic surfaces + such as nose cones, tails/transitions and fin sets. + + These surfaces expose a lift-curve slope ``clalpha`` (a ``Function`` of + Mach), a geometric center of pressure ``cpz`` and, for fins, a pair of roll + forcing/damping coefficients. + + The in-flight normal force and its moment are computed with the classic + Barrowman method (see :meth:`compute_forces_and_moments`): the normal force + uses the true total angle of attack and acts at the geometric center of + pressure, and its moment about the center of dry mass is the geometric + transport (``cp ^ force``). This reproduces the formulation used in + RocketPy's flight-test validation. The resultant force is therefore reported + at the geometric center of pressure (:attr:`force_application_point`), which + the surface-local frame maps to the body frame through + :meth:`_default_surface_rotation`. + + The class also derives the linear normal-force slopes ``cN_alpha`` (pitch + plane) and ``cY_beta`` (yaw plane), which feed the stability and + center-of-pressure diagnostics; the geometric cp is carried by the force + application point, so the moment slopes ``cm_alpha`` / ``cn_beta`` are zero. + Fin roll uses the coefficient model: ``cl_0`` (cant forcing) and ``cl_p`` + (roll damping). + + Subclasses must compute ``self.clalpha`` (Function of Mach) and the geometric + center of pressure before calling ``super().__init__`` (which passes the + geometric cp through ``center_of_pressure``), and, for fins, set + ``self.roll_parameters = [clf_delta, cld_omega, cant_angle_rad]``. + """ + + # Geometry-defined Barrowman surfaces are axisymmetric by construction + # (``cY_beta = -cN_alpha``, etc.), so they contribute identically to the + # pitch and yaw planes. The individual ``Fin`` overrides this back to False. + is_axisymmetric = True + + @staticmethod + def _beta(mach): + """Prandtl-Glauert compressibility factor used to correct subsonic + force coefficients of the nose cone, fins and tails/transitions, as in + Barrowman. + + Parameters + ---------- + mach : int, float + Mach number. + + Returns + ------- + beta : float + Compressibility factor based on the Mach number. + + References + ---------- + [1] Barrowman, James S. https://arc.aiaa.org/doi/10.2514/6.1979-504 + """ + if mach < 0.8: + return np.sqrt(1 - mach**2) + elif mach < 1.1: + return np.sqrt(1 - 0.8**2) + else: + return np.sqrt(mach**2 - 1) + + def _default_surface_rotation(self): + """Rotation from the surface-local frame to the body frame. A Barrowman + surface is defined in a frame flipped 180 degrees about the transverse + axis relative to the body frame (its z axis runs from the nose toward the + tail), so its geometric center of pressure maps to the body frame through + this rotation. This is RocketPy's classic convention, so the surface's + center of pressure lands at the same body-frame point as before the + generic-surface refactor. + """ + return Matrix([[-1, 0, 0], [0, 1, 0], [0, 0, -1]]) + + def evaluate_coefficients(self): + """Populate the coefficient slopes used by the stability diagnostics + from the surface geometry. Called by ``GenericSurface.__init__`` and + again whenever the geometry changes. + + Sets the normal-force slopes ``cN_alpha`` (pitch) and ``cY_beta`` (yaw) + and the fin roll coefficients when present. The geometric center of + pressure is carried by the force application point (not the moment + coefficients), so ``cm_alpha`` / ``cn_beta`` are zero. The in-flight + force and moment are computed geometrically in + :meth:`compute_forces_and_moments`. + """ + clalpha = self.clalpha # normal-force-curve slope, a Function of Mach + + # Axisymmetric Barrowman normal force: equal-magnitude slopes in the + # pitch and yaw planes. The yaw-plane (side-force) slope is opposite in + # sign due to the body-frame axis convention. + self.cN_alpha = self._mach_coefficient(clalpha.get_value_opt, "cN_alpha") + self.cY_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach), "cY_beta" + ) + + # The center of pressure is carried by the force application point, so + # the moment slopes add no further offset (the diagnostic recovers the + # geometric cp from the application point alone). + self.cm_alpha = self._mach_coefficient(lambda mach: 0.0, "cm_alpha") + self.cn_beta = self._mach_coefficient(lambda mach: 0.0, "cn_beta") + + # Fin roll forcing (cant) and damping, when present. + roll_parameters = getattr(self, "roll_parameters", None) + if roll_parameters is not None: + clf_delta, cld_omega, cant_angle_rad = roll_parameters + self.cl_0 = self._mach_coefficient( + lambda mach: clf_delta.get_value_opt(mach) * cant_angle_rad, "cl_0" + ) + self.cl_p = self._mach_coefficient(cld_omega.get_value_opt, "cl_p") + + def compute_forces_and_moments( + self, + stream_velocity, + stream_speed, + stream_mach, + rho, + cp, + omega, + *args, # pylint: disable=unused-argument + ): + """Compute the surface's forces and moments with the classic Barrowman + method. Called at each simulation step. + + The normal force uses the true total angle of attack between the flow + and the body axis, ``attack_angle = arccos(-v_z / |v|)``, giving + ``0.5 * rho * V**2 * A_ref * clalpha(Mach) * attack_angle``. It is + applied perpendicular to the body axis (along the transverse flow) at the + geometric center of pressure, and its moment about the rocket's center of + dry mass is the geometric transport ``cp ^ force``. Fin sets add their + roll moment on top. + + Parameters + ---------- + stream_velocity : Vector + Velocity of the airflow relative to the surface, in the body frame. + stream_speed : float + Magnitude of the airflow speed. + stream_mach : float + Mach number of the airflow. + rho : float + Air density. + cp : Vector + Surface center of pressure relative to the center of dry mass, in + the body frame (the force-application point; see + :attr:`force_application_point`). + omega : tuple of float + Body angular velocity about the x, y, z axes. Only the roll + component (``omega[2]``) is used, by fin sets. + *args + Extra positional arguments accepted for signature compatibility with + the generic surface (``density``, ``dynamic_viscosity``, ``z``); + unused by the Barrowman model. + + Returns + ------- + tuple of float + The forces (x, y, z) and the moments about the x, y, z axes, in the + body frame. + """ + R1 = R2 = R3 = M1 = M2 = M3 = 0.0 + + stream_vx, stream_vy, stream_vz = stream_velocity + if stream_vx**2 + stream_vy**2 != 0: + stream_vzn = stream_vz / stream_speed + if -stream_vzn < 1: + attack_angle = np.arccos(-stream_vzn) + c_lift = self.clalpha.get_value_opt(stream_mach) * attack_angle + lift = 0.5 * rho * stream_speed**2 * self.reference_area * c_lift + # Normal force, perpendicular to the body axis, directed along + # the transverse component of the flow. + transverse_norm = (stream_vx**2 + stream_vy**2) ** 0.5 + R1 = lift * stream_vx / transverse_norm + R2 = lift * stream_vy / transverse_norm + # The normal force acts at the geometric center of pressure, + # which ``cp`` already locates relative to the center of dry + # mass; transport its moment from there. + force = Vector([R1, R2, R3]) + M1, M2, M3 = cp ^ force + + # Fin roll (cant forcing + rate damping); zero for non-fin surfaces. + M3 += self._roll_moment(stream_speed, stream_mach, rho, omega) + + return R1, R2, R3, M1, M2, M3 + + def _roll_moment(self, stream_speed, mach, rho, omega): + """Roll moment from the linear roll coefficients: cant forcing plus + reduced-rate damping. Returns 0 for surfaces without fins, whose roll + coefficients are identically zero. + """ + reduced_roll_rate = ( + omega[2] * self.reference_length / (2 * stream_speed) + if stream_speed > 0 + else 0.0 + ) + # The Barrowman roll coefficients depend only on Mach and the roll rate. + args = (0.0, 0.0, mach, 0.0, 0.0, 0.0, reduced_roll_rate) + cl = self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) + return ( + 0.5 + * rho + * stream_speed**2 + * self.reference_area + * self.reference_length + * cl + ) + + def _mach_coefficient(self, func_of_mach, name="coefficient"): + """Wrap a Mach-only callable into an :class:`AeroCoefficient` that + depends only on Mach but is callable over the full coefficient argument + tuple. Storing it at one dimension keeps the Mach table un-smeared and + evaluates with a single argument in the hot loop. + """ + return AeroCoefficient( + func_of_mach, + depends_on=("mach",), + control_variables=self.control_variables, + name=name, + ) diff --git a/rocketpy/rocket/aero_surface/aero_coefficient.py b/rocketpy/rocket/aero_surface/aero_coefficient.py new file mode 100644 index 000000000..09ebe5445 --- /dev/null +++ b/rocketpy/rocket/aero_surface/aero_coefficient.py @@ -0,0 +1,655 @@ +import copy +import csv +import inspect + +from rocketpy.mathutils import Function + +# Single source of truth for the seven base coefficient independent variables. +BASE_INDEPENDENT_VARS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", +] + + +def build_independent_vars(control_variables=()): + """Build the ordered independent-variable list of a coefficient/surface. + + The seven base axes (``BASE_INDEPENDENT_VARS``), plus any + ``control_variables`` (axes supplied externally, e.g. by a controller). + Shared by :class:`AeroCoefficient` and :class:`GenericSurface` so the + ordering is defined in exactly one place. + """ + return list(BASE_INDEPENDENT_VARS) + list(control_variables) + + +class AeroCoefficient: + """A single aerodynamic coefficient (such as lift or drag), stored using + only the variables it actually depends on.""" + + def __init__( + self, + source, + depends_on=None, + control_variables=(), + name="coefficient", + extrapolation=None, + interpolation=None, + single_var=None, + ): + """Build a coefficient from a value, a data table, or a function. + + A plain number is stored as a constant. Anything else is stored as a + :class:`Function` of only the variables it depends on, so a coefficient + that varies with Mach alone stays a simple 1-D curve instead of being + spread across all seven variables. When the coefficient is evaluated, + the variables it does not use are simply ignored. + + Most of the time you only pass ``source`` and leave ``depends_on`` as + ``None``, so the variables are worked out automatically. This is the + same input a :class:`GenericSurface` accepts. Pass ``depends_on`` + yourself only when the source and the order of its inputs are already + known (used internally by the Barrowman surfaces and when loading a + saved rocket). + + Parameters + ---------- + source : int, float, str, list, tuple, callable, Function, or AeroCoefficient + The coefficient value, given in one of these forms: + + - **number**: a constant coefficient that never changes. + - **function or lambda**: a coefficient computed from its inputs. + Name the arguments after the variables they use (e.g. + ``lambda alpha, mach: ...``), or give one argument per variable, + or a single argument together with ``single_var``. + - **Function**: a :class:`Function` you already built, used as is. + If ``extrapolation`` is given it is applied to a copy, so the + Function you passed in is left unchanged. + - **list or tuple of data points**: a table of values, read the + same way as the same data in a CSV file. The variables it depends + on are worked out from the table, using ``single_var`` for a + one-input table. + - **str**: the path to a data file. A ``.csv`` file has one column + per variable (named in the header) and the coefficient value in + the last column; a headerless two-column file is a table of + ``single_var`` versus the value. + - **AeroCoefficient**: an existing coefficient, reused as is. This + lets one coefficient be shared by several surfaces and lets a + rocket be saved and loaded. + depends_on : sequence of str, optional + The variables this coefficient actually uses, chosen from the + surface's variables: the seven base ones ``"alpha"``, ``"beta"``, + ``"mach"``, ``"reynolds"``, ``"pitch_rate"``, ``"yaw_rate"``, + ``"roll_rate"``, plus any names in + ``control_variables``. List them in the same order as the source's + own inputs (a function's arguments, a CSV's columns). For example, + ``()`` for a constant, ``("mach",)`` for a Mach-only curve, or the + whole list for something that uses every variable. A name that is + not one of the surface's variables raises a ``ValueError``. Leave it + as ``None`` (the default) to have it worked out from ``source``. + control_variables : sequence of str, optional + Names of extra variables, such as control-surface deflections set by + a controller. They are added after the seven base variables, in the + order given. Empty for ordinary surfaces. Default ``()``. + name : str, optional + A readable name for the coefficient (e.g. ``"cL_alpha"`` or + ``"Drag Coefficient with Power Off"``). It appears in error messages, + so a clear name makes problems easier to spot. Default + ``"coefficient"``. + extrapolation : str, optional + What the coefficient does outside the range of its data table: + ``"constant"`` holds the value at the nearest edge (the safe default + for aerodynamic coefficients, which should not shoot off to + unrealistic values), ``"natural"`` keeps following the curve, and + ``"zero"`` returns ``0``. ``None`` (the default) leaves a + :class:`Function` you passed in unchanged and uses ``"constant"`` for + a table built here. Has no effect on a constant or a function, which + are evaluated directly. + interpolation : str, optional + How the coefficient reads values *between* the points of its data + table, for example ``"linear"``, ``"akima"`` or ``"spline"`` for a + one-input table. Only affects data tables (CSV files, lists of + points, a :class:`Function`); it has no effect on a constant or a + function. ``None`` (the default) leaves a :class:`Function` you + passed in unchanged and uses ``"linear"`` for a table built here. + single_var : str, optional + Which variable a one-input table or function maps to. Used only when + working out the variables of a single-input source: a headerless + two-column CSV, a one-input :class:`Function`, or a one-argument + function. ``None`` (the default) guesses it from the input's label + and otherwise falls back to the first variable. Ignored when + ``depends_on`` is given. Default ``None``. + """ + self.name = name + self.extrapolation = extrapolation + self.interpolation = interpolation + self.control_variables = tuple(control_variables) + # ``control_variables`` completes the full ordered variable list: every + # coefficient's argument order and each variable's position. This is a + # surface-wide property, distinct from ``depends_on`` (the subset a + # single coefficient reads), and it is passed in rather than derived + # from ``depends_on``: inferring ``depends_on`` already needs this list. + self.independent_vars = tuple(build_independent_vars(control_variables)) + # Infer the stored source and its dependencies from the raw input when + # ``depends_on`` is not given. + if depends_on is None: + source, depends_on = self._resolve_input(source, single_var) + extrapolation = self.extrapolation + interpolation = self.interpolation + # ``depends_on`` is kept in the given order because it matches the + # positional argument order of the stored source (callable parameters, + # CSV columns, …). ``_indices`` then maps the full argument tuple + # to the source's own argument order. + self.depends_on = tuple(depends_on) + unknown = [var for var in self.depends_on if var not in self.independent_vars] + if unknown: + raise ValueError( + f"{name} depends on unknown variable(s) {unknown}; " + f"valid variables are {list(self.independent_vars)}." + ) + self._indices = tuple( + self.independent_vars.index(var) for var in self.depends_on + ) + + self.is_zero = False + self._constant = None + if isinstance(source, Function): + # Only override interpolation/extrapolation when explicitly asked, + # and always on a copy (the Function may be shared elsewhere). + if interpolation is not None or extrapolation is not None: + source = copy.deepcopy(source) + # Interpolation names like "akima"/"spline" are 1-D concepts; a + # multi-dimensional Function (e.g. a regular grid) keeps its own + # interpolation, whose method is fixed when the grid is built, so + # a 1-D name here would wrongly fall back to "shepard". + if interpolation is not None and source.__dom_dim__ == 1: + source.set_interpolation(interpolation) + if extrapolation is not None: + source.set_extrapolation(extrapolation) + self.function = source + elif callable(source): + self.function = Function( + source, + list(self.depends_on) or ["x"], + [name], + interpolation=interpolation or "linear", + extrapolation=extrapolation or "constant", + ) + else: + # Scalar constant. + self._constant = float(source) + self.is_zero = self._constant == 0.0 + self.function = Function(self._constant) + + self._evaluate = self.function.get_value_opt + + def _resolve_input(self, source, single_var): + """Infer ``(stored source, depends_on)`` from a raw coefficient input. + + Parameters + ---------- + source : int, float, str, list, tuple, callable, Function or AeroCoefficient + Raw coefficient input: a scalar, a CSV file path (or any other path + read by :class:`Function`), a list/tuple of data points, a callable, + a pre-built :class:`Function`, or an existing ``AeroCoefficient``. + single_var : str or None + Name of the independent variable a one-dimensional input depends on. + When ``None``, it is inferred from the source (see + :meth:`_infer_single_var` / :meth:`_infer_callable_depends_on`). + + Returns + ------- + tuple + ``(stored_source, depends_on)`` where ``stored_source`` is the scalar + or :class:`Function` kept internally and ``depends_on`` is the tuple + of independent-variable names it depends on. + """ + name = self.name + independent_vars = self.independent_vars + n_vars = len(independent_vars) + + if isinstance(source, AeroCoefficient): + # An already-built coefficient passed straight through, re-keyed to + # this surface's variable order. Adopt its extrapolation when none + # was requested. + if self.extrapolation is None: + self.extrapolation = source.extrapolation + value = ( + source._constant if source._constant is not None else source.function + ) + return value, source.depends_on + + if isinstance(source, str): + if source.lower().endswith(".csv"): + return self._load_csv( + source, + name, + independent_vars, + extrapolation=self.extrapolation or "constant", + interpolation=self.interpolation or "linear", + single_var=single_var, + ) + # Any other path is read by Function + source = Function( + source, + interpolation=self.interpolation or "linear", + extrapolation=self.extrapolation or "constant", + ) + + # A list/tuple of data points is parsed by Function and handled below + if isinstance(source, (list, tuple)): + try: + source = Function( + list(source), + interpolation=self.interpolation or "linear", + extrapolation=self.extrapolation or "constant", + ) + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid list/tuple input for {name}: could not be parsed " + "into a Function of the independent variables." + ) from exc + + if isinstance(source, Function): + dom_dim = source.__dom_dim__ + if dom_dim == n_vars: + return source, list(independent_vars) + if dom_dim == 1: + # A 1-D Function depends on ``single_var`` when given, else on + # the first independent variable unless its input name matches. + return source, [ + single_var or self._infer_single_var(source, independent_vars) + ] + raise ValueError( + f"{name} Function must have {n_vars} input arguments " + f"({', '.join(independent_vars)}) or be one-dimensional." + ) + + if callable(source): + return source, self._infer_callable_depends_on( + source, independent_vars, name, single_var=single_var + ) + + # Anything else must be a scalar number. + try: + float(source) + except (TypeError, ValueError) as exc: + raise TypeError( + f"Invalid input for {name}: must be a number, a CSV file path, " + "a list of data points, a callable, or a Function." + ) from exc + return source, () + + @staticmethod + def _load_csv( + file_path, + name, + independent_vars, + extrapolation="constant", + interpolation="linear", + single_var=None, + ): # pylint: disable=too-many-statements + """Load a coefficient CSV at minimal dimension. + + Expects header-based CSV data whose columns (except the last) are + independent variables among ``independent_vars``; the last column is the + coefficient value. The coefficient is stored over only the columns that + are present, in their header order. A headerless two-column file is + treated as a one-dimensional table over ``single_var``. + + Parameters + ---------- + file_path : str + Path to the CSV file. + name : str + Coefficient name, used for error messages and the Function output. + independent_vars : sequence of str + The owning surface's ordered independent variables, used to validate + the CSV header columns. + extrapolation : str, optional + Extrapolation method for the loaded ``Function``. Defaults to + ``"constant"`` (holds the edge value past the tabulated range). + interpolation : str, optional + Interpolation method for the loaded ``Function``. Defaults to + ``"linear"``. For 1-D and non-grid tables it is used directly; a + strict Cartesian grid uses ``"regular_grid"`` with the method mapped + from this value (see :meth:`Function.from_regular_grid_csv`). + single_var : str, optional + Independent variable a headerless two-column table depends on. + Defaults to the first independent variable. + + Returns + ------- + tuple + ``(function, depends_on)`` where ``function`` is a low-dimensional + ``Function`` over the present columns and ``depends_on`` lists those + columns. Consumed by :meth:`_resolve_input`. + """ + independent_vars = list(independent_vars) + + try: + with open(file_path, mode="r") as file: + reader = csv.reader(file) + header = next(reader) + except (FileNotFoundError, IOError) as e: + raise ValueError(f"Error reading {name} CSV file: {e}") from e + except StopIteration as e: + raise ValueError(f"Invalid or empty CSV file for {name}.") from e + + if not header: + raise ValueError(f"Invalid or empty CSV file for {name}.") + + header = [column.strip() for column in header] + + # Headerless two-column (x, coefficient) table: a 1-D table over + # ``single_var`` (e.g. a Mach-only drag curve given as ``mach, cd``). + def _is_numeric(value): + try: + float(value) + return True + except (TypeError, ValueError): + return False + + if len(header) == 2 and all(_is_numeric(cell) for cell in header): + csv_func = Function( + file_path, + interpolation=interpolation, + extrapolation=extrapolation, + ) + return csv_func, [single_var or independent_vars[0]] + + present_columns = [col for col in independent_vars if col in header] + + invalid_columns = [col for col in header[:-1] if col not in independent_vars] + if invalid_columns: + raise ValueError( + f"Invalid independent variable(s) in {name} CSV: " + f"{invalid_columns}. Valid options are: {independent_vars}." + ) + + if header[-1] in independent_vars: + raise ValueError( + f"Last column in {name} CSV must be the coefficient" + " value, not an independent variable." + ) + + if not present_columns: + raise ValueError(f"No independent variables found in {name} CSV.") + + ordered_present_columns = [ + col for col in header[:-1] if col in independent_vars + ] + + csv_func = Function.from_regular_grid_csv( + file_path, + ordered_present_columns, + name, + extrapolation=extrapolation, + interpolation=interpolation, + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation=interpolation, + extrapolation=extrapolation, + ) + + return csv_func, ordered_present_columns + + @staticmethod + def _infer_single_var(function, independent_vars): + """Best-effort name of the variable a 1-D Function depends on.""" + try: + label = function.__inputs__[0] + except (AttributeError, IndexError, TypeError): + return independent_vars[0] + label_lower = str(label).lower() + # Exact match first; then substring, longest variable name first, so a + # label containing a longer variable name binds to it rather than to a + # shorter variable that happens to be a substring of it. + for var in independent_vars: + if var == label_lower: + return var + for var in sorted(independent_vars, key=len, reverse=True): + if var in label_lower: + return var + return independent_vars[0] + + @staticmethod + def _infer_callable_depends_on(func, independent_vars, name, single_var=None): + """Work out which variables a function coefficient uses, from its + arguments. + + Three ways to write the function are accepted, tried in this order: + + 1. One argument plus ``single_var``: the function takes a single + argument and ``single_var`` says which variable it is, whatever the + argument is named (e.g. a Mach-only drag curve ``lambda mach: ...`` + with ``single_var="mach"``). + 2. Arguments named after variables: every argument name matches one of + the surface's variables, so the names themselves list what the + function uses (e.g. ``lambda alpha, mach: ...`` uses ``alpha`` and + ``mach``). + 3. One argument per variable: the function has exactly as many arguments + as there are variables, so it is taken to use all of them, whatever + the arguments are named (e.g. ``lambda a, b, m, r, p, q, rr: ...`` + for the seven base variables). + + Anything else raises ``ValueError``. + """ + n_vars = len(independent_vars) + try: + params = list(inspect.signature(func).parameters.values()) + except (TypeError, ValueError): # pragma: no cover - builtins + params = [] + names = [p.name for p in params] + + if single_var and len(names) == 1: + return [single_var] + if names and set(names) <= set(independent_vars): + return names + if len(names) == n_vars: + return list(independent_vars) + raise ValueError( + f"{name} callable must accept {n_vars} positional arguments " + f"({', '.join(independent_vars)}) or name its parameters after the " + "independent variables it depends on." + ) + + @property + def is_zero_coefficient(self): + """Kept-for-compatibility alias of ``is_zero``: whether the coefficient + is the constant 0 (the linear model uses it to skip zero terms).""" + return self.is_zero + + @property + def __dom_dim__(self): + """Number of variables the coefficient is called with.""" + return len(self.independent_vars) + + def get_value_opt(self, *args): + """Fast evaluation without input checking (mirrors + :meth:`Function.get_value_opt`). + + Receives every variable, passes on only the ones this coefficient uses, + and evaluates the source. A constant is returned right away. + """ + if self._constant is not None: + return self._constant + return self._evaluate(*(args[i] for i in self._indices)) + + # Calling the coefficient is the same as the fast evaluator; the linear + # model grabs ``get_value_opt`` directly for the hot loop. + __call__ = get_value_opt + + def __mul__(self, other): + """Scale the coefficient by ``other``, returning a new AeroCoefficient. + + Used by the Monte Carlo drag factor (``coefficient *= factor``). The + underlying constant or :class:`Function` is scaled while ``depends_on``, + the independent-variable axes and ``extrapolation`` are preserved. + """ + source = self._constant if self._constant is not None else self.function + return AeroCoefficient( + source * other, + self.depends_on, + self.control_variables, + self.name, + extrapolation=self.extrapolation, + interpolation=self.interpolation, + ) + + __rmul__ = __mul__ + + def __repr__(self): + """Return a concise representation showing the constant or dependencies.""" + if self._constant is not None: + return f"AeroCoefficient({self.name}={self._constant})" + return f"AeroCoefficient({self.name}, depends_on={self.depends_on})" + + def slice(self, *free_variables, at=None): + """Return a :class:`Function` of only the chosen variables, holding the + others fixed. + + This gives a lower-dimensional view of the coefficient, handy for + inspection or plotting. For example, ``cL.slice("alpha", "mach")`` is the + lift coefficient as a function of angle of attack and Mach, with sideslip, + Reynolds number and the rotation rates held at zero; ``cD.slice("mach")`` + is a Mach-only drag curve. + + Parameters + ---------- + *free_variables : str + Names of the variables to keep as inputs, in the order you want them + (for example ``"mach"`` or ``"alpha", "mach"``). Each must be one of + this coefficient's independent variables. + at : dict, optional + Values to hold the remaining variables at, keyed by variable name. + Any not listed are held at 0. + + Returns + ------- + Function + A Function of ``free_variables`` that evaluates this coefficient with + the remaining variables held fixed. + """ + fixed = dict(at or {}) + unknown = [ + var + for var in list(free_variables) + list(fixed) + if var not in self.independent_vars + ] + if unknown: + raise ValueError( + f"{self.name} has no independent variable(s) {unknown}; valid " + f"variables are {list(self.independent_vars)}." + ) + + free_positions = [self.independent_vars.index(var) for var in free_variables] + baseline = [fixed.get(var, 0.0) for var in self.independent_vars] + + if not free_variables: + return Function(self.get_value_opt(*baseline)) + + def sliced(*values): + args = list(baseline) + for position, value in zip(free_positions, values): + args[position] = value + return self.get_value_opt(*args) + + # Give the wrapper an explicit signature so Function reads the right + # number of inputs (its domain dimension comes from the parameter count). + sliced.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in free_variables + ) + return Function(sliced, list(free_variables), [self.name]) + + def slope(self, variable, *free_variables, at=None, dx=1e-6): + """Return the derivative of this coefficient with respect to one variable + as a :class:`Function` of the chosen free variables. + + This is the aerodynamic slope, such as a lift-curve slope. For example, + ``cL.slope("alpha", "mach")`` is the lift-curve slope ``d(cL)/d(alpha)`` + as a function of Mach, taken at ``alpha = 0`` with sideslip, Reynolds + number and the rotation rates held at zero. + + Parameters + ---------- + variable : str + Name of the variable to differentiate with respect to (for example + ``"alpha"`` or ``"beta"``). Must be one of this coefficient's + independent variables, and must not also appear in + ``free_variables``. + *free_variables : str + Names of the variables to keep as inputs of the resulting slope, in + the order you want them (for example ``"mach"``). Each must be one of + this coefficient's independent variables. Leave empty to get the + slope at a single point. + at : dict, optional + Values to hold the remaining variables at, keyed by variable name. + The value for ``variable`` is the point the derivative is taken at + (default 0, the linearization point). Any variable not listed is held + at 0. + dx : float, optional + Step size used for the numerical differentiation. Default 1e-6. + + Returns + ------- + Function + A Function of ``free_variables`` giving ``d(self)/d(variable)`` with + the remaining variables held fixed. + """ + fixed = dict(at or {}) + overlap = [var for var in free_variables if var == variable] + if overlap: + raise ValueError( + f"{variable!r} cannot be both differentiated and kept free." + ) + # Point to differentiate at, defaulting to the linearization point (0). + diff_point = fixed.pop(variable, 0.0) + name = f"d({self.name})/d({variable})" + + def evaluate_slope(*free_values): + # Hold the fixed variables and the current free-variable values, + # keep only ``variable`` free, and differentiate along it. + slice_at = dict(fixed) + for var, value in zip(free_variables, free_values): + slice_at[var] = value + return self.slice(variable, at=slice_at).differentiate(diff_point, dx=dx) + + if not free_variables: + return Function(evaluate_slope(), name) + + evaluate_slope.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in free_variables + ) + return Function(evaluate_slope, list(free_variables), [name]) + + def to_dict(self, **kwargs): # pylint: disable=unused-argument + """Serialize the coefficient for :class:`rocketpy._encoders.RocketPyEncoder`.""" + return { + "source": self._constant if self._constant is not None else self.function, + "depends_on": list(self.depends_on), + "control_variables": list(self.control_variables), + "name": self.name, + "extrapolation": self.extrapolation, + "interpolation": self.interpolation, + } + + @classmethod + def from_dict(cls, data): + """Rebuild an :class:`AeroCoefficient` from its :meth:`to_dict` form.""" + return cls( + data["source"], + data["depends_on"], + data.get("control_variables", ()), + data["name"], + extrapolation=data.get("extrapolation"), + interpolation=data.get("interpolation"), + ) diff --git a/rocketpy/rocket/aero_surface/aero_surface.py b/rocketpy/rocket/aero_surface/aero_surface.py index 6727476c6..f2a561885 100644 --- a/rocketpy/rocket/aero_surface/aero_surface.py +++ b/rocketpy/rocket/aero_surface/aero_surface.py @@ -1,159 +1,46 @@ -from abc import ABC, abstractmethod +import warnings +from abc import ABC -import numpy as np +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface -from rocketpy.mathutils.vector_matrix import Matrix +_AEROSURFACE_DEPRECATION_MESSAGE = ( + "`AeroSurface` is deprecated and will be removed in a future major " + "release. RocketPy's aerodynamic surfaces now derive from `GenericSurface`; " + "use `GenericSurface` (or a concrete surface class such as `NoseCone`, " + "`TrapezoidalFins`, `Tail`, ...) instead. Note that `isinstance(surface, " + "AeroSurface)` still returns True for all surfaces." +) class AeroSurface(ABC): - """Abstract class used to define aerodynamic surfaces.""" - - def __init__(self, name, reference_area, reference_length): - self.reference_area = reference_area - self.reference_length = reference_length - self.name = name - - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - - self._rotation_surface_to_body = Matrix( - [ - [-1, 0, 0], - [0, 1, 0], - [0, 0, -1], - ] + """Deprecated base class for aerodynamic surfaces. + + .. deprecated:: + ``AeroSurface`` is no longer the base of RocketPy's aerodynamic + surfaces, which now all derive from :class:`GenericSurface`. It is kept + only as a deprecated compatibility shim and will be removed in a future + major release. Importing, instantiating or subclassing it emits a + ``DeprecationWarning``. + + For backward compatibility, :class:`GenericSurface` (and therefore every + concrete surface) is registered as a *virtual subclass*, so existing + ``isinstance(surface, AeroSurface)`` and + ``issubclass(type(surface), AeroSurface)`` checks keep working. + """ + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + warnings.warn( + _AEROSURFACE_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2 ) - @staticmethod - def _beta(mach): - """Defines a parameter that is often used in aerodynamic - equations. It is commonly used in the Prandtl factor which - corrects subsonic force coefficients for compressible flow. - This is applied to the lift coefficient of the nose cone, - fins and tails/transitions as in [1]. - - Parameters - ---------- - mach : int, float - Number of mach. - - Returns - ------- - beta : int, float - Value that characterizes flow speed based on the mach number. - - References - ---------- - [1] Barrowman, James S. https://arc.aiaa.org/doi/10.2514/6.1979-504 - """ - - if mach < 0.8: - return np.sqrt(1 - mach**2) - elif mach < 1.1: - return np.sqrt(1 - 0.8**2) - else: - return np.sqrt(mach**2 - 1) - - @abstractmethod - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the aerodynamic surface in local - coordinates. - - Returns - ------- - None - """ - - @abstractmethod - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def evaluate_geometrical_parameters(self): - """Evaluates the geometrical parameters of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def info(self): - """Prints and plots summarized information of the aerodynamic surface. - - Returns - ------- - None - """ - - @abstractmethod - def all_info(self): - """Prints and plots all the available information of the aero surface. - - Returns - ------- - None - """ - - def compute_forces_and_moments( - self, - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - *args, - ): # pylint: disable=unused-argument - """Computes the forces and moments acting on the aerodynamic surface. - Used in each time step of the simulation. This method is valid for - the barrowman aerodynamic models. + def __init__(self, *args, **kwargs): # pylint: disable=unused-argument + warnings.warn( + _AEROSURFACE_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2 + ) - Parameters - ---------- - stream_velocity : tuple - Tuple containing the stream velocity components in the body frame. - stream_speed : int, float - Speed of the stream in m/s. - stream_mach : int, float - Mach number of the stream. - rho : int, float - Density of the stream in kg/m^3. - cp : Vector - Center of pressure coordinates in the body frame. - args : tuple - Additional arguments. - kwargs : dict - Additional keyword arguments. - Returns - ------- - tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. - """ - R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0 - cpz = cp[2] - stream_vx, stream_vy, stream_vz = stream_velocity - if stream_vx**2 + stream_vy**2 != 0: - # Normalize component stream velocity in body frame - stream_vzn = stream_vz / stream_speed - if -1 * stream_vzn < 1: - attack_angle = np.arccos(-stream_vzn) - c_lift = self.cl.get_value_opt(attack_angle, stream_mach) - # Component lift force magnitude - lift = 0.5 * rho * (stream_speed**2) * self.reference_area * c_lift - # Component lift force components - lift_dir_norm = (stream_vx**2 + stream_vy**2) ** 0.5 - lift_xb = lift * (stream_vx / lift_dir_norm) - lift_yb = lift * (stream_vy / lift_dir_norm) - # Total lift force - R1, R2, R3 = lift_xb, lift_yb, 0 - # Total moment - M1, M2, M3 = -cpz * lift_yb, cpz * lift_xb, 0 - return R1, R2, R3, M1, M2, M3 +# Register GenericSurface (and thus all concrete surfaces) as a virtual +# subclass so that ``isinstance(surface, AeroSurface)`` remains True during the +# deprecation period. Virtual registration does not trigger ``__init_subclass__``. +AeroSurface.register(GenericSurface) diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index e7417aaa9..0e7a5965f 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -6,12 +6,13 @@ from rocketpy.plots.aero_surface_plots import _AirBrakesPlots from rocketpy.prints.aero_surface_prints import _AirBrakesPrints -from .aero_surface import AeroSurface +from .controllable_generic_surface import ControllableGenericSurface -# TODO: review airbrakes implementation to make it more in line with events -class AirBrakes(AeroSurface): - """AirBrakes class. Inherits from AeroSurface. +class AirBrakes(ControllableGenericSurface): + """AirBrakes class. Inherits from :class:`ControllableGenericSurface`, using + ``deployment_level`` as its single control variable and a multivariable drag + coefficient. Attributes ---------- @@ -91,7 +92,7 @@ def __init__( Default is False. deployment_level : float, optional Initial deployment level, ranging from 0 to 1. Deployment level is - the fraction of the total airbrake area that is Deployment. Default + the fraction of the total airbrake area that is deployed. Default is 0. name : str, optional Name of the air brakes. Default is "AirBrakes". @@ -100,45 +101,73 @@ def __init__( ------- None """ - super().__init__(name, reference_area, None) + self.clamp = clamp + self.override_rocket_drag = override_rocket_drag + self.initial_deployment_level = deployment_level self.drag_coefficient_curve = drag_coefficient_curve - # TODO: this drag coefficient needs to be a function of more parameters - # just like generic surface coefficients + # Back-compatible 2-input (deployment level, Mach) drag curve, kept for + # display/serialization and as the source of the multivariable drag + # coefficient below. self.drag_coefficient = Function( drag_coefficient_curve, inputs=["Deployment Level", "Mach"], outputs="Drag Coefficient", interpolation="linear", ) - self.clamp = clamp - self.override_rocket_drag = override_rocket_drag - self.initial_deployment_level = deployment_level + + # Multivariable drag coefficient over the generic-surface inputs plus the + # ``deployment_level`` control axis. The deployment-0 ⇒ Cd 0 rule applies + # only when the air brakes add to (rather than override) the rocket drag. + def drag_coefficient_function( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + deployment_level, + ): # pylint: disable=unused-argument + if deployment_level == 0 and not self.override_rocket_drag: + return 0.0 + return self.drag_coefficient.get_value_opt(deployment_level, mach) + + super().__init__( + reference_area=reference_area, + reference_length=2 * (reference_area / np.pi) ** 0.5, + coefficients={"cD": drag_coefficient_function}, + center_of_pressure=(0, 0, 0), + name=name, + controls=("deployment_level",), + ) + self.deployment_level = deployment_level self.prints = _AirBrakesPrints(self) self.plots = _AirBrakesPlots(self) - @property - def deployment_level(self): - """Returns the deployment level of the air brakes.""" - return self._deployment_level - - @deployment_level.setter - def deployment_level(self, value): - # Check if deployment level is within bounds and warn user if not - if value < 0 or value > 1: - # Clamp deployment level if clamp is True + def _clamp_control(self, name, value): + """Clamp ``deployment_level`` to ``[0, 1]`` (or warn if ``clamp`` is + False), preserving the historical AirBrakes behavior.""" + if name == "deployment_level" and (value < 0 or value > 1): if self.clamp: - # Make sure deployment level is between 0 and 1 - value = np.clip(value, 0, 1) + value = float(np.clip(value, 0, 1)) else: - # Raise warning if clamp is False warnings.warn( f"Deployment level of {self.name} is smaller than 0 or " + "larger than 1. Extrapolation for the drag coefficient " + "curve will be used.", UserWarning, ) - self._deployment_level = value + return value + + @property + def deployment_level(self): + """Returns the deployment level of the air brakes.""" + return self.control_state["deployment_level"] + + @deployment_level.setter + def deployment_level(self, value): + self.set_control("deployment_level", value) def _reset(self): """Resets the air brakes to their initial state. This is ran at the @@ -146,44 +175,6 @@ def _reset(self): state.""" self.deployment_level = self.initial_deployment_level - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the aerodynamic surface in local - coordinates. - - For air brakes, all components of the center of pressure position are - 0. - - Returns - ------- - None - """ - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - self.cp = (self.cpx, self.cpy, self.cpz) - - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the aerodynamic surface. - - For air brakes, the current model assumes no lift is generated. - Therefore, the lift coefficient (C_L) and its derivative relative to the - angle of attack (C_L_alpha), is 0. - - Returns - ------- - None - """ - self.clalpha = Function( - lambda mach: 0, - "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: 0, - ["Alpha (rad)", "Mach"], - "Lift Coefficient", - ) - def evaluate_geometrical_parameters(self): """Evaluates the geometrical parameters of the aerodynamic surface. diff --git a/rocketpy/rocket/aero_surface/controllable_generic_surface.py b/rocketpy/rocket/aero_surface/controllable_generic_surface.py new file mode 100644 index 000000000..f312d95c6 --- /dev/null +++ b/rocketpy/rocket/aero_surface/controllable_generic_surface.py @@ -0,0 +1,236 @@ +from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.tools import from_hex_decode, to_hex_encode + + +class ControllableGenericSurface(GenericSurface): + """A generic aerodynamic surface whose coefficients also depend on one or + more control inputs (canards, grid fins, elevons, air-brake deployment, and + so on) set by a controller while the rocket flies. + + On top of the seven standard variables of :class:`GenericSurface` + (``alpha``, ``beta``, ``mach``, ``reynolds``, ``pitch_rate``, ``yaw_rate``, + ``roll_rate``), each coefficient takes one extra input per control, in the + order listed in ``controls``. A controller updates the current control + values every simulation step (see ``Rocket.add_controllable_surface``), and + they are passed to the coefficients automatically. + + Attributes + ---------- + ControllableGenericSurface.control_variables : list of str + Names of the controls, in the order the coefficients expect them. + ControllableGenericSurface.control_state : dict + Current value of each control (starts at 0). + """ + + # TODO: deflection-dependent static-margin diagnostics. + # + # The in-flight dynamics are correct: the deflection feeds the coefficient + # functions live every step (see ``_coefficient_arguments``), and the surface + # never physically moves, so its force-application point / ``cp_to_cdm`` cache + # cannot go stale (unlike an individual fin's cant angle, which IS a physical + # reconfiguration and is refreshed via ``Rocket.refresh_controlled_components``). + # + # The gap is diagnostic-only. The derived ``center_of_pressure_z`` / + # ``aerodynamic_center`` come from ``cm_alpha = d(cm)/d(alpha)`` evaluated ONCE + # (in ``_set_stability_accessors``) with the control variables frozen at their + # value at construction (0). So if ``cm`` couples alpha and a control axis + # (e.g. an ``alpha * deflection`` term), the reported ``static_margin`` is + # pinned to the zero-deflection configuration and does not track ``set_control``. + # It also is not a single well-defined number: the static margin of a deflected + # control surface is inherently a function of the control input. + # + # To address this properly (not a correctness fix, defer until there is a real + # need), likely some combination of: + # - an ``initial_deflection`` (per-control) argument in ``__init__`` so the + # derived cp accessors are built about a chosen reference deflection rather + # than always 0; + # - re-deriving the cp accessors when the deflection changes -- reuse the + # fin mechanism: bump ``_geometry_version`` in ``set_control`` and have + # ``Rocket.refresh_controlled_components`` re-run the derived-cp step; + # - dedicated stability plots/prints that sweep the static margin (and cp) + # OVER the control-deflection range, since a single scalar margin is the + # wrong abstraction for a controllable surface. + + def __init__( + self, + reference_area, + reference_length, + coefficients, + center_of_pressure=(0, 0, 0), + name="Controllable Generic Surface", + controls=("deflection",), + reynolds_length=None, + extrapolation=None, + interpolation=None, + active_during="always", + ): + """Create a controllable generic aerodynamic surface. + + Parameters + ---------- + reference_area : int, float + Reference area of the surface, in squared meters. + reference_length : int, float + Reference length of the surface, in meters. + coefficients : dict + The six force and moment coefficients (``cL``, ``cQ``, ``cD``, + ``cm``, ``cn``, ``cl``), by name. Each one can be a constant, a + function, or a path to a data file, and depends on the seven base + variables **plus** the controls listed in ``controls`` (in that + order). Any you leave out are set to 0. + center_of_pressure : tuple, list, optional + Application point of the aerodynamic forces and moments in the local + surface frame. Default ``(0, 0, 0)``. + name : str, optional + Name of the surface. Default ``"Controllable Generic Surface"``. + controls : iterable of str, optional + Names of the controls, such as a canard deflection angle. Default + ``("deflection",)``. Each name becomes an extra input to every + coefficient and a key in :attr:`control_state`. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficients. See :class:`GenericSurface`. ``None`` (the default) + uses ``reference_length`` (the diameter). + extrapolation : str or dict, optional + What tabulated coefficients do outside their data range: + ``"constant"`` holds the nearest edge value, ``"natural"`` keeps + following the curve, ``"zero"`` returns 0. Give one string for all + coefficients or a dict keyed by coefficient name. ``None`` (the + default) uses ``"constant"`` for tables built here and leaves a + pre-built :class:`Function` unchanged. + interpolation : str or dict, optional + How tabulated coefficients read values between points (for example + ``"linear"``, ``"akima"`` or ``"spline"`` for a 1-D table; see + :class:`rocketpy.GenericSurface` for the full list by table type). + Give one string for all coefficients or a dict keyed by coefficient + name. ``None`` (the default) uses ``"linear"`` for tables built here + and leaves a pre-built :class:`Function` unchanged. + active_during : str or callable, optional + When this surface produces force during a simulation: ``"always"`` + (default), ``"power_on"`` (only while the motor burns, e.g. jet + vanes), ``"power_off"`` (only after burnout), or a function + ``active_during(t, flight)``. See :class:`GenericSurface` for details. + """ + # These must be set before ``super().__init__`` so coefficient + # processing (arity, CSV validation) and the derived-cp accessors see + # the extended variable list (via the ``independent_vars`` property, + # which appends ``control_variables``) and the current control values. + self.control_variables = list(controls) + self.control_state = {name: 0.0 for name in self.control_variables} + + super().__init__( + reference_area=reference_area, + reference_length=reference_length, + coefficients=coefficients, + center_of_pressure=center_of_pressure, + name=name, + reynolds_length=reynolds_length, + extrapolation=extrapolation, + interpolation=interpolation, + active_during=active_during, + ) + # ``self.prints``/``self.plots`` are the generic ones wired by the base. + + def _coefficient_arguments( + self, + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + ): + """Append the current control-variable values (in + ``self.control_variables`` order) to the standard inputs.""" + base = super()._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + ) + controls = tuple(self.control_state[name] for name in self.control_variables) + return base + controls + + def _clamp_control(self, name, value): # pylint: disable=unused-argument + """Hook to constrain a control value before it is stored. The base class + applies no clamping; subclasses (e.g. ``AirBrakes``) may override.""" + return value + + def set_control(self, name, value): + """Set the current value of a control variable (applying any clamping). + + Parameters + ---------- + name : str + Name of the control variable; must be one of + :attr:`control_variables`. + value : float + New control value. + """ + if name not in self.control_state: + raise KeyError( + f"Unknown control variable '{name}'. " + f"Valid controls are: {self.control_variables}." + ) + self.control_state[name] = self._clamp_control(name, value) + + def get_control(self, name): + """Return the current value of a control variable.""" + return self.control_state[name] + + def to_dict( # pylint: disable=unused-argument + self, include_outputs=False, **kwargs + ): + # A preset ``active_during`` is stored as is; a custom (t, flight) -> bool + # function is pickled to text when allowed, otherwise dropped to "always" + # (a function cannot be restored without pickling). + active_during = self.active_during + if callable(active_during): + active_during = ( + to_hex_encode(active_during) + if kwargs.get("allow_pickle", True) + else "always" + ) + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "coefficients": { + "cN": self.cN, + "cY": self.cY, + "cA": self.cA, + "cm": self.cm, + "cn": self.cn, + "cl": self.cl, + }, + "center_of_pressure": self.center_of_pressure, + "name": self.name, + "controls": self.control_variables, + "reynolds_length": self.reynolds_length, + "active_during": active_during, + } + + @classmethod + def from_dict(cls, data): + # A preset ``active_during`` is used as is; anything else is unpickled + # back into the original function (falling back to "always" if it cannot + # be restored). + active_during = data.get("active_during", "always") + if active_during not in ("always", "power_on", "power_off"): + try: + active_during = from_hex_decode(active_during) + except (TypeError, ValueError): + active_during = "always" + return cls( + reference_area=data["reference_area"], + reference_length=data["reference_length"], + coefficients=data["coefficients"], + center_of_pressure=data.get("center_of_pressure", (0, 0, 0)), + name=data.get("name", "Controllable Generic Surface"), + controls=data.get("controls", ("deflection",)), + reynolds_length=data.get("reynolds_length"), + active_during=active_during, + ) diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py index f6b09f797..0f2fa7864 100644 --- a/rocketpy/rocket/aero_surface/fins/_base_fin.py +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -5,13 +5,22 @@ from rocketpy.mathutils.function import Function -from ..aero_surface import AeroSurface - - -class _BaseFin(AeroSurface): +from .._barrowman_surface import _BarrowmanSurface +from ..linear_generic_surface import LinearGenericSurface + + +# TODO: review note: airfoil handling can now be fully implemented. That is +# instead of getting just the clalpha from the airfoil, we can get and use the +# full lift curve. We need to check if simulation does not break with this +# change. If we use a full curve for airfoils, then we will have fin stall +# (abrupt cN drop), but the other surfaces do not have this drop, they simply +# will have their generated normal forces grow linearly with AoA, this can lead +# to wrong behaviour at high AoA. +class _BaseFin(_BarrowmanSurface): """ Base class for fins, shared by both Fin and Fins classes. - Inherits from AeroSurface. + Inherits from :class:`_BarrowmanSurface`, translating the fin geometry into + the linear generic-surface coefficient model. Handles shared initialization logic and common properties. """ @@ -47,8 +56,13 @@ def __init__( self.geometry = None self.reference_area = np.pi * rocket_radius**2 + self.reference_length = self.rocket_diameter - super().__init__(name, self.reference_area, self.rocket_diameter) + # The linear generic-surface machinery is initialized lazily by + # ``_finalize_barrowman`` once the concrete subclass has set up its + # geometry strategy and the first ``_update_geometry_chain`` has + # produced ``clalpha``, ``cpz`` and ``roll_parameters``. + self._barrowman_initialized = False def _update_reference_quantities(self): """Update quantities that depend on rocket radius.""" @@ -56,11 +70,32 @@ def _update_reference_quantities(self): self.reference_length = self.rocket_diameter def _update_geometry_chain(self): - """Update geometry-dependent quantities in dependency order.""" + """Update geometry-dependent quantities in dependency order, then + (re)build the generic-surface coefficients from the new geometry.""" self.evaluate_geometrical_parameters() self.evaluate_center_of_pressure() self.evaluate_lift_coefficient() self.evaluate_roll_parameters() + if self._barrowman_initialized: + # Geometry changed after construction: refresh the coefficients. + self.evaluate_coefficients() + self.compute_all_coefficients() + self._evaluate_stability_derivatives() + else: + self._finalize_barrowman() + + def _finalize_barrowman(self): + """Initialize the linear generic-surface machinery from the geometry + computed by the first ``_update_geometry_chain`` call.""" + LinearGenericSurface.__init__( + self, + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=self.name, + ) + self._barrowman_initialized = True @property def rocket_radius(self): @@ -292,7 +327,7 @@ def evaluate_single_fin_lift_coefficient(self): 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) ) - # Lift coefficient derivative for a single fin + # Normal-force coefficient derivative for a single fin def lift_source(mach): return ( clalpha2D(mach) @@ -308,7 +343,7 @@ def lift_source(mach): self.clalpha_single_fin = Function( lift_source, "Mach", - "Lift coefficient derivative for a single fin", + "Normal-force coefficient derivative for a single fin", ) @abstractmethod diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py index f809bca29..1db9dc75d 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py @@ -77,11 +77,12 @@ class EllipticalFin(Fin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. EllipticalFin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. EllipticalFin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( @@ -175,8 +176,8 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py index 4576bd1f3..74aea986a 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py @@ -80,11 +80,12 @@ class EllipticalFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. EllipticalFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. EllipticalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py index 5fec8a099..f5d71e932 100644 --- a/rocketpy/rocket/aero_surface/fins/fin.py +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -81,16 +81,22 @@ class Fin(_BaseFin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. Fin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Fin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. Fin.roll_parameters : list List containing the roll moment lift coefficient, the roll moment damping coefficient and the cant angle in radians. """ + # A single fin contributes unequally to the pitch and yaw planes, so it is + # not axisymmetric on its own. A complete, evenly spaced set may still be + # axisymmetric collectively, which the rocket's numeric check resolves. + is_axisymmetric = False + def __init__( self, angular_position, @@ -148,6 +154,12 @@ def __init__( self._angular_position = angular_position self._angular_position_rad = math.radians(angular_position) + def _update_geometry_chain(self): + """Run the base geometry/coefficient chain, then (re)build the body to + fin rotation matrices.""" + super()._update_geometry_chain() + self.evaluate_rotation_matrix() + @property def cant_angle(self): return self._cant_angle @@ -203,14 +215,7 @@ def evaluate_lift_coefficient(self): self.clalpha = self.clalpha_single_fin * self.lift_interference_factor - # Cl = clalpha * alpha - self.cl = Function( - lambda alpha, mach: alpha * self.clalpha(mach), - ["Alpha (rad)", "Mach"], - "Lift coefficient", - ) - - return self.cl + return self.clalpha def evaluate_roll_parameters(self): """Calculates and returns the fin set's roll coefficients. @@ -318,6 +323,35 @@ def evaluate_rotation_matrix(self): self._rotation_fin_to_body = R_body_to_fin.transpose self._rotation_surface_to_body = self._rotation_fin_to_body + @property + def force_application_point(self): + """Point where the fin's aerodynamic force is applied, in body frame. + + Returns + ------- + Vector + The fin's center of pressure ``[cpx, cpy, cpz]``. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + def evaluate_coefficients(self): + """Evaluate the fin's normal-force slope coefficients. + + Sets ``cN_alpha`` (pitch plane) and ``cY_beta`` (yaw plane) from the + fin's normal-force slope projected onto each plane by its angular + position. Moment coefficients are left at zero since the moment is + transported geometrically in :meth:`compute_forces_and_moments`. + """ + clalpha = self.clalpha + sin_sq = math.sin(self.angular_position_rad) ** 2 + cos_sq = math.cos(self.angular_position_rad) ** 2 + self.cN_alpha = self._mach_coefficient( + lambda mach: clalpha.get_value_opt(mach) * sin_sq + ) + self.cY_beta = self._mach_coefficient( + lambda mach: -clalpha.get_value_opt(mach) * cos_sq + ) + def compute_forces_and_moments( self, stream_velocity, @@ -344,6 +378,10 @@ def compute_forces_and_moments( Center of pressure coordinates in the body frame. omega: tuple[float, float, float] Tuple containing angular velocities around the x, y, z axes. + *args + Extra positional arguments accepted for signature compatibility with + the generic surface (e.g. ``density``, ``dynamic_viscosity``, + ``z``). Unused by the fin's Barrowman model. Returns ------- @@ -363,7 +401,8 @@ def compute_forces_and_moments( * rho * stream_speed**2 * self.reference_area - * self.cl.get_value_opt(attack_angle, stream_mach) + * self.clalpha.get_value_opt(stream_mach) + * attack_angle ) # Force in body frame R1, R2, R3 = self._rotation_fin_to_body @ Vector([X, 0, 0]) @@ -438,7 +477,7 @@ def to_dict(self, include_outputs=False): data.update( { "cp": self.cp, - "cl": self.cl, + "clalpha": self.clalpha, "roll_parameters": self.roll_parameters, "rocket_diameter": self.rocket_diameter, "diameter": self.rocket_diameter, diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index b61bfcc19..781857be3 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -80,11 +80,12 @@ class Fins(_BaseFin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. Fins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Fins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. Fins.roll_parameters : list List containing the roll moment lift coefficient, the roll moment damping coefficient and the cant angle in radians. @@ -165,7 +166,7 @@ def evaluate_lift_coefficient(self): """ self.evaluate_single_fin_lift_coefficient() - # Lift coefficient derivative for n fins corrected with Fin-Body interference + # Normal-force coefficient derivative for n fins corrected with Fin-Body interference self.clalpha_multiple_fins = ( self.fin_num_correction(self.n) * self.lift_interference_factor @@ -173,19 +174,12 @@ def evaluate_lift_coefficient(self): ) # Function of mach number self.clalpha_multiple_fins.set_inputs("Mach") self.clalpha_multiple_fins.set_outputs( - f"Lift coefficient derivative for {self.n:.0f} fins" + f"Normal-force coefficient derivative for {self.n:.0f} fins" ) self.clalpha = self.clalpha_multiple_fins - # Cl = clalpha * alpha - self.cl = Function( - lambda alpha, mach: alpha * self.clalpha_multiple_fins(mach), - ["Alpha (rad)", "Mach"], - "Lift coefficient", - ) - - return self.cl + return self.clalpha def evaluate_roll_parameters(self): """Calculates and returns the fin set's roll coefficients. @@ -200,11 +194,18 @@ def evaluate_roll_parameters(self): """ clf_delta = ( self.roll_forcing_interference_factor - * self.fin_num_correction(self.n) + * self.n * (self.Yma + self.rocket_radius) * self.clalpha_single_fin / self.reference_length ) # Function of mach number + # NOTE: roll forcing scales with the full fin count ``n`` -- every + # identically-canted fin contributes the same roll moment, with no + # cancellation. This differs from the normal-force slope, which uses the + # ``fin_num_correction(n)`` (~n/2) multiple-fin factor because fins at + # different roll angles partially cancel in pitch/yaw. Using + # ``fin_num_correction(n)`` here previously halved the roll forcing (and + # the roll rate) of a fin set relative to the equivalent individual fins. clf_delta.set_inputs("Mach") clf_delta.set_outputs("Roll moment forcing coefficient derivative") clf_delta.set_title( @@ -251,66 +252,6 @@ def fin_num_correction(n): else: return n / 2 - def compute_forces_and_moments( - self, - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - omega, - *args, - ): # pylint: disable=arguments-differ - """Computes the forces and moments acting on the aerodynamic surface. - - Parameters - ---------- - stream_velocity : tuple of float - The velocity of the airflow relative to the surface. - stream_speed : float - The magnitude of the airflow speed. - stream_mach : float - The Mach number of the airflow. - rho : float - Air density. - cp : Vector - Center of pressure coordinates in the body frame. - omega: tuple[float, float, float] - Tuple containing angular velocities around the x, y, z axes. - - Returns - ------- - tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. - """ - - R1, R2, R3, M1, M2, _ = super().compute_forces_and_moments( - stream_velocity, - stream_speed, - stream_mach, - rho, - cp, - ) - clf_delta, cld_omega, cant_angle_rad = self.roll_parameters - M3_forcing = ( - (1 / 2 * rho * stream_speed**2) - * self.reference_area - * self.reference_length - * clf_delta.get_value_opt(stream_mach) - * cant_angle_rad - ) - M3_damping = ( - (1 / 2 * rho * stream_speed) - * self.reference_area - * (self.reference_length) ** 2 - * cld_omega.get_value_opt(stream_mach) - * omega[2] - / 2 - ) - M3 = M3_forcing + M3_damping - return R1, R2, R3, M1, M2, M3 - def to_dict(self, **kwargs): if self.airfoil: if kwargs.get("discretize", False): @@ -335,16 +276,14 @@ def to_dict(self, **kwargs): } if kwargs.get("include_outputs", False): - cl = self.cl + clalpha = self.clalpha if kwargs.get("discretize", False): - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) + clalpha = clalpha.set_discrete(0, 4, 50) data.update( { "cp": self.cp, - "cl": cl, + "clalpha": clalpha, "roll_parameters": self.roll_parameters, "rocket_diameter": self.rocket_diameter, "diameter": self.rocket_diameter, diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py index 5099d322b..eedb4b76f 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fin.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py @@ -72,11 +72,12 @@ class FreeFormFin(Fin): Fin set local center of pressure z coordinate. Has units of length and is given in meters. FreeFormFin.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. FreeFormFin.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. FreeFormFin.mac_length : float Mean aerodynamic chord length of the fin set. FreeFormFin.mac_lead : float @@ -172,8 +173,8 @@ def evaluate_center_of_pressure(self): def shape_points(self): return self.geometry.shape_points - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fins.py b/rocketpy/rocket/aero_surface/fins/free_form_fins.py index d7c7e9512..d6186e9eb 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fins.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fins.py @@ -73,11 +73,12 @@ class FreeFormFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. FreeFormFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. FreeFormFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. FreeFormFins.mac_length : float Mean aerodynamic chord length of the fin set. FreeFormFins.mac_lead : float diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py index c58055945..49e594ec1 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py @@ -163,7 +163,6 @@ def __init__( ) self._update_geometry_chain() self.evaluate_shape() - self.evaluate_rotation_matrix() self.prints = _TrapezoidalFinPrints(self) self.plots = _TrapezoidalFinPlots(self) @@ -220,8 +219,8 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def to_dict(self, include_outputs=False): - data = super().to_dict(include_outputs=include_outputs) + def to_dict(self, include_outputs=False, **kwargs): + data = super().to_dict(include_outputs=include_outputs, **kwargs) data.update(self.geometry.get_data(include_outputs=include_outputs)) return data diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py index 2c9adea58..dfffa4a83 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py @@ -82,11 +82,12 @@ class TrapezoidalFins(Fins): Fin set local center of pressure z coordinate. Has units of length and is given in meters. TrapezoidalFins.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. TrapezoidalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. """ def __init__( diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 4b83e0e4f..174a95836 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,11 +1,94 @@ -import copy -import csv +import inspect import math import numpy as np from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector +from rocketpy.plots.aero_surface_plots import _GenericSurfacePlots +from rocketpy.prints.aero_surface_prints import _GenericSurfacePrints +from rocketpy.rocket.aero_surface.aero_coefficient import ( + AeroCoefficient, + build_independent_vars, +) +from rocketpy.tools import from_hex_decode, to_hex_encode + + +def _as_function(func, independent_vars, name): + """Wrap a variadic callable as a :class:`Function` over ``independent_vars``. + + ``Function`` reads its domain dimension from the callable's parameter count, + so a variadic wrapper is given an explicit signature to advertise one + parameter per independent variable. + """ + func.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in independent_vars + ) + return Function(func, list(independent_vars), [name]) + + +def wind_to_body_coefficients(c_lift, c_drag, c_side, independent_vars): + """Rotate wind-frame force coefficients into the body frame. + + Given the lift, drag and side-force coefficients (each callable over the + surface's independent-variable tuple, with the angle of attack and sideslip + as the first two variables), return the body-frame normal, side and axial + coefficients ``(cN, cY, cA)`` as :class:`Function`s over the same variables. + """ + lift, drag, side = c_lift.get_value_opt, c_drag.get_value_opt, c_side.get_value_opt + + def normal(*args): + alpha, beta = args[0], args[1] + transverse = math.sin(beta) * side(*args) + math.cos(beta) * drag(*args) + return math.cos(alpha) * lift(*args) + math.sin(alpha) * transverse + + def yaw_side(*args): + beta = args[1] + return math.cos(beta) * side(*args) - math.sin(beta) * drag(*args) + + def axial(*args): + alpha, beta = args[0], args[1] + transverse = math.sin(beta) * side(*args) + math.cos(beta) * drag(*args) + return -math.sin(alpha) * lift(*args) + math.cos(alpha) * transverse + + return ( + _as_function(normal, independent_vars, "cN"), + _as_function(yaw_side, independent_vars, "cY"), + _as_function(axial, independent_vars, "cA"), + ) + + +def body_to_wind_coefficients(c_normal, c_side, c_axial, independent_vars): + """Rotate body-frame force coefficients into the wind frame. + + Inverse of :func:`wind_to_body_coefficients`: given the body-frame normal, + side and axial coefficients, return the wind-frame lift, drag and + side-force coefficients ``(cL, cD, cQ)`` as :class:`Function`s. + """ + normal = c_normal.get_value_opt + side = c_side.get_value_opt + axial = c_axial.get_value_opt + + def lift(*args): + alpha = args[0] + return math.cos(alpha) * normal(*args) - math.sin(alpha) * axial(*args) + + def drag(*args): + alpha, beta = args[0], args[1] + longitudinal = math.sin(alpha) * normal(*args) + math.cos(alpha) * axial(*args) + return -math.sin(beta) * side(*args) + math.cos(beta) * longitudinal + + def yaw_side(*args): + alpha, beta = args[0], args[1] + longitudinal = math.sin(alpha) * normal(*args) + math.cos(alpha) * axial(*args) + return math.cos(beta) * side(*args) + math.sin(beta) * longitudinal + + return ( + _as_function(lift, independent_vars, "cL"), + _as_function(drag, independent_vars, "cD"), + _as_function(yaw_side, independent_vars, "cQ"), + ) class GenericSurface: @@ -14,6 +97,10 @@ class GenericSurface: attack, sideslip angle, Mach number, Reynolds number, pitch rate, yaw rate and roll rate.""" + # Whether this surface contributes identically to the pitch and yaw planes. + # ``False`` for a generic surface (its coefficients may differ between planes) + is_axisymmetric = False + def __init__( self, reference_area, @@ -21,6 +108,11 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Surface", + reynolds_length=None, + interpolation=None, + extrapolation=None, + force_convention=None, + active_during="always", ): """Create a generic aerodynamic surface, defined by its aerodynamic coefficients. This surface is used to model any aerodynamic surface @@ -35,6 +127,20 @@ def __init__( "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". The independent variable columns can be provided in any order. + The Reynolds number ("reynolds") is by default built on the reference + length (the rocket diameter). Published rocket data and tools often base + Reynolds on the **body length** instead, which for a slender rocket is + much larger (Re scales with the chosen length). If your coefficient + table uses a different length than the reference length, pass that + length as ``reynolds_length`` so the Reynolds number the simulation + feeds your table matches the one it was built against. + + The angular-rate inputs ("pitch_rate", "yaw_rate", "roll_rate") are the + conventional **non-dimensional reduced rates**, + ``q* = q * L_ref / (2 * V)`` (and likewise for ``r``/``p``). + Provide coefficient tables against the reduced rates, not the raw body + rates in rad/s. + See Also -------- :ref:`genericsurfaces`. @@ -45,54 +151,440 @@ def __init__( Reference area of the aerodynamic surface. Has the unit of meters squared. Commonly defined as the rocket's cross-sectional area. reference_length : int, float - Reference length of the aerodynamic surface. Has the unit of meters. - Commonly defined as the rocket's diameter. + Reference length of the aerodynamic surface, in meters. Commonly the + rocket's diameter. Used to non-dimensionalize the moment coefficients + and the reduced rotation rates, and (unless ``reynolds_length`` is + given) as the length scale of the Reynolds number. coefficients: dict - List of coefficients. If a coefficient is omitted, it is set to 0. - The valid coefficients are:\n - cL: str, callable, optional - Lift coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n - cQ: str, callable, optional - Side force coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n - cD: str, callable, optional - Drag coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + The six force and moment coefficients, by name. Any you leave out are + set to 0. Each one can be a constant number, a function of the flow + variables, a list of data points, or a path to a CSV file. By default + the force coefficients are the body-frame ones (see + ``force_convention``); the wind-frame names ``cL``/``cQ``/``cD`` are + also accepted. The coefficients are:\n + cN: str, callable, optional + Normal force coefficient (body frame). Default is 0.\n + cY: str, callable, optional + Side force coefficient (body frame). Default is 0.\n + cA: str, callable, optional + Axial force coefficient (body frame). Default is 0.\n cm: str, callable, optional - Pitch moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Pitch moment coefficient. Default is 0.\n cn: str, callable, optional - Yaw moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Yaw moment coefficient. Default is 0.\n cl: str, callable, optional - Roll moment coefficient. Can be a path to a CSV file or a callable. - Default is 0.\n + Roll moment coefficient. Default is 0.\n center_of_pressure : tuple, list, optional Application point of the aerodynamic forces and moments. The center of pressure is defined in the local coordinate system of the aerodynamic surface. The default value is (0, 0, 0). name : str, optional - Name of the aerodynamic surface. Default is 'GenericSurface'. + Name of the aerodynamic surface. Default is 'Generic Surface'. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficients. Set it to the length your Reynolds-dependent + coefficient data was tabulated against (for example the rocket's + body length, if your table uses a length-based Reynolds number). + ``None`` (the default) uses ``reference_length`` (the diameter). Has + no effect unless a coefficient actually depends on "reynolds". + interpolation : str or dict, optional + How tabulated coefficients interpolate between points. The accepted + methods depend on the coefficient's dimensionality: a 1-D table + (e.g. a Mach-only curve) accepts ``"linear"``, ``"akima"``, + ``"spline"`` and ``"polynomial"``; a multi-dimensional scattered + table accepts ``"linear"``, ``"shepard"`` and ``"rbf"``; and a + multi-dimensional table on a regular Cartesian grid accepts + ``"linear"``, ``"nearest"``, ``"slinear"``, ``"cubic"``, + ``"quintic"`` and ``"pchip"`` (with ``"spline"`` mapped to + ``"cubic"`` and ``"akima"`` to ``"pchip"``). Pass a single string to + use that method for every coefficient, or a dict keyed by coefficient + name to set them individually (coefficients left out of the dict fall + back to the default). ``None`` (the default) uses ``"linear"`` for + tables built here and keeps a pre-built ``Function``'s own setting. + extrapolation : str or dict, optional + How tabulated coefficients behave outside their data range: + ``"constant"`` holds the value at the nearest data edge, + ``"natural"`` keeps following the curve, and ``"zero"`` returns 0. + Pass a single string to use that method for every coefficient, or a + dict keyed by coefficient name to set them individually (coefficients + left out of the dict fall back to the default). ``None`` (the + default) uses ``"constant"`` for tables built here and keeps whatever + a pre-built ``Function`` already carries. Only affects tabulated + sources (constants and callables are evaluated directly). + force_convention : str, optional + The frame your force coefficients are given in. ``"wind"`` for the + wind-frame coefficients ``cL`` (lift), ``cQ`` (side) and + ``cD`` (drag); ``"body"`` for the body-frame coefficients ``cN`` + (normal), ``cY`` (side) and ``cA`` (axial), the convention used by + DATCOM, wind tunnels and Barrowman. The moment coefficients + (``cm``, ``cn``, ``cl``) are the same in both. ``None`` (the default) + infers the frame from the coefficient names you pass. Whichever frame + you use, all nine coefficients are available as attributes afterwards + (the other frame is computed on demand). + active_during : str or callable, optional + When this surface produces aerodynamic force during a simulation. + Use it to model a surface that is only present in part of the flight, + such as jet vanes that only work while the motor burns, or a base + drag that only appears after burnout. Accepts: + + - ``"always"`` (default): the surface always contributes force. + - ``"power_on"``: only while the motor is burning (up to the motor's + burn-out time). + - ``"power_off"``: only after the motor has burned out. + - a function ``active_during(t, flight)`` returning ``True`` when the + surface is active at time ``t`` (in seconds) of the given + :class:`Flight`. Use this for any custom window. """ + # Externally-supplied axes (e.g. control deflections). Subclasses set + # this before ``super().__init__``. Defaults to none for plain surfaces. + self.control_variables = getattr(self, "control_variables", ()) + # Ordered independent variables accepted by every coefficient: the seven + # base axes, plus any ``control_variables`` + self.independent_vars = build_independent_vars(self.control_variables) + self.reference_area = reference_area self.reference_length = reference_length + self.reynolds_length = ( + reference_length if reynolds_length is None else reynolds_length + ) self.center_of_pressure = center_of_pressure self.cp = center_of_pressure self.cpx = center_of_pressure[0] self.cpy = center_of_pressure[1] self.cpz = center_of_pressure[2] self.name = name + self.active_during = self._validate_active_during(active_during) + self.is_active = self._build_activation_check(self.active_during) + + self._rotation_surface_to_body = self._default_surface_rotation() + + self._build_coefficients( + coefficients, interpolation, extrapolation, force_convention + ) + + self.evaluate_coefficients() + self._evaluate_stability_derivatives() - self._rotation_surface_to_body = Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + # Reporting layers. Subclasses override these with their own (more + # specific) prints/plots after calling ``super().__init__``. + self.prints = _GenericSurfacePrints(self) + self.plots = _GenericSurfacePlots(self) + def _default_surface_rotation(self): + """Rotation from the surface-local frame to the body frame. It is applied + to the :attr:`force_application_point` when the rocket locates each + surface's center of pressure relative to the center of dry mass. A plain + generic surface takes its center of pressure as already body-aligned + (the identity); geometry-defined (Barrowman) surfaces override this. + """ + return Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + + @staticmethod + def _validate_active_during(active_during): + """Check the ``active_during`` policy and return it unchanged. + + Accepts one of the preset strings ``"always"``, ``"power_on"``, + ``"power_off"`` or a callable ``(t, flight) -> bool``; anything else + raises a ``ValueError`` so a typo is caught at construction rather than + silently keeping the surface active. + """ + if callable(active_during) or active_during in ( + "always", + "power_on", + "power_off", + ): + return active_during + raise ValueError( + "`active_during` must be one of 'always', 'power_on', 'power_off' " + "or a callable(t, flight) -> bool; " + f"got {active_during!r}." + ) + + @staticmethod + def _build_activation_check(active_during): + """Resolve an ``active_during`` policy into the ``is_active(t, flight)`` + function the flight integrator calls for every surface each step to skip + the ones that are not currently active. + + Resolving it once here keeps that per-step check free of policy + branching. A custom callable is used unchanged; each preset becomes a + small function of the simulation time ``t`` (in seconds) and the + ``flight`` being run, and ``"always"`` becomes a function that simply + returns ``True``. + """ + if callable(active_during): + return active_during + if active_during == "power_on": + return lambda t, flight: t < flight.rocket.motor.burn_out_time + if active_during == "power_off": + return lambda t, flight: t >= flight.rocket.motor.burn_out_time + return lambda t, flight: True # "always" + + @property + def force_application_point(self): + """Local point (surface frame) at which the resultant force is applied + when transporting its moment to the rocket's center of dry mass. This is + the center of pressure ``self.cp``; any residual couple is carried by the + ``cm``/``cn``/``cl`` coefficients. + """ + return Vector([self.cpx, self.cpy, self.cpz]) + + @property + def cL(self): + """Wind-frame lift coefficient, as a :class:`Function` of the surface's + independent variables. Derived from the canonical body-frame ``cN``, + ``cY`` and ``cA`` by the angle-of-attack/sideslip rotation.""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[0] + + @property + def cD(self): + """Wind-frame drag coefficient (derived from ``cN``/``cY``/``cA``).""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[1] + + @property + def cQ(self): + """Wind-frame side-force coefficient (derived from ``cN``/``cY``/``cA``).""" + return body_to_wind_coefficients( + self.cN, self.cY, self.cA, self.independent_vars + )[2] + + def evaluate_coefficients(self): + """Hook for subclasses to (re)populate the aerodynamic coefficient + ``Function``s from their geometry. The base class builds coefficients + directly from the user-provided dictionary, so this is a no-op here. + Subclasses that derive coefficients from geometry (e.g. the Barrowman + surfaces) override this and call it again whenever their geometry + changes. + + Returns + ------- + None + """ + + def _evaluate_stability_derivatives(self): + """Compute the coefficient derivatives used for stability and store them + as the ``cN_alpha``, ``cm_alpha``, ``cY_beta`` and ``cn_beta`` + attributes, then build the center-of-pressure accessors from them. + + A plain generic surface recovers each derivative from its body-frame + force and moment coefficients by numerical differentiation at + ``alpha = beta = 0`` with zero rates. The Barrowman surfaces instead set + these four attributes directly from geometry and only reuse + :meth:`_set_stability_accessors` (see the :class:`LinearGenericSurface` + override). + + Returns + ------- + None + """ + self.cN_alpha = AeroCoefficient( + self.cN.slope("alpha", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cN_alpha", + ) + self.cm_alpha = AeroCoefficient( + self.cm.slope("alpha", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cm_alpha", + ) + self.cY_beta = AeroCoefficient( + self.cY.slope("beta", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cY_beta", + ) + self.cn_beta = AeroCoefficient( + self.cn.slope("beta", "mach"), + depends_on=("mach",), + control_variables=self.control_variables, + name="cn_beta", + ) + self._set_stability_accessors() + + def _set_stability_accessors(self): + """Build the pitch- and yaw-plane center-of-pressure accessors from the + stored coefficient derivatives (``cN_alpha``/``cm_alpha`` and + ``cY_beta``/``cn_beta``), each evaluated at ``alpha = beta = 0`` with + zero rates. + + Each accessor is a Mach-only :class:`Function` giving the surface's + center of pressure along the body z-axis. It combines the surface's + local application point with the offset implied by its moment + coefficient (``cp = application point - (moment slope / force slope) * + L_ref``). When a surface produces no force at some Mach the center of + pressure is undefined, so it falls back to the geometric application + point and drops out of the force-weighted average. + + Returns + ------- + None + """ + reference_length = self.reference_length + local_cpz = self.force_application_point[2] + + def _cp_z(force_coeff, moment_coeff): + def cp_z(mach): + slope = force_coeff.get_value_opt(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0) + if slope == 0: + return local_cpz + moment = moment_coeff.get_value_opt(0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0) + return local_cpz - moment / slope * reference_length + + return Function(cp_z, "Mach", "Center of pressure to local origin (m)") + + self.center_of_pressure_z = _cp_z(self.cN_alpha, self.cm_alpha) + self.center_of_pressure_z_yaw = _cp_z(self.cY_beta, self.cn_beta) + + @staticmethod + def _coefficient_option(option, coeff_name): + """Resolve a per-coefficient interpolation/extrapolation setting. + + ``option`` may be a single value applied to every coefficient, a dict + mapping coefficient names to values (coefficients absent from the dict + fall back to the ``AeroCoefficient`` default), or ``None``. + + Parameters + ---------- + option : str, dict, or None + The interpolation/extrapolation argument passed to ``__init__``. + coeff_name : str + Name of the coefficient being built (e.g. ``"cD"``, ``"cm_alpha"``). + + Returns + ------- + str or None + The value to forward to :class:`AeroCoefficient` for this coefficient. + """ + if isinstance(option, dict): + return option.get(coeff_name) + return option + + # Force-coefficient names in each frame. Moments (cm/cn/cl) are frame-shared. + _WIND_FORCE_NAMES = ("cL", "cQ", "cD") + _BODY_FORCE_NAMES = ("cN", "cY", "cA") + + def _force_frames_present(self, coefficients): + """Report which force frames the input coefficient names belong to, as + ``(has_wind, has_body)``. + + A generic surface matches the plain force names (``cL``/``cQ``/``cD`` for + wind, ``cN``/``cY``/``cA`` for body). The linear model overrides this to + match those same names as derivative prefixes (``cL_alpha`` ...). + """ + keys = set(coefficients) + has_wind = bool(keys & set(self._WIND_FORCE_NAMES)) + has_body = bool(keys & set(self._BODY_FORCE_NAMES)) + return has_wind, has_body + + def _resolve_force_convention(self, coefficients, force_convention): + """Decide whether the input force coefficients are given in the wind + frame (``cL``/``cQ``/``cD``) or the body frame (``cN``/``cY``/``cA``). + + When ``force_convention`` is ``None`` the frame is inferred from the + coefficient names; mixing the two frames is rejected. With no force + coefficients to infer from, the canonical body frame is assumed. + """ + has_wind, has_body = self._force_frames_present(coefficients) + if force_convention is None: + if has_wind and has_body: + raise ValueError( + "Mixed wind (cL/cQ/cD) and body (cN/cY/cA) force " + "coefficients; pass force_convention='wind' or 'body'." + ) + return "wind" if has_wind else "body" + if force_convention not in ("wind", "body"): + raise ValueError( + f"force_convention must be 'wind' or 'body', got {force_convention!r}." + ) + return force_convention + + def _wind_input_to_body(self, coefficients): + """Convert a wind-frame force-coefficient input (``cL``/``cQ``/``cD``) + into the canonical body-frame coefficients (``cN``/``cY``/``cA``), + leaving the moment coefficients untouched.""" + wind = {} + passthrough = {} + for name, value in coefficients.items(): + if name in self._WIND_FORCE_NAMES: + wind[name] = value + else: + passthrough[name] = value + + def as_coefficient(source, name): + return AeroCoefficient( + source, + control_variables=self.control_variables, + name=name, + ) + + c_normal, c_yaw, c_axial = wind_to_body_coefficients( + as_coefficient(wind.get("cL", 0), "cL"), + as_coefficient(wind.get("cD", 0), "cD"), + as_coefficient(wind.get("cQ", 0), "cQ"), + self.independent_vars, + ) + return {"cN": c_normal, "cY": c_yaw, "cA": c_axial, **passthrough} + + def _build_coefficients( + self, coefficients, interpolation, extrapolation, force_convention + ): + """Resolve the force-coefficient frame and store the surface's + aerodynamic coefficients as :class:`AeroCoefficient` attributes. + + Runs the full coefficient setup from the user input: picks the force + frame, converts a wind-frame input to the canonical body frame, fills in + any coefficient the user left out with its default (0), and stores each + one as an attribute (``self.cN``, ``self.cm``, ...). + + Parameters + ---------- + coefficients : dict + The user-provided coefficients (see :meth:`__init__`). + interpolation, extrapolation : str, dict, or None + The interpolation/extrapolation settings (see :meth:`__init__`). + force_convention : str or None + The frame the input force coefficients are given in, or ``None`` to + infer it from the coefficient names. + """ default_coefficients = self._get_default_coefficients() + self.force_convention = self._resolve_force_convention( + coefficients, force_convention + ) + # Wind-frame force input (cL/cQ/cD) is converted once to the canonical + # body-frame coefficients before validation. Each surface supplies the + # conversion appropriate to its coefficients: the generic surface rotates + # the full force coefficients, while the linear model recombines the + # coefficient derivatives (see LinearGenericSurface._wind_input_to_body). + # A non-dict input falls through to _check_coefficients, which rejects it. + if self.force_convention == "wind" and isinstance(coefficients, dict): + coefficients = self._wind_input_to_body(coefficients) self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) + + # ``_needs_reynolds`` lets the flight loop skip the per-step atmosphere + # lookups when no coefficient uses the Reynolds number. Only these + # primary coefficients are checked: they are what the surface evaluates, + # and the linear model's combined coefficients are linear combinations of + # them, so a Reynolds dependence always shows up here. + self._needs_reynolds = False for coeff, coeff_value in coefficients.items(): - value = self._process_input(coeff_value, coeff) + value = AeroCoefficient( + coeff_value, + control_variables=self.control_variables, + name=coeff, + extrapolation=self._coefficient_option(extrapolation, coeff), + interpolation=self._coefficient_option(interpolation, coeff), + ) setattr(self, coeff, value) + if "reynolds" in value.depends_on: + self._needs_reynolds = True def _get_default_coefficients(self): """Returns default coefficients @@ -104,9 +596,9 @@ def _get_default_coefficients(self): are the default values. """ default_coefficients = { - "cL": 0, - "cQ": 0, - "cD": 0, + "cN": 0, + "cY": 0, + "cA": 0, "cm": 0, "cn": 0, "cl": 0, @@ -131,9 +623,13 @@ def _complete_coefficients(self, input_coefficients, default_coefficients): coefficients : dict Coefficients dictionary used to setup coefficient attributes """ - coefficients = copy.deepcopy(input_coefficients) + # Shallow copy: only missing keys are added, so the user's dict is left + # intact. The values are not mutated here (each is wrapped in an + # AeroCoefficient, which copies it when it needs its own settings), so + # there is no need to deep-copy potentially large tabulated coefficients. + coefficients = dict(input_coefficients) for coeff, value in default_coefficients.items(): - if coeff not in coefficients.keys(): + if coeff not in coefficients: coefficients[coeff] = value return coefficients @@ -192,45 +688,66 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. + The body-frame force components ``(R1, R2, R3)`` and the moments + ``(pitch, yaw, roll)``. """ # Precompute common values dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area dyn_pressure_area_length = dyn_pressure_area * self.reference_length - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cL( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - side = dyn_pressure_area * self.cQ( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - drag = dyn_pressure_area * self.cD( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + # Coefficient arguments (base 7 vars, plus any extra axes appended by + # subclasses such as control deflections). + args = self._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, ) + # Body-frame force components straight from the body-frame coefficients + # (normal cN, side cY, axial cA); no wind-to-body rotation needed. + normal = dyn_pressure_area * self.cN(*args) + yaw_side = dyn_pressure_area * self.cY(*args) + axial = dyn_pressure_area * self.cA(*args) + R1 = yaw_side + R2 = -normal + R3 = -axial + # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cm( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - yaw = dyn_pressure_area_length * self.cn( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) - roll = dyn_pressure_area_length * self.cl( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + pitch = dyn_pressure_area_length * self.cm(*args) + yaw = dyn_pressure_area_length * self.cn(*args) + roll = dyn_pressure_area_length * self.cl(*args) - return lift, side, drag, pitch, yaw, roll + return R1, R2, R3, pitch, yaw, roll + + def _coefficient_arguments( + self, + alpha, + beta, + mach, + reynolds, + pitch_rate, + yaw_rate, + roll_rate, + ): + """Returns the argument tuple passed to every coefficient ``Function``, + in ``self.independent_vars`` order. The base class provides the seven + standard inputs. Subclasses (e.g. :class:`ControllableGenericSurface`) + override this to append further axes such as control deflections. + """ + return (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) def compute_forces_and_moments( self, @@ -279,208 +796,125 @@ def compute_forces_and_moments( (pitch, yaw, roll) in the body frame. """ # Reynolds number at the surface altitude. Computed here (rather than in - # the flight loop) since it is only needed by generic surfaces. - comp_density = density.get_value_opt(z) - comp_dynamic_viscosity = dynamic_viscosity.get_value_opt(z) - reynolds = ( - comp_density * stream_speed * self.reference_length / comp_dynamic_viscosity - if comp_dynamic_viscosity > 0 - else 0 - ) + # the flight loop) since it is only needed by generic surfaces, and only + # when a coefficient actually depends on it -- otherwise the two + # atmosphere lookups are skipped for every surface, every step. + if self._needs_reynolds: + comp_density = density.get_value_opt(z) + comp_dynamic_viscosity = dynamic_viscosity.get_value_opt(z) + reynolds = ( + comp_density + * stream_speed + * self.reynolds_length + / comp_dynamic_viscosity + if comp_dynamic_viscosity > 0 + else 0 + ) + else: + reynolds = 0.0 - # Stream velocity in standard aerodynamic frame + # Stream velocity in standard wind frame stream_velocity = -stream_velocity # Angles of attack and sideslip alpha = np.arctan2(stream_velocity[1], stream_velocity[2]) beta = np.arctan2(stream_velocity[0], stream_velocity[2]) - # Compute aerodynamic forces and moments - lift, side, drag, pitch, yaw, roll = self._compute_from_coefficients( + # Non-dimensionalize the body angular rates into the conventional reduced + # rates (e.g. ``q* = q * L_ref / (2 * V)``). + reduced_rate_factor = ( + self.reference_length / (2 * stream_speed) if stream_speed > 0 else 0.0 + ) + + # Body-frame force components and moments straight from the body-frame + # coefficients (no wind-to-body rotation: the coefficients already live + # in the body frame). ``alpha``/``beta`` are still passed to the + # coefficients, they just no longer rotate the force. + R1, R2, R3, pitch, yaw, roll = self._compute_from_coefficients( rho, stream_speed, alpha, beta, stream_mach, reynolds, - omega[0], # q - omega[1], # r - omega[2], # p + omega[0] * reduced_rate_factor, # q* reduced pitch rate + omega[1] * reduced_rate_factor, # r* reduced yaw rate + omega[2] * reduced_rate_factor, # p* reduced roll rate ) - # Conversion from aerodynamic frame to body frame - rotation_matrix = Matrix( - [ - [1, 0, 0], - [0, math.cos(alpha), -math.sin(alpha)], - [0, math.sin(alpha), math.cos(alpha)], - ] - ) @ Matrix( - [ - [math.cos(beta), 0, -math.sin(beta)], - [0, 1, 0], - [math.sin(beta), 0, math.cos(beta)], - ] - ) - R1, R2, R3 = rotation_matrix @ Vector([side, -lift, -drag]) - # Dislocation of the aerodynamic application point to CDM M1, M2, M3 = Vector([pitch, yaw, roll]) + (cp ^ Vector([R1, R2, R3])) return R1, R2, R3, M1, M2, M3 - def _process_input(self, input_data, coeff_name): - """Process the input data, either as a CSV file or a callable function. + def to_dict(self, include_outputs=False, **kwargs): # pylint: disable=unused-argument + # The stored coefficients are always the canonical body-frame set (the + # names from ``_get_default_coefficients``: cN/cY/cA/... for a generic + # surface, the derivative set for the linear model), so they are saved + # with ``force_convention="body"`` and rebuilt directly on load. + coefficients = { + name: getattr(self, name) for name in self._get_default_coefficients() + } + # A preset ``active_during`` is stored as is; a custom (t, flight) -> bool + # function is pickled to text when allowed, otherwise dropped to "always" + # (a function cannot be restored without pickling). + active_during = self.active_during + if callable(active_during): + active_during = ( + to_hex_encode(active_during) + if kwargs.get("allow_pickle", True) + else "always" + ) + return { + "reference_area": self.reference_area, + "reference_length": self.reference_length, + "reynolds_length": self.reynolds_length, + "coefficients": coefficients, + "center_of_pressure": self.center_of_pressure, + "name": self.name, + "force_convention": "body", + "active_during": active_during, + } - Parameters - ---------- - input_data : str or callable - Input data to be processed, either a path to a CSV or a callable. - coeff_name : str - Name of the coefficient being processed for error reporting. + @classmethod + def from_dict(cls, data): + # A preset ``active_during`` is used as is; anything else is unpickled + # back into the original function (falling back to "always" if it cannot + # be restored). + active_during = data.get("active_during", "always") + if active_during not in ("always", "power_on", "power_off"): + try: + active_during = from_hex_decode(active_during) + except (TypeError, ValueError): + active_during = "always" + return cls( + reference_area=data["reference_area"], + reference_length=data["reference_length"], + coefficients=data["coefficients"], + center_of_pressure=data.get("center_of_pressure", (0, 0, 0)), + name=data.get("name", "Generic Surface"), + reynolds_length=data.get("reynolds_length"), + force_convention=data.get("force_convention", "body"), + active_during=active_during, + ) + + def info(self): + """Prints a summary of the surface's geometry and aerodynamic + coefficients. Subclasses override this with surface-specific summaries. Returns ------- - Function - Function object with 7 input arguments (alpha, beta, mach, reynolds, - pitch_rate, yaw_rate, roll_rate). + None """ - if isinstance(input_data, str): - # Input is assumed to be a file path to a CSV - return self.__load_generic_surface_csv(input_data, coeff_name) - elif isinstance(input_data, Function): - if input_data.__dom_dim__ != 7: - raise ValueError( - f"{coeff_name} function must have 7 input arguments" - " (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)." - ) - return input_data - elif callable(input_data): - # Check if callable has 7 inputs (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - if input_data.__code__.co_argcount != 7: - raise ValueError( - f"{coeff_name} function must have 7 input arguments" - " (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)." - ) - return Function( - input_data, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) - elif input_data == 0: - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: 0, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) - else: - raise TypeError( - f"Invalid input for {coeff_name}: must be a CSV file path" - " or a callable." - ) + self.prints.geometry() + self.prints.coefficients() - def __load_generic_surface_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load GenericSurface coefficient CSV into a 7D Function. + def all_info(self): + """Prints and plots all available information of the surface. - This loader expects header-based CSV data with one or more independent - variables among: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, - roll_rate. + Returns + ------- + None """ - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - header = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not header: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - header = [column.strip() for column in header] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient" - " value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [ - col for col in header[:-1] if col in independent_vars - ] - - csv_func = Function.from_regular_grid_csv( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="natural", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="natural", - ) - - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) + self.prints.all() + self.plots.all() diff --git a/rocketpy/rocket/aero_surface/linear_generic_surface.py b/rocketpy/rocket/aero_surface/linear_generic_surface.py index 3e7ed9a55..3b313bfef 100644 --- a/rocketpy/rocket/aero_surface/linear_generic_surface.py +++ b/rocketpy/rocket/aero_surface/linear_generic_surface.py @@ -1,13 +1,20 @@ +import inspect + from rocketpy.mathutils import Function from rocketpy.plots.aero_surface_plots import _LinearGenericSurfacePlots from rocketpy.prints.aero_surface_prints import _LinearGenericSurfacePrints +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +# TODO: review note: ControllableGenericSurface should also be able to be modelled +# based on LinearGenericSurface.... class LinearGenericSurface(GenericSurface): - """Class that defines a generic linear aerodynamic surface. This class is - used to define aerodynamic surfaces that have aerodynamic coefficients - defined as linear functions of the coefficients derivatives.""" + """An aerodynamic surface whose forces and moments vary linearly with the + flow angles and the rotation rates. Instead of full coefficient tables, you + give the coefficient *derivatives* (slopes) -- for example how much the normal + force changes per radian of angle of attack -- and the surface adds them up + linearly.""" def __init__( self, @@ -16,6 +23,11 @@ def __init__( coefficients, center_of_pressure=(0, 0, 0), name="Generic Linear Surface", + reynolds_length=None, + interpolation=None, + extrapolation=None, + force_convention=None, + active_during="always", ): """Create a generic linear aerodynamic surface, defined by its aerodynamic coefficients derivatives. This surface is used to model any @@ -29,6 +41,13 @@ def __init__( contain at least one of the following: "alpha", "beta", "mach", "reynolds", "pitch_rate", "yaw_rate" and "roll_rate". + By default the force-coefficient derivatives are the body-frame ones + (``cN_*`` normal, ``cY_*`` side, ``cA_*`` axial; see + ``force_convention``). You may instead give the wind-frame derivatives + ``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag) -- for example + ``cL_alpha`` in place of ``cN_alpha``; they are converted once to the + body-frame set at construction. + See Also -------- :ref:`genericsurfaces`. @@ -42,59 +61,70 @@ def __init__( Reference length of the aerodynamic surface. Has the unit of meters. Commonly defined as the rocket's diameter. coefficients: dict, optional - List of coefficients. If a coefficient is omitted, it is set to 0. - The valid coefficients are:\n - cL_0: callable, str, optional - Coefficient of lift at zero angle of attack. Default is 0.\n - cL_alpha: callable, str, optional - Coefficient of lift derivative with respect to angle of attack. + The coefficient derivatives (slopes), by name. Any you leave out are + set to 0. Each one can be a constant, a function, or a path to a data + file, and says how one force or moment coefficient changes with one + variable (angle in radians, or a non-dimensional rotation rate). The + names follow the pattern ``_``: the coefficient + is normal force ``cN``, side force ``cY``, axial force ``cA``, pitch moment ``cm``, + yaw moment ``cn`` or roll moment ``cl``; the variable is ``0`` (the + value at zero angle of attack, zero sideslip and zero rates), + ``alpha``, ``beta``, ``p`` (roll rate), ``q`` (pitch rate) or ``r`` + (yaw rate). With ``force_convention="wind"`` the force derivatives are + named after the wind-frame coefficients instead (lift ``cL``, side + ``cQ``, drag ``cD`` -- e.g. ``cL_alpha``, ``cD_0``, ``cQ_beta``); the + moment names are unchanged. The full (body-frame) list is:\n + cN_0: callable, str, optional + Coefficient of normal force at zero angle of attack. Default is 0.\n + cN_alpha: callable, str, optional + Coefficient of normal force derivative with respect to angle of attack. Default is 0.\n - cL_beta: callable, str, optional - Coefficient of lift derivative with respect to sideslip angle. + cN_beta: callable, str, optional + Coefficient of normal force derivative with respect to sideslip angle. Default is 0.\n - cL_p: callable, str, optional - Coefficient of lift derivative with respect to roll rate. + cN_p: callable, str, optional + Coefficient of normal force derivative with respect to roll rate. Default is 0.\n - cL_q: callable, str, optional - Coefficient of lift derivative with respect to pitch rate. + cN_q: callable, str, optional + Coefficient of normal force derivative with respect to pitch rate. Default is 0.\n - cL_r: callable, str, optional - Coefficient of lift derivative with respect to yaw rate. + cN_r: callable, str, optional + Coefficient of normal force derivative with respect to yaw rate. Default is 0.\n - cQ_0: callable, str, optional + cY_0: callable, str, optional Coefficient of side force at zero angle of attack. Default is 0.\n - cQ_alpha: callable, str, optional + cY_alpha: callable, str, optional Coefficient of side force derivative with respect to angle of attack. Default is 0.\n - cQ_beta: callable, str, optional + cY_beta: callable, str, optional Coefficient of side force derivative with respect to sideslip angle. Default is 0.\n - cQ_p: callable, str, optional + cY_p: callable, str, optional Coefficient of side force derivative with respect to roll rate. Default is 0.\n - cQ_q: callable, str, optional + cY_q: callable, str, optional Coefficient of side force derivative with respect to pitch rate. Default is 0.\n - cQ_r: callable, str, optional + cY_r: callable, str, optional Coefficient of side force derivative with respect to yaw rate. Default is 0.\n - cD_0: callable, str, optional - Coefficient of drag at zero angle of attack. Default is 0.\n - cD_alpha: callable, str, optional - Coefficient of drag derivative with respect to angle of attack. + cA_0: callable, str, optional + Coefficient of axial force at zero angle of attack. Default is 0.\n + cA_alpha: callable, str, optional + Coefficient of axial force derivative with respect to angle of attack. Default is 0.\n - cD_beta: callable, str, optional - Coefficient of drag derivative with respect to sideslip angle. + cA_beta: callable, str, optional + Coefficient of axial force derivative with respect to sideslip angle. Default is 0.\n - cD_p: callable, str, optional - Coefficient of drag derivative with respect to roll rate. + cA_p: callable, str, optional + Coefficient of axial force derivative with respect to roll rate. Default is 0.\n - cD_q: callable, str, optional - Coefficient of drag derivative with respect to pitch rate. + cA_q: callable, str, optional + Coefficient of axial force derivative with respect to pitch rate. Default is 0.\n - cD_r: callable, str, optional - Coefficient of drag derivative with respect to yaw rate. + cA_r: callable, str, optional + Coefficient of axial force derivative with respect to yaw rate. Default is 0.\n cm_0: callable, str, optional Coefficient of pitch moment at zero angle of attack. @@ -154,8 +184,56 @@ def __init__( Application point of the aerodynamic forces and moments. The center of pressure is defined in the local coordinate system of the aerodynamic surface. The default value is (0, 0, 0). - name : str - Name of the aerodynamic surface. Default is 'GenericSurface'. + name : str, optional + Name of the aerodynamic surface. Default is 'Generic Linear + Surface'. + reynolds_length : int, float, optional + Length scale, in meters, of the Reynolds number passed to the + coefficient derivatives. See :class:`GenericSurface`. ``None`` (the + default) uses ``reference_length`` (the diameter). + interpolation : str or dict, optional + How tabulated coefficient derivatives interpolate between points. + The accepted methods depend on the coefficient's dimensionality: a + 1-D table (e.g. a Mach-only curve) accepts ``"linear"``, ``"akima"``, + ``"spline"`` and ``"polynomial"``; a multi-dimensional scattered + table accepts ``"linear"``, ``"shepard"`` and ``"rbf"``; and a + multi-dimensional table on a regular Cartesian grid accepts + ``"linear"``, ``"nearest"``, ``"slinear"``, ``"cubic"``, + ``"quintic"`` and ``"pchip"`` (with ``"spline"`` mapped to + ``"cubic"`` and ``"akima"`` to ``"pchip"``). Accepts either a simple + string or a dict keyed by coefficient name (names left out fall back + to the default). ``None`` (the default) uses ``"linear"`` for tables + built here and keeps a pre-built ``Function``'s own setting. + extrapolation : str or dict, optional + How tabulated coefficient derivatives behave outside their data + range: ``"constant"`` holds the value at the nearest data edge, + ``"natural"`` keeps following the curve, and ``"zero"`` returns 0. + Accepts either a simple string or a dict keyed by coefficient name + (names left out fall back to the default). ``None`` (the default) + uses ``"constant"`` for tables built here and keeps whatever a + pre-built ``Function`` already carries. Only affects tabulated + sources (constants and callables are evaluated directly). + force_convention : str, optional + The frame your force-coefficient derivatives are given in. ``"body"`` + for the body-frame derivatives ``cN_*`` (normal), ``cY_*`` (side) and + ``cA_*`` (axial); ``"wind"`` for the wind-frame derivatives + ``cL_*`` (lift), ``cQ_*`` (side) and ``cD_*`` (drag). The moment + derivatives (``cm_*``, ``cn_*``, ``cl_*``) are the same in both. + ``None`` (the default) infers the frame from the coefficient names you + pass and assumes body when none are given. A wind-frame input is + converted once to the body-frame derivatives the surface stores, by + linearizing the angle-of-attack/sideslip rotation about zero: the + straight renames ``cN_0 = cL_0``, ``cN_beta = cL_beta``, the rate + derivatives, and the cross terms ``cN_alpha = cL_alpha + cD_0``, + ``cY_beta = cQ_beta - cD_0``, ``cA_alpha = cD_alpha - cL_0`` and + ``cA_beta = cD_beta + cQ_0``. At zero angle this reduces to + ``cN = cL``, ``cY = cQ``, ``cA = cD``. + active_during : str or callable, optional + When this surface produces aerodynamic force during a simulation: + ``"always"`` (default), ``"power_on"`` (only while the motor burns), + ``"power_off"`` (only after burnout), or a function + ``active_during(t, flight)`` returning ``True`` when the surface is + active at time ``t``. See :class:`GenericSurface` for details. """ super().__init__( @@ -164,6 +242,11 @@ def __init__( coefficients=coefficients, center_of_pressure=center_of_pressure, name=name, + reynolds_length=reynolds_length, + extrapolation=extrapolation, + interpolation=interpolation, + force_convention=force_convention, + active_during=active_during, ) self.compute_all_coefficients() @@ -171,6 +254,18 @@ def __init__( self.prints = _LinearGenericSurfacePrints(self) self.plots = _LinearGenericSurfacePlots(self) + def _evaluate_stability_derivatives(self): + """Build the center-of-pressure accessors for the linear model. + + The linear model already stores the coefficient derivatives + ``cN_alpha``/``cm_alpha`` (pitch) and ``cY_beta``/``cn_beta`` (yaw) as + the surface's own coefficients, so there is nothing to differentiate: + :meth:`_set_stability_accessors` reads them directly (evaluated at zero + alpha/beta and zero rates). Damping derivatives (``_p/_q/_r``) are + intentionally excluded from the stability center of pressure. + """ + self._set_stability_accessors() + def _get_default_coefficients(self): """Returns default coefficients @@ -181,24 +276,24 @@ def _get_default_coefficients(self): are the default values. """ default_coefficients = { - "cL_0": 0, - "cL_alpha": 0, - "cL_beta": 0, - "cL_p": 0, - "cL_q": 0, - "cL_r": 0, - "cQ_0": 0, - "cQ_alpha": 0, - "cQ_beta": 0, - "cQ_p": 0, - "cQ_q": 0, - "cQ_r": 0, - "cD_0": 0, - "cD_alpha": 0, - "cD_beta": 0, - "cD_p": 0, - "cD_q": 0, - "cD_r": 0, + "cN_0": 0, + "cN_alpha": 0, + "cN_beta": 0, + "cN_p": 0, + "cN_q": 0, + "cN_r": 0, + "cY_0": 0, + "cY_alpha": 0, + "cY_beta": 0, + "cY_p": 0, + "cY_q": 0, + "cY_r": 0, + "cA_0": 0, + "cA_alpha": 0, + "cA_beta": 0, + "cA_p": 0, + "cA_q": 0, + "cA_r": 0, "cm_0": 0, "cm_alpha": 0, "cm_beta": 0, @@ -220,82 +315,285 @@ def _get_default_coefficients(self): } return default_coefficients - def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): - """Compute the forcing coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_0(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - + c_alpha(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * alpha - + c_beta(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * beta + # Body force-coefficient prefix -> wind force-coefficient prefix, used to + # name the accepted wind-frame inputs. The per-plane suffixes (_0, _alpha, + # _beta, _p, _q, _r) and the moment coefficients (cm/cn/cl) are frame-shared. + _BODY_TO_WIND_PREFIX = {"cN": "cL", "cY": "cQ", "cA": "cD"} + + def _force_frames_present(self, coefficients): + """Detect the force frame from the derivative-name prefixes: a wind key + looks like ``cL_alpha``/``cD_0``/``cQ_beta`` and a body key like + ``cN_alpha``/``cA_0``/``cY_beta``. The moment derivatives (``cm_*``, + ``cn_*``, ``cl_*``) are frame-shared and ignored here. + """ + prefixes = {key.split("_", 1)[0] for key in coefficients} + has_wind = bool(prefixes & set(self._WIND_FORCE_NAMES)) + has_body = bool(prefixes & set(self._BODY_FORCE_NAMES)) + return has_wind, has_body + + def _wind_default_coefficient_names(self): + """The valid wind-frame input names: the body defaults with the force + prefixes swapped to wind (``cN_* -> cL_*``, ``cY_* -> cQ_*``, + ``cA_* -> cD_*``); the moment names are unchanged. + """ + names = set() + for key in self._get_default_coefficients(): + prefix, sep, suffix = key.partition("_") + wind_prefix = self._BODY_TO_WIND_PREFIX.get(prefix, prefix) + names.add(f"{wind_prefix}{sep}{suffix}") + return names + + def _wind_input_to_body(self, coefficients): + """Convert wind-frame coefficient derivatives (``cL_*``/``cD_*``/``cQ_*``) + into the canonical body-frame derivatives (``cN_*``/``cY_*``/``cA_*``). + + The full body-frame force coefficients are the wind ones rotated by the + angle of attack and sideslip; linearizing that rotation about + ``alpha = beta = 0`` gives, to first order, a coefficient-derivative map + with four cross-frame terms:: + + cN_alpha = cL_alpha + cD_0 cA_alpha = cD_alpha - cL_0 + cY_beta = cQ_beta - cD_0 cA_beta = cD_beta + cQ_0 + + Every other derivative is a straight rename (``cN_0 = cL_0``, + ``cN_beta = cL_beta``, the rate derivatives ``cN_p = cL_p`` ..., and the + wind side/axial analogues). At zero angle this reduces to ``cN = cL``, + ``cY = cQ``, ``cA = cD``, matching the generic surface. The moment + derivatives (``cm_*``/``cn_*``/``cl_*``) are frame-shared and pass + through unchanged. + """ + invalid = set(coefficients) - self._wind_default_coefficient_names() + if invalid: + raise ValueError( + f"Invalid coefficient name(s) used in key(s): {', '.join(invalid)}. " + "Check the documentation for valid names." ) - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], + def wind(name): + return coefficients.get(name, 0) + + body = { + "cN_0": wind("cL_0"), + "cN_alpha": self._combine(wind("cL_alpha"), wind("cD_0"), 1.0, "cN_alpha"), + "cN_beta": wind("cL_beta"), + "cN_p": wind("cL_p"), + "cN_q": wind("cL_q"), + "cN_r": wind("cL_r"), + "cY_0": wind("cQ_0"), + "cY_alpha": wind("cQ_alpha"), + "cY_beta": self._combine(wind("cQ_beta"), wind("cD_0"), -1.0, "cY_beta"), + "cY_p": wind("cQ_p"), + "cY_q": wind("cQ_q"), + "cY_r": wind("cQ_r"), + "cA_0": wind("cD_0"), + "cA_alpha": self._combine(wind("cD_alpha"), wind("cL_0"), -1.0, "cA_alpha"), + "cA_beta": self._combine(wind("cD_beta"), wind("cQ_0"), 1.0, "cA_beta"), + "cA_p": wind("cD_p"), + "cA_q": wind("cD_q"), + "cA_r": wind("cD_r"), + } + # Moment derivatives are the same in both frames; pass them through. + for name, value in coefficients.items(): + if name.split("_", 1)[0] not in self._WIND_FORCE_NAMES: + body[name] = value + return body + + def _as_coefficient(self, source, name): + """Wrap a raw coefficient input as an :class:`AeroCoefficient` over this + surface's variables (used when recombining wind-frame derivatives). + """ + return AeroCoefficient( + source, + control_variables=self.control_variables, + name=name, ) - def compute_damping_coefficient(self, c_p, c_q, c_r): - """Compute the damping coefficient from the derivatives of the - aerodynamic coefficients.""" - - def total_coefficient( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ): - return ( - c_p(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * roll_rate - + c_q(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * pitch_rate - + c_r(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - * yaw_rate - ) + def _combine(self, first, second, sign, name): + """Return a coefficient equal to ``first + sign * second``. - return Function( - total_coefficient, - [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ], - ["coefficient"], + When one term is identically zero the other is returned directly (as a + renamed coefficient), so a derivative that is really just a rename keeps + its original, low-dimensional form. Otherwise the two are summed by a + small wrapper evaluated over the full variable tuple. + """ + coeff_first = self._as_coefficient(first, name) + coeff_second = self._as_coefficient(second, name) + if coeff_second.is_zero: + return coeff_first + if coeff_first.is_zero: + return coeff_second if sign > 0 else coeff_second * -1.0 + first_opt = coeff_first.get_value_opt + second_opt = coeff_second.get_value_opt + + def combined(*args): + return first_opt(*args) + sign * second_opt(*args) + + combined.__signature__ = inspect.Signature( + inspect.Parameter(var, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for var in self.independent_vars ) + return self._as_coefficient(combined, name) + + _COEFFICIENT_INPUTS = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + + def compute_forcing_coefficient(self, c_0, c_alpha, c_beta): + """Compose the forcing coefficient ``c_0 + c_alpha*alpha + c_beta*beta``, + evaluating only the non-zero terms. + + Two hot-loop optimizations: ``get_value_opt`` is the unvalidated fast + evaluator (for callable-source coefficients it is the raw source), and + terms that are identically zero are skipped entirely. For a Barrowman + surface each forcing coefficient has at most one non-zero derivative, so + this typically collapses to a single source call (or to a constant 0). + + Parameters + ---------- + c_0 : AeroCoefficient + Zero-angle derivative (constant term). + c_alpha : AeroCoefficient + Derivative with respect to the angle of attack ``alpha``. + c_beta : AeroCoefficient + Derivative with respect to the sideslip angle ``beta``. + + Returns + ------- + Function + Coefficient as a function of the independent variables + ``(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)``. + """ + has_0 = not getattr(c_0, "is_zero_coefficient", False) + has_alpha = not getattr(c_alpha, "is_zero_coefficient", False) + has_beta = not getattr(c_beta, "is_zero_coefficient", False) + c_0_opt = c_0.get_value_opt + c_alpha_opt = c_alpha.get_value_opt + c_beta_opt = c_beta.get_value_opt + + if not (has_0 or has_alpha or has_beta): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_0: + value += c_0_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + if has_alpha: + value += ( + c_alpha_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * alpha + ) + if has_beta: + value += ( + c_beta_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * beta + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) + + def compute_damping_coefficient(self, c_p, c_q, c_r): + """Compose the damping coefficient + ``c_p*roll_rate + c_q*pitch_rate + c_r*yaw_rate``, evaluating only the + non-zero terms (see :meth:`compute_forcing_coefficient`). For a Barrowman + surface only ``cl_p`` (roll damping) is non-zero, so most damping + coefficients collapse to a constant 0. + + Parameters + ---------- + c_p : AeroCoefficient + Derivative with respect to the roll rate ``roll_rate``. + c_q : AeroCoefficient + Derivative with respect to the pitch rate ``pitch_rate``. + c_r : AeroCoefficient + Derivative with respect to the yaw rate ``yaw_rate``. + + Returns + ------- + Function + Coefficient as a function of the independent variables + ``(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate)``. + """ + has_p = not getattr(c_p, "is_zero_coefficient", False) + has_q = not getattr(c_q, "is_zero_coefficient", False) + has_r = not getattr(c_r, "is_zero_coefficient", False) + c_p_opt = c_p.get_value_opt + c_q_opt = c_q.get_value_opt + c_r_opt = c_r.get_value_opt + + if not (has_p or has_q or has_r): + + def total_coefficient( # pylint: disable=unused-argument + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + return 0.0 + + else: + + def total_coefficient( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ): + value = 0.0 + if has_p: + value += ( + c_p_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * roll_rate + ) + if has_q: + value += ( + c_q_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * pitch_rate + ) + if has_r: + value += ( + c_r_opt( + alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + ) + * yaw_rate + ) + return value + + return Function(total_coefficient, self._COEFFICIENT_INPUTS, ["coefficient"]) def compute_all_coefficients(self): """Compute all the aerodynamic coefficients from the derivatives.""" - # pylint: disable=invalid-name - self.cLf = self.compute_forcing_coefficient( - self.cL_0, self.cL_alpha, self.cL_beta + self.cNf = self.compute_forcing_coefficient( + self.cN_0, self.cN_alpha, self.cN_beta ) - self.cLd = self.compute_damping_coefficient(self.cL_p, self.cL_q, self.cL_r) + self.cNd = self.compute_damping_coefficient(self.cN_p, self.cN_q, self.cN_r) - self.cQf = self.compute_forcing_coefficient( - self.cQ_0, self.cQ_alpha, self.cQ_beta + self.cYf = self.compute_forcing_coefficient( + self.cY_0, self.cY_alpha, self.cY_beta ) - self.cQd = self.compute_damping_coefficient(self.cQ_p, self.cQ_q, self.cQ_r) + self.cYd = self.compute_damping_coefficient(self.cY_p, self.cY_q, self.cY_r) - self.cDf = self.compute_forcing_coefficient( - self.cD_0, self.cD_alpha, self.cD_beta + self.cAf = self.compute_forcing_coefficient( + self.cA_0, self.cA_alpha, self.cA_beta ) - self.cDd = self.compute_damping_coefficient(self.cD_p, self.cD_q, self.cD_r) + self.cAd = self.compute_damping_coefficient(self.cA_p, self.cA_q, self.cA_r) self.cmf = self.compute_forcing_coefficient( self.cm_0, self.cm_alpha, self.cm_beta @@ -312,6 +610,13 @@ def compute_all_coefficients(self): ) self.cld = self.compute_damping_coefficient(self.cl_p, self.cl_q, self.cl_r) + self.cN = self.cNf + self.cY = self.cYf + self.cA = self.cAf + self.cm = self.cmf + self.cn = self.cnf + self.cl = self.clf + def _compute_from_coefficients( self, rho, @@ -342,69 +647,47 @@ def _compute_from_coefficients( reynolds : float Reynolds number. pitch_rate : float - Pitch rate in radians per second. + Non-dimensional (reduced) pitch rate, ``q * L_ref / (2 * V)``. yaw_rate : float - Yaw rate in radians per second. + Non-dimensional (reduced) yaw rate, ``r * L_ref / (2 * V)``. roll_rate : float - Roll rate in radians per second. + Non-dimensional (reduced) roll rate, ``p * L_ref / (2 * V)``. Returns ------- tuple of float - The aerodynamic forces (lift, side_force, drag) and moments - (pitch, yaw, roll) in the body frame. + The body-frame force components ``(R1, R2, R3)`` and the moments + ``(pitch, yaw, roll)``. """ # Precompute common values dyn_pressure_area = 0.5 * rho * stream_speed**2 * self.reference_area - dyn_pressure_area_damping = ( - 0.5 * rho * stream_speed * self.reference_area * self.reference_length / 2 - ) dyn_pressure_area_length = dyn_pressure_area * self.reference_length - dyn_pressure_area_length_damping = ( - 0.5 - * rho - * stream_speed - * self.reference_area - * self.reference_length**2 - / 2 - ) + args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) - # Compute aerodynamic forces - lift = dyn_pressure_area * self.cLf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cLd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + # Body-frame forces (forcing + reduced-rate damping), straight from the + # body-frame coefficients: normal cN, side cY, axial cA. + normal = dyn_pressure_area * ( + self.cNf.get_value_opt(*args) + self.cNd.get_value_opt(*args) ) - - side = dyn_pressure_area * self.cQf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cQd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + yaw_side = dyn_pressure_area * ( + self.cYf.get_value_opt(*args) + self.cYd.get_value_opt(*args) ) - - drag = dyn_pressure_area * self.cDf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_damping * self.cDd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + axial = dyn_pressure_area * ( + self.cAf.get_value_opt(*args) + self.cAd.get_value_opt(*args) ) + r1 = yaw_side + r2 = -normal + r3 = -axial - # Compute aerodynamic moments - pitch = dyn_pressure_area_length * self.cmf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cmd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + # Compute aerodynamic moments (forcing + reduced-rate damping) + pitch = dyn_pressure_area_length * ( + self.cmf.get_value_opt(*args) + self.cmd.get_value_opt(*args) ) - - yaw = dyn_pressure_area_length * self.cnf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cnd( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + yaw = dyn_pressure_area_length * ( + self.cnf.get_value_opt(*args) + self.cnd.get_value_opt(*args) ) - - roll = dyn_pressure_area_length * self.clf( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate - ) + dyn_pressure_area_length_damping * self.cld( - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate + roll = dyn_pressure_area_length * ( + self.clf.get_value_opt(*args) + self.cld.get_value_opt(*args) ) - return lift, side, drag, pitch, yaw, roll + return r1, r2, r3, pitch, yaw, roll diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index 240a61a5c..7f17c159b 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -7,10 +7,10 @@ from rocketpy.plots.aero_surface_plots import _NoseConePlots from rocketpy.prints.aero_surface_prints import _NoseConePrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class NoseCone(AeroSurface): +class NoseCone(_BarrowmanSurface): """Keeps nose cone information. Note @@ -65,11 +65,12 @@ class NoseCone(AeroSurface): Nose cone local center of pressure z coordinate. Has units of length and is given in meters. NoseCone.cl : Function - Function which defines the lift coefficient as a function of the angle - of attack and the Mach number. Takes as input the angle of attack in - radians and the Mach number. Returns the lift coefficient. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. NoseCone.clalpha : float - Lift coefficient slope. Has units of 1/rad. + Normal-force coefficient slope. Has units of 1/rad. NoseCone.plots : plots.aero_surface_plots._NoseConePlots This contains all the plots methods. Use help(NoseCone.plots) to know more about it. @@ -129,7 +130,9 @@ def __init__( # pylint: disable=too-many-statements None """ rocket_radius = rocket_radius or base_radius - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._rocket_radius = rocket_radius self._base_radius = base_radius @@ -163,6 +166,16 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry (clalpha, cpz) into the linear + # generic-surface coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _NoseConePlots(self) self.prints = _NoseConePrints(self) @@ -464,12 +477,7 @@ def evaluate_lift_coefficient(self): self.clalpha = Function( lambda mach: 2 * self.radius_ratio**2, "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: self.clalpha(mach) * alpha, - ["Alpha (rad)", "Mach"], - "Cl", + f"Normal-force coefficient derivative for {self.name}", ) def evaluate_k(self): @@ -551,15 +559,10 @@ def to_dict(self, **kwargs): } if kwargs.get("include_outputs", False): clalpha = self.clalpha - cl = self.cl if kwargs.get("discretize", False): clalpha = clalpha.set_discrete(0, 4, 50) - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) data["cp"] = self.cp data["clalpha"] = clalpha - data["cl"] = cl return data diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 7d3a9bd30..a6fc75b56 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -1,12 +1,11 @@ import numpy as np -from rocketpy.mathutils.function import Function from rocketpy.prints.aero_surface_prints import _RailButtonsPrints -from .aero_surface import AeroSurface +from .generic_surface import GenericSurface -class RailButtons(AeroSurface): +class RailButtons(GenericSurface): """Class that defines a rail button pair or group. Attributes @@ -28,6 +27,10 @@ class RailButtons(AeroSurface): calculated but flight dynamics remain unaffected. """ + # Rail buttons carry no aerodynamic force, so they contribute nothing to + # either plane: axisymmetric for the pitch/yaw check. + is_axisymmetric = True + def __init__( self, buttons_distance, @@ -53,14 +56,24 @@ def __init__( If not provided, it will be calculated when the RailButtons object is added to a Rocket object. """ - super().__init__(name, None, None) self.buttons_distance = buttons_distance self.angular_position = angular_position self.button_height = button_height - self.name = name self.rocket_radius = rocket_radius - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() + + # Rail buttons produce no aerodynamic force; they are modeled as a + # generic surface with all-zero coefficients. The reference area/length + # are placeholders (never used, since rail buttons are not part of the + # rocket's aerodynamic_surfaces) computed from the rocket radius when + # available. + reference_radius = rocket_radius or 1.0 + super().__init__( + reference_area=np.pi * reference_radius**2, + reference_length=2 * reference_radius, + coefficients={}, + center_of_pressure=(0, 0, 0), + name=name, + ) self.prints = _RailButtonsPrints(self) @@ -68,47 +81,6 @@ def __init__( def angular_position_rad(self): return np.radians(self.angular_position) - def evaluate_center_of_pressure(self): - """Evaluates the center of pressure of the rail buttons. Rail buttons - do not contribute to the center of pressure of the rocket. - - Returns - ------- - None - """ - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - self.cp = (self.cpx, self.cpy, self.cpz) - - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the rail buttons. Rail - buttons do not contribute to the lift coefficient of the rocket. - - Returns - ------- - None - """ - self.clalpha = Function( - lambda mach: 0, - "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: 0, - ["Alpha (rad)", "Mach"], - "Cl", - ) - - def evaluate_geometrical_parameters(self): - """Evaluates the geometrical parameters of the rail buttons. Rail - buttons do not contribute to the geometrical parameters of the rocket. - - Returns - ------- - None - """ - def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "buttons_distance": self.buttons_distance, diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py index 3e738f99c..7ccd2e1e3 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -4,10 +4,10 @@ from rocketpy.plots.aero_surface_plots import _TailPlots from rocketpy.prints.aero_surface_prints import _TailPrints -from .aero_surface import AeroSurface +from ._barrowman_surface import _BarrowmanSurface -class Tail(AeroSurface): +class Tail(_BarrowmanSurface): """Class that defines a tail. Currently only accepts conical tails. Note @@ -41,10 +41,12 @@ class Tail(AeroSurface): Tail.cp : tuple Tuple containing the coordinates of the center of pressure of the tail. Tail.cl : Function - Function that returns the lift coefficient of the tail. The function - is defined as a function of the angle of attack and the mach number. + Roll-moment coefficient, inherited from the generic-surface model + (a function of the flow variables). Zero for a nose cone or tail; for a + fin set it carries the cant forcing and roll damping. The lift-curve + slope is ``clalpha``. Tail.clalpha : float - Lift coefficient slope. Has the unit of 1/rad. + Normal-force coefficient slope. Has the unit of 1/rad. Tail.slant_length : float Slant length of the tail. The slant length is defined as the distance between the top and bottom of the tail. The slant length is measured @@ -76,7 +78,9 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" ------- None """ - super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + self.name = name + self.reference_area = np.pi * rocket_radius**2 + self.reference_length = 2 * rocket_radius self._top_radius = top_radius self._bottom_radius = bottom_radius @@ -87,6 +91,16 @@ def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail" self.evaluate_lift_coefficient() self.evaluate_center_of_pressure() + # Translate the Barrowman geometry into the linear generic-surface + # coefficient model and build the shared compute path. + super().__init__( + reference_area=self.reference_area, + reference_length=self.reference_length, + coefficients={}, + center_of_pressure=(self.cpx, self.cpy, self.cpz), + name=name, + ) + self.plots = _TailPlots(self) self.prints = _TailPrints(self) @@ -172,12 +186,7 @@ def evaluate_lift_coefficient(self): ) ), "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: self.clalpha(mach) * alpha, - ["Alpha (rad)", "Mach"], - "Cl", + f"Normal-force coefficient derivative for {self.name}", ) def evaluate_center_of_pressure(self): @@ -218,18 +227,13 @@ def to_dict(self, **kwargs): if kwargs.get("include_outputs", False): clalpha = self.clalpha - cl = self.cl if kwargs.get("discretize", False): clalpha = clalpha.set_discrete(0, 4, 50) - cl = cl.set_discrete( - (-np.pi / 6, 0), (np.pi / 6, 2), (10, 10), mutate_self=False - ) data.update( { "cp": self.cp, "clalpha": clalpha, - "cl": cl, "slant_length": self.slant_length, "surface_area": self.surface_area, } diff --git a/rocketpy/rocket/helpers.py b/rocketpy/rocket/helpers.py new file mode 100644 index 000000000..26593c832 --- /dev/null +++ b/rocketpy/rocket/helpers.py @@ -0,0 +1,280 @@ +"""Helper functions backing some :class:`rocketpy.Rocket` methods. + +These carry the heavier computations behind the rocket's full-body aerodynamic +reduction (``to_coefficients`` / ``to_surface``), kept out of ``rocket.py`` so +that module stays focused on the rocket's public interface. Each function takes +the rocket it operates on as its first argument. +""" + +import math + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient + + +def zero_drag(rocket, which): + """Set one of the rocket's built-in drag curves to zero. ``which`` is + ``"power_on"`` or ``"power_off"``. Rebuilds the ``*_drag_7d``, + ``*_drag_by_mach`` and the public ``*_drag`` alias to match, mirroring how + they are built in ``Rocket.__init__``. + """ + label = "Power On" if which == "power_on" else "Power Off" + setattr( + rocket, + f"{which}_drag_7d", + AeroCoefficient( + 0, + name=f"Drag Coefficient with {label}", + extrapolation="constant", + single_var="mach", + ), + ) + by_mach = Function( + lambda mach: 0.0, + inputs="Mach Number", + outputs=f"Drag Coefficient with {label}", + interpolation="linear", + extrapolation="constant", + ) + setattr(rocket, f"{which}_drag_by_mach", by_mach) + setattr(rocket, f"{which}_drag", by_mach) + setattr(rocket, f"_{which}_drag_input", 0) + + +def summed_force_and_moment(rocket, alpha, beta, mach, omega, speed=1.0): + """Total body-frame force ``(R1, R2, R3)`` and moment ``(M1, M2, M3)`` about + the center of dry mass, summed over every aerodynamic surface of ``rocket`` + at a flow state and set of body rates. + + Mirrors the per-surface computation the flight integrator performs: each + surface is fed its own local stream velocity, which includes the + ``omega x cp`` lever-arm term, so the sum captures the pitch and yaw damping + the distributed surfaces produce through their fore-and-aft positions. + Evaluated at unit air density; the result scales out of any dimensionless + coefficient, and the chosen ``speed`` cancels from every coefficient built + from it. ``omega`` is the body angular rate in rad/s. + """ + stream_direction = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_at_cdm = stream_direction / abs(stream_direction) * speed + body_rates = Vector(list(omega)) + density = Function(1.0) + dynamic_viscosity = Function(1e30) # vanishing-Reynolds limit + speed_of_sound = speed / mach if mach > 0 else 1e30 + totals = np.zeros(6) + for surface, _ in rocket.aerodynamic_surfaces: + cp = rocket.surfaces_cp_to_cdm[surface] + comp_stream = stream_at_cdm - (body_rates ^ cp) + comp_speed = abs(comp_stream) + forces = surface.compute_forces_and_moments( + comp_stream, + comp_speed, + comp_speed / speed_of_sound, + 1.0, + cp, + body_rates, + density, + dynamic_viscosity, + 0.0, + ) + totals += np.array(forces) + return totals + + +def neutral_point_and_slope(rocket, alpha, beta, mach, plane="pitch", step=1e-4): + """Local (tangent) neutral point and force-curve slope at a finite incidence. + + Generalizes the aerodynamic center to a non-zero angle of attack. The neutral + point is the point about which the aerodynamic moment does not change for a + *small* perturbation of the incidence angle around the given ``(alpha, beta)`` + state, i.e. the tangent of the moment-versus-force curve at that state. It is + obtained by central-differencing the rocket's summed body-frame force and + moment (about the center of dry mass) with respect to the plane's incidence + angle, then forming ``x_cdm + csys * L_ref * (dCm/da) / (dCN/da)``. + + For a rocket whose surfaces are all linear in incidence (the built-in + Barrowman surfaces) the result is independent of ``alpha``/``beta`` and equals + :attr:`rocketpy.Rocket.aerodynamic_center`. It moves with incidence only when + a surface's normal-force coefficient is nonlinear in the incidence angle (for + example a Galejs ``sin**2(alpha)`` body-lift term added as a + :class:`rocketpy.GenericSurface`). + + Parameters + ---------- + rocket : rocketpy.Rocket + The rocket to evaluate. + alpha, beta : float + Angle of attack and sideslip angle, in radians, defining the state the + neutral point is taken about. + mach : float + Free-stream Mach number. + plane : str, optional + ``"pitch"`` (perturb ``alpha``, use the normal force and pitch moment) or + ``"yaw"`` (perturb ``beta``, use the side force and yaw moment). Default + ``"pitch"``. + step : float, optional + Half-step, in radians, of the central difference. Default ``1e-4``. + + Returns + ------- + tuple of float + ``(neutral_point, slope)``: the neutral-point axial position in the + user-defined rocket frame, and the force-curve slope ``dCN/da`` (pitch) + or ``dCY/db`` (yaw) at the state. When the slope vanishes (no lift at all) + the neutral point falls back to the zero-incidence aerodynamic center. + """ + rocket.evaluate_surfaces_cp_to_cdm() + reference_length = 2 * rocket.radius + dynamic_pressure_area = 0.5 * rocket.area # unit speed, unit density + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + + def coefficients(a, b): + r1, r2, _, m1, m2, _ = summed_force_and_moment( + rocket, a, b, mach, (0.0, 0.0, 0.0) + ) + if plane == "yaw": + return r1 / dynamic_pressure_area, m2 / dynamic_pressure_area_length + return -r2 / dynamic_pressure_area, m1 / dynamic_pressure_area_length + + if plane == "yaw": + force_high, moment_high = coefficients(alpha, beta + step) + force_low, moment_low = coefficients(alpha, beta - step) + else: + force_high, moment_high = coefficients(alpha + step, beta) + force_low, moment_low = coefficients(alpha - step, beta) + + force_slope = (force_high - force_low) / (2 * step) + moment_slope = (moment_high - moment_low) / (2 * step) + if force_slope == 0: + center = ( + rocket.aerodynamic_center_yaw + if plane == "yaw" + else rocket.aerodynamic_center + ) + return center.get_value_opt(mach), 0.0 + neutral_point = rocket.center_of_dry_mass_position + ( + rocket._csys * reference_length * moment_slope / force_slope + ) + return neutral_point, force_slope + + +def full_body_coefficients(rocket, machs=None, force_convention="body"): + """Compute the rocket's lumped stability-derivative set, split by motor + phase. Backs :meth:`rocketpy.Rocket.to_coefficients`; see that method for the + full description of the returned coefficient sets and their limitations. + """ + if force_convention not in ("body", "wind"): + raise ValueError( + f"force_convention must be 'body' or 'wind', got {force_convention!r}." + ) + if machs is None: + machs = np.arange(0.0, 3.01, 0.02) + machs = np.asarray(machs, dtype=float) + # Make sure each surface's center-of-pressure offset is current. + rocket.evaluate_surfaces_cp_to_cdm() + + reference_length = 2 * rocket.radius + dynamic_pressure_area = 0.5 * rocket.area # unit speed, unit density + dynamic_pressure_area_length = dynamic_pressure_area * reference_length + + def coefficients_at(alpha, beta, red_pitch, red_yaw, red_roll, mach): + # reduced rate -> body rate at unit speed: omega = rate * 2 V / L_ref + rate_factor = 2.0 / reference_length + omega = ( + red_pitch * rate_factor, + red_yaw * rate_factor, + red_roll * rate_factor, + ) + r1, r2, _, m1, m2, m3 = summed_force_and_moment( + rocket, alpha, beta, mach, omega + ) + return { + "cN": -r2 / dynamic_pressure_area, + "cY": r1 / dynamic_pressure_area, + "cm": m1 / dynamic_pressure_area_length, + "cn": m2 / dynamic_pressure_area_length, + "cl": m3 / dynamic_pressure_area_length, + } + + step = 1e-5 + + def slope(field, coeff): + values = [] + for mach in machs: + state = { + "alpha": 0.0, + "beta": 0.0, + "red_pitch": 0.0, + "red_yaw": 0.0, + "red_roll": 0.0, + } + high = coefficients_at(mach=mach, **{**state, field: step}) + low = coefficients_at(mach=mach, **{**state, field: -step}) + values.append((high[coeff] - low[coeff]) / (2 * step)) + return np.array(values) + + # Motor-independent derivative values on the Mach grid, in the body frame. + # The rocket's shape does not change with the motor, so these are shared by + # both phases; only the drag below differs. + derivatives = { + "cN_alpha": slope("alpha", "cN"), + "cm_alpha": slope("alpha", "cm"), + "cN_q": slope("red_pitch", "cN"), + "cm_q": slope("red_pitch", "cm"), + "cY_beta": slope("beta", "cY"), + "cn_beta": slope("beta", "cn"), + "cY_r": slope("red_yaw", "cY"), + "cn_r": slope("red_yaw", "cn"), + "cl_p": slope("red_roll", "cl"), + } + # The drag is a Mach curve at zero incidence (the axial coefficient's + # constant term), and it is the one term that differs by motor phase. + drag_by_phase = { + "power_off": rocket.power_off_drag_by_mach, + "power_on": rocket.power_on_drag_by_mach, + } + + result = {} + for phase, drag_curve in drag_by_phase.items(): + values = { + **derivatives, + "cA_0": np.array([drag_curve.get_value_opt(m) for m in machs]), + } + if force_convention == "wind": + values = body_derivatives_to_wind(values) + result[phase] = { + coeff_name: Function( + np.column_stack([machs, curve]), + "Mach", + coeff_name, + interpolation="akima", + extrapolation="constant", + ) + for coeff_name, curve in values.items() + } + return result + + +def body_derivatives_to_wind(body): + """Express a body-frame derivative set (``cN_*``/``cY_*``/``cA_*``) in the + wind-frame names (``cL_*``/``cQ_*``/``cD_*``); the moment derivatives are + frame-shared. Two cross terms fold the axial force in at incidence, + ``cL_alpha = cN_alpha - cA_0`` and ``cQ_beta = cY_beta + cA_0`` -- the linear + inverse of the wind-to-body rotation :class:`LinearGenericSurface` applies to + a wind-frame input, so feeding the result back with + ``force_convention="wind"`` recovers the same body-frame surface. Operates on + the tabulated derivative values (arrays over the Mach grid). + """ + drag = body.get("cA_0", 0.0) + rename = {"cN": "cL", "cY": "cQ", "cA": "cD"} + wind = {} + for key, value in body.items(): + prefix, sep, suffix = key.partition("_") + wind[f"{rename.get(prefix, prefix)}{sep}{suffix}"] = value + if "cN_alpha" in body: + wind["cL_alpha"] = body["cN_alpha"] - drag + if "cY_beta" in body: + wind["cQ_beta"] = body["cY_beta"] + drag + return wind diff --git a/rocketpy/rocket/point_mass_rocket.py b/rocketpy/rocket/point_mass_rocket.py index 5965a9c72..dc198c41f 100644 --- a/rocketpy/rocket/point_mass_rocket.py +++ b/rocketpy/rocket/point_mass_rocket.py @@ -57,11 +57,11 @@ class PointMassRocket(Rocket): power_on_drag_input : int, float, callable, array, string, Function Original user input for the drag coefficient with motor on. Preserved for reconstruction and Monte Carlo workflows. - power_off_drag_7d : Function - Drag coefficient function with seven inputs in the order: + power_off_drag_7d : AeroCoefficient + Drag coefficient callable over seven independent variables in the order: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. - power_on_drag_7d : Function - Drag coefficient function with seven inputs in the order: + power_on_drag_7d : AeroCoefficient + Drag coefficient callable over seven independent variables in the order: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. power_off_drag_by_mach : Function Convenience wrapper for power-off drag as a Mach-only function. diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index c16c799ce..32f32a231 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,4 +1,3 @@ -import csv import inspect import math import warnings @@ -15,18 +14,26 @@ from rocketpy.rocket.aero_surface import ( AirBrakes, EllipticalFins, + Fin, Fins, NoseCone, RailButtons, Tail, TrapezoidalFins, ) +from rocketpy.rocket.aero_surface.aero_coefficient import AeroCoefficient from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.rocket.aero_surface.linear_generic_surface import LinearGenericSurface from rocketpy.rocket.components import Components +from rocketpy.rocket.helpers import ( + full_body_coefficients, + neutral_point_and_slope, + zero_drag, +) from rocketpy.rocket.parachute import Parachute from rocketpy.tools import ( deprecated, @@ -133,16 +140,19 @@ class Rocket: Collection of air brakes of the rocket. Rocket._controllers : list Collection of controllers of the rocket. - Rocket.cp_position : Function - Function of Mach number expressing the rocket's center of pressure - position relative to user defined rocket reference system. - See :doc:`Positions and Coordinate Systems ` - for more information. + Rocket.aerodynamic_center : Function + Function of Mach number expressing the rocket's aerodynamic center + (the linearized, small-incidence center of pressure) position relative + to the user defined rocket reference system. ``Rocket.cp_position`` is an + alias for this attribute. See :doc:`Positions and Coordinate Systems + ` for more information. Rocket.stability_margin : Function - Stability margin of the rocket, in calibers, as a function of mach - number and time. Stability margin is defined as the distance between - the center of pressure and the center of mass, divided by the - rocket's diameter. + Stability margin of the rocket, in calibers, as a function of angle of + attack (radians), mach number and time. Stability margin is defined as + the distance between the center of pressure and the center of mass, + divided by the rocket's diameter. The angle-of-attack argument matters + only when a surface is nonlinear in incidence (see + ``Rocket.is_incidence_linear``); otherwise it has no effect. Rocket.static_margin : Function Static margin of the rocket, in calibers, as a function of time. Static margin is defined as the distance between the center of pressure and the @@ -162,12 +172,14 @@ class Rocket: Rocket.power_on_drag_input : int, float, callable, string, array, Function Original user input for rocket's drag coefficient when the motor is on. Preserved for reconstruction and Monte Carlo workflows. - Rocket.power_off_drag_7d : Function - Rocket's drag coefficient with motor off as a 7D function of - (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate). - Rocket.power_on_drag_7d : Function - Rocket's drag coefficient with motor on as a 7D function of - (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate). + Rocket.power_off_drag_7d : AeroCoefficient + Rocket's drag coefficient with motor off, callable over the seven + independent variables (alpha, beta, mach, reynolds, pitch_rate, + yaw_rate, roll_rate) and stored at its intrinsic dimensionality. + Rocket.power_on_drag_7d : AeroCoefficient + Rocket's drag coefficient with motor on, callable over the seven + independent variables (alpha, beta, mach, reynolds, pitch_rate, + yaw_rate, roll_rate) and stored at its intrinsic dimensionality. Rocket.power_off_drag_by_mach : Function Rocket's drag coefficient with motor off as a function of Mach number. Rocket.power_on_drag_by_mach : Function @@ -343,34 +355,63 @@ def __init__( # pylint: disable=too-many-statements self.sensors_by_name = {} self.aerodynamic_surfaces = Components() self.surfaces_cp_to_cdm = {} + # Set once a full-body model replaces the modeled aerodynamics + # (add_full_body_aerodynamics(overwrite=True)); warns on later surface adds. + self._aerodynamics_overwritten = False self.rail_buttons = Components() - self.cp_position = Function( + self._aerodynamic_center = Function( lambda mach: 0, inputs="Mach Number", - outputs="Center of Pressure Position (m)", + outputs="Aerodynamic Center Position (m)", ) - self.total_lift_coeff_der = Function( + self._total_lift_coeff_der = Function( lambda mach: 0, inputs="Mach Number", outputs="Total Lift Coefficient Derivative", ) - self.static_margin = Function( + self._static_margin = Function( lambda time: 0, inputs="Time (s)", outputs="Static Margin (c)" ) - self.stability_margin = Function( - lambda mach, time: 0, - inputs=["Mach", "Time (s)"], + self._stability_margin = Function( + lambda alpha, mach, time: 0, + inputs=["Angle of Attack (rad)", "Mach", "Time (s)"], outputs="Stability Margin (c)", ) + # Yaw-plane counterparts. The pitch-plane attributes above remain the + # primary (default) margin; these expose the yaw plane for + # non-axisymmetric rockets (see ``evaluate_center_of_pressure``). + self._aerodynamic_center_yaw = Function( + lambda mach: 0, + inputs="Mach Number", + outputs="Aerodynamic Center Position - Yaw (m)", + ) + self._total_side_coeff_der = Function( + lambda mach: 0, + inputs="Mach Number", + outputs="Total Side Coefficient Derivative", + ) + self._static_margin_yaw = Function( + lambda time: 0, inputs="Time (s)", outputs="Static Margin - Yaw (c)" + ) + self._stability_margin_yaw = Function( + lambda beta, mach, time: 0, + inputs=["Sideslip Angle (rad)", "Mach", "Time (s)"], + outputs="Stability Margin - Yaw (c)", + ) - # Define aerodynamic drag coefficients - # Coefficients used during flight simulation - self.power_off_drag_7d = self.__process_drag_input( - power_off_drag, "Drag Coefficient with Power Off" + # Define aerodynamic drag coefficients used during flight simulation + self.power_off_drag_7d = AeroCoefficient( + power_off_drag, + name="Drag Coefficient with Power Off", + extrapolation="constant", + single_var="mach", ) - self.power_on_drag_7d = self.__process_drag_input( - power_on_drag, "Drag Coefficient with Power On" + self.power_on_drag_7d = AeroCoefficient( + power_on_drag, + name="Drag Coefficient with Power On", + extrapolation="constant", + single_var="mach", ) self.power_on_drag_by_mach = Function( lambda mach: self.power_on_drag_7d(0, 0, mach, 0, 0, 0, 0), @@ -412,10 +453,14 @@ def __init__( # pylint: disable=too-many-statements self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - # Evaluate stability (even though no aerodynamic surfaces are present yet) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The aerodynamic center and the margins are evaluated lazily + self._cp_outdated = True + self._margin_outdated = True + # Whether the neutral point moves with angle of attack; set when the + # margins are evaluated (see evaluate_stability_margin). + self._is_incidence_linear = True + # Flag for rocket non-axisymmetric warning. Used to show warning once. + self._axisymmetry_warned = False # Initialize plots and prints object self.prints = _RocketPrints(self) @@ -609,43 +654,324 @@ def evaluate_thrust_to_weight(self): self.thrust_to_weight.set_outputs("Thrust/Weight") self.thrust_to_weight.set_title("Thrust to Weight ratio") - def evaluate_center_of_pressure(self): - """Evaluates rocket center of pressure position relative to user defined - rocket reference system. It can be called as many times as needed, as it - will update the center of pressure function every time it is called. The - code will iterate through all aerodynamic surfaces and consider each of - their center of pressure position and derivative of the coefficient of - lift as a function of Mach number. + # Lazily-evaluated aerodynamic outputs. + + def _ensure_aerodynamic_center(self): + """Recompute the pitch/yaw aerodynamic centers if a surface changed.""" + if self._cp_outdated: + self.evaluate_center_of_pressure() # clears ``_cp_outdated`` + + def _ensure_margins(self): + """Recompute the static/stability margins if a surface or the center of + mass changed. The underlying aerodynamic center is refreshed lazily by + the margin source closures.""" + if self._margin_outdated: + self._margin_outdated = False + self.evaluate_stability_margin() + self.evaluate_static_margin() + + @property + def aerodynamic_center(self): + """Pitch-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center + + @property + def aerodynamic_center_yaw(self): + """Yaw-plane aerodynamic center vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._aerodynamic_center_yaw + + def neutral_point(self, alpha, mach): + """Pitch-plane neutral point at a finite angle of attack, in meters. + + The neutral point is the point about which the aerodynamic pitching + moment does not change for a small change in angle of attack. It is the + angle-of-attack-aware generalization of the + :attr:`aerodynamic_center`: evaluated at ``alpha = 0`` the two are equal, + and for a rocket built only from the linear Barrowman surfaces the + neutral point does not move with angle of attack at all. + + It moves with angle of attack only when a surface's normal force is + nonlinear in the angle of attack, for example a Galejs body-lift term + (growing like ``sin**2(alpha)``) added as a + :class:`rocketpy.GenericSurface`. In that case the neutral point migrates + as the angle of attack changes, exactly the behavior OpenRocket models, + and the flight stability margin follows it. + + Parameters + ---------- + alpha : float + Angle of attack, in radians, to evaluate the neutral point at. + mach : float + Free-stream Mach number. Returns ------- - self.cp_position : Function - Function of Mach number expressing the rocket's center of pressure - position relative to user defined rocket reference system. - See :doc:`Positions and Coordinate Systems ` - for more information. + float + Axial position of the pitch-plane neutral point in the user-defined + rocket coordinate system, in meters. + """ + return neutral_point_and_slope(self, alpha, 0.0, mach, "pitch")[0] + + def neutral_point_yaw(self, beta, mach): + """Yaw-plane neutral point at a finite sideslip angle, in meters. + + Yaw-plane counterpart of :meth:`neutral_point`: the point about which the + yaw moment does not change for a small change in sideslip angle, + evaluated at the given sideslip angle. Equal to + :attr:`aerodynamic_center_yaw` at ``beta = 0``. + + Parameters + ---------- + beta : float + Sideslip angle, in radians, to evaluate the neutral point at. + mach : float + Free-stream Mach number. + + Returns + ------- + float + Axial position of the yaw-plane neutral point in the user-defined + rocket coordinate system, in meters. + """ + return neutral_point_and_slope(self, 0.0, beta, mach, "yaw")[0] + + @property + def total_lift_coeff_der(self): + """Total normal-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_lift_coeff_der + + @property + def total_side_coeff_der(self): + """Total side-force-coefficient derivative vs Mach (lazily evaluated).""" + self._ensure_aerodynamic_center() + return self._total_side_coeff_der + + @property + def static_margin(self): + """Pitch-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin + + @property + def static_margin_yaw(self): + """Yaw-plane static margin (calibers) vs time (lazily evaluated).""" + self._ensure_margins() + return self._static_margin_yaw + + @property + def stability_margin(self): + """Pitch-plane stability margin (calibers) as a function of angle of + attack (radians), Mach and time (lazily evaluated). The angle-of-attack + argument matters only for a rocket that is nonlinear in incidence (see + :attr:`is_incidence_linear`); otherwise it has no effect and the margin + reduces to the Mach-and-time value.""" + self._ensure_margins() + return self._stability_margin + + @property + def stability_margin_yaw(self): + """Yaw-plane stability margin (calibers) as a function of sideslip angle + (radians), Mach and time (lazily evaluated). Equal to + :attr:`stability_margin` for an axisymmetric rocket.""" + self._ensure_margins() + return self._stability_margin_yaw + + @property + def length(self): + """Overall aerodynamic length of the rocket, in meters. + + This is the axial distance from the fore-most point of the rocket (the + nose cone tip) to the aft-most point of the rocket. It is measured along + the rocket axis and does not depend on the chosen coordinate-system + orientation. + + The aft-most point is usually the trailing edge of the last fin set or + the base of the aft tail, but if the motor nozzle extends past the last + aerodynamic surface, the nozzle sets the aft end instead. The rocket + must have at least one aerodynamic surface with a defined axial extent + (a nose cone, tail or fin set); otherwise a ``ValueError`` is raised. + + This length is what the hobby-rocketry convention of expressing the + static/stability margin as a *percentage of body length* is measured + against, as opposed to the caliber (diameter) convention used by + ``static_margin`` and ``stability_margin``. + + Returns + ------- + float + Overall aerodynamic length of the rocket, in meters. """ - # Re-Initialize total lift coefficient derivative and center of pressure position - self.total_lift_coeff_der.set_source(lambda mach: 0) - self.cp_position.set_source(lambda mach: 0) + fore_points = [] + aft_points = [] + for surface, position in self.aerodynamic_surfaces: + if isinstance(surface, (NoseCone, Tail)): + axial_extent = surface.length + elif isinstance(surface, (Fins, Fin)): + axial_extent = surface.root_chord + else: + # Generic/controllable surfaces have no defined axial extent; + # they contribute a single point at their reference position. + axial_extent = 0.0 + # The reference point and the point one axial extent toward the tail + # (the tail direction is -_csys along the z axis). Taking the global + # extremes makes the result independent of which end is the reference. + fore_points.append(position.z) + aft_points.append(position.z - self._csys * axial_extent) + + if not fore_points: + raise ValueError( + "The rocket must have at least one aerodynamic surface to have a " + "defined length." + ) + + all_points = fore_points + aft_points + # Include the nozzle if a real motor extends past the aerodynamic + # surfaces. nozzle_position is already in the rocket reference frame. + if getattr(self, "motor", None) is not None and not isinstance( + self.motor, EmptyMotor + ): + all_points.append(self.nozzle_position) + return max(all_points) - min(all_points) + + def evaluate_center_of_pressure(self): + """Evaluates the rocket's aerodynamic center (and cp_position) as a + function of Mach number, relative to the user-defined rocket reference + system. + + The aerodynamic center is the linearized (small-incidence, alpha=beta=0) + center of pressure: the normal-force-slope-weighted average of every + aerodynamic surface's location. + + It is computed independently for the **pitch** plane + (``aerodynamic_center``, from the normal-force/pitch-moment slopes) and + the **yaw** plane (``aerodynamic_center_yaw``, from the + side-force/yaw-moment slopes). For an axisymmetric rocket the two + coincide. When they differ (a non-axisymmetric configuration), a warning + is raised because the scalar ``static_margin``/``stability_margin`` + attributes describe the pitch plane only. - # Calculate total lift coefficient derivative and center of pressure + Returns + ------- + self.aerodynamic_center : Function + Function of Mach number expressing the rocket's pitch-plane + aerodynamic center position relative to the user-defined rocket + reference system. See :doc:`Positions and Coordinate Systems + ` for more information. + """ + # Mark the pitch/yaw centers up to date before computing, so that a read + # of the ``aerodynamic_center`` property during this method (the + # ``is_axisymmetric`` check below) returns the value being built here + # rather than recursing back into this method. + self._cp_outdated = False + + # Re-Initialize total force coefficient derivatives and AC positions + self._total_lift_coeff_der.set_source(lambda mach: 0) + self._aerodynamic_center.set_source(lambda mach: 0) + self._total_side_coeff_der.set_source(lambda mach: 0) + self._aerodynamic_center_yaw.set_source(lambda mach: 0) + + # Calculate total force coefficient derivatives and aerodynamic center if len(self.aerodynamic_surfaces) > 0: for aero_surface, position in self.aerodynamic_surfaces: - if isinstance(aero_surface, GenericSurface): - continue - # ref_factor corrects lift for different reference areas - ref_factor = (aero_surface.rocket_radius / self.radius) ** 2 - self.total_lift_coeff_der += ref_factor * aero_surface.clalpha - self.cp_position += ( - ref_factor - * aero_surface.clalpha - * (position.z - self._csys * aero_surface.cpz) + # Force-curve slopes as Functions of Mach, from the surface's + # coefficient derivatives sliced at zero alpha/beta and zero + # rates. The yaw slope is the sign-flipped ``cY_beta`` so an + # axisymmetric surface gives the same signed weight as the pitch + # plane (their margins then coincide when symmetric). + lift_coeff_der = aero_surface.cN_alpha.slice("mach") + side_coeff_der = -1.0 * aero_surface.cY_beta.slice("mach") + cp_z = aero_surface.center_of_pressure_z + cp_z_yaw = aero_surface.center_of_pressure_z_yaw + # ref_factor corrects force for different reference areas + ref_factor = aero_surface.reference_area / self.area + self._total_lift_coeff_der += ref_factor * lift_coeff_der + self._aerodynamic_center += ( + ref_factor * lift_coeff_der * (position.z - self._csys * cp_z) ) - # Avoid errors when only generic surfaces are added - if self.total_lift_coeff_der.get_value(0) != 0: - self.cp_position /= self.total_lift_coeff_der - return self.cp_position + + # Yaw plane. + self._total_side_coeff_der += ref_factor * side_coeff_der + self._aerodynamic_center_yaw += ( + ref_factor * side_coeff_der * (position.z - self._csys * cp_z_yaw) + ) + # Avoid errors when only zero-lift surfaces are added + if self._total_lift_coeff_der.get_value(0) != 0: + self._aerodynamic_center /= self._total_lift_coeff_der + if self._total_side_coeff_der.get_value(0) != 0: + self._aerodynamic_center_yaw /= self._total_side_coeff_der + + # Non-axisymmetry advisory. Latched once per configuration: the flag is + # re-armed whenever a surface is added (see add_surfaces) + if not self._axisymmetry_warned and not self.is_axisymmetric: + self._axisymmetry_warned = True + max_diff = self._cp_plane_max_difference() + warnings.warn( + "Pitch- and yaw-plane aerodynamic centers differ " + f"(max difference ~{max_diff:.4g} m): the rocket is not " + "axisymmetric. 'aerodynamic_center', 'static_margin' and " + "'stability_margin' describe the PITCH plane. Use " + "'aerodynamic_center_yaw', 'static_margin_yaw' and " + "'stability_margin_yaw' for the yaw plane.", + stacklevel=2, + ) + + return self._aerodynamic_center + + def _cp_plane_max_difference(self): + """Largest pitch- vs yaw-plane aerodynamic center difference, in meters. + The difference is sampled densely across the subsonic, transonic and""" + # 0 to 3 in 0.2 steps covers RocketPy's flight regimes (sub/trans/ + # supersonic) with enough resolution that a real asymmetry, which spans a + # Mach *range*, cannot fall entirely between sample points. This only + # runs for rockets that contain a generic surface or individual fin (see + # the by-construction short-circuit in ``is_axisymmetric``). + sample_machs = np.linspace(0.0, 3.0, 16) + return max( + abs( + self.aerodynamic_center.get_value_opt(mach) + - self.aerodynamic_center_yaw.get_value_opt(mach) + ) + for mach in sample_machs + ) + + @property + def is_axisymmetric(self): + """``True`` when the rocket's pitch- and yaw-plane aerodynamic centers + coincide (to caliber-scale tolerance). When ``False`` the rocket is not + axisymmetric: ``aerodynamic_center``, ``static_margin`` and + ``stability_margin`` describe the PITCH plane only and differ from their + ``*_yaw`` counterparts (``aerodynamic_center_yaw``, + ``static_margin_yaw``, ``stability_margin_yaw``).""" + # Nose, tail and fin sets contribute identically to both planes. + if all( + getattr(surface, "is_axisymmetric", False) + for surface, _ in self.aerodynamic_surfaces + ): + return True + # Tolerance relative to the rocket diameter (caliber-scale). + return self._cp_plane_max_difference() <= 1e-6 * (2 * self.radius) + + @property + def is_incidence_linear(self): + """``True`` when the rocket's aerodynamics are linear in the angle of + attack, so the neutral point (and therefore the stability margin) does + not move as the angle of attack changes. This holds for a rocket built + only from the linear Barrowman surfaces. It is ``False`` when a surface's + normal force is nonlinear in incidence, such as a Galejs body-lift term + added as a :class:`rocketpy.GenericSurface`, in which case + :attr:`stability_margin` varies with its angle-of-attack argument.""" + self._ensure_margins() + return self._is_incidence_linear + + @property + def cp_position(self): + """Alias for :attr:`aerodynamic_center`. Traditional center of pressure + position, defined as the linearized (small-incidence) center of pressure, + is the same as the aerodynamic center.""" + return self.aerodynamic_center def evaluate_surfaces_cp_to_cdm(self): """Calculates the relative position of each aerodynamic surface center @@ -674,39 +1000,138 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): (position.z - self.center_of_dry_mass_position) * self._csys, ] ) - # position of the center of pressure in body frame + # position of the force application point in body frame. Every surface + # applies its resultant force at its center of pressure and transports + # the moment geometrically; the surface-local application point is mapped + # into the body frame by ``_rotation_surface_to_body`` + application_point = getattr( + surface, + "force_application_point", + Vector([surface.cpx, surface.cpy, surface.cpz]), + ) pos = ( - surface._rotation_surface_to_body - @ Vector([surface.cpx, surface.cpy, surface.cpz]) - + pos_origin + surface._rotation_surface_to_body @ application_point + pos_origin ) # TODO: this should be recomputed whenever cant angle changes for fin self.surfaces_cp_to_cdm[surface] = pos + def _evaluate_is_incidence_linear(self): + """Detect whether the rocket's aerodynamics are linear in the angle of + attack, i.e. whether the neutral point moves as the angle of attack + changes. Returns ``True`` for a rocket built only from the linear + Barrowman surfaces and ``False`` when a surface's normal force is + nonlinear in incidence (for example a Galejs body-lift term added as a + :class:`rocketpy.GenericSurface`). + + The check central-differences the neutral point at zero and at five + degrees of incidence, in both the pitch and yaw planes; if either plane + moves, the rocket is treated as incidence-nonlinear. + """ + probe_mach = 0.3 + probe_alpha = math.radians(5.0) + for plane in ("pitch", "yaw"): + if plane == "yaw": + point_zero = neutral_point_and_slope(self, 0.0, 0.0, probe_mach, "yaw") + point_five = neutral_point_and_slope( + self, 0.0, probe_alpha, probe_mach, "yaw" + ) + else: + point_zero = neutral_point_and_slope(self, 0.0, 0.0, probe_mach, "pitch") + point_five = neutral_point_and_slope( + self, probe_alpha, 0.0, probe_mach, "pitch" + ) + if abs(point_five[0] - point_zero[0]) > 1e-6: + return False + return True + + def _neutral_point_margin_slope(self, incidence, mach, time, plane="pitch"): + """Stability margin (in calibers) and local normal-force-curve slope at a + single flow state, the shared computation behind ``stability_margin`` and + the flight dynamic-stability oscillator. + + For a rocket that is linear in incidence the neutral point is the + zero-incidence :attr:`aerodynamic_center` and ``incidence`` is ignored, + keeping the fast analytic path (and byte-for-byte the previous margin + values). Otherwise the neutral point and slope are found at ``incidence`` + by :func:`rocketpy.rocket.helpers.neutral_point_and_slope`, so a surface + that is nonlinear in the angle of attack (e.g. a Galejs body-lift + :class:`rocketpy.GenericSurface`) makes the margin move with incidence. + + Parameters + ---------- + incidence : float + Angle of attack (pitch) or sideslip angle (yaw), in radians. + mach : float + Free-stream Mach number. + time : float + Flight time, in seconds, at which the center of mass is taken. + plane : str, optional + ``"pitch"`` or ``"yaw"``. Default ``"pitch"``. + + Returns + ------- + tuple of float + ``(margin, slope)``: the stability margin in calibers and the local + force-curve slope (``dCN/dalpha`` for pitch, ``dCY/dbeta`` for yaw). + """ + self._ensure_margins() + if plane == "yaw": + center = self.aerodynamic_center_yaw + slope_curve = self.total_side_coeff_der + else: + center = self.aerodynamic_center + slope_curve = self.total_lift_coeff_der + + if self._is_incidence_linear: + neutral_point = center.get_value_opt(mach) + slope = slope_curve.get_value_opt(mach) + elif plane == "yaw": + neutral_point, slope = neutral_point_and_slope( + self, 0.0, incidence, mach, "yaw" + ) + else: + neutral_point, slope = neutral_point_and_slope( + self, incidence, 0.0, mach, "pitch" + ) + + margin = ( + self._csys + * (self.center_of_mass.get_value_opt(time) - neutral_point) + / (2 * self.radius) + ) + return margin, slope + def evaluate_stability_margin(self): - """Calculates the stability margin of the rocket as a function of mach - number and time. + """Calculates the stability margin of the rocket as a function of angle + of attack, Mach number and time. Returns ------- stability_margin : Function - Stability margin of the rocket, in calibers, as a function of mach - number and time. Stability margin is defined as the distance between - the center of pressure and the center of mass, divided by the - rocket's diameter. + Stability margin of the rocket, in calibers, as a function of angle + of attack (radians), Mach number and time. The stability margin is + the distance between the center of pressure and the center of mass, + divided by the rocket's diameter. It depends on the angle of attack + only when a surface is nonlinear in incidence; for a rocket built + from the linear Barrowman surfaces the angle-of-attack argument has + no effect. """ - self.stability_margin.set_source( - lambda mach, time: ( - ( - ( - self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(mach) - ) - / (2 * self.radius) - ) - * self._csys - ) + self._is_incidence_linear = self._evaluate_is_incidence_linear() + self._stability_margin.set_source( + lambda alpha, mach, time: self._neutral_point_margin_slope( + alpha, mach, time, "pitch" + )[0] + ) + self._stability_margin.set_inputs(["Angle of Attack (rad)", "Mach", "Time (s)"]) + # Yaw-plane stability margin (equal to the pitch plane when axisymmetric) + self._stability_margin_yaw.set_source( + lambda beta, mach, time: self._neutral_point_margin_slope( + beta, mach, time, "yaw" + )[0] ) - return self.stability_margin + self._stability_margin_yaw.set_inputs( + ["Sideslip Angle (rad)", "Mach", "Time (s)"] + ) + return self._stability_margin def evaluate_static_margin(self): """Calculates the static margin of the rocket as a function of time. @@ -719,24 +1144,42 @@ def evaluate_static_margin(self): pressure and the center of mass, divided by the rocket's diameter. """ # Calculate static margin - self.static_margin.set_source( + self._static_margin.set_source( lambda time: ( ( self.center_of_mass.get_value_opt(time) - - self.cp_position.get_value_opt(0) + - self.aerodynamic_center.get_value_opt(0) ) / (2 * self.radius) ) ) # Change sign if coordinate system is upside down - self.static_margin *= self._csys - self.static_margin.set_inputs("Time (s)") - self.static_margin.set_outputs("Static Margin (c)") - self.static_margin.set_title("Static Margin") - self.static_margin.set_discrete( + self._static_margin *= self._csys + self._static_margin.set_inputs("Time (s)") + self._static_margin.set_outputs("Static Margin (c)") + self._static_margin.set_title("Static Margin") + self._static_margin.set_discrete( + lower=0, upper=self.motor.burn_out_time, samples=200 + ) + + # Yaw-plane static margin (equal to the pitch plane when axisymmetric) + self._static_margin_yaw.set_source( + lambda time: ( + ( + self.center_of_mass.get_value_opt(time) + - self.aerodynamic_center_yaw.get_value_opt(0) + ) + / (2 * self.radius) + ) + ) + self._static_margin_yaw *= self._csys + self._static_margin_yaw.set_inputs("Time (s)") + self._static_margin_yaw.set_outputs("Static Margin - Yaw (c)") + self._static_margin_yaw.set_title("Static Margin - Yaw") + self._static_margin_yaw.set_discrete( lower=0, upper=self.motor.burn_out_time, samples=200 ) - return self.static_margin + return self._static_margin def evaluate_dry_inertias(self): """Calculates and returns the rocket's dry inertias relative to @@ -1066,10 +1509,9 @@ def add_motor(self, motor, position): # pylint: disable=too-many-statements self.evaluate_inertias() self.evaluate_reduced_mass() self.evaluate_thrust_to_weight() - self.evaluate_center_of_pressure() self.evaluate_surfaces_cp_to_cdm() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # The motor changes the CM (and the margins) + self._margin_outdated = True self.evaluate_com_to_cdm_function() self.evaluate_nozzle_gyration_tensor() @@ -1134,6 +1576,14 @@ def add_surfaces(self, surfaces, positions): ------- None """ + if self._aerodynamics_overwritten: + warnings.warn( + "This rocket's aerodynamics were overwritten by a full-body " + "model (add_full_body_aerodynamics(overwrite=True)); the surface(s) " + "you are adding now will be summed on top of that model.", + UserWarning, + stacklevel=2, + ) if isinstance(surfaces, Iterable): if isinstance(positions, Iterable): if len(surfaces) != len(positions): @@ -1148,9 +1598,250 @@ def add_surfaces(self, surfaces, positions): else: self.__add_single_surface(surfaces, positions) - self.evaluate_center_of_pressure() - self.evaluate_stability_margin() - self.evaluate_static_margin() + # Adding a surface changes both the aerodynamic center and the margins + self._cp_outdated = True + self._margin_outdated = True + # Re-arm the non-axisymmetry advisory: the warning is latched once per + # configuration (see evaluate_center_of_pressure), so a new surface may + # legitimately warn again about the new configuration. + self._axisymmetry_warned = False + + def add_full_body_aerodynamics(self, surfaces, position=None, overwrite=False): + """Add a prebuilt full-body aerodynamic surface: the whole rocket + modeled as a single surface. Instead of (or in addition to) modeling + each component, this lets you provide a set of coefficients for the + whole rocket, which is often easier. + + Parameters + ---------- + surfaces : GenericSurface or list of GenericSurface + The prebuilt full-body surface, or a list of them (for example a + power-on/power-off pair, each carrying its own ``active_during``). + Any of: + + - a :class:`GenericSurface`; + - a :class:`LinearGenericSurface`; + - a :class:`ControllableGenericSurface` for coefficients that + also depend on control-deflection axes. + + Reference the surface's coefficients to the rocket cross-section + area and diameter (build it with ``reference_area=rocket.area`` and + ``reference_length=2 * rocket.radius``) so it sums consistently with + the rest of the rocket. Because it is just another aerodynamic + surface, a full-body model can be **mixed** with modeled add-on + surfaces (e.g. use ``add_full_body_aerodynamics`` together with + ``add_tail``): they simply add. + + A rocket's aerodynamics usually differ between powered and coasting + flight. To capture this, build two surfaces, set each one's + ``active_during`` to ``"power_on"`` and ``"power_off"``, and pass + them together as a list; each then produces force only during its + phase. + position : int, float, optional + Position along the rocket's center axis (in the user coordinate + system) where the surface's resultant force is applied and about + which its moment coefficients are taken. Defaults to the center of + dry mass position. + overwrite : bool, optional + If ``True``, make this the rocket's only aerodynamics: every + aerodynamic surface already on the rocket is removed first, and both + built-in drag curves (``power_on_drag`` and ``power_off_drag``) are + cleared. Default ``False`` (the model is added on top of the + existing aerodynamics). + + Returns + ------- + GenericSurface or list of GenericSurface + The surface(s) added. + """ + if position is None: + position = self.center_of_dry_mass_position + + if overwrite: + self._clear_aerodynamic_surfaces() + + surface_list = ( + list(surfaces) if isinstance(surfaces, (list, tuple)) else [surfaces] + ) + for surface in surface_list: + self.add_surfaces(surface, position) + + if overwrite: + # Re-arm the "added after" guard now that the full-body model is set. + self._aerodynamics_overwritten = True + + return surfaces + + def _clear_aerodynamic_surfaces(self): + """Wipe the rocket's aerodynamics so a full-body model can fully replace + them: remove every aerodynamic surface, clear both built-in drag curves, + and reset the derived stability caches. Used by + :meth:`add_full_body_aerodynamics` with ``overwrite=True``. + """ + self.aerodynamic_surfaces.clear() + self.surfaces_cp_to_cdm.clear() + # Clear both built-in drag curves; the supplied surface(s) now provide + # the complete aerodynamics, including any drag they carry. + zero_drag(self, "power_on") + zero_drag(self, "power_off") + warnings.warn( + "add_full_body_aerodynamics(overwrite=True): the rocket's existing " + "aerodynamic surfaces and both built-in drag curves (power_on_drag, " + "power_off_drag) were cleared; the supplied surface(s) now provide " + "the complete aerodynamics, including any drag they carry.", + UserWarning, + stacklevel=3, + ) + self._cp_outdated = True + self._margin_outdated = True + self._axisymmetry_warned = False + # New adds are welcome again; the guard is re-armed once the full-body + # surfaces are in place (see add_full_body_aerodynamics). + self._aerodynamics_overwritten = False + + def to_coefficients(self, machs=None, force_convention="body"): + """Return the whole rocket's aerodynamic coefficients, split by motor + phase. + + Sweeps the rocket's aerodynamic surfaces and lumps them into the + rocket's complete stability-derivative set about the dry center of mass: + the normal-force and pitch-moment slopes ``cN_alpha``/``cm_alpha`` + (pitch), the side-force and yaw-moment slopes ``cY_beta``/``cn_beta`` + (yaw), the pitch and yaw rate damping ``cN_q``/``cm_q`` and + ``cY_r``/``cn_r``, the fin roll damping ``cl_p`` and the drag ``cA_0``. + + The result is returned as two coefficient sets, ``"power_off"`` + (coasting) and ``"power_on"`` (motor burning). + + Important + --------- + The resulting coefficients are a **linear summary tabulated only against + Mach**: the derivatives are taken at zero angle of attack, zero sideslip + and zero rates, so only their Mach dependence is kept. This leaves out: + + - **Incidence and rate nonlinearity.** Only the slope at zero is + retained, so any curvature in angle of attack, sideslip or the body + rates is not represented. + - **Reynolds dependence.** The derivatives are measured in the + vanishing-Reynolds limit, so a coefficient that varies with Reynolds + number is frozen at that value rather than following the flight + Reynolds number. + - **Control-surface dependence.** Deflection axes of a + :class:`ControllableGenericSurface` are not carried into the summary. + - **Induced drag.** The axial coefficient is constant in incidence, so + drag does not increase with angle of attack. + + These are exactly the assumptions of RocketPy's built-in Barrowman + surfaces (:class:`NoseCone`, :class:`Tail` and the fin sets), which are + already linear, Mach-tabulated and Reynolds-independent. A rocket built + only from them is therefore reproduced exactly, with nothing lost. The + limitations matter only when you have added a :class:`GenericSurface` or + :class:`ControllableGenericSurface` (or a Reynolds-dependent + :class:`LinearGenericSurface`) whose coefficients truly vary with + incidence beyond a straight line, with Reynolds number, or with a + control deflection. + + Parameters + ---------- + machs : sequence of float, optional + Mach numbers at which the derivatives are sampled and tabulated. + Defaults to ``0`` to ``3`` in steps of ``0.02``. + force_convention : str, optional + The frame the force coefficients are named in. ``"body"`` (default) + gives the body-frame set : normal ``cN_*``, side ``cY_*`` and axial + ``cA_0`` (drag). ``"wind"`` gives the wind-frame set: lift + ``cL_*``, side ``cQ_*`` and drag ``cD_0``. The moment derivatives + (``cm_*``, ``cn_*``, ``cl_p``) are the same in both. + + Returns + ------- + dict + A dict with keys ``"power_off"`` and ``"power_on"``. Each value is + itself a dict mapping a coefficient name to its curve over Mach (a + :class:`rocketpy.Function`). The body-frame set is ``cN_alpha``, + ``cm_alpha``, ``cN_q``, ``cm_q``, ``cY_beta``, ``cn_beta``, + ``cY_r``, ``cn_r``, ``cl_p`` and ``cA_0``. + """ + return full_body_coefficients(self, machs, force_convention) + + def to_surface( + self, + machs=None, + force_convention="body", + name="Full Body Aerodynamics", + ): + """Collapse the whole assembled rocket aerodynamics into two + :class:`rocketpy.LinearGenericSurface` objects, one for coasting and one + for powered flight. It reproduces the source rocket's aerodynamics, so a + bare rocket carrying the same body and motor plus this pair flies the + same as the fully modeled rocket. + + Important + --------- + The resulting coefficients are a **linear summary tabulated only against + Mach**: the derivatives are taken at zero angle of attack, zero sideslip + and zero rates, so only their Mach dependence is kept. This leaves out: + + - **Incidence and rate nonlinearity.** Only the slope at zero is + retained, so any curvature in angle of attack, sideslip or the body + rates is not represented. + - **Reynolds dependence.** The derivatives are measured in the + vanishing-Reynolds limit, so a coefficient that varies with Reynolds + number is frozen at that value rather than following the flight + Reynolds number. + - **Control-surface dependence.** Deflection axes of a + :class:`ControllableGenericSurface` are not carried into the summary. + - **Induced drag.** The axial coefficient is constant in incidence, so + drag does not increase with angle of attack. + + These are exactly the assumptions of RocketPy's built-in Barrowman + surfaces (:class:`NoseCone`, :class:`Tail` and the fin sets), which are + already linear, Mach-tabulated and Reynolds-independent. A rocket built + only from them is therefore reproduced exactly, with nothing lost. The + limitations matter only when you have added a :class:`GenericSurface` or + :class:`ControllableGenericSurface` (or a Reynolds-dependent + :class:`LinearGenericSurface`) whose coefficients truly vary with + incidence beyond a straight line, with Reynolds number, or with a + control deflection. + + Parameters + ---------- + machs : sequence of float, optional + Mach numbers at which the derivatives are sampled and tabulated. + Defaults to ``0`` to ``3`` in steps of ``0.02``. + force_convention : str, optional + The frame the force coefficients are expressed in. ``"body"`` + (default) gives the body-frame set: normal ``cN``, side ``cY`` and + axial ``cA`` (drag). ``"wind"`` gives the wind-frame set: lift + ``cL``, side ``cQ`` and drag ``cD``. The moment coefficients are the + same in both. + name : str, optional + Base name of the returned surfaces. + Default ``"Full Body Aerodynamics"``. + + Returns + ------- + list of rocketpy.LinearGenericSurface + Two surfaces, ``[power_off, power_on]``, each carrying the whole + rocket's coefficient derivatives referenced to the rocket + cross-section area and diameter and taken about the center of dry + mass, and gated to its motor phase. + """ + coefficients = self.to_coefficients( + machs=machs, + force_convention=force_convention, + ) + return [ + LinearGenericSurface( + reference_area=self.area, + reference_length=2 * self.radius, + coefficients=coefficients[phase], + force_convention=force_convention, + name=f"{name} ({phase.replace('_', ' ')})", + active_during=phase, + ) + for phase in ("power_off", "power_on") + ] def _add_controllers(self, controllers): """Adds a controller to the rocket. @@ -1279,7 +1970,7 @@ def add_nose( @deprecated( reason="This method is set to be deprecated in version 1.0.0 and fully " - "removed by version 2.0.0", + "removed by version 1.14.0", alternative="Rocket.add_trapezoidal_fins", ) def add_fins(self, *args, **kwargs): # pragma: no cover @@ -2230,8 +2921,14 @@ def to_dict(self, **kwargs): if kwargs.get("include_outputs", False): thrust_to_weight = self.thrust_to_weight - cp_position = self.cp_position - stability_margin = self.stability_margin + aerodynamic_center = self.aerodynamic_center + # The zero-incidence design surface (Mach, time), a 2-D slice of the + # angle-of-attack-aware stability_margin, for output inspection. + stability_margin = Function( + lambda mach, time: self.stability_margin.get_value_opt(0.0, mach, time), + inputs=["Mach", "Time (s)"], + outputs="Stability Margin (c)", + ) center_of_mass = self.center_of_mass motor_center_of_mass_position = self.motor_center_of_mass_position reduced_mass = self.reduced_mass @@ -2243,7 +2940,9 @@ def to_dict(self, **kwargs): thrust_to_weight = thrust_to_weight.set_discrete_based_on_model( self.motor.thrust, mutate_self=False ) - cp_position = cp_position.set_discrete(0, 4, 25, mutate_self=False) + aerodynamic_center = aerodynamic_center.set_discrete( + 0, 4, 25, mutate_self=False + ) stability_margin = stability_margin.set_discrete( (0, self.motor.burn_time[0]), (2, self.motor.burn_time[1]), @@ -2293,7 +2992,7 @@ def to_dict(self, **kwargs): rocket_dict["cp_eccentricity_y"] = self.cp_eccentricity_y rocket_dict["thrust_eccentricity_x"] = self.thrust_eccentricity_x rocket_dict["thrust_eccentricity_y"] = self.thrust_eccentricity_y - rocket_dict["cp_position"] = cp_position + rocket_dict["aerodynamic_center"] = aerodynamic_center rocket_dict["stability_margin"] = stability_margin rocket_dict["static_margin"] = self.static_margin rocket_dict["nozzle_position"] = self.nozzle_position @@ -2368,271 +3067,3 @@ def from_dict(cls, data): rocket._add_controllers(controller) return rocket - - def __process_drag_input(self, input_data, coeff_name): - """Process drag coefficient input and normalize it to a 7D Function. - - Parameters - ---------- - input_data : int, float, str, callable, Function - Input data to be processed. - coeff_name : str - Name of the coefficient being processed for error reporting. - - Returns - ------- - Function - Function object with 7 input arguments in the following order: - alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. - """ - inputs = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - # Helper: lift a 1D Mach-only source into the required 7D signature. - def _wrap_mach_only_source(mach_source): - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: ( - mach_source(mach) - ), - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # Helper: enforce that Function-based inputs are either 1D (Mach) or 7D. - def _validate_function_domain_dimension(function): - if function.__dom_dim__ not in (1, 7): - raise ValueError( - f"{coeff_name} function must have either 1 input argument " - "(mach) or 7 input arguments (alpha, beta, mach, reynolds, " - "pitch_rate, yaw_rate, roll_rate), in that order." - ) - - # Helper: count required positional arguments in a callable. - def _count_positional_args(callable_obj): - signature = inspect.signature(callable_obj) - positional_params = [ - parameter - for parameter in signature.parameters.values() - if parameter.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - and parameter.default is inspect.Parameter.empty - ] - return len(positional_params) - - # Case 1: string input can be a CSV path or any Function-supported source. - if isinstance(input_data, str): - if input_data.lower().endswith(".csv"): - return self.__load_rocket_drag_csv(input_data, coeff_name) - - function_data = Function(input_data) - _validate_function_domain_dimension(function_data) - if function_data.__dom_dim__ == 7: - function_data.set_extrapolation("constant") - return function_data - return _wrap_mach_only_source(function_data.get_value_opt) - - # Case 2: Function input is accepted directly after domain validation. - if isinstance(input_data, Function): - _validate_function_domain_dimension(input_data) - if input_data.__dom_dim__ == 7: - input_data.set_extrapolation("constant") - return input_data - return _wrap_mach_only_source(input_data.get_value_opt) - - # Case 3: callable input must expose either 1 (Mach) or 7 arguments. - if callable(input_data): - n_positional_args = _count_positional_args(input_data) - if n_positional_args not in (1, 7): - raise ValueError( - f"{coeff_name} callable must have either 1 positional " - "argument (mach) or 7 positional arguments (alpha, beta, " - "mach, reynolds, pitch_rate, yaw_rate, roll_rate), in that " - "order." - ) - - if n_positional_args == 1: - return _wrap_mach_only_source(input_data) - - return Function( - input_data, - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # Case 4: scalar input means a constant drag coefficient in all conditions. - if isinstance(input_data, (int, float)): - return Function( - lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: ( - float(input_data) - ), - inputs, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - # If is list/tuple try to pass it to a function. - # If composed of lists/tuples len 2, then interpret as function of mach - # Otherwise interpret it as function of all 7 variables - # This reuses Function's parser and then feeds back into this same pipeline. - if isinstance(input_data, (list, tuple)): - if all( - isinstance(item, (list, tuple)) and (len(item) == 2 or len(item) == 8) - for item in input_data - ): - try: - return self.__process_drag_input( - Function(list(input_data)), coeff_name - ) - except (TypeError, ValueError) as e: - raise ValueError( - f"Invalid list/tuple format for {coeff_name}. Expected " - "a list of [mach, coefficient] pairs or a list of " - "[alpha, beta, mach, reynolds, pitch_rate, yaw_rate, " - "roll_rate, coefficient] entries." - ) from e - - raise TypeError( - f"Invalid input for {coeff_name}: must be int, float, CSV file path, " - "Function, or callable." - ) - - def __load_rocket_drag_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load Rocket drag CSV into a 7D Function. - - Supports either headerless two-column (mach, coefficient) tables or - header-based multi-variable CSV tables. - """ - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - def _is_numeric(value): - try: - float(value) - return True - except (TypeError, ValueError): - try: - int(value) - return True - except (TypeError, ValueError): - return False - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - first_row = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not first_row: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - is_headerless_two_column = len(first_row) == 2 and all( - _is_numeric(cell) for cell in first_row - ) - - if is_headerless_two_column: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def mach_wrapper( - _alpha, - _beta, - mach, - _reynolds, - _pitch_rate, - _yaw_rate, - _roll_rate, - ): - return csv_func(mach) - - return Function( - mach_wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - header = [column.strip() for column in first_row] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient " - "value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [ - col for col in header[:-1] if col in independent_vars - ] - - csv_func = Function.from_regular_grid_csv( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="constant", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) diff --git a/rocketpy/simulation/events/event.py b/rocketpy/simulation/events/event.py index bfcd1051d..4029653a9 100644 --- a/rocketpy/simulation/events/event.py +++ b/rocketpy/simulation/events/event.py @@ -25,7 +25,7 @@ class Event: - """Event helper with trigger/callback execution and exact-time support. + """A rule that runs an action during a flight when a condition is met. An ``Event`` is the main way RocketPy reacts to conditions during a flight. It pairs a ``trigger`` predicate with a ``callback`` action: at @@ -147,11 +147,11 @@ def __init__( # pylint: disable=too-many-arguments slower with no gain in accuracy. Automatically forced to ``False`` when ``sampling_rate`` is ``None``. changes_dynamics : bool, optional - Set to ``True`` when the callback changes the simulation dynamics or - any parameter affecting the ODE derivative. This includes mutating an - attribute of any simulation object, and using the - ``set_derivative``, ``start_flight_phase``, or ``terminate_flight`` - commands. Defaults to ``False``. + Set to ``True`` when the callback changes anything that affects the + equations of motion. This includes changing an attribute of any + simulation object, and using the ``set_derivative``, + ``start_flight_phase``, or ``terminate_flight`` commands. Defaults to + ``False``. name : str, optional Human-readable identifier used in logs and debugging. Defaults to ``"Custom Event"``. @@ -174,12 +174,11 @@ def __init__( # pylint: disable=too-many-arguments - 3: Controller events - 4: Custom / user-defined events (default) needs : list of str or None, optional - Declares which expensive simulation values the event's trigger and - callback actually access. Valid keys are ``'state_dot'``, - ``'pressure'``, and ``'state_history'``. The default``None`` is - treated as an empty set and no expensive kwargs are computed. - Supply a list with the keys this event accesses so the runtime - computes them. + Which of the slower-to-compute simulation values the event's trigger + and callback actually use, so the rest are skipped. Valid keys are + ``'state_dot'``, ``'pressure'`` and ``'state_history'``. The default + ``None`` means none of them are computed. List the keys your event + uses to have them provided in ``kwargs``. See Also -------- @@ -306,6 +305,9 @@ def __call__(self, trigger_only=False, callback_only=False, reset=True, **kwargs If True, only execute the callback without evaluating the trigger condition. The exact time function and disable_on function are also called. + reset : bool, optional + If True (default), reset the event's queued commands (via + ``_reset_commands``) before evaluating the trigger. kwargs : dict Keyword arguments passed to the trigger and callback functions. diff --git a/rocketpy/simulation/events/event_builders.py b/rocketpy/simulation/events/event_builders.py index 6eeb56248..0e170255f 100644 --- a/rocketpy/simulation/events/event_builders.py +++ b/rocketpy/simulation/events/event_builders.py @@ -160,7 +160,7 @@ def apogee_event_exact_time_function(state, **_kwargs): ---------- state : array_like Interpolated flight state vector without time. - **kwargs : dict + **_kwargs : dict Event context (unused here). Returns diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index baff48a38..2ae53bc41 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -609,6 +609,10 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements A custom ``scipy.integrate.OdeSolver`` can be passed as well. For more information on the integration methods, see the scipy documentation [1]_. + simulation_mode : str, optional + Degrees of freedom used to integrate the trajectory. Either "6DOF" + (full translational and rotational dynamics) or "3DOF" (point-mass + translation only). Default is "6DOF". custom_events : Event or list[Event], optional Event or list of Events to be monitored during flight. See Event class for more details. Default is None. @@ -2300,28 +2304,234 @@ def attitude_frequency_response(self): @cached_property def static_margin(self): - """Static margin of the rocket.""" + """Static margin of the rocket (linear, zero-airspeed reference).""" return self.rocket.static_margin + def _incidence(self, time): + """Total angle of attack, in radians (unsigned), used as the incidence + for the stability quantities. This is exact for a crosswind in the pitch + plane and a close approximation for combined pitch-and-yaw incidence.""" + return abs(np.radians(self.angle_of_attack.get_value_opt(time))) + @funcify_method("Time (s)", "Stability Margin (c)", "linear", "zero") def stability_margin(self): - """Stability margin of the rocket along the flight, it considers the - variation of the center of pressure position according to the mach - number, as well as the variation of the center of gravity position - according to the propellant mass evolution. + """Pitch-plane stability margin along the flight, in calibers. - Parameters - ---------- - None + This samples the rocket's own :attr:`Rocket.stability_margin` along the + flight's realized angle of attack, Mach number and time. It therefore + accounts for the center-of-mass shift as propellant burns, the variation + of the aerodynamic center with Mach number, and, when a surface is + nonlinear in incidence (e.g. a Galejs body-lift + :class:`rocketpy.GenericSurface`), the migration of the neutral point + with the flight angle of attack. In that last case the margin oscillates + as the angle of attack oscillates; for a rocket built only from the + linear Barrowman surfaces the angle of attack has no effect and this + reduces to the Mach-and-time margin. + + For non-axisymmetric rockets, this represents the pitch plane. + + Returns + ------- + stability : rocketpy.Function + Stability margin in calibers as a function of time. + """ + rocket_margin = self.rocket.stability_margin + margin = np.array( + [ + rocket_margin.get_value_opt( + self._incidence(t), self.mach_number.get_value_opt(t), t + ) + for t in self.time + ] + ) + return np.column_stack((self.time, margin)) + + @funcify_method("Time (s)", "Stability Margin - Yaw (c)", "linear", "zero") + def stability_margin_yaw(self): + """Yaw-plane stability margin along the flight, in calibers. + + Yaw-plane counterpart of :meth:`stability_margin`, sampling the rocket's + :attr:`Rocket.stability_margin_yaw` along the flight. Equals + :meth:`stability_margin` for an axisymmetric rocket. For a + non-axisymmetric rocket (e.g. single-plane canards) it differs, since the + pitch and yaw aerodynamic centers no longer coincide. Returns ------- stability : rocketpy.Function - Stability margin as a rocketpy.Function of time. The stability margin - is defined as the distance between the center of pressure and the - center of gravity, divided by the rocket diameter. + Yaw-plane stability margin in calibers as a function of time. + """ + rocket_margin = self.rocket.stability_margin_yaw + margin = np.array( + [ + rocket_margin.get_value_opt( + self._incidence(t), self.mach_number.get_value_opt(t), t + ) + for t in self.time + ] + ) + return np.column_stack((self.time, margin)) + + # Dynamic stability. The equations behind the two methods below (the lateral + # inertia and the linearized oscillator) are documented in + # docs/technical/aerodynamics/center_of_pressure_and_stability.rst. + def _lateral_inertia(self, dry_lateral_inertia, motor_lateral_inertia): + """Lateral moment of inertia about the instantaneous center of mass, as + an array over ``self.time``. Uses the reduced-mass formulation of the + equations of motion: ``I_L = I_dry + I_motor(t) + mu(t) b^2`` with + ``mu`` the dry/propellant reduced mass and ``b`` the (initial) + dry-mass-to-propellant distance.""" + dry_mass = self.rocket.dry_mass + b = ( + -( + self.rocket.center_of_propellant_position.get_value_opt(0) + - self.rocket.center_of_dry_mass_position + ) + * self.rocket._csys + ) + inertia = np.empty(len(self.time)) + for i, t in enumerate(self.time): + propellant_mass = self.rocket.motor.propellant_mass.get_value_opt(t) + total = propellant_mass + dry_mass + mu = (propellant_mass * dry_mass / total) if total > 0 else 0.0 + inertia[i] = ( + dry_lateral_inertia + motor_lateral_inertia.get_value_opt(t) + mu * b**2 + ) + return inertia + + def _dynamic_stability(self, plane, lateral_inertia): + """Linearized oscillator coefficients for one plane, as arrays over + ``self.time``: corrective moment coefficient ``C1`` (restoring moment + per radian), damping moment coefficient ``C2`` (aerodynamic + jet), + undamped natural frequency ``omega_n`` and damping ratio ``zeta``. + + ``plane`` is ``"pitch"`` or ``"yaw"``; ``lateral_inertia`` is the array + from :meth:`_lateral_inertia`. The restoring moment ``C1`` is built from + the angle-of-attack-aware margin and force-curve slope taken from the + rocket (:meth:`Rocket._neutral_point_margin_slope`) at the flight angle + of attack, so for a rocket that is nonlinear in incidence the natural + frequency and damping ratio move with the angle of attack. The + aerodynamic damping ``C2`` uses each surface's zero-incidence slope (its + second-order incidence dependence is neglected). """ - return [(t, self.rocket.stability_margin(m, t)) for t, m in self.mach_number] + rocket = self.rocket + area = rocket.area + diameter = 2 * rocket.radius + csys = rocket._csys + nozzle_position = rocket.nozzle_position + mass_flow_rate = rocket.motor.total_mass_flow_rate + + corrective = np.empty(len(self.time)) + damping = np.empty(len(self.time)) + for i, t in enumerate(self.time): + mach = self.mach_number.get_value_opt(t) + dynamic_pressure = self.dynamic_pressure.get_value_opt(t) + speed = self.speed.get_value_opt(t) + density = self.density.get_value_opt(t) + center_of_mass = self.rocket.center_of_mass.get_value_opt(t) + + # Angle-of-attack-aware margin and local restoring-force slope, from + # the rocket, at this instant's incidence, Mach and time. + margin, force_slope = rocket._neutral_point_margin_slope( + self._incidence(t), mach, t, plane + ) + + # Corrective moment per radian: q A C_Nalpha (x_cm - x_np). + corrective[i] = dynamic_pressure * area * force_slope * margin * diameter + + # Aerodynamic damping: 0.5 rho V A sum_i (A_i/A) C_Nalpha_i arm_i^2. + damping_aero = 0.0 + for surface, position in self.rocket.aerodynamic_surfaces: + slope = surface.cN_alpha.get_value_opt( + 0.0, 0.0, mach, 0.0, 0.0, 0.0, 0.0 + ) + cp_position = ( + position.z - csys * surface.center_of_pressure_z.get_value_opt(mach) + ) + arm = cp_position - center_of_mass + ref_factor = surface.reference_area / area + damping_aero += ref_factor * slope * arm**2 + damping_aero *= 0.5 * density * speed * area + + # Jet (propulsive) damping: mdot (x_nozzle - x_cm)^2. + damping_jet = ( + abs(mass_flow_rate.get_value_opt(t)) + * (nozzle_position - center_of_mass) ** 2 + ) + damping[i] = damping_aero + damping_jet + + positive_corrective = np.clip(corrective, 0.0, None) + # The damping ratio C2 / (2 sqrt(C1 I)) is ill-conditioned when the + # corrective moment C1 is ~0, i.e. at very low airspeed (on the launch + # rail): the propulsive (jet) part of C2 is already at full strength + # while C1 -> 0, so the ratio blows up even though there is no real + # oscillation yet. Only report it where C1 is a meaningful fraction of + # its flight maximum; elsewhere it is left at 0. The natural frequency + # sqrt(C1 / I) has C1 in the numerator, so it stays well-behaved. + max_corrective = positive_corrective.max(initial=0.0) + meaningful = positive_corrective > 1e-4 * max_corrective + with np.errstate(divide="ignore", invalid="ignore"): + natural_frequency = np.sqrt(positive_corrective / lateral_inertia) + denominator = 2.0 * np.sqrt(positive_corrective * lateral_inertia) + damping_ratio = np.divide( + damping, + denominator, + out=np.zeros_like(damping), + where=meaningful, + ) + return corrective, damping, natural_frequency, damping_ratio + + @funcify_method("Time (s)", "Corrective Moment Coefficient (N m/rad)", "linear") + def corrective_moment_coefficient(self): + """Pitch-plane corrective (restoring) moment coefficient ``C1`` as a + function of time -- the aerodynamic restoring moment per radian of angle + of attack. Positive for a statically stable rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + corrective, _, _, _ = self._dynamic_stability("pitch", inertia) + return np.column_stack((self.time, corrective)) + + @funcify_method("Time (s)", "Damping Moment Coefficient (N m s/rad)", "linear") + def damping_moment_coefficient(self): + """Pitch-plane damping moment coefficient ``C2`` as a function of time -- + the moment opposing the pitch rate, summing aerodynamic damping (from + every surface) and propulsive (jet) damping.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, damping, _, _ = self._dynamic_stability("pitch", inertia) + return np.column_stack((self.time, damping)) + + @funcify_method("Time (s)", "Pitch Natural Frequency (rad/s)", "linear") + def pitch_natural_frequency(self): + """Undamped natural frequency of the pitch oscillation, + ``omega_n = sqrt(C1 / I_L)``, as a function of time (rad/s).""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, natural_frequency, _ = self._dynamic_stability("pitch", inertia) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Pitch Damping Ratio", "linear") + def pitch_damping_ratio(self): + """Damping ratio of the pitch oscillation, + ``zeta = C2 / (2 sqrt(C1 I_L))``, as a function of time. ``zeta < 1`` is + underdamped (oscillatory), ``zeta > 1`` overdamped.""" + inertia = self._lateral_inertia(self.rocket.dry_I_11, self.rocket.motor.I_11) + _, _, _, damping_ratio = self._dynamic_stability("pitch", inertia) + return np.column_stack((self.time, damping_ratio)) + + @funcify_method("Time (s)", "Yaw Natural Frequency (rad/s)", "linear") + def yaw_natural_frequency(self): + """Undamped natural frequency of the yaw oscillation as a function of + time (rad/s). Equals :meth:`pitch_natural_frequency` for an axisymmetric + rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, natural_frequency, _ = self._dynamic_stability("yaw", inertia) + return np.column_stack((self.time, natural_frequency)) + + @funcify_method("Time (s)", "Yaw Damping Ratio", "linear") + def yaw_damping_ratio(self): + """Damping ratio of the yaw oscillation as a function of time. Equals + :meth:`pitch_damping_ratio` for an axisymmetric rocket.""" + inertia = self._lateral_inertia(self.rocket.dry_I_22, self.rocket.motor.I_22) + _, _, _, damping_ratio = self._dynamic_stability("yaw", inertia) + return np.column_stack((self.time, damping_ratio)) # Rail Button Forces diff --git a/rocketpy/simulation/helpers/event_calling.py b/rocketpy/simulation/helpers/event_calling.py index 2af5abc71..5bbb2601e 100644 --- a/rocketpy/simulation/helpers/event_calling.py +++ b/rocketpy/simulation/helpers/event_calling.py @@ -30,11 +30,29 @@ def build_event_kwargs( Parameters ---------- + flight : Flight + Flight instance whose rocket, environment and sensors are exposed. + time : float + Current simulation time. + state : array_like + Current flight state vector. + step_size : float + Size of the current integration step. + phase : FlightPhase + Active flight phase, providing the state derivative. + rollback : bool, optional + Whether this call happens during a rollback; shifts the state-history + window by one extra step. Defaults to False. needs : frozenset of str, optional Union of ``Event.needs`` across all events that will consume the returned dict. Only keys present in ``needs`` are computed for the expensive values: ``state_dot``, ``pressure``, ``state_history``. Defaults to empty (compute nothing expensive). + + Returns + ------- + dict + Keyword arguments consumed by event triggers and callbacks. """ kwargs = { "time": time, @@ -72,10 +90,25 @@ def update_overshootable_event_kwargs( Parameters ---------- + flight : Flight + Flight instance whose environment is used to recompute derived values. + phase : FlightPhase + Active flight phase, providing the state derivative. + event_kwargs : dict + Kwargs dict (from :func:`build_event_kwargs`) updated in place. + interpolated_time : float + Interpolated time of the overshootable node. + interpolated_state : array_like + Interpolated flight state at the node. needs : frozenset of str, optional Union of ``Event.needs`` across all overshootable events at this node. Expensive values are skipped when absent from ``needs``. Defaults to empty (compute nothing expensive). + + Returns + ------- + dict + The updated ``event_kwargs``. """ event_kwargs["time"] = interpolated_time event_kwargs["state"] = interpolated_state diff --git a/rocketpy/simulation/helpers/event_commands.py b/rocketpy/simulation/helpers/event_commands.py index 348e5add5..7d3391eb4 100644 --- a/rocketpy/simulation/helpers/event_commands.py +++ b/rocketpy/simulation/helpers/event_commands.py @@ -28,6 +28,9 @@ def apply_event_commands( Index of the current flight phase. node_index : int Index of the current time node. + command_time : float + Simulation time used to apply the commands when the event does not + provide an exact time. Returns ------- @@ -70,10 +73,6 @@ def apply_rollback_command(flight, time, state): ---------- flight : Flight Flight instance being updated. - event_results : dict - Result payload returned by the event system. - phase : _FlightPhase - Current flight phase. time : float Interpolated simulation time to restore. state : array_like @@ -94,6 +93,8 @@ def apply_disable_commands(_, event_results, node_index, event, phase, time): Parameters ---------- + _ : Flight + Flight instance (unused; accepted for a uniform command signature). event_results : dict Result payload returned by the event system. node_index : int @@ -102,6 +103,8 @@ def apply_disable_commands(_, event_results, node_index, event, phase, time): Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the events are disabled. Returns ------- @@ -144,6 +147,8 @@ def apply_enable_commands(flight, event_results, node_index, event, phase, time) Parameters ---------- + flight : Flight + Flight instance being updated. event_results : dict Result payload returned by the event system. node_index : int @@ -152,6 +157,8 @@ def apply_enable_commands(flight, event_results, node_index, event, phase, time) Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the events are enabled. Returns ------- @@ -260,6 +267,8 @@ def apply_new_phase_or_derivative( Index of the current flight phase. node_index : int Index of the current time node. + time : float + Simulation time at which the new phase or derivative takes effect. Returns ------- @@ -317,6 +326,8 @@ def apply_termination(flight, event_results, phase, phase_index, node_index, tim Index of the current flight phase. node_index : int Index of the current time node. + time : float + Simulation time at which the flight is terminated. Returns ------- @@ -358,12 +369,10 @@ def apply_event_list_updates(flight, event_results, phase, time): Flight instance being updated. event_results : dict Result payload returned by the event system. - node_index : int - Index of the current time node. - event : Event - Event currently being processed. phase : _FlightPhase Current flight phase. + time : float + Simulation time at which the new events are scheduled. Returns ------- diff --git a/rocketpy/simulation/helpers/flight_derivatives.py b/rocketpy/simulation/helpers/flight_derivatives.py index 1426e2cd3..42a4165d4 100644 --- a/rocketpy/simulation/helpers/flight_derivatives.py +++ b/rocketpy/simulation/helpers/flight_derivatives.py @@ -47,6 +47,79 @@ def _compute_drag_7d_inputs( return alpha, beta, stream_mach, reynolds +def _aerodynamic_drag_force( + flight, time, rho, stream_speed, alpha, beta, mach, reynolds, omega +): + """Total rocket axial aerodynamic (drag) force, including air brakes. + + Selects the power-on/power-off drag curve based on the motor burn state, and + then adds (or, when ``override_rocket_drag`` is set, substitutes) the drag of + any deployed air brakes, evaluated through the generic-surface coefficient + machinery. + + Parameters + ---------- + flight : Flight + Flight object providing the rocket. + time : float + Simulation time, used to select the power-on vs power-off drag curve. + rho : float + Air density. + stream_speed : float + Freestream speed magnitude. + alpha, beta, mach, reynolds : float + Standard aerodynamic coefficient inputs at the current state. + omega : tuple of float + Body angular rates ``(omega1, omega2, omega3)``. + + Returns + ------- + float + The axial (body z) aerodynamic drag force. + """ + rocket = flight.rocket + if time < rocket.motor.burn_out_time: + drag_coefficient = rocket.power_on_drag_7d( + alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + ) + else: + drag_coefficient = rocket.power_off_drag_7d( + alpha, beta, mach, reynolds, omega[0], omega[1], omega[2] + ) + drag_force = -0.5 * rho * stream_speed**2 * rocket.area * drag_coefficient + + # Air brakes are drag-only and may override the rocket drag. + for air_brakes in rocket.air_brakes: + if air_brakes.deployment_level > 0: + # Air brakes are a (controllable) generic surface, so feed the + # coefficient the non-dimensional reduced rates, like every other + # generic surface (see GenericSurface.compute_forces_and_moments). + reduced = ( + air_brakes.reference_length / (2 * stream_speed) + if stream_speed > 0 + else 0.0 + ) + air_brakes_cd = air_brakes.cD.get_value_opt( + *air_brakes._coefficient_arguments( + alpha, + beta, + mach, + reynolds, + omega[0] * reduced, + omega[1] * reduced, + omega[2] * reduced, + ) + ) + air_brakes_force = ( + -0.5 * rho * stream_speed**2 * air_brakes.reference_area * air_brakes_cd + ) + if air_brakes.override_rocket_drag: + drag_force = air_brakes_force # Substitutes rocket drag + else: + drag_force += air_brakes_force + return drag_force + + def udot_rail1(flight, t, u, post_processing=False): """Compute the 1-DOF rail-flight state derivative. @@ -282,43 +355,17 @@ def u_dot(flight, t, u, post_processing=False): rho, dynamic_viscosity, ) - if t < flight.rocket.motor.burn_out_time: - drag_coeff = flight.rocket.power_on_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - else: - drag_coeff = flight.rocket.power_off_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - R3 = -0.5 * rho * (free_stream_speed**2) * flight.rocket.area * drag_coeff - for air_brakes in flight.rocket.air_brakes: - if air_brakes.deployment_level > 0: - air_brakes_cd = air_brakes.drag_coefficient.get_value_opt( - air_brakes.deployment_level, free_stream_mach - ) - air_brakes_force = ( - -0.5 - * rho - * (free_stream_speed**2) - * air_brakes.reference_area - * air_brakes_cd - ) - if air_brakes.override_rocket_drag: - R3 = air_brakes_force # Substitutes rocket drag coefficient - else: - R3 += air_brakes_force + R3 = _aerodynamic_drag_force( + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, + (omega1, omega2, omega3), + ) # Off center moment M1 += flight.rocket.cp_eccentricity_y * R3 M2 -= flight.rocket.cp_eccentricity_x * R3 @@ -330,6 +377,8 @@ def u_dot(flight, t, u, post_processing=False): velocity_in_body_frame = Vector([vx_b, vy_b, vz_b]) w = Vector([omega1, omega2, omega3]) for aero_surface, _ in flight.rocket.aerodynamic_surfaces: + if not aero_surface.is_active(t, flight): + continue # Component cp relative to CDM in body frame comp_cp = flight.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame @@ -549,36 +598,26 @@ def u_dot_generalized_3dof(flight, t, u, post_processing=False): dynamic_viscosity, ) - # Drag computation - if t < flight.rocket.motor.burn_out_time: - cd = flight.rocket.power_on_drag_7d( - alpha, beta, mach, reynolds, omega1, omega2, omega3 - ) - else: - cd = flight.rocket.power_off_drag_7d( - alpha, beta, mach, reynolds, omega1, omega2, omega3 - ) - + # Drag computation (rocket body drag + air brakes) R1, R2 = 0, 0 - R3 = -0.5 * rho * free_stream_speed**2 * flight.rocket.area * cd - - for air_brake in flight.rocket.air_brakes: - if air_brake.deployment_level > 0: - ab_cd = air_brake.drag_coefficient.get_value_opt( - air_brake.deployment_level, mach - ) - ab_force = ( - -0.5 * rho * free_stream_speed**2 * air_brake.reference_area * ab_cd - ) - if air_brake.override_rocket_drag: - R3 = ab_force - else: - R3 += ab_force + R3 = _aerodynamic_drag_force( + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, + (omega1, omega2, omega3), + ) # Velocity in body frame vb_body = Kt @ v for surface, _ in flight.rocket.aerodynamic_surfaces: + if not surface.is_active(t, flight): + continue cp = flight.rocket.surfaces_cp_to_cdm[surface] vb_component = vb_body + (w ^ cp) @@ -806,47 +845,25 @@ def u_dot_generalized(flight, t, u, post_processing=False): + flight.rocket.motor.pressure_thrust(pressure), 0, ) - drag_coeff = flight.rocket.power_on_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) else: net_thrust = 0 - drag_coeff = flight.rocket.power_off_drag_7d( - alpha, - beta, - mach, - reynolds, - omega1, - omega2, - omega3, - ) - R3 += -0.5 * rho * (free_stream_speed**2) * flight.rocket.area * drag_coeff - for air_brakes in flight.rocket.air_brakes: - if air_brakes.deployment_level > 0: - air_brakes_cd = air_brakes.drag_coefficient.get_value_opt( - air_brakes.deployment_level, free_stream_mach - ) - air_brakes_force = ( - -0.5 - * rho - * (free_stream_speed**2) - * air_brakes.reference_area - * air_brakes_cd - ) - if air_brakes.override_rocket_drag: - R3 = air_brakes_force # Substitutes rocket drag coefficient - else: - R3 += air_brakes_force + R3 = _aerodynamic_drag_force( + flight, + t, + rho, + free_stream_speed, + alpha, + beta, + mach, + reynolds, + (omega1, omega2, omega3), + ) # Get rocket velocity in body frame velocity_in_body_frame = Kt @ v # Calculate lift and moment for each component of the rocket for aero_surface, _ in flight.rocket.aerodynamic_surfaces: + if not aero_surface.is_active(t, flight): + continue # Component cp relative to CDM in body frame comp_cp = flight.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame diff --git a/rocketpy/simulation/helpers/flight_phase.py b/rocketpy/simulation/helpers/flight_phase.py index fbdd0b746..1a9d3642a 100644 --- a/rocketpy/simulation/helpers/flight_phase.py +++ b/rocketpy/simulation/helpers/flight_phase.py @@ -276,6 +276,9 @@ def add_phase( name : str, optional A descriptive name to identify the phase in logs and debug output. Default is None. + **kwargs + Additional keyword arguments forwarded to the ``_FlightPhase`` + constructor (e.g. ``parachute``). Returns ------- diff --git a/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py b/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py index 35ab9c1a2..600325391 100644 --- a/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py +++ b/tests/fixtures/generic_surfaces/linear_generic_surfaces_fixtures.py @@ -13,7 +13,7 @@ def filename_valid_coeff_linear_generic_surface(tmpdir_factory): { "alpha": [0, 1, 2, 3, 0.1], "mach": [3, 2, 1, 0, 0.2], - "cL_0": [4, 2, 2, 4, 5], + "cN_0": [4, 2, 2, 4, 5], } ).to_csv(filename, index=False) @@ -24,7 +24,7 @@ def filename_valid_coeff_linear_generic_surface(tmpdir_factory): params=( { "alpha": [0, 1, 2, 3, 0.1], - "cL_0": [4, 2, 2, 4, 5], + "cN_0": [4, 2, 2, 4, 5], "mach": [3, 2, 1, 0, 0.2], }, { diff --git a/tests/fixtures/rockets/rocket_fixtures.py b/tests/fixtures/rockets/rocket_fixtures.py index 9cb3caa3c..ac2e6c418 100644 --- a/tests/fixtures/rockets/rocket_fixtures.py +++ b/tests/fixtures/rockets/rocket_fixtures.py @@ -1,7 +1,258 @@ +import math + import numpy as np import pytest -from rocketpy import Rocket +from rocketpy import Function, GenericSurface, LinearGenericSurface, Rocket +from rocketpy.mathutils.vector_matrix import Vector + +# TODO: review note: gotta test execution speed of changes in this branch + + +def _linear_surface_from_barrowman(surface): + """Build a LinearGenericSurface that reproduces a Barrowman surface's aero. + + Reads the coefficient curves off a standard (Barrowman) aerodynamic surface + -- its normal-force-curve slope ``clalpha`` as a function of Mach and, for fin + sets, its roll cant and damping coefficients -- and packs them into the + body-frame coefficient derivatives of an equivalent + :class:`LinearGenericSurface`. The pitch- and yaw-plane slopes follow the + Barrowman sign convention (``cN_alpha = clalpha`` and ``cY_beta = -clalpha``). + + The returned surface applies its force at its own origin (center of pressure + ``(0, 0, 0)``); the caller is expected to add it at the surface's center of + pressure station (``barrowman_station - clalpha_cp``) so that its force + lands, and its static margin reads, at the same point as the Barrowman + surface. Its ``cpz`` (the distance from the surface origin to its center of + pressure) is exposed on the returned object as ``barrowman_cpz`` to make that + offset easy for the caller. + + Parameters + ---------- + surface : rocketpy.NoseCone, rocketpy.Tail or rocketpy fin set + A standard Barrowman aerodynamic surface to copy the aero curves from. + + Returns + ------- + rocketpy.LinearGenericSurface + A linear generic surface with the same normal force and (for fins) roll + behaviour as ``surface``, carrying the source surface's ``cpz`` as + ``barrowman_cpz``. + """ + clalpha = surface.clalpha # normal-force-curve slope, a Function of Mach + coefficients = { + "cN_alpha": clalpha, + "cY_beta": lambda mach: -clalpha.get_value_opt(mach), + } + # Fin sets carry roll coefficients: cant forcing (zero when uncanted) and + # roll-rate damping. Other surfaces have no roll_parameters. + if getattr(surface, "roll_parameters", None) is not None: + coefficients["cl_0"] = surface.cl_0 + coefficients["cl_p"] = surface.cl_p + linear_surface = LinearGenericSurface( + reference_area=surface.reference_area, + reference_length=surface.reference_length, + coefficients=coefficients, + center_of_pressure=(0, 0, 0), + name=f"{surface.name}_linear", + ) + linear_surface.barrowman_cpz = surface.cpz + return linear_surface + + +def _generic_surface_from_barrowman(surface): + """Build a :class:`GenericSurface` that reproduces a Barrowman surface's aero. + + Same idea as :func:`_linear_surface_from_barrowman`, but expressed as a plain + :class:`GenericSurface`: instead of coefficient *slopes*, the total body-frame + coefficients are given directly as the linear expansion of the Barrowman + curves. The normal force is ``cN = clalpha(mach) * alpha``, the side force is + ``cY = -clalpha(mach) * beta`` and (for fins) the roll moment is + ``cl = cl_0(mach) + cl_p(mach) * roll_rate``. Because these are the same + functions the linear surface builds internally, the two surfaces produce + identical forces and moments; this fixture exercises the plain generic-surface + code path. + + The surface applies its force at its own origin (center of pressure + ``(0, 0, 0)``); the source surface's ``cpz`` is exposed as ``barrowman_cpz`` + so the caller can add it at the Barrowman center-of-pressure station, exactly + as for the linear surface. + + Parameters + ---------- + surface : rocketpy.NoseCone, rocketpy.Tail or rocketpy fin set + A standard Barrowman aerodynamic surface to copy the aero curves from. + + Returns + ------- + rocketpy.GenericSurface + A generic surface with the same normal force and (for fins) roll + behaviour as ``surface``, carrying the source surface's ``cpz`` as + ``barrowman_cpz``. + """ + clalpha = surface.clalpha # normal-force-curve slope, a Function of Mach + + # Coefficient callables must accept the full 7-variable argument tuple. The + # slope ``clalpha`` is a Function of Mach only; the fin roll coefficients + # ``cl_0``/``cl_p`` are AeroCoefficients evaluated over the full tuple. + def make_normal(slope): + def cN(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + return slope.get_value_opt(mach) * alpha + + return cN + + def make_side(slope): + def cY(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + return -slope.get_value_opt(mach) * beta + + return cY + + coefficients = {"cN": make_normal(clalpha), "cY": make_side(clalpha)} + if getattr(surface, "roll_parameters", None) is not None: + cl_0, cl_p = surface.cl_0, surface.cl_p + + def make_roll(forcing, damping): + def cl(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + args = (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) + cant = forcing.get_value_opt(*args) + return cant + damping.get_value_opt(*args) * roll_rate + + return cl + + coefficients["cl"] = make_roll(cl_0, cl_p) + + generic_surface = GenericSurface( + reference_area=surface.reference_area, + reference_length=surface.reference_length, + coefficients=coefficients, + center_of_pressure=(0, 0, 0), + name=f"{surface.name}_generic", + ) + generic_surface.barrowman_cpz = surface.cpz + return generic_surface + + +# The three Barrowman surfaces of the Calisto rocket and the axial stations +# (in the tail_to_nose user coordinate system) at which their origins sit. +_CALISTO_SURFACE_STATIONS = ( + ("nose", 1.160), + ("tail", -1.313), + ("fins", -1.168), +) + + +def _full_body_force_and_moment(rocket, alpha, beta, mach, omega, speed=1.0): + """Total body-frame force and moment about the dry center of mass, summed + over every aerodynamic surface at a flow state and set of body rates. + + Reproduces the flight integrator's per-surface computation: each surface is + fed its own local stream velocity, which includes the ``omega ^ cp`` + lever-arm term, so the sum captures the pitch/yaw damping the distributed + surfaces produce. Evaluated at unit air density; the result scales out of any + coefficient ratio. Used to lump the distributed surfaces into a single + full-body coefficient set. + """ + stream_direction = Vector([-math.tan(beta), -math.tan(alpha), -1.0]) + stream_at_cdm = stream_direction / abs(stream_direction) * speed + body_rates = Vector(list(omega)) + density = Function(1.0) + dynamic_viscosity = Function(1e30) # vanishing-Reynolds limit + speed_of_sound = speed / mach if mach > 0 else 1e30 + totals = np.zeros(6) + for surface, _ in rocket.aerodynamic_surfaces: + cp = rocket.surfaces_cp_to_cdm[surface] + comp_stream = stream_at_cdm - (body_rates ^ cp) + comp_speed = abs(comp_stream) + forces = surface.compute_forces_and_moments( + comp_stream, + comp_speed, + comp_speed / speed_of_sound, + 1.0, + cp, + body_rates, + density, + dynamic_viscosity, + 0.0, + ) + totals += np.array(forces) + return totals + + +def _full_body_derivatives(reference_rocket): + """Lump a rocket's distributed aerodynamic surfaces into one full-body + stability-derivative set (about the dry center of mass), as named slopes for + a :class:`LinearGenericSurface`. + + A single surface placed at the center of mass has no lever arm of its own, so + the pitch/yaw damping the distributed fore-and-aft surfaces produce has to be + carried explicitly by rate derivatives. Every derivative is measured by + finite-differencing the distributed aerodynamics + (:func:`_full_body_force_and_moment`) and tabulated against Mach: the static + slopes ``cN_alpha``/``cm_alpha`` (pitch) and ``cY_beta``/``cn_beta`` (yaw), + the rate-damping slopes ``cN_q``/``cm_q`` and ``cY_r``/``cn_r``, and the fin + roll damping ``cl_p``. Axial (drag) is left out so the rocket's own drag curve + still applies. Handed to a linear surface, these reproduce the reference + rocket's aerodynamics as a lumped model -- the way a measured or CFD + stability-derivative set is used. + """ + reference_length = 2 * reference_rocket.radius + area = reference_rocket.area + machs = np.arange(0.0, 3.01, 0.02) + step = 1e-5 + dyn_area = 0.5 * area # unit speed, unit density + dyn_area_length = dyn_area * reference_length + + def coefficients_at(alpha, beta, red_pitch, red_yaw, red_roll, mach): + rate_factor = 2.0 / reference_length # reduced rate -> omega at unit speed + omega = (red_pitch * rate_factor, red_yaw * rate_factor, red_roll * rate_factor) + r1, r2, r3, m1, m2, m3 = _full_body_force_and_moment( + reference_rocket, alpha, beta, mach, omega + ) + return { + "cN": -r2 / dyn_area, + "cY": r1 / dyn_area, + "cm": m1 / dyn_area_length, + "cn": m2 / dyn_area_length, + "cl": m3 / dyn_area_length, + } + + def slope(field, coeff): + values = [] + for mach in machs: + state = { + "alpha": 0.0, + "beta": 0.0, + "red_pitch": 0.0, + "red_yaw": 0.0, + "red_roll": 0.0, + } + high = dict(state, **{field: step}) + low = dict(state, **{field: -step}) + c_high = coefficients_at(mach=mach, **high) + c_low = coefficients_at(mach=mach, **low) + values.append((c_high[coeff] - c_low[coeff]) / (2 * step)) + return Function( + np.column_stack([machs, values]), + "Mach", + f"{coeff}_slope", + interpolation="akima", + extrapolation="constant", + ) + + # Named slopes consumed directly by LinearGenericSurface: the rate names + # ``cN_q``/``cm_q`` multiply the reduced pitch rate, ``cY_r``/``cn_r`` the + # reduced yaw rate and ``cl_p`` the reduced roll rate. + return { + "cN_alpha": slope("alpha", "cN"), + "cm_alpha": slope("alpha", "cm"), + "cN_q": slope("red_pitch", "cN"), + "cm_q": slope("red_pitch", "cm"), + "cY_beta": slope("beta", "cY"), + "cn_beta": slope("beta", "cn"), + "cY_r": slope("red_yaw", "cY"), + "cn_r": slope("red_yaw", "cn"), + "cl_p": slope("red_roll", "cl"), + } @pytest.fixture @@ -184,6 +435,245 @@ def calisto_robust( return calisto +@pytest.fixture +def calisto_linear_generic( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto built entirely from LinearGenericSurfaces instead of Barrowman ones. + + This is the same rocket as ``calisto_robust`` -- same body, motor, rail + buttons and parachutes at the same stations -- but its nose cone, tail and + fin set are each replaced by a body-frame ``LinearGenericSurface``. The + coefficient curves of those linear surfaces are extracted from the matching + standard (Barrowman) surfaces: each linear surface reuses the standard + surface's normal-force-curve slope, center of pressure and (for the fins) + roll damping. Because the aero data is identical, this rocket's flight + closely tracks the standard Calisto's while exercising the linear + generic-surface aerodynamic path -- the one whose forces and moments are + built directly in the body frame from the coefficient derivatives, with no + wind-to-body rotation. It is a standalone rocket (it does not reuse the + shared ``calisto`` fixture), so a test may build both it and ``calisto_robust`` + and compare their flights. + + Parameters + ---------- + cesaroni_m1670 : rocketpy.SolidMotor + The Calisto motor. This is a pytest fixture too. + calisto_nose_cone : rocketpy.NoseCone + The standard nose cone whose aero curves are copied. This is a pytest + fixture too. + calisto_tail : rocketpy.Tail + The standard boat tail whose aero curves are copied. This is a pytest + fixture too. + calisto_trapezoidal_fins : rocketpy.TrapezoidalFins + The standard fin set whose aero curves are copied. This is a pytest + fixture too. + calisto_main_chute : rocketpy.Parachute + The main parachute of the Calisto rocket. This is a pytest fixture too. + calisto_drogue_chute : rocketpy.Parachute + The drogue parachute of the Calisto rocket. This is a pytest fixture too. + + Returns + ------- + rocketpy.Rocket + The Calisto rocket whose nose cone, tail and fins are all + LinearGenericSurfaces. + """ + calisto = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + calisto.add_motor(cesaroni_m1670, position=-1.373) + # Replace each Barrowman surface with an equivalent LinearGenericSurface. A + # Barrowman surface is placed by its origin and carries its center of + # pressure at ``cpz`` aft of that origin; a generic surface applies its force + # at its own origin. So each linear surface is added at the Barrowman + # surface's center-of-pressure station (station - cpz, with tail_to_nose + # csys = +1), which lands its force -- and its static margin -- at the same + # point as calisto_robust. + for surface, station in ( + (calisto_nose_cone, 1.160), + (calisto_tail, -1.313), + (calisto_trapezoidal_fins, -1.168), + ): + linear_surface = _linear_surface_from_barrowman(surface) + calisto.add_surfaces(linear_surface, station - linear_surface.barrowman_cpz) + calisto.set_rail_buttons( + upper_button_position=0.082, + lower_button_position=-0.618, + angular_position=0, + ) + calisto.parachutes.append(calisto_main_chute) + calisto.parachutes.append(calisto_drogue_chute) + return calisto + + +def _bare_calisto(motor): + """The Calisto body and motor with no aerodynamic surfaces, parachutes or + rail buttons yet -- the shared starting point for the generic-surface + Calistos below.""" + calisto = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + calisto.add_motor(motor, position=-1.373) + return calisto + + +@pytest.fixture +def calisto_generic( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto built entirely from :class:`GenericSurface` objects. + + The exact counterpart of ``calisto_linear_generic``, but each surface is a + plain :class:`GenericSurface` carrying the *total* body-frame coefficients + (``cN = clalpha * alpha`` ...) instead of a :class:`LinearGenericSurface` + carrying the coefficient slopes. The two express the same aerodynamics + through different code paths, so this rocket's flight should match both + ``calisto_robust`` and ``calisto_linear_generic``. It is standalone (it does + not reuse the shared ``calisto`` fixture), so a test may build it alongside + the others and compare their flights. + + Parameters + ---------- + cesaroni_m1670 : rocketpy.SolidMotor + The Calisto motor. This is a pytest fixture too. + calisto_nose_cone : rocketpy.NoseCone + The standard nose cone whose aero curves are copied. This is a pytest + fixture too. + calisto_tail : rocketpy.Tail + The standard boat tail whose aero curves are copied. This is a pytest + fixture too. + calisto_trapezoidal_fins : rocketpy.TrapezoidalFins + The standard fin set whose aero curves are copied. This is a pytest + fixture too. + calisto_main_chute : rocketpy.Parachute + The main parachute of the Calisto rocket. This is a pytest fixture too. + calisto_drogue_chute : rocketpy.Parachute + The drogue parachute of the Calisto rocket. This is a pytest fixture too. + + Returns + ------- + rocketpy.Rocket + The Calisto rocket whose nose cone, tail and fins are all + GenericSurfaces. + """ + calisto = _bare_calisto(cesaroni_m1670) + barrowman_surfaces = { + "nose": calisto_nose_cone, + "tail": calisto_tail, + "fins": calisto_trapezoidal_fins, + } + for label, station in _CALISTO_SURFACE_STATIONS: + generic_surface = _generic_surface_from_barrowman(barrowman_surfaces[label]) + calisto.add_surfaces(generic_surface, station - generic_surface.barrowman_cpz) + calisto.set_rail_buttons( + upper_button_position=0.082, + lower_button_position=-0.618, + angular_position=0, + ) + calisto.parachutes.append(calisto_main_chute) + calisto.parachutes.append(calisto_drogue_chute) + return calisto + + +@pytest.fixture +def calisto_full_aerodynamics( + cesaroni_m1670, + calisto_nose_cone, + calisto_tail, + calisto_trapezoidal_fins, + calisto_main_chute, + calisto_drogue_chute, +): + """Calisto flown from a single full-body stability-derivative set. + + Instead of modeling each surface, this rocket carries one + :class:`LinearGenericSurface` added through + :meth:`Rocket.add_full_body_aerodynamics` that lumps the whole rocket into a + set of named coefficient slopes referenced to the dry center of mass. The + slopes are extracted from an equivalent ``calisto_generic`` rocket + (:func:`_full_body_derivatives`): the static normal-force and + pitch/yaw-moment slopes plus the pitch/yaw rate damping the distributed + surfaces produce through their lever arms and the fin roll damping. The + built-in drag curve is kept (no drag coefficient is supplied), exactly as for + the modeled Calisto, so this rocket's flight should match ``calisto_robust``, + ``calisto_linear_generic`` and ``calisto_generic``. + + Parameters + ---------- + cesaroni_m1670 : rocketpy.SolidMotor + The Calisto motor. This is a pytest fixture too. + calisto_nose_cone : rocketpy.NoseCone + The standard nose cone whose aero curves are lumped in. This is a pytest + fixture too. + calisto_tail : rocketpy.Tail + The standard boat tail whose aero curves are lumped in. This is a pytest + fixture too. + calisto_trapezoidal_fins : rocketpy.TrapezoidalFins + The standard fin set whose aero curves are lumped in. This is a pytest + fixture too. + calisto_main_chute : rocketpy.Parachute + The main parachute of the Calisto rocket. This is a pytest fixture too. + calisto_drogue_chute : rocketpy.Parachute + The drogue parachute of the Calisto rocket. This is a pytest fixture too. + + Returns + ------- + rocketpy.Rocket + The Calisto rocket flown from a single full-body derivative set. + """ + # Distributed reference rocket (same motor, so the same dry center of mass) + # whose lumped derivatives feed the single full-body surface. + reference = _bare_calisto(cesaroni_m1670) + barrowman_surfaces = { + "nose": calisto_nose_cone, + "tail": calisto_tail, + "fins": calisto_trapezoidal_fins, + } + for label, station in _CALISTO_SURFACE_STATIONS: + generic_surface = _generic_surface_from_barrowman(barrowman_surfaces[label]) + reference.add_surfaces(generic_surface, station - generic_surface.barrowman_cpz) + + calisto = _bare_calisto(cesaroni_m1670) + full_body_surface = LinearGenericSurface( + reference_area=calisto.area, + reference_length=2 * calisto.radius, + coefficients=_full_body_derivatives(reference), + name="Calisto full body aerodynamics", + ) + calisto.add_full_body_aerodynamics(full_body_surface) + calisto.set_rail_buttons( + upper_button_position=0.082, + lower_button_position=-0.618, + angular_position=0, + ) + calisto.parachutes.append(calisto_main_chute) + calisto.parachutes.append(calisto_drogue_chute) + return calisto + + @pytest.fixture def calisto_nose_to_tail_robust( calisto_nose_to_tail, diff --git a/tests/unit/mathutils/test_function.py b/tests/unit/mathutils/test_function.py index 93c439def..e2853f1df 100644 --- a/tests/unit/mathutils/test_function.py +++ b/tests/unit/mathutils/test_function.py @@ -1505,3 +1505,90 @@ def test_regular_grid_invalid_source_raises(bad_source, match): outputs=["z"], interpolation="regular_grid", ) + + +def test_regular_grid_sorts_unsorted_axes(): + """A descending (or shuffled) axis is sorted, with the grid data reordered + to match, so the resulting Function matches the equivalent ascending grid.""" + x_axis = np.array([0.0, 1.0, 2.0]) + y_axis = np.array([0.0, 1.0, 2.0]) + x_grid, y_grid = np.meshgrid(x_axis, y_axis, indexing="ij") + data = 2.0 * x_grid + 3.0 * y_grid + + ascending = Function( + ([x_axis, y_axis], data), interpolation="regular_grid", extrapolation="natural" + ) + # First axis descending, data reversed along that axis to describe the SAME + # surface. It must be normalized to ascending and yield identical values. + descending = Function( + ([x_axis[::-1], y_axis], data[::-1, :]), + interpolation="regular_grid", + extrapolation="natural", + ) + assert np.all(np.diff(descending._grid_axes[0]) > 0) + assert np.isclose(descending(1.5, 0.5), ascending(1.5, 0.5)) + + +def test_regular_grid_repeated_axis_coordinate_raises(): + """An axis with duplicate coordinates cannot form a grid and raises a clear + error instead of a cryptic SciPy failure.""" + with pytest.raises(ValueError, match="repeated coordinates"): + Function( + ([np.array([0.0, 1.0, 1.0]), np.array([0.0, 1.0, 2.0])], np.ones((3, 3))), + interpolation="regular_grid", + ) + + +def test_from_regular_grid_csv_falls_back_when_too_few_points(tmp_path): + """A smooth grid method (e.g. cubic) needs enough points per axis; when the + grid is too coarse, from_regular_grid_csv warns and falls back to linear.""" + filename = tmp_path / "coarse_grid.csv" + # 2x2 grid: too few points for cubic (which needs 4 per axis). + filename.write_text("mach,alpha,cL\n0,0,0\n0,1,1\n1,0,1\n1,1,2\n", encoding="utf-8") + with pytest.warns(UserWarning, match="falling back to 'linear'"): + func = Function.from_regular_grid_csv( + str(filename), + ["mach", "alpha"], + "cL", + extrapolation="constant", + interpolation="cubic", + ) + assert func is not None + assert getattr(func, "_grid_method", "linear") == "linear" + + +def test_regular_grid_caches_domain_bounds(bilinear_grid_2d): + """The N-D hot path caches per-dimension domain bounds at source time.""" + assert np.allclose(bilinear_grid_2d._domain_min, [0.0, 0.0]) + assert np.allclose(bilinear_grid_2d._domain_max, [2.0, 2.0]) + + +def test_regular_grid_dict_round_trip(bilinear_grid_2d): + """A regular_grid Function round-trips through to_dict/from_dict, rebuilding + from the (axes, grid_data) structure rather than the flat scatter source.""" + restored = Function.from_dict(bilinear_grid_2d.to_dict()) + + assert restored.get_interpolation_method() == "regular_grid" + assert restored.get_domain_dim() == 2 + for x, y in [(0.5, 1.5), (1.25, 0.75), (2.0, 2.0)]: + assert np.isclose(restored(x, y), bilinear_grid_2d(x, y)) + + +def test_regular_grid_dict_round_trip_preserves_method(tmp_path): + """A non-default grid method (e.g. pchip) survives to_dict/from_dict.""" + filename = tmp_path / "grid.csv" + rows = ["x,y,z"] + for x in (0, 1, 2, 3): + for y in (0, 1, 2, 3): + rows.append(f"{x},{y},{x + 10 * y**2}") + filename.write_text("\n".join(rows) + "\n", encoding="utf-8") + + original = Function.from_regular_grid_csv( + str(filename), ["x", "y"], "z", extrapolation="constant", interpolation="akima" + ) + assert original._grid_method == "pchip" + + restored = Function.from_dict(original.to_dict()) + assert restored.get_interpolation_method() == "regular_grid" + assert getattr(restored, "_grid_method", "linear") == "pchip" + assert np.isclose(restored(0.5, 1.5), original(0.5, 1.5)) diff --git a/tests/unit/rocket/aero_surface/test_aero_coefficient.py b/tests/unit/rocket/aero_surface/test_aero_coefficient.py new file mode 100644 index 000000000..e795c02ac --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_aero_coefficient.py @@ -0,0 +1,213 @@ +"""Unit tests for the AeroCoefficient minimal-dimension coefficient store.""" + +import pytest + +from rocketpy import Function +from rocketpy.rocket.aero_surface.aero_coefficient import ( + AeroCoefficient, + build_independent_vars, +) + +IV = ["alpha", "beta", "mach", "reynolds", "pitch_rate", "yaw_rate", "roll_rate"] + + +# -- Construction & evaluation ------------------------------------------------ + + +def test_constant_coefficient_is_zero_flagged(): + zero = AeroCoefficient(0, (), name="cD") + assert zero.is_zero is True + assert zero.is_zero_coefficient is True + assert zero(0.1, 0.2, 0.3, 0, 0, 0, 0) == 0.0 + + const = AeroCoefficient(0.7, (), name="cD") + assert const.is_zero is False + assert const(1, 2, 3, 4, 5, 6, 7) == 0.7 + assert const.get_value_opt(1, 2, 3, 4, 5, 6, 7) == 0.7 + + +def test_call_is_get_value_opt(): + # __call__ is aliased to get_value_opt; both must behave identically. + assert AeroCoefficient.__call__ is AeroCoefficient.get_value_opt + + +def test_mach_only_coefficient_maps_arguments(): + coeff = AeroCoefficient(lambda mach: 2 * mach, ("mach",), name="cL_alpha") + assert coeff.depends_on == ("mach",) + # Only the mach argument (index 2) should be used. + assert coeff(99, 99, 0.3, 99, 99, 99, 99) == pytest.approx(0.6) + assert coeff.get_value_opt(99, 99, 0.3, 99, 99, 99, 99) == pytest.approx(0.6) + + +def test_function_source_stored_directly(): + f = Function(lambda mach: mach**2, "mach", "cD") + coeff = AeroCoefficient(f, ("mach",), name="cD") + assert coeff.function is f + assert coeff(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(0.25) + + +def test_depends_on_preserves_source_argument_order(): + # depends_on order must match the source's positional order, even when it + # differs from the independent-variable order (e.g. shuffled CSV columns). + coeff = AeroCoefficient( + lambda mach, alpha: 10 * mach + alpha, ("mach", "alpha"), name="cL" + ) + # full args: alpha=1 (idx0), mach=2 (idx2) -> source(mach=2, alpha=1) = 21 + assert coeff(1, 0, 2, 0, 0, 0, 0) == pytest.approx(21) + + +def test_unknown_dependency_raises(): + with pytest.raises(ValueError, match="unknown variable"): + AeroCoefficient(lambda x: x, ("bogus",), name="cL") + + +def test_dom_dim_matches_full_arity(): + coeff = AeroCoefficient(0, (), name="cD") + assert coeff.__dom_dim__ == len(IV) + + +def test_repr_constant_and_function(): + assert "0.5" in repr(AeroCoefficient(0.5, (), name="cD")) + function_repr = repr(AeroCoefficient(lambda mach: mach, ("mach",), name="cL")) + assert "depends_on" in function_repr and "mach" in function_repr + + +# -- Independent-variable axes (control) -------------------------------------- + + +def test_build_independent_vars_base_and_controls(): + assert build_independent_vars() == IV + assert build_independent_vars(control_variables=("defl",)) == IV + ["defl"] + + +def test_control_variable_axis_is_appended(): + coeff = AeroCoefficient( + lambda deflection: 2 * deflection, + ("deflection",), + control_variables=("deflection",), + name="cL", + ) + assert coeff.independent_vars[-1] == "deflection" + assert coeff(0, 0, 0, 0, 0, 0, 0, 4) == pytest.approx(8) + + +# -- constructor inference: scalar ------------------------------------------------------- + + +def test_from_input_scalar(): + coeff = AeroCoefficient(0, name="cm") + assert coeff.is_zero is True + + +def test_from_input_non_numeric_raises(): + with pytest.raises(TypeError, match="must be a number"): + AeroCoefficient(object(), name="cD") + + +# -- constructor inference: callable ----------------------------------------------------- + + +def test_from_input_full_arity_callable(): + coeff = AeroCoefficient(lambda a, b, m, r, p, q, rr: a + m, name="cL") + assert coeff.depends_on == tuple(IV) + assert coeff(0.1, 0, 0.3, 0, 0, 0, 0) == pytest.approx(0.4) + + +def test_from_input_named_subset_callable(): + coeff = AeroCoefficient(lambda alpha, mach: alpha * mach, name="cL") + assert coeff.depends_on == ("alpha", "mach") + assert coeff(2, 0, 3, 0, 0, 0, 0) == pytest.approx(6) + + +def test_from_input_rejects_unmappable_callable(): + with pytest.raises(ValueError, match="callable must accept"): + AeroCoefficient(lambda x, y, z: x, name="cL") + + +# -- constructor inference: Function ----------------------------------------------------- + + +def test_from_input_full_dim_function(): + f = Function(lambda a, b, m, r, p, q, rr: a + m, IV, "cL") + coeff = AeroCoefficient(f, name="cL") + assert coeff.depends_on == tuple(IV) + assert coeff(0.1, 0, 0.3, 0, 0, 0, 0) == pytest.approx(0.4) + + +def test_from_input_1d_function_infers_mach(): + f = Function(lambda mach: mach**2, "Mach", "cD") + coeff = AeroCoefficient(f, name="cD") + assert coeff.depends_on == ("mach",) + assert coeff(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(0.25) + + +def test_from_input_function_with_bad_dimension_raises(): + f = Function(lambda a, b: a + b, ["alpha", "beta"], "cL") + with pytest.raises(ValueError, match="must have 7 input arguments"): + AeroCoefficient(f, name="cL") + + +# -- constructor inference: CSV path ----------------------------------------------------- + + +def test_from_input_csv_loads_at_minimal_dimension(tmp_path): + csv_file = tmp_path / "coeffs.csv" + csv_file.write_text("mach,cD\n0.0,0.0\n1.0,3.0\n2.0,6.0\n") + + coeff = AeroCoefficient(str(csv_file), name="cD") + assert coeff.depends_on == ("mach",) + assert coeff(0, 0, 2, 0, 0, 0, 0) == pytest.approx(6) + + +def test_load_csv_rejects_unknown_column(tmp_path): + csv_file = tmp_path / "coeffs.csv" + csv_file.write_text("bogus,cD\n0.0,0.0\n1.0,3.0\n") + + with pytest.raises(ValueError, match="Invalid independent variable"): + AeroCoefficient(str(csv_file), name="cD") + + +# -- constructor inference: AeroCoefficient round trip ----------------------------------- + + +def test_roundtrip_callable_passthrough(): + original = AeroCoefficient(lambda alpha, mach: alpha + mach, name="cL") + rebuilt = AeroCoefficient(original, name="cL") + assert rebuilt.depends_on == original.depends_on + assert rebuilt(0.5, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + original(0.5, 0, 0.3, 0, 0, 0, 0) + ) + + +def test_roundtrip_constant_passthrough(): + original = AeroCoefficient(0.9, name="cD") + rebuilt = AeroCoefficient(original, name="cD") + assert rebuilt._constant == pytest.approx(0.9) + assert rebuilt(1, 2, 3, 4, 5, 6, 7) == pytest.approx(0.9) + + +def test_to_dict_from_dict_preserves_axes(): + original = AeroCoefficient( + lambda deflection: deflection, + ("deflection",), + control_variables=("deflection",), + name="cL", + ) + rebuilt = AeroCoefficient.from_dict(original.to_dict()) + assert rebuilt.control_variables == ("deflection",) + assert rebuilt.independent_vars == original.independent_vars + + +# -- _infer_single_var fallbacks ---------------------------------------------- + + +def test_infer_single_var_unmatched_label_defaults_to_first(): + f = Function(lambda gamma: gamma, "gamma", "cD") + assert AeroCoefficient._infer_single_var(f, IV) == IV[0] + + +def test_infer_single_var_missing_inputs_defaults_to_first(): + class NoInputs: + pass + + assert AeroCoefficient._infer_single_var(NoInputs(), IV) == IV[0] diff --git a/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py new file mode 100644 index 000000000..ebc00c72a --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_barrowman_generic_equivalence.py @@ -0,0 +1,164 @@ +"""Regression tests for the GenericSurface-rooted aerodynamic hierarchy. + +After the refactor, every aerodynamic surface (Barrowman or generic) exposes +the coefficient derivatives ``cN_alpha``/``cY_beta`` and the +``center_of_pressure_z`` accessor used by the rocket's center-of-pressure / +stability-margin computation. (Barrowman surfaces still compute their flight +forces with the classic geometric method; the derivatives feed only the +stability diagnostics.) These tests pin the properties the refactor guarantees. +""" + +import warnings + +import numpy as np +import pytest + +from rocketpy import LinearGenericSurface, NoseCone, Tail, TrapezoidalFins + + +def test_barrowman_derived_cp_matches_geometric_cp(): + """The derived ``center_of_pressure_z`` diagnostic must reproduce the + geometric cp of each Barrowman surface.""" + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + tail = Tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, rocket_radius=0.0635 + ) + fins = TrapezoidalFins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, rocket_radius=0.0635 + ) + + for surface in (nose, tail, fins): + for mach in (0.0, 0.5, 0.9): + assert ( + pytest.approx( + surface.center_of_pressure_z.get_value_opt(mach), rel=1e-6, abs=1e-9 + ) + == surface.cpz + ) + # The normal-force slope derivative must equal the Barrowman clalpha. + assert pytest.approx( + nose.cN_alpha.get_value_opt(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + ) == nose.clalpha.get_value_opt(0.0) + + +def test_generic_surface_contributes_to_static_margin(calisto_motorless): + """A generic surface must now contribute to the rocket center of pressure + (previously generic surfaces were skipped, breaking stability margin).""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + + cp_without_generic = rocket.aerodynamic_center.get_value_opt(0.2) + + # A lifting generic surface placed aft should move the cp aft (more stable). + generic = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cN_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + }, + name="generic_fins", + ) + rocket.add_surfaces(generic, positions=-1.0) + + cp_with_generic = rocket.aerodynamic_center.get_value_opt(0.2) + assert cp_with_generic != pytest.approx(cp_without_generic) + assert np.isfinite(cp_with_generic) + + +def test_zero_lift_surface_does_not_break_cp(calisto_motorless): + """A surface with no normal-force slope must drop out of the lift-weighted + cp average without producing NaNs.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + cp_reference = rocket.aerodynamic_center.get_value_opt(0.2) + + drag_only = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={"cA_0": lambda a, b, m, re, p, q, r: 0.5}, + name="drag_only", + ) + rocket.add_surfaces(drag_only, positions=-1.0) + + cp_after = rocket.aerodynamic_center.get_value_opt(0.2) + assert np.isfinite(cp_after) + assert cp_after == pytest.approx(cp_reference) + + +def test_axisymmetric_rocket_pitch_equals_yaw_margin(calisto_motorless): + """An axisymmetric rocket must have identical pitch and yaw margins and + must not raise the asymmetry warning.""" + rocket = calisto_motorless + with warnings.catch_warnings(): + warnings.simplefilter("error") # asymmetry warning would fail the test + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, span=0.100, root_chord=0.120, tip_chord=0.040, position=-1.04 + ) + rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194 + ) + + for mach in (0.0, 0.5, 0.9): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach), abs=1e-9 + ) + assert rocket.static_margin.get_value_opt(0) == pytest.approx( + rocket.static_margin_yaw.get_value_opt(0), abs=1e-9 + ) + + +def test_non_axisymmetric_rocket_splits_margins_and_warns(calisto_motorless): + """A non-axisymmetric generic surface must yield distinct pitch/yaw margins + and raise a warning that the scalar margin describes the pitch plane only. + + The advisory is emitted lazily -- on the first evaluation of the aerodynamic + center, not eagerly at add time -- so adding the surface itself is silent.""" + rocket = calisto_motorless + rocket.add_nose(length=0.55829, kind="vonkarman", position=1.278) + asymmetric = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cN_alpha": lambda a, b, m, re, p, q, r: 2.0, + "cm_alpha": lambda a, b, m, re, p, q, r: -1.0, + "cY_beta": lambda a, b, m, re, p, q, r: -2.0, + "cn_beta": lambda a, b, m, re, p, q, r: 2.0, + }, + name="asym", + ) + + rocket.add_surfaces(asymmetric, positions=-1.0) + + # Warning fires once, on the first aerodynamic-center evaluation. + with pytest.warns(UserWarning, match="not\\s+axisymmetric"): + ac_pitch = rocket.aerodynamic_center.get_value_opt(0.2) + + assert ac_pitch != pytest.approx(rocket.aerodynamic_center_yaw.get_value_opt(0.2)) + assert rocket.static_margin.get_value_opt(0) != pytest.approx( + rocket.static_margin_yaw.get_value_opt(0) + ) + + +def test_barrowman_surface_uses_geometric_compute_path(): + """Barrowman surfaces compute their normal force and moment with the classic + Barrowman method (their own ``compute_forces_and_moments``): the resultant + force is reported at the geometric center of pressure and its moment is + transported geometrically from there.""" + from rocketpy.rocket.aero_surface._barrowman_surface import _BarrowmanSurface + from rocketpy.rocket.aero_surface.generic_surface import GenericSurface + + nose = NoseCone( + length=0.55829, kind="vonkarman", base_radius=0.0635, rocket_radius=0.0635 + ) + assert isinstance(nose, GenericSurface) + # Force is reported at the surface's geometric center of pressure. + assert tuple(nose.force_application_point) == (nose.cpx, nose.cpy, nose.cpz) + # Uses the Barrowman geometric compute, not the generic coefficient path. + assert ( + nose.compute_forces_and_moments.__func__ + is _BarrowmanSurface.compute_forces_and_moments + ) diff --git a/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py new file mode 100644 index 000000000..8e893b14c --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_controllable_generic_surface.py @@ -0,0 +1,140 @@ +"""Unit tests for ControllableGenericSurface and the controllable-surface +controller linkage.""" + +import pytest + +from rocketpy import ControllableGenericSurface, Function, GenericSurface +from rocketpy.mathutils.vector_matrix import Vector + +DENSITY = Function(lambda z: 1.16) +VISCOSITY = Function(lambda z: 1.8e-5) + + +def _moment_at_deflection(surface, deflection, comp="pitch"): + surface.set_control("deflection", deflection) + r1, r2, r3, m1, m2, m3 = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + ) + return {"pitch": m1, "yaw": m2, "roll": m3}[comp] + + +def test_control_variable_extends_independent_vars(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + assert surface.independent_vars[:7] == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + assert surface.independent_vars[7:] == ["deflection"] + assert surface.control_state == {"deflection": 0.0} + + +def test_deflection_produces_proportional_control_moment(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cm": lambda a, b, m, re, p, q, r, deflection: 0.5 * deflection}, + ) + m0 = _moment_at_deflection(surface, 0.0) + m1 = _moment_at_deflection(surface, 0.1) + m2 = _moment_at_deflection(surface, 0.2) + assert m0 == pytest.approx(0.0) + assert m2 == pytest.approx(2 * m1) + assert m1 != pytest.approx(0.0) + + +def test_multiple_named_controls(): + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={"cn": lambda a, b, m, re, p, q, r, dp, dy: 0.3 * dy}, + controls=("delta_pitch", "delta_yaw"), + ) + assert surface.independent_vars[7:] == ["delta_pitch", "delta_yaw"] + surface.set_control("delta_yaw", 0.5) + yaw = surface.compute_forces_and_moments( + Vector([0, 0, -100]), + 100, + 0.29, + 1.16, + Vector([0, 0, 0]), + Vector([0, 0, 0]), + DENSITY, + VISCOSITY, + 100.0, + )[4] + assert yaw != pytest.approx(0.0) + + +def test_set_control_unknown_name_raises(): + surface = ControllableGenericSurface( + reference_area=1, reference_length=0.2, coefficients={} + ) + with pytest.raises(KeyError): + surface.set_control("not_a_control", 0.1) + + +def test_plain_generic_surface_default_independent_vars_unchanged(): + surface = GenericSurface(reference_area=1, reference_length=0.2, coefficients={}) + assert surface.independent_vars == [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + + +def test_active_during_preset_round_trips_through_dict(): + """A preset activation policy survives to_dict/from_dict (jet-vane case).""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during="power_on", + ) + restored = ControllableGenericSurface.from_dict(surface.to_dict()) + assert restored.active_during == "power_on" + + +def test_active_during_callable_round_trips_through_dict(): + """A custom activation function is pickled through to_dict/from_dict and + restored to a working callable.""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during=lambda t, flight: t < 1.0, + ) + restored = ControllableGenericSurface.from_dict(surface.to_dict()) + assert callable(restored.active_during) + assert restored.active_during(0.5, None) is True + assert restored.active_during(2.0, None) is False + + +def test_active_during_callable_dropped_when_pickling_disabled(): + """With allow_pickle=False a custom function cannot be stored, so it saves + as the 'always' preset rather than a broken reference.""" + surface = ControllableGenericSurface( + reference_area=1, + reference_length=0.2, + coefficients={}, + active_during=lambda t, flight: t < 1.0, + ) + assert surface.to_dict(allow_pickle=False)["active_during"] == "always" diff --git a/tests/unit/rocket/aero_surface/test_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_generic_surfaces.py index c16a1b592..5bbe81395 100644 --- a/tests/unit/rocket/aero_surface/test_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_generic_surfaces.py @@ -1,8 +1,18 @@ +import json +from types import SimpleNamespace + import pytest -from rocketpy import Function, GenericSurface +from rocketpy import Function, GenericSurface, LinearGenericSurface +from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder from rocketpy.mathutils import Vector + +def _rpy_round_trip(obj): + """Encode ``obj`` and decode it back through the .rpy encoder/decoder.""" + return json.loads(json.dumps(obj, cls=RocketPyEncoder), cls=RocketPyDecoder) + + REFERENCE_AREA = 1 REFERENCE_LENGTH = 1 @@ -10,12 +20,12 @@ @pytest.mark.parametrize( "coefficients", [ - "cL", + "cN", {"invalid_name": 0}, - {"cL": "inexistent_file.csv"}, - {"cL": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, - {"cL": lambda x1: 0}, - {"cL": {}}, + {"cN": "inexistent_file.csv"}, + {"cN": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, + {"cN": lambda x1: 0}, + {"cN": {}}, ], ) def test_invalid_initialization(coefficients): @@ -37,7 +47,7 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff): GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename_invalid_coeff)}, + coefficients={"cN": str(filename_invalid_coeff)}, ) @@ -45,11 +55,11 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff): "coefficients", [ {}, - {"cL": 0}, + {"cN": 0}, { - "cL": 0, - "cQ": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), - "cD": lambda x1, x2, x3, x4, x5, x6, x7: 0, + "cN": 0, + "cY": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), + "cA": lambda x1, x2, x3, x4, x5, x6, x7: 0, }, ], ) @@ -70,7 +80,7 @@ def test_valid_initialization_from_csv(filename_valid_coeff): GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename_valid_coeff)}, + coefficients={"cN": str(filename_valid_coeff)}, ) @@ -79,27 +89,146 @@ def test_csv_independent_variables_accept_any_order(tmp_path): regardless of independent variable column order.""" filename = tmp_path / "valid_coefficients_shuffled_order.csv" filename.write_text( - "mach,alpha,cL\n0,0,0\n0,1,10\n2,0,2\n2,1,12\n", + "mach,alpha,cN\n0,0,0\n0,1,10\n2,0,2\n2,1,12\n", encoding="utf-8", ) generic_surface = GenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL": str(filename)}, + coefficients={"cN": str(filename)}, ) - closure = generic_surface.cL.source.__closure__ - csv_function = next( - cell.cell_contents - for cell in closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over its CSV columns, in + # header order; AeroCoefficient maps the full argument tuple onto them. + assert generic_surface.cN.depends_on == ("mach", "alpha") + csv_function = generic_surface.cN.function - assert generic_surface.cL(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) + assert generic_surface.cN(1, 0, 2, 0, 0, 0, 0) == pytest.approx(12) assert csv_function.get_interpolation_method() == "regular_grid" +POINTS = [[0, 0], [1, 1], [2, 4], [3, 9]] + + +def test_interpolation_extrapolation_scalar_applies_to_all(): + """A single interpolation/extrapolation string is applied to every + tabulated coefficient.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS, "cA": POINTS}, + extrapolation="constant", + interpolation="akima", + ) + for coeff in (gs.cN, gs.cA): + assert coeff.function.get_interpolation_method() == "akima" + assert coeff.function.get_extrapolation_method() == "constant" + + +def test_interpolation_extrapolation_per_coefficient_dict(): + """A dict configures interpolation/extrapolation per coefficient; omitted + coefficients keep the default.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS, "cA": POINTS}, + extrapolation={"cA": "constant"}, + interpolation={"cN": "akima"}, + ) + assert gs.cN.function.get_interpolation_method() == "akima" + assert gs.cA.function.get_extrapolation_method() == "constant" + # cN was not in the extrapolation dict, so it keeps the tabulated default. + assert gs.cN.function.get_interpolation_method() == "akima" + assert gs.cA.function.get_interpolation_method() == "linear" + + +def test_prebuilt_function_interpolation_left_unchanged(): + """A pre-built Function keeps its own interpolation/extrapolation when none + is requested, and is copied (not mutated) when they are overridden.""" + source = Function(POINTS, interpolation="spline", extrapolation="zero") + + unchanged = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": source}) + assert unchanged.cN.function.get_interpolation_method() == "spline" + assert unchanged.cN.function.get_extrapolation_method() == "zero" + + overridden = GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": source}, + interpolation="linear", + extrapolation="constant", + ) + assert overridden.cN.function.get_interpolation_method() == "linear" + # The original Function must not have been mutated in place. + assert source.get_interpolation_method() == "spline" + + +def test_tabulated_coefficient_defaults_to_constant_extrapolation(): + """Tabulated coefficients default to constant extrapolation, so they do not + run to non-physical values past their data.""" + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": POINTS}, + ) + assert gs.cN.function.get_extrapolation_method() == "constant" + + +def _write_grid_csv(path): + """A 4x4 (mach, alpha) Cartesian grid, nonlinear in alpha so interpolation + methods produce distinguishable values. 4 points per axis lets "cubic" fit. + """ + rows = ["mach,alpha,cN"] + for mach in (0, 1, 2, 3): + for alpha in (0.0, 0.1, 0.2, 0.3): + rows.append(f"{mach},{alpha},{mach + 10 * alpha**2}") + path.write_text("\n".join(rows) + "\n", encoding="utf-8") + return str(path) + + +@pytest.mark.parametrize( + "interpolation, expected_grid_method", + [("linear", "linear"), ("spline", "cubic"), ("akima", "pchip")], +) +def test_grid_csv_interpolation_maps_to_scipy_method( + tmp_path, interpolation, expected_grid_method +): + """A gridded CSV honors the interpolation argument by mapping it onto the + RegularGridInterpolator method (no silent fallback to shepard).""" + filename = _write_grid_csv(tmp_path / "grid.csv") + + gs = GenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={"cN": filename}, + interpolation=interpolation, + ) + function = gs.cN.function + # The Function stays a regular grid (not clobbered to shepard) ... + assert function.get_interpolation_method() == "regular_grid" + # ... with the mapped scipy method threaded through. + assert getattr(function, "_grid_method", "linear") == expected_grid_method + + +def test_grid_csv_cubic_differs_from_linear(tmp_path): + """The mapped grid method actually changes interpolation off the grid nodes, + confirming it is not ignored.""" + filename = _write_grid_csv(tmp_path / "grid.csv") + + linear = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": filename}, interpolation="linear" + ) + cubic = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": filename}, interpolation="spline" + ) + # Interior off-node point at alpha=0.15, mach=0.5 (argument order is + # alpha, beta, mach, ...): the nonlinear-in-alpha grid makes cubic and + # linear disagree there. + args = (0.15, 0.0, 0.5, 0, 0, 0, 0) + assert linear.cN(*args) != pytest.approx(cubic.cN(*args)) + + def test_compute_forces_and_moments(): """Checks if there are not logical errors in compute forces and moments""" @@ -117,3 +246,229 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_angular_rates_are_non_dimensionalized(): + """Coefficients receive the conventional reduced rate q* = q L_ref / (2 V), + not the raw body rate in rad/s.""" + ref_area, ref_length = 2.0, 0.5 + # Roll-moment coefficient that simply returns the roll rate it is given, so + # the resulting roll moment exposes which rate value reached the coefficient. + gs = GenericSurface(ref_area, ref_length, {"cl": lambda roll_rate: roll_rate}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # The coefficient saw the reduced rate, ... + assert roll_moment == pytest.approx(dyn_pressure_area_length * reduced_roll) + # ... not the raw rad/s rate. + assert roll_moment != pytest.approx(dyn_pressure_area_length * raw_roll) + + +class _ExplodingAtmosphere: + """Stand-in for density/viscosity whose lookup raises, so a test can assert + the Reynolds computation (and thus the lookup) is skipped.""" + + def get_value_opt(self, z): + raise AssertionError("atmosphere lookup should have been skipped") + + +def test_reynolds_length_defaults_to_reference_length(): + gs = GenericSurface(REFERENCE_AREA, 0.2, {"cN": 0}) + assert gs.reynolds_length == 0.2 + + +def test_reynolds_length_override(): + gs = GenericSurface(REFERENCE_AREA, 0.2, {"cN": 0}, reynolds_length=4.0) + assert gs.reynolds_length == 4.0 + # The moment/rate reference length is left untouched. + assert gs.reference_length == 0.2 + + +def test_needs_reynolds_reflects_coefficient_dependence(): + without = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda mach: mach} + ) + assert without._needs_reynolds is False + + with_re = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda reynolds: reynolds} + ) + assert with_re._needs_reynolds is True + + +def test_reynolds_computation_skipped_when_no_coefficient_uses_it(): + """A surface with no Reynolds-dependent coefficient must not perform the + per-step atmosphere lookups (the exploding stand-ins would raise if it did).""" + gs = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": lambda mach: mach}) + gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -100)), + stream_speed=100, + stream_mach=0.3, + rho=1.0, + cp=Vector((0, 0, 0)), + omega=(0, 0, 0), + density=_ExplodingAtmosphere(), + dynamic_viscosity=_ExplodingAtmosphere(), + z=0, + ) + + +def test_reynolds_uses_reynolds_length_not_reference_length(): + """The Reynolds number handed to the coefficients is built on + ``reynolds_length``, not the (diameter) reference length.""" + ref_area, ref_length, re_length = 1.0, 0.2, 4.0 + rho_atm, mu, speed, rho = 1.2, 2.0e-5, 100.0, 1.0 + # cN returns the Reynolds number it is given, so the normal force exposes it. + gs = GenericSurface( + ref_area, + ref_length, + {"cN": lambda reynolds: reynolds}, + reynolds_length=re_length, + ) + + _, r2, *_ = gs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0.3, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, 0), + density=Function(rho_atm), + dynamic_viscosity=Function(mu), + z=0, + ) + + # R2 = -normal = -(0.5 rho V^2 A_ref) * Re_seen + reynolds_seen = -r2 / (0.5 * rho * speed**2 * ref_area) + assert reynolds_seen == pytest.approx(rho_atm * speed * re_length / mu) + # ... which differs from the diameter-based value. + assert reynolds_seen != pytest.approx(rho_atm * speed * ref_length / mu) + + +def _fake_flight(burn_out_time): + """Minimal stand-in exposing only what ``is_active`` reads + (``flight.rocket.motor.burn_out_time``), so no real Flight is built.""" + return SimpleNamespace( + rocket=SimpleNamespace(motor=SimpleNamespace(burn_out_time=burn_out_time)) + ) + + +def test_active_during_defaults_to_always(): + """By default a surface is active at every time.""" + gs = GenericSurface(REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}) + flight = _fake_flight(burn_out_time=3.0) + assert gs.active_during == "always" + assert gs.is_active(0.0, flight) is True + assert gs.is_active(5.0, flight) is True + + +def test_active_during_power_on_gates_at_burnout(): + """A power-on surface is active up to (not including) burnout.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}, active_during="power_on" + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(2.999, flight) is True + assert gs.is_active(3.0, flight) is False + assert gs.is_active(4.0, flight) is False + + +def test_active_during_power_off_gates_at_burnout(): + """A power-off surface is active only from burnout onward.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 1}, active_during="power_off" + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(2.999, flight) is False + assert gs.is_active(3.0, flight) is True + assert gs.is_active(4.0, flight) is True + + +def test_active_during_accepts_callable(): + """A custom predicate receives (t, flight) and drives activation.""" + seen = [] + + def only_after_one_second(t, flight): + seen.append((t, flight)) + return t > 1.0 + + gs = GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": 1}, + active_during=only_after_one_second, + ) + flight = _fake_flight(burn_out_time=3.0) + assert gs.is_active(0.5, flight) is False + assert gs.is_active(2.0, flight) is True + # The predicate was called with the time and the flight object. + assert seen[0] == (0.5, flight) + + +def test_active_during_invalid_value_raises(): + """An unknown activation policy is rejected at construction.""" + with pytest.raises(ValueError, match="active_during"): + GenericSurface( + REFERENCE_AREA, + REFERENCE_LENGTH, + {"cN": 1}, + active_during="sometimes", + ) + + +def test_generic_surface_round_trips_through_encoder(): + """A GenericSurface survives the full .rpy encode/decode: coefficients, + reynolds_length and a custom activation function are all restored.""" + gs = GenericSurface( + reference_area=1.0, + reference_length=0.2, + coefficients={"cN": lambda mach: 2 * mach, "cm": 0.1}, + reynolds_length=4.0, + active_during=lambda t, flight: t < 3.0, + ) + restored = _rpy_round_trip(gs) + + assert isinstance(restored, GenericSurface) + assert restored.reynolds_length == 4.0 + assert restored.cN(0, 0, 0.5, 0, 0, 0, 0) == pytest.approx(1.0) + assert restored.cm(0, 0, 0, 0, 0, 0, 0) == pytest.approx(0.1) + assert restored.active_during(1.0, None) is True + assert restored.active_during(5.0, None) is False + + +def test_linear_generic_surface_round_trips_through_encoder(): + """A LinearGenericSurface restores its derivative coefficients and the + Reynolds length through the .rpy encode/decode.""" + lgs = LinearGenericSurface( + reference_area=1.0, + reference_length=0.2, + coefficients={"cN_alpha": 2.0, "cm_alpha": -0.5}, + reynolds_length=3.0, + ) + restored = _rpy_round_trip(lgs) + + assert isinstance(restored, LinearGenericSurface) + assert restored.reynolds_length == 3.0 + assert restored.cN_alpha(0, 0, 0, 0, 0, 0, 0) == pytest.approx(2.0) + assert restored.cm_alpha(0, 0, 0, 0, 0, 0, 0) == pytest.approx(-0.5) + + +def test_generic_surface_preset_active_during_round_trips(): + """A preset activation policy round-trips as the plain string.""" + gs = GenericSurface( + REFERENCE_AREA, REFERENCE_LENGTH, {"cN": 0}, active_during="power_on" + ) + assert _rpy_round_trip(gs).active_during == "power_on" diff --git a/tests/unit/rocket/aero_surface/test_individual_fins.py b/tests/unit/rocket/aero_surface/test_individual_fins.py index d232e0772..6db540a8a 100644 --- a/tests/unit/rocket/aero_surface/test_individual_fins.py +++ b/tests/unit/rocket/aero_surface/test_individual_fins.py @@ -7,7 +7,9 @@ from rocketpy import ( EllipticalFin, + EllipticalFins, FreeFormFin, + FreeFormFins, Rocket, TrapezoidalFin, TrapezoidalFins, @@ -375,16 +377,124 @@ def test_calisto_finset_vs_four_individual_fins_close(): mach_grid = np.linspace(0, 2, 21) # Act - cp_finset = finset_rocket.cp_position(mach_grid) - cp_individual = individual_fins_rocket.cp_position(mach_grid) + cp_finset = finset_rocket.aerodynamic_center(mach_grid) + cp_individual = individual_fins_rocket.aerodynamic_center(mach_grid) clalpha_finset = finset_rocket.total_lift_coeff_der(mach_grid) clalpha_individual = individual_fins_rocket.total_lift_coeff_der(mach_grid) - lift_correction = TrapezoidalFins.fin_num_correction(4) / 4 - clalpha_individual_corrected = np.array(clalpha_individual) * lift_correction - # Assert + # Assert. Each individual fin projects its lift slope onto the pitch plane by + # sin(phi)**2, so an evenly spaced set of 4 sums to fin_num_correction(4) = 2 + # in the plane -- matching the fin set directly, with no extra correction. np.testing.assert_allclose(cp_individual, cp_finset, rtol=1e-6, atol=1e-6) - np.testing.assert_allclose(clalpha_individual_corrected, clalpha_finset) + np.testing.assert_allclose(clalpha_individual, clalpha_finset) + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_canted_individual_fin_builds_and_places(fin_cls, geometry): + """A canted individual fin of any shape must build its body<->fin rotation + matrices at construction, so it can be placed on a rocket. Regression test + for a crash where non-trapezoidal individual fins lacked + ``_rotation_fin_to_body_uncanted`` and failed in + ``_compute_leading_edge_position``.""" + fin = fin_cls(angular_position=30, cant_angle=2.0, **geometry) + assert hasattr(fin, "_rotation_fin_to_body_uncanted") + position = fin._compute_leading_edge_position(-1.168, 1) + assert position is not None + + +@pytest.mark.parametrize( + "fin_cls, geometry", + [ + ( + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + (EllipticalFin, dict(root_chord=0.120, span=0.100, rocket_radius=0.0635)), + ( + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_individual_fin_roll_moment_independent_of_angular_position(fin_cls, geometry): + """A canted individual fin's roll moment must be the same at any angular + position (rotational symmetry about the roll axis). Regression test for a bug + where the fin's center of pressure was not rotated to its azimuth (the + rotation matrix was left as the identity), making the roll moment vary with + angular position.""" + stream_velocity = Vector([0, 0, -1.0]) + omega = Vector([0, 0, 0]) + + roll_moments = [] + for angle in (0, 90, 180, 270): + rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + fin = fin_cls(angular_position=angle, cant_angle=2.0, **geometry) + rocket.add_surfaces(fin, -1.168) + cp = rocket.surfaces_cp_to_cdm[fin] + roll = fin.compute_forces_and_moments( + stream_velocity, 1.0, 0.3, 1.0, cp, omega + )[5] + roll_moments.append(roll) + + np.testing.assert_allclose(roll_moments, roll_moments[0], rtol=1e-9) + assert abs(roll_moments[0]) > 0 + + +@pytest.mark.parametrize( + "set_cls, fin_cls, geometry", + [ + ( + TrapezoidalFins, + TrapezoidalFin, + dict(root_chord=0.120, tip_chord=0.040, span=0.100, rocket_radius=0.0635), + ), + ( + EllipticalFins, + EllipticalFin, + dict(root_chord=0.120, span=0.100, rocket_radius=0.0635), + ), + ( + FreeFormFins, + FreeFormFin, + dict(shape_points=[(0, 0), (0.06, 0.1), (0.12, 0.0)], rocket_radius=0.0635), + ), + ], +) +def test_finset_roll_forcing_equals_n_single_fins(set_cls, fin_cls, geometry): + """A fin set's roll forcing coefficient must scale with the full fin count + ``n`` (every identically-canted fin adds the same roll moment), so it equals + ``n`` times a single fin's roll forcing -- for every fin shape. Regression + test for a bug where the set used the normal-force ``fin_num_correction(n)`` + (~n/2), halving the roll forcing (and roll rate) of a fin set.""" + n = 4 + finset = set_cls(n=n, cant_angle=2.0, **geometry) + single = fin_cls(angular_position=0, cant_angle=2.0, **geometry) + + mach_grid = np.linspace(0, 2, 11) + clf_finset = finset.roll_parameters[0](mach_grid) + clf_single = single.roll_parameters[0](mach_grid) + np.testing.assert_allclose(clf_finset, n * np.array(clf_single), rtol=1e-6) @pytest.mark.parametrize( diff --git a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py index 4f0695143..7bb884220 100644 --- a/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py +++ b/tests/unit/rocket/aero_surface/test_linear_generic_surfaces.py @@ -1,21 +1,24 @@ import pytest -from rocketpy import Function, LinearGenericSurface +from rocketpy import Function, GenericSurface, LinearGenericSurface from rocketpy.mathutils import Vector REFERENCE_AREA = 1 REFERENCE_LENGTH = 1 +# (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) +_ARGS = (0.0, 0.0, 0.5, 1e6, 0.0, 0.0, 0.0) + @pytest.mark.parametrize( "coefficients", [ - "cL_0", + "cN_0", {"invalid_name": 0}, - {"cL_0": "inexistent_file.csv"}, - {"cL_0": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, - {"cL_0": lambda x1: 0}, - {"cL_0": {}}, + {"cN_0": "inexistent_file.csv"}, + {"cN_0": Function(lambda x1, x2, x3, x4, x5, x6: 0)}, + {"cN_0": lambda x1: 0}, + {"cN_0": {}}, ], ) def test_invalid_initialization(coefficients): @@ -37,7 +40,7 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff_linear_generic_s LinearGenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL_0": str(filename_invalid_coeff_linear_generic_surface)}, + coefficients={"cN_0": str(filename_invalid_coeff_linear_generic_surface)}, ) @@ -45,11 +48,11 @@ def test_invalid_initialization_from_csv(filename_invalid_coeff_linear_generic_s "coefficients", [ {}, - {"cL_0": 0}, + {"cN_0": 0}, { - "cL_0": 0, - "cQ_0": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), - "cD_0": lambda x1, x2, x3, x4, x5, x6, x7: 0, + "cN_0": 0, + "cY_0": Function(lambda x1, x2, x3, x4, x5, x6, x7: 0), + "cA_0": lambda x1, x2, x3, x4, x5, x6, x7: 0, }, ], ) @@ -70,7 +73,7 @@ def test_valid_initialization_from_csv(filename_valid_coeff_linear_generic_surfa LinearGenericSurface( reference_area=REFERENCE_AREA, reference_length=REFERENCE_LENGTH, - coefficients={"cL_0": str(filename_valid_coeff_linear_generic_surface)}, + coefficients={"cN_0": str(filename_valid_coeff_linear_generic_surface)}, ) @@ -91,3 +94,138 @@ def test_compute_forces_and_moments(): z=0, ) assert forces_and_moments == (0, 0, 0, 0, 0, 0) + + +def test_roll_damping_uses_reduced_rate(): + """The roll-damping derivative cl_p is applied to the reduced roll rate + p* = p L_ref / (2 V). This must equal the previous raw-rate scaling + (0.5 rho V A L^2 / 2) * cl_p * p, confirming the change is result-identical + for the linear model.""" + ref_area, ref_length, cl_p = 2.0, 0.5, 3.0 + lgs = LinearGenericSurface(ref_area, ref_length, {"cl_p": cl_p}) + + rho, speed, raw_roll = 1.2, 10.0, 4.0 + *_, roll_moment = lgs.compute_forces_and_moments( + stream_velocity=Vector((0, 0, -speed)), # along centerline -> alpha=beta=0 + stream_speed=speed, + stream_mach=0, + rho=rho, + cp=Vector((0, 0, 0)), + omega=(0, 0, raw_roll), # raw body roll rate p, rad/s + density=Function(1.0), + dynamic_viscosity=Function(1.0), + z=0, + ) + + reduced_roll = raw_roll * ref_length / (2 * speed) + dyn_pressure_area_length = 0.5 * rho * speed**2 * ref_area * ref_length + # New (reduced-rate) formulation: + assert roll_moment == pytest.approx(dyn_pressure_area_length * cl_p * reduced_roll) + # Old (raw-rate) formulation -- identical value: + old_damping_scaling = 0.5 * rho * speed * ref_area * ref_length**2 / 2 + assert roll_moment == pytest.approx(old_damping_scaling * cl_p * raw_roll) + + +def test_force_convention_inference(): + """The input frame is inferred from the derivative names when + ``force_convention`` is not given, defaulting to body when there are no + force derivatives to infer from.""" + assert LinearGenericSurface(1, 1, {"cN_alpha": 2.0}).force_convention == "body" + assert LinearGenericSurface(1, 1, {"cL_alpha": 2.0}).force_convention == "wind" + # Moment-only / empty input carries no force frame -> body (canonical). + assert LinearGenericSurface(1, 1, {"cm_alpha": 1.0}).force_convention == "body" + assert LinearGenericSurface(1, 1, {}).force_convention == "body" + + +def test_mixed_frame_input_raises(): + """Supplying both wind (cL_*) and body (cN_*) force derivatives without + declaring the frame is rejected.""" + with pytest.raises(ValueError, match="[Mm]ixed"): + LinearGenericSurface(1, 1, {"cL_alpha": 1.0, "cN_0": 1.0}) + + +def test_invalid_wind_coefficient_name_raises(): + """A wind-frame input with an unknown derivative name is rejected.""" + with pytest.raises(ValueError, match="Invalid coefficient name"): + LinearGenericSurface(1, 1, {"cL_gamma": 1.0}, force_convention="wind") + + +def test_wind_derivatives_convert_to_body_first_order(): + """Wind-frame derivatives are converted to the body-frame set by linearizing + the wind/body rotation about zero: the four cross terms plus straight + renames. A nonzero base drag ``cD_0`` couples into the normal- and + axial-force slopes.""" + surface = LinearGenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={ + "cL_0": 0.1, + "cL_alpha": 5.0, + "cD_0": 0.3, + "cD_alpha": 0.2, + "cQ_beta": -4.0, + "cQ_0": 0.05, + "cm_alpha": -2.0, # frame-shared moment, must pass through unchanged + }, + force_convention="wind", + ) + assert surface.force_convention == "wind" + assert surface.cN_0.get_value_opt(*_ARGS) == pytest.approx(0.1) # cL_0 + assert surface.cN_alpha.get_value_opt(*_ARGS) == pytest.approx(5.3) # cL_alpha+cD_0 + assert surface.cY_beta.get_value_opt(*_ARGS) == pytest.approx(-4.3) # cQ_beta-cD_0 + assert surface.cA_0.get_value_opt(*_ARGS) == pytest.approx(0.3) # cD_0 + assert surface.cA_alpha.get_value_opt(*_ARGS) == pytest.approx(0.1) # cD_alpha-cL_0 + assert surface.cA_beta.get_value_opt(*_ARGS) == pytest.approx(0.05) # cD_beta+cQ_0 + assert surface.cm_alpha.get_value_opt(*_ARGS) == pytest.approx(-2.0) # passthrough + + +def test_wind_input_matches_body_input(): + """A wind-frame surface equals the body-frame surface built from the + hand-converted derivatives, at an arbitrary angle.""" + wind = LinearGenericSurface( + 1, + 1, + coefficients={"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0}, + force_convention="wind", + ) + body = LinearGenericSurface( + 1, + 1, + coefficients={ + "cN_0": 0.1, + "cN_alpha": 5.3, + "cA_0": 0.3, + "cA_alpha": -0.1, # cD_alpha - cL_0 + "cY_beta": -4.3, + }, + ) + args = (0.02, 0.01, 0.5, 1e6, 0.0, 0.0, 0.0) + for coeff in ("cN", "cY", "cA"): + assert getattr(wind, coeff).get_value_opt(*args) == pytest.approx( + getattr(body, coeff).get_value_opt(*args) + ) + + +def test_wind_linear_matches_generic_surface_to_first_order(): + """The body-frame forces of a wind-input linear surface agree with the + exact rotation used by GenericSurface to first order in the flow angles.""" + coeffs = {"cL_0": 0.1, "cL_alpha": 5.0, "cD_0": 0.3, "cQ_beta": -4.0} + linear = LinearGenericSurface(0.01, 0.1, coeffs, force_convention="wind") + generic = GenericSurface( + 0.01, + 0.1, + coefficients={ + "cL": lambda a, b, m, re, p, q, r: 0.1 + 5.0 * a, + "cD": lambda a, b, m, re, p, q, r: 0.3, + "cQ": lambda a, b, m, re, p, q, r: -4.0 * b, + }, + force_convention="wind", + ) + eps = 1e-3 + for alpha, beta in [(eps, 0.0), (0.0, eps), (eps, eps)]: + args = (alpha, beta, 0.5, 1e6, 0.0, 0.0, 0.0) + for coeff in ("cN", "cY", "cA"): + # Difference is second order in the angle (~1e-6 at eps=1e-3). + assert getattr(linear, coeff).get_value_opt(*args) == pytest.approx( + getattr(generic, coeff).get_value_opt(*args), abs=1e-5 + ) diff --git a/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py new file mode 100644 index 000000000..a0c413080 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_surface_coefficient_completeness.py @@ -0,0 +1,206 @@ +"""Guard tests ensuring every concrete aerodynamic surface exposes the full +coefficient contract the rocket and flight code rely on. + +Each surface must provide the six force/moment coefficients (``cL``, ``cQ``, +``cD``, ``cm``, ``cn``, ``cl``) and the four stability derivatives +(``cN_alpha``, ``cY_beta``, ``cm_alpha``, ``cn_beta``) as callables over its +independent-variable tuple, plus the pitch and yaw center-of-pressure accessors. +These tests catch a subclass silently omitting one. +""" + +import numpy as np +import pytest + +from rocketpy import ( + AirBrakes, + ControllableGenericSurface, + EllipticalFin, + EllipticalFins, + FreeFormFin, + FreeFormFins, + GenericSurface, + LinearGenericSurface, + NoseCone, + Tail, + TrapezoidalFin, + TrapezoidalFins, +) + +R = 0.0635 # a representative rocket radius, in meters +_SHAPE = [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)] + + +def _make_surfaces(): + """One instance of every concrete aerodynamic surface class.""" + area, length = np.pi * R**2, 2 * R + return { + "GenericSurface": GenericSurface( + reference_area=area, reference_length=length, coefficients={"cL": 1.0} + ), + "LinearGenericSurface": LinearGenericSurface( + reference_area=area, + reference_length=length, + coefficients={"cN_alpha": 2.0}, + ), + "ControllableGenericSurface": ControllableGenericSurface( + reference_area=area, + reference_length=length, + coefficients={"cL": lambda a, b, m, re, p, q, r, d: 1.0}, + ), + "AirBrakes": AirBrakes( + drag_coefficient_curve=lambda deployment, mach: 0.5, + reference_area=area, + ), + "NoseCone": NoseCone( + length=0.55829, kind="vonkarman", base_radius=R, rocket_radius=R + ), + "Tail": Tail(top_radius=R, bottom_radius=0.0435, length=0.06, rocket_radius=R), + "TrapezoidalFins": TrapezoidalFins( + n=4, span=0.1, root_chord=0.12, tip_chord=0.04, rocket_radius=R + ), + "EllipticalFins": EllipticalFins( + n=4, span=0.1, root_chord=0.12, rocket_radius=R + ), + "FreeFormFins": FreeFormFins(n=4, shape_points=_SHAPE, rocket_radius=R), + "TrapezoidalFin": TrapezoidalFin( + angular_position=0, + span=0.1, + root_chord=0.12, + tip_chord=0.04, + rocket_radius=R, + ), + "EllipticalFin": EllipticalFin( + angular_position=0, span=0.1, root_chord=0.12, rocket_radius=R + ), + "FreeFormFin": FreeFormFin( + angular_position=0, shape_points=_SHAPE, rocket_radius=R + ), + } + + +SURFACES = _make_surfaces() + +# All nine force/moment coefficients: the wind-frame forces (lift cL, side cQ, +# drag cD), the body-frame forces (normal cN, side cY, axial cA), and the moments +# (pitch cm, yaw cn, roll cl). The body-frame trio is derived from the wind trio +# (or vice versa) by the angle-of-attack/sideslip rotation. Note the +# case-sensitive distinction between ``cL`` (lift) and ``cl`` (roll). +FORCE_MOMENT_COEFFICIENTS = ( + "cL", + "cQ", + "cD", + "cN", + "cY", + "cA", + "cm", + "cn", + "cl", +) +STABILITY_DERIVATIVES = ("cN_alpha", "cY_beta", "cm_alpha", "cn_beta") + + +def _surface_params(): + return [pytest.param(name, id=name) for name in SURFACES] + + +def _coefficient_arguments(surface): + """A representative independent-variable tuple for the surface: the seven + base variables (alpha, beta, mach, reynolds, and the three rates) plus any + control axes the surface adds, filled with zeros.""" + base = [0.05, 0.02, 0.5, 1e6, 0.0, 0.0, 0.0] + extra = len(surface.independent_vars) - len(base) + return tuple(base + [0.0] * max(0, extra)) + + +@pytest.mark.parametrize("name", _surface_params()) +@pytest.mark.parametrize("coefficient", FORCE_MOMENT_COEFFICIENTS) +def test_force_moment_coefficient_is_callable(name, coefficient): + """Every surface exposes all nine force/moment coefficients (wind cL/cQ/cD, + body cN/cY/cA, moments cm/cn/cl) as coefficients callable over the + independent-variable tuple that return a finite value.""" + surface = SURFACES[name] + coeff = getattr(surface, coefficient, None) + assert coeff is not None, f"{name} is missing coefficient {coefficient}" + value = coeff.get_value_opt(*_coefficient_arguments(surface)) + assert np.isfinite(value), f"{name}.{coefficient} returned {value}" + + +@pytest.mark.parametrize("name", _surface_params()) +@pytest.mark.parametrize("derivative", STABILITY_DERIVATIVES) +def test_stability_derivative_is_callable(name, derivative): + """Every surface exposes the stability derivatives cN_alpha, cY_beta, + cm_alpha and cn_beta used by the rocket's center-of-pressure computation.""" + surface = SURFACES[name] + coeff = getattr(surface, derivative, None) + assert coeff is not None, f"{name} is missing derivative {derivative}" + value = coeff.get_value_opt(*_coefficient_arguments(surface)) + assert np.isfinite(value), f"{name}.{derivative} returned {value}" + + +@pytest.mark.parametrize("name", _surface_params()) +def test_center_of_pressure_accessors(name): + """Every surface exposes the pitch and yaw center-of-pressure accessors used + by the rocket's aerodynamic-center computation.""" + surface = SURFACES[name] + for attr in ("center_of_pressure_z", "center_of_pressure_z_yaw"): + accessor = getattr(surface, attr, None) + assert accessor is not None, f"{name} is missing {attr}" + assert np.isfinite(accessor.get_value_opt(0.5)) + + +_ARGS = (0.15, 0.08, 0.5, 1e6, 0.0, 0.0, 0.0) + + +def test_body_input_is_recovered_by_body_accessors(): + """Coefficients supplied in the body frame are recovered by the body-frame + accessors (they round-trip through the canonical wind-frame storage).""" + from rocketpy import GenericSurface + + surface = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={ + "cN": lambda a, b, m, re, p, q, r: 2.0 * a, + "cA": lambda a, b, m, re, p, q, r: 0.5, + "cY": lambda a, b, m, re, p, q, r: 1.5 * b, + }, + ) + assert surface.force_convention == "body" + assert surface.cN.get_value_opt(*_ARGS) == pytest.approx(2.0 * _ARGS[0]) + assert surface.cA.get_value_opt(*_ARGS) == pytest.approx(0.5) + assert surface.cY.get_value_opt(*_ARGS) == pytest.approx(1.5 * _ARGS[1]) + + +def test_wind_and_body_input_agree_at_zero_angle(): + """cL == cN, cD == cA and cQ == cY at zero angle of attack and sideslip, + regardless of the frame the coefficients were supplied in.""" + from rocketpy import GenericSurface + + wind = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cL": lambda a, b, m, re, p, q, r: 2.0}, + force_convention="wind", + ) + body = GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cN": lambda a, b, m, re, p, q, r: 2.0}, + force_convention="body", + ) + zero = (0.0, 0.0, 0.5, 1e6, 0.0, 0.0, 0.0) + assert wind.cN.get_value_opt(*zero) == pytest.approx(2.0) + assert body.cL.get_value_opt(*zero) == pytest.approx(2.0) + + +def test_mixed_frame_input_raises(): + """Supplying both wind and body force coefficients without declaring the + frame is rejected.""" + from rocketpy import GenericSurface + + with pytest.raises(ValueError, match="[Mm]ixed"): + GenericSurface( + reference_area=0.01, + reference_length=0.1, + coefficients={"cL": 1.0, "cN": 1.0}, + ) diff --git a/tests/unit/rocket/test_generic_calisto_equivalence.py b/tests/unit/rocket/test_generic_calisto_equivalence.py new file mode 100644 index 000000000..0882a5111 --- /dev/null +++ b/tests/unit/rocket/test_generic_calisto_equivalence.py @@ -0,0 +1,69 @@ +"""Non-Flight equivalence checks for the generic-surface Calisto rockets. + +``calisto_robust`` (Barrowman surfaces), ``calisto_linear_generic`` (per-surface +LinearGenericSurface), ``calisto_generic`` (per-surface GenericSurface) and +``calisto_full_aerodynamics`` (a single lumped full-body GenericSurface) all +describe the same Calisto aerodynamics. These tests pin that equivalence at the +coefficient/stability level -- without running a Flight -- so a regression in any +of the generic-surface code paths is caught cheaply. The full 6-DOF flight +comparison lives in ``tests/unit/simulation/test_flight.py``. +""" + +import numpy as np +import pytest + +GENERIC_FIXTURES = [ + "calisto_linear_generic", + "calisto_generic", + "calisto_full_aerodynamics", +] + + +@pytest.mark.parametrize("generic_name", GENERIC_FIXTURES) +def test_generic_calisto_matches_static_margin(request, calisto_robust, generic_name): + """Each generic-surface Calisto reproduces the Barrowman rocket's static + margin and Mach-dependent aerodynamic center.""" + generic = request.getfixturevalue(generic_name) + assert generic.static_margin(0) == pytest.approx( + calisto_robust.static_margin(0), rel=1e-3 + ) + for mach in (0.0, 0.3, 0.8, 1.2, 2.0): + assert generic.aerodynamic_center.get_value_opt(mach) == pytest.approx( + calisto_robust.aerodynamic_center.get_value_opt(mach), abs=1e-3 + ) + + +@pytest.mark.parametrize("generic_name", GENERIC_FIXTURES) +def test_generic_calisto_matches_aggregate_slopes( + request, calisto_robust, generic_name +): + """Each generic-surface Calisto reproduces the Barrowman rocket's total + normal-force and side-force curve slopes (the aggregate ``cN_alpha`` and + ``cY_beta`` the whole rocket presents) across a range of Mach numbers.""" + generic = request.getfixturevalue(generic_name) + for mach in (0.2, 0.6, 0.9, 1.5, 2.5): + assert generic.total_lift_coeff_der.get_value_opt(mach) == pytest.approx( + calisto_robust.total_lift_coeff_der.get_value_opt(mach), rel=2e-3 + ) + assert generic.total_side_coeff_der.get_value_opt(mach) == pytest.approx( + calisto_robust.total_side_coeff_der.get_value_opt(mach), rel=2e-3 + ) + + +def test_generic_and_linear_calisto_are_identical( + calisto_generic, calisto_linear_generic +): + """The per-surface GenericSurface and LinearGenericSurface Calistos express + the same coefficients through different code paths, so their aggregate + force-curve slopes and aerodynamic centers must agree to numerical + precision.""" + for mach in (0.2, 0.7, 1.3, 2.5): + for attr in ( + "total_lift_coeff_der", + "total_side_coeff_der", + "aerodynamic_center", + "aerodynamic_center_yaw", + ): + generic = getattr(calisto_generic, attr).get_value_opt(mach) + linear = getattr(calisto_linear_generic, attr).get_value_opt(mach) + assert generic == pytest.approx(linear, rel=1e-9, abs=1e-12) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 3c7725fa5..02fbed668 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -34,7 +34,7 @@ def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto): rocket.center_of_mass(burn_time[1]) / (2 * rocket.radius), 1e-8 ) == pytest.approx(rocket.static_margin(burn_time[1]), 1e-8) assert pytest.approx(rocket.total_lift_coeff_der(0), 1e-8) == pytest.approx(0, 1e-8) - assert pytest.approx(rocket.cp_position(0), 1e-8) == pytest.approx(0, 1e-8) + assert pytest.approx(rocket.aerodynamic_center(0), 1e-8) == pytest.approx(0, 1e-8) @pytest.mark.parametrize( @@ -53,7 +53,7 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist assert static_margin_final == pytest.approx(calisto.static_margin(np.inf), 1e-8) assert clalpha == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_nose(length=0.55829 * m, kind=type_, position=(1.160) * m) assert pytest.approx(dimensionless_calisto.static_margin(0), 1e-8) == pytest.approx( @@ -66,8 +66,8 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calist dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): @@ -91,7 +91,7 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == cpz + assert calisto.aerodynamic_center(0) == cpz dimensionless_calisto.add_tail( top_radius=0.0635 * m, @@ -109,8 +109,8 @@ def test_add_tail_assert_cp_cm_plus_tail(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -142,11 +142,13 @@ def test_add_trapezoidal_fins_sweep_angle( assert translate - cpz == pytest.approx(expected_fin_cpz, 0.01) # Check lift coefficient derivative - cl_alpha = fin_set.cl(1, 0.0) + cl_alpha = fin_set.clalpha(0.0) assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) @pytest.mark.parametrize( @@ -182,11 +184,13 @@ def test_add_trapezoidal_fins_sweep_length( assert translate - cpz == pytest.approx(expected_fin_cpz, 0.01) # Check lift coefficient derivative - cl_alpha = fin_set.cl(1, 0.0) + cl_alpha = fin_set.clalpha(0.0) assert cl_alpha == pytest.approx(expected_clalpha, 0.01) # Check rocket's center of pressure (just double checking) - assert translate - calisto.cp_position(0) == pytest.approx(expected_cpz_cm, 0.01) + assert translate - calisto.aerodynamic_center(0) == pytest.approx( + expected_cpz_cm, 0.01 + ) assert isinstance(calisto.aerodynamic_surfaces[0].component, NoseCone) @@ -223,7 +227,7 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): assert np.abs(clalpha) == pytest.approx( np.abs(calisto.total_lift_coeff_der(0)), 1e-8 ) - assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) + assert calisto.aerodynamic_center(0) == pytest.approx(cpz, 1e-8) dimensionless_calisto.add_trapezoidal_fins( 4, @@ -242,8 +246,8 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): dimensionless_calisto.total_lift_coeff_der(0), 1e-8 ) == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert pytest.approx( - dimensionless_calisto.cp_position(0) / m, 1e-8 - ) == pytest.approx(calisto.cp_position(0), 1e-8) + dimensionless_calisto.aerodynamic_center(0) / m, 1e-8 + ) == pytest.approx(calisto.aerodynamic_center(0), 1e-8) @pytest.mark.parametrize( @@ -732,22 +736,16 @@ def test_drag_csv_header_order_independent_for_multivariable_input(tmp_path): drag_ordered = rocket_ordered.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) drag_swapped = rocket_swapped.power_off_drag_7d(0, 0, 0.8, 0.15, 0, 0, 0) - ordered_closure = rocket_ordered.power_off_drag_7d.source.__closure__ - swapped_closure = rocket_swapped.power_off_drag_7d.source.__closure__ - ordered_csv_function = next( - cell.cell_contents - for cell in ordered_closure - if isinstance(cell.cell_contents, Function) - ) - swapped_csv_function = next( - cell.cell_contents - for cell in swapped_closure - if isinstance(cell.cell_contents, Function) - ) + # The coefficient is stored at minimal dimension over the present columns, + # keyed by name, so column order in the header does not matter. + ordered_csv_function = rocket_ordered.power_off_drag_7d.function + swapped_csv_function = rocket_swapped.power_off_drag_7d.function assert drag_ordered == pytest.approx(0.95) assert drag_swapped == pytest.approx(0.95) assert drag_swapped == pytest.approx(drag_ordered) + assert set(rocket_ordered.power_off_drag_7d.depends_on) == {"mach", "reynolds"} + assert set(rocket_swapped.power_off_drag_7d.depends_on) == {"mach", "reynolds"} assert ordered_csv_function.get_interpolation_method() == "regular_grid" assert swapped_csv_function.get_interpolation_method() == "regular_grid" diff --git a/tests/unit/rocket/test_stability_rework.py b/tests/unit/rocket/test_stability_rework.py new file mode 100644 index 000000000..8c27b07d1 --- /dev/null +++ b/tests/unit/rocket/test_stability_rework.py @@ -0,0 +1,330 @@ +"""Tests for the reworked stability model: the aerodynamic center, the +cp_position alias, the reconstructed nonlinear center of pressure, and the +aggregate aerodynamic coefficients.""" + +import numpy as np +import pytest + +from rocketpy import Function, GenericSurface, LinearGenericSurface, Rocket + + +def _full_body_surface(rocket, coefficients, name="Full Body Aerodynamics", **kwargs): + """Build a full-body GenericSurface referenced to the rocket dimensions, + the way a user would before handing it to ``add_full_body_aerodynamics``.""" + return GenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients=coefficients, + name=name, + **kwargs, + ) + + +def test_cp_position_alias_matches_aerodynamic_center(calisto_robust): + """``cp_position`` is a plain alias of ``aerodynamic_center`` (no warning).""" + rocket = calisto_robust + assert rocket.cp_position.get_value_opt(0.3) == pytest.approx( + rocket.aerodynamic_center.get_value_opt(0.3) + ) + + +def test_length_spans_nose_tip_to_aft_surface(calisto_robust): + """The overall length runs from the nose tip to the aft-most surface, and + does not depend on the coordinate-system orientation.""" + rocket = calisto_robust + # Nose tip at z = 1.160; tail base at z = -1.313 - 0.060 = -1.373. + assert rocket.length == pytest.approx(1.160 - (-1.373), abs=1e-9) + + +def test_length_orientation_independent(calisto_robust, calisto_nose_to_tail): + """The same physical rocket has the same length in either orientation.""" + # Mirror of calisto_robust: nose tip at z=0, then every surface reference + # sits at the same distance from the nose tip as in calisto_robust, so the + # physical rocket (and its length) is identical. The aft-most point is the + # tail base at z = 2.473 + 0.060 = 2.533. + calisto_nose_to_tail.add_nose(length=0.55829, kind="vonKarman", position=0.0) + calisto_nose_to_tail.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=2.473 + ) + calisto_nose_to_tail.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.040, span=0.100, position=2.328 + ) + assert calisto_nose_to_tail.length == pytest.approx(calisto_robust.length, abs=1e-9) + + +def test_length_extends_to_nozzle_past_surfaces(calisto_robust, cesaroni_m1670): + """When the motor nozzle extends aft of the last aerodynamic surface, the + length runs from the nose tip to the nozzle rather than to the surface.""" + rocket = calisto_robust + # Move the motor aft so its nozzle (at the motor origin, z = -2.0 in the + # rocket frame) sits past the tail base at z = -1.373. + rocket.add_motor(cesaroni_m1670, position=-2.0) + assert rocket.nozzle_position == pytest.approx(-2.0, abs=1e-9) + assert rocket.length == pytest.approx(1.160 - (-2.0), abs=1e-9) + + +def test_length_requires_a_surface(calisto): + """A rocket with no aerodynamic surfaces has no defined length.""" + with pytest.raises(ValueError, match="at least one aerodynamic surface"): + _ = calisto.length + + +def test_axisymmetric_rocket_planes_coincide(calisto_robust): + """An axisymmetric rocket has matching pitch and yaw aerodynamic centers.""" + rocket = calisto_robust + assert rocket.is_axisymmetric + for mach in (0.0, 0.5, 1.0): + assert rocket.aerodynamic_center.get_value_opt(mach) == pytest.approx( + rocket.aerodynamic_center_yaw.get_value_opt(mach) + ) + + +def test_add_full_body_aerodynamics(calisto_robust): + """A prebuilt full-body surface is added and contributes to the rocket + aggregate (rocket-as-GenericSurface).""" + rocket = calisto_robust + base_slope = rocket.total_lift_coeff_der.get_value_opt(0.3) + n_before = len(rocket.aerodynamic_surfaces) + + surface = _full_body_surface(rocket, {"cN": lambda a, b, m, re, p, q, r: 2.0 * a}) + returned = rocket.add_full_body_aerodynamics(surface) + + assert returned is surface + assert len(rocket.aerodynamic_surfaces) == n_before + 1 + # A single surface is active during the whole flight by default. + assert surface.active_during == "always" + # The full-body surface exposes the uniform coefficient accessors. + assert surface.cN(np.radians(5), 0, 0.3, 0, 0, 0, 0) == pytest.approx( + 2.0 * np.radians(5) + ) + # Its normal-force slope adds to the rocket aggregate lift-curve slope. + assert rocket.total_lift_coeff_der.get_value_opt(0.3) > base_slope + + +def test_add_full_body_aerodynamics_linear_surface_with_damping(calisto_robust): + """A LinearGenericSurface stability-derivative set (with pitch and roll + damping) is accepted as a full-body model, and its damping derivatives + stay inspectable as named attributes.""" + rocket = calisto_robust + surface = LinearGenericSurface( + reference_area=rocket.area, + reference_length=2 * rocket.radius, + coefficients={ + "cN_alpha": 2.0, + "cm_alpha": -1.0, + "cm_q": -50.0, + "cl_p": -5.0, + }, + name="Full body derivatives", + ) + # overwrite clears the built-in drag even though this surface carries none. + with pytest.warns(UserWarning, match="were cleared"): + created = rocket.add_full_body_aerodynamics(surface, overwrite=True) + + assert created is surface + assert len(rocket.aerodynamic_surfaces) == 1 + assert surface.cm_q.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx(-50.0) + # The surface has no drag coefficient, so the rocket now has no drag at all. + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_to_surface_round_trips(calisto_robust, cesaroni_m1670): + """``to_surface`` lumps the whole rocket into a power-off/power-on pair of + :class:`LinearGenericSurface` (carrying the pitch/yaw/roll damping and each + phase's drag) that reproduces the rocket's stability when added to a bare + rocket -- the inverse of ``add_full_body_aerodynamics``.""" + surfaces = calisto_robust.to_surface() + assert isinstance(surfaces, list) and len(surfaces) == 2 + power_off, power_on = surfaces + assert all(isinstance(s, LinearGenericSurface) for s in surfaces) + assert power_off.active_during == "power_off" + assert power_on.active_during == "power_on" + # The rate damping is captured as named, inspectable derivatives. + assert power_off.cm_q.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) < 0 # pitch damping + assert power_off.cl_p.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) < 0 # roll damping + # Each surface carries its own phase's drag as the axial coefficient. + assert power_off.cA.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + calisto_robust.power_off_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + assert power_on.cA.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + calisto_robust.power_on_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + + # A bare rocket (same body and motor) carrying only the lumped pair + # reproduces the modeled rocket's static margin and aerodynamic center. The + # surfaces carry the drag, so overwrite clears the bare rocket's curves. + bare = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + bare.add_motor(cesaroni_m1670, position=-1.373) + with pytest.warns(UserWarning, match="were cleared"): + bare.add_full_body_aerodynamics(surfaces, overwrite=True) + + assert bare.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert bare.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert bare.static_margin(0) == pytest.approx( + calisto_robust.static_margin(0), rel=1e-3 + ) + for mach in (0.3, 0.8, 1.5): + assert bare.aerodynamic_center.get_value_opt(mach) == pytest.approx( + calisto_robust.aerodynamic_center.get_value_opt(mach), abs=1e-3 + ) + + +def test_to_coefficients_returns_phase_pair(calisto_robust): + """``to_coefficients`` returns a ``power_off``/``power_on`` pair of coefficient + sets, each a dict of Mach curves. The stability derivatives are the same in + both; only the drag ``cA_0`` differs by motor phase.""" + coeffs = calisto_robust.to_coefficients() + assert set(coeffs) == {"power_off", "power_on"} + body_keys = { + "cN_alpha", + "cm_alpha", + "cN_q", + "cm_q", + "cY_beta", + "cn_beta", + "cY_r", + "cn_r", + "cl_p", + "cA_0", + } + assert set(coeffs["power_off"]) == body_keys + assert set(coeffs["power_on"]) == body_keys + assert all(isinstance(c, Function) for c in coeffs["power_off"].values()) + + # Stability derivatives are identical between phases; only drag differs. + assert coeffs["power_on"]["cN_alpha"].get_value_opt(0.3) == pytest.approx( + coeffs["power_off"]["cN_alpha"].get_value_opt(0.3) + ) + assert coeffs["power_off"]["cA_0"].get_value_opt(0.3) == pytest.approx( + calisto_robust.power_off_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + assert coeffs["power_on"]["cA_0"].get_value_opt(0.3) == pytest.approx( + calisto_robust.power_on_drag_by_mach.get_value_opt(0.3), rel=1e-6 + ) + + # It is exactly what ``to_surface`` wraps into surfaces. + power_off, _ = calisto_robust.to_surface() + assert power_off.cN_alpha.get_value_opt(0, 0, 0.3, 0, 0, 0, 0) == pytest.approx( + coeffs["power_off"]["cN_alpha"].get_value_opt(0.3) + ) + + # Wind naming flows through to each phase set. + wind = calisto_robust.to_coefficients(force_convention="wind") + assert "cL_alpha" in wind["power_off"] and "cD_0" in wind["power_off"] + with pytest.raises(ValueError, match="force_convention"): + calisto_robust.to_coefficients(force_convention="bogus") + + +def test_to_surface_force_convention(calisto_robust): + """``force_convention`` selects the coefficient naming (body ``cN``/``cA`` vs + wind ``cL``/``cD``) while yielding an equivalent surface pair.""" + args = (0, 0, 0.3, 0, 0, 0, 0) + drag = calisto_robust.power_off_drag_by_mach.get_value_opt(0.3) + + body_off, _ = calisto_robust.to_surface(force_convention="body") + wind_off, _ = calisto_robust.to_surface(force_convention="wind") + assert body_off.force_convention == "body" + assert wind_off.force_convention == "wind" + # Same underlying model either way (both store body-frame derivatives). + assert wind_off.cN_alpha.get_value_opt(*args) == pytest.approx( + body_off.cN_alpha.get_value_opt(*args) + ) + # The body axial and wind drag both equal the phase's drag. + assert body_off.cA.get_value_opt(*args) == pytest.approx(drag, rel=1e-6) + assert wind_off.cD.get_value_opt(*args) == pytest.approx(drag, rel=1e-6) + + with pytest.raises(ValueError, match="force_convention"): + calisto_robust.to_surface(force_convention="bogus") + + +def test_add_full_body_aerodynamics_power_on_off(calisto_robust): + """A power-on/power-off pair, passed as a list, adds two phase-gated + full-body surfaces.""" + rocket = calisto_robust + n_before = len(rocket.aerodynamic_surfaces) + + power_on = _full_body_surface( + rocket, {"cD": 0.3}, name="Full body (power on)", active_during="power_on" + ) + power_off = _full_body_surface( + rocket, {"cD": 0.5}, name="Full body (power off)", active_during="power_off" + ) + created = rocket.add_full_body_aerodynamics([power_on, power_off]) + + assert len(created) == 2 + assert len(rocket.aerodynamic_surfaces) == n_before + 2 + assert created[0].active_during == "power_on" + assert created[1].active_during == "power_off" + + +def test_add_full_body_aerodynamics_single_phase(calisto_robust): + """A single phase-gated surface (e.g. base drag after burnout) may be added + on its own.""" + rocket = calisto_robust + surface = _full_body_surface(rocket, {"cD": 0.5}, active_during="power_off") + created = rocket.add_full_body_aerodynamics(surface) + assert created is surface + assert surface.active_during == "power_off" + + +def test_add_full_body_aerodynamics_overwrite_replaces_surfaces_and_drag( + calisto_robust, +): + """overwrite=True removes existing surfaces and clears both built-in drag + curves; the supplied surface then provides the only drag.""" + rocket = calisto_robust + assert len(rocket.aerodynamic_surfaces) > 1 + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) > 0 + + surface = _full_body_surface( + rocket, {"cA": 0.4, "cN": lambda a, b, m, re, p, q, r: 2.0 * a} + ) + with pytest.warns(UserWarning, match="were cleared"): + rocket.add_full_body_aerodynamics(surface, overwrite=True) + + # Only the full-body surface remains. + assert len(rocket.aerodynamic_surfaces) == 1 + assert rocket.aerodynamic_surfaces[0].component is surface + # Both built-in drag curves were cleared (the surface carries the drag now). + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_add_full_body_aerodynamics_overwrite_always_clears_drag(calisto_robust): + """overwrite=True clears both built-in drag curves unconditionally, even for + a phase-gated surface that carries no drag of its own.""" + rocket = calisto_robust + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) > 0 + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) > 0 + + power_on = _full_body_surface(rocket, {"cD": 0.3}, active_during="power_on") + power_off = _full_body_surface(rocket, {"cN": 1.0}, active_during="power_off") + with pytest.warns(UserWarning, match="were cleared"): + rocket.add_full_body_aerodynamics([power_on, power_off], overwrite=True) + + # Both curves are cleared regardless of phase or whether a surface carries + # drag; the supplied surfaces are the complete aerodynamics. + assert rocket.power_on_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + assert rocket.power_off_drag_by_mach.get_value_opt(0.3) == pytest.approx(0.0) + + +def test_add_full_body_aerodynamics_overwrite_warns_on_later_add(calisto_robust): + """After an overwrite, adding another surface warns that it stacks on the + full-body model.""" + rocket = calisto_robust + surface = _full_body_surface(rocket, {"cD": 0.4}) + with pytest.warns(UserWarning): # the overwrite itself warns about drag + rocket.add_full_body_aerodynamics(surface, overwrite=True) + + later = _full_body_surface(rocket, {"cN": 1.0}) + with pytest.warns(UserWarning, match="stacks on|summed on top"): + rocket.add_full_body_aerodynamics(later) diff --git a/tests/unit/simulation/test_event_scheduler.py b/tests/unit/simulation/test_event_scheduler.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/simulation/test_flight.py b/tests/unit/simulation/test_flight.py index eacd2f3e1..b74513554 100644 --- a/tests/unit/simulation/test_flight.py +++ b/tests/unit/simulation/test_flight.py @@ -7,7 +7,7 @@ import pytest from scipy import optimize -from rocketpy import Components, Flight, Function, Rocket +from rocketpy import Components, Flight, Function, LinearGenericSurface, Rocket plt.rcParams.update({"figure.max_open_warning": 0}) @@ -648,6 +648,105 @@ def test_stability_static_margins( assert np.all(np.abs(moments) <= 1e-10) +def test_linear_generic_surface_flight_is_stable( + calisto_linear_generic, example_plain_env +): + """A Calisto whose fin set is a body-frame LinearGenericSurface flies stably. + + The linear surface builds its forces and moments directly in the body frame + from the coefficient derivatives (no wind-to-body rotation). With a positive + normal-force slope placed aft it must give a positive static margin and the + rocket must reach a finite apogee while staying aligned with the flow (a + small angle of attack, i.e. no tumbling). + """ + rocket = calisto_linear_generic + assert any( + isinstance(surface, LinearGenericSurface) + for surface, _ in rocket.aerodynamic_surfaces + ) + assert rocket.static_margin(0) > 0 + + test_flight = Flight( + environment=example_plain_env, + rocket=rocket, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + + assert test_flight.apogee_time > test_flight.out_of_rail_time + assert np.isfinite(test_flight.apogee) + assert test_flight.apogee > example_plain_env.elevation + # A stable rocket keeps a small angle of attack throughout the ascent. Only + # the ascent off the rail is checked: while the rocket is still on the rail + # its speed is ~0, so the angle of attack is reported as a degenerate 90 + # degrees (arccos of 0) for every launcher, stable or not. + aoa_source = test_flight.angle_of_attack.get_source() + ascent = aoa_source[:, 0] > test_flight.out_of_rail_time + angle_of_attack = aoa_source[ascent, 1] + assert np.nanmax(np.abs(angle_of_attack)) < 45 + + +@pytest.mark.parametrize( + "generic_rocket_name, apogee_rel_tol", + [ + ("calisto_linear_generic", 5e-3), + ("calisto_generic", 5e-3), + ("calisto_full_aerodynamics", 1e-2), + ], +) +def test_generic_surface_calisto_flight_matches_barrowman( + request, calisto_robust, example_plain_env, generic_rocket_name, apogee_rel_tol +): + """A Calisto rebuilt from generic surfaces flies the same as the Barrowman + Calisto. + + The reference ``calisto_robust`` models each surface with the classic + Barrowman method. The three generic-surface Calistos carry the same + aerodynamics through different code paths: per-surface + ``LinearGenericSurface`` (coefficient slopes), per-surface + ``GenericSurface`` (total coefficients), and a single full-body + ``LinearGenericSurface`` added with ``add_full_body_aerodynamics`` (the lumped + stability-derivative set, including the rate damping the distributed + surfaces produce through their lever arms). Flown from the same launcher in + still air, each must reach essentially the same apogee and leave the rail at + the same time and speed. Only the ascent is compared (``terminate_on_apogee``); + the descent under identical parachutes adds nothing to the comparison. + + The per-surface models reproduce the Barrowman forces almost exactly; the + lumped full-body model is a point approximation of the distributed + surfaces, so it is held to a slightly looser apogee tolerance. + """ + generic_rocket = request.getfixturevalue(generic_rocket_name) + + launch = dict( + environment=example_plain_env, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=True, + ) + reference_flight = Flight(rocket=calisto_robust, **launch) + generic_flight = Flight(rocket=generic_rocket, **launch) + + # Leaving the rail is driven by thrust and the shared drag curve, so every + # model must agree tightly here. + assert generic_flight.out_of_rail_time == pytest.approx( + reference_flight.out_of_rail_time, rel=1e-3 + ) + assert generic_flight.out_of_rail_velocity == pytest.approx( + reference_flight.out_of_rail_velocity, rel=1e-3 + ) + # Apogee reflects the whole aerodynamic ascent. + assert generic_flight.apogee == pytest.approx( + reference_flight.apogee, rel=apogee_rel_tol + ) + assert generic_flight.apogee_time == pytest.approx( + reference_flight.apogee_time, rel=apogee_rel_tol + ) + + def test_max_acceleration_power_off_time_with_controllers( flight_calisto_air_brakes, ):