diff --git a/.github/workflows/lint_black.yaml b/.github/workflows/lint_black.yaml deleted file mode 100644 index f1bc1b751..000000000 --- a/.github/workflows/lint_black.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: LintBlack - -# Adapted: https://github.com/marketplace/actions/lint-action - -on: [pull_request] - -jobs: - run-linters: - name: Run linters - runs-on: ubuntu-latest - - steps: - - name: Check out Git repository - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: 3.9 - - - name: Install Python dependencies - run: pip install black[jupyter] - - - name: Run linters - uses: wearerequired/lint-action@v1 - with: - black: true - auto_fix: true diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 000000000..1eee717d2 --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,43 @@ +name: Linters + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "**.py" + - "**.ipynb" + - ".github/**" + - "pyproject.toml" + - "requirements*" + - ".pylintrc" + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[all] + pip install .[tests] + pip install pylint isort flake8 black + - name: Run isort + run: isort --check-only rocketpy/ tests/ docs/ --profile black + - name: Run black + uses: psf/black@stable + with: + options: "--check rocketpy/ tests/ docs/" + jupyter: true + - name: Run flake8 + run: flake8 rocketpy/ tests/ + - name: Run pylint + run: | + pylint rocketpy/ tests/ diff --git a/.github/workflows/test-pytest-slow.yaml b/.github/workflows/test-pytest-slow.yaml new file mode 100644 index 000000000..f2dfa1ad6 --- /dev/null +++ b/.github/workflows/test-pytest-slow.yaml @@ -0,0 +1,55 @@ +name: Scheduled Tests + +on: + schedule: + - cron: "0 17 */14 * 5" # every 2 weeks, always on a Friday at 17:00 + timezone: "America/Sao_Paulo" + +defaults: + run: + shell: bash + +jobs: + pytest: + if: github.ref == "refs/heads/master" || github.ref == "refs/heads/develop" + runs-on: ubuntu-latest + strategy: + matrix: + fail-fast: false + python-version: [3.9, 3.12] + + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install rocketpy + run: pip install . + + - name: Test importing rocketpy + run: python -c "import sys, rocketpy; print(f'{rocketpy.__name__} running on Python {sys.version}')" + + - name: Install test dependencies + run: | + pip install -r requirements-tests.txt + pip install .[all] + + - name: Run Unit Tests + run: pytest tests/unit --cov=rocketpy + + - name: Run Documentation Tests + run: pytest rocketpy --doctest-modules --cov=rocketpy --cov-append + + - name: Run Integration Tests + run: pytest tests/integration --cov=rocketpy --cov-append + + - name: Run Acceptance Tests + run: pytest tests/acceptance --cov=rocketpy --cov-append --cov-report=xml + + - name: Run slow tests + run: pytest tests -vv -m slow --runslow --cov=rocketpy --cov-append --cov-report=xml diff --git a/.github/workflows/test_pytest.yaml b/.github/workflows/test_pytest.yaml index 3257f7c1a..9646bbf9c 100644 --- a/.github/workflows/test_pytest.yaml +++ b/.github/workflows/test_pytest.yaml @@ -45,7 +45,9 @@ jobs: run: python -c "import sys, rocketpy; print(f'{rocketpy.__name__} running on Python {sys.version}')" - name: Install test dependencies - run: pip install -r requirements-tests.txt + run: | + pip install -r requirements-tests.txt + pip install .[all] - name: Run Unit Tests run: pytest tests/unit --cov=rocketpy diff --git a/.gitignore b/.gitignore index 6b349a562..92ab5e3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -162,9 +162,6 @@ cython_debug/ *.docx *.pdf -# VSCode project settings -.vscode/ - # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/.pylintrc b/.pylintrc index 328effebb..dbb2cb11d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -46,7 +46,7 @@ fail-under=10 #from-stdin= # Files or directories to be skipped. They should be base names, not paths. -ignore=CVS, docs +ignore=CVS, docs, data, # Add files or directories matching the regular expressions patterns to the # ignore-list. The regex matches against paths and can be in Posix or Windows @@ -212,7 +212,24 @@ good-names=FlightPhases, fin_set_NACA, fin_set_E473, HIRESW_dictionary, - + prop_I_11, + Kt, # transformation matrix transposed + clalpha2D, + clalpha2D_incompressible, + r_NOZ, # Nozzle position vector + rocket_dry_I_33, + rocket_dry_I_11, + motor_I_33_at_t, + motor_I_11_at_t, + motor_I_33_derivative_at_t, + motor_I_11_derivative_at_t, + M3_forcing, + M3_damping, + CM_to_CDM, + CM_to_CPM, + center_of_mass_without_motor_to_CDM, + motor_center_of_dry_mass_to_CDM, + generic_motor_cesaroni_M1520, # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted @@ -222,6 +239,8 @@ good-names-rgxs= ^[a-z][0-9]?$, # Single lowercase characters, possibly followe ^(dry_|propellant_)[A-Z]+_\d+$, # Variables starting with 'dry_' or 'propellant_', followed by uppercase characters, underscore, and digits ^[a-z]+_ISA$, # Lowercase words ending with '_ISA' ^plot(1D|2D)$, # Variables starting with 'plot' followed by '1D' or '2D' + S_noz*, # nozzle gyration tensor + r_CM*, # center of mass position and its variations # Include a hint for the correct naming format with invalid-name. include-naming-hint=no @@ -312,16 +331,16 @@ exclude-too-few-public-methods= ignored-parents= # Maximum number of arguments for function / method. -max-args=10 +max-args=15 # Maximum number of attributes for a class (see R0902). -max-attributes=30 +max-attributes=50 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 # Maximum number of branch for function / method body. -max-branches=12 +max-branches=25 # Maximum number of locals for function / method body. max-locals=30 @@ -330,16 +349,16 @@ max-locals=30 max-parents=7 # Maximum number of public methods for a class (see R0904). -max-public-methods=40 +max-public-methods=25 # Maximum number of return / yield for function / method body. -max-returns=6 +max-returns=25 # Maximum number of statements in function / method body. -max-statements=50 +max-statements=25 # Minimum number of public methods for a class (see R0903). -min-public-methods=2 +min-public-methods=0 [EXCEPTIONS] @@ -454,14 +473,32 @@ disable=raw-checker-failed, file-ignored, suppressed-message, useless-suppression, - deprecated-pragma, + deprecated-pragma, # because we have some peniding deprecations in the code. use-symbolic-message-instead, use-implicit-booleaness-not-comparison-to-string, use-implicit-booleaness-not-comparison-to-zero, - no-else-return, + no-else-return, # this is a style preference, we don't need to follow it inconsistent-return-statements, - unspecified-encoding, + unspecified-encoding, # this is not a relevant issue. no-member, # because we use funcify_method decorator + invalid-overridden-method, # because we use funcify_method decorator + too-many-function-args, # because we use funcify_method decorator + fixme, # because we use TODO in the code + missing-module-docstring, # not useful for most of the modules. + attribute-defined-outside-init, # to avoid more than 200 errors (code works fine) + not-an-iterable, # rocketpy Functions are iterable, false positive + too-many-function-args, # gives false positives for Function calls + method-hidden, # avoids some errors in tank_geometry and flight classes + missing-timeout, # not a problem to use requests without timeout + protected-access, # we use private attriubutes out of the class (maybe we should fix this) + duplicate-code, # repeating code is a bad thing, but should we really care about it? + line-too-long, # black already takes care of this + missing-function-docstring, # this is too verbose. + redefined-outer-name, # too verbose, and doesn't add much value + method-cache-max-size-none, + no-else-raise, # pointless + + # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..3b60e9fc0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,23 @@ +{ + "recommendations": [ + "ambooth.git-rename", + "github.vscode-pull-request-github", + "gruntfuggly.todo-tree", + "mechatroner.rainbow-csv", + "ms-python.black-formatter", + "ms-python.debugpy", + "ms-python.pylint", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-toolsai.jupyter", + "ms-toolsai.jupyter-keymap", + "ms-toolsai.jupyter-renderers", + "ms-toolsai.vscode-jupyter-cell-tags", + "ms-toolsai.vscode-jupyter-slideshow", + "ms-vscode.cmake-tools", + "ms-vscode.makefile-tools", + "njpwerner.autodocstring", + "streetsidesoftware.code-spell-checker", + "trond-snekvik.simple-rst", + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..16014706e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,214 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "autoDocstring.docstringFormat": "numpy", + "cSpell.enableFiletypes": [ + "python", + "jupyter", + "markdown", + "restructuredtext" + ], + "cSpell.language": "en-US", + "cSpell.words": [ + "Abdulklech", + "adjugate", + "akima", + "allclose", + "altitudemode", + "Alves", + "amax", + "arange", + "arccos", + "arcsin", + "arctan", + "argmax", + "argmin", + "argsort", + "atol", + "attrname", + "autoclass", + "autofetch", + "autopep", + "autouse", + "axhline", + "axup", + "axvline", + "behaviour", + "bijective", + "brentq", + "Calebe", + "calisto", + "Calisto", + "Cardano's", + "cardanos", + "carlo", + "CDEFGHJKLMNPQRSTUVWXX", + "Ceotto", + "cesaroni", + "Cesaroni", + "cftime", + "changealphaint", + "Chassikos", + "clabel", + "clalpha", + "cmap", + "cmcens", + "coeff", + "coeffs", + "colorbar", + "colormaps", + "contourf", + "conusnest", + "cstride", + "csys", + "datapoints", + "ddot", + "deletechars", + "dimgrey", + "discretizes", + "disp", + "dtype", + "ECMWF", + "edgecolor", + "epsabs", + "epsrel", + "errstate", + "evals", + "exponentiated", + "extrap", + "facecolor", + "fastapi", + "Fernandes", + "fftfreq", + "figsize", + "filt", + "fmax", + "fmin", + "fontsize", + "freestream", + "funcified", + "funcify", + "GEFS", + "genfromtxt", + "geopotential", + "geopotentials", + "getdata", + "getfixturevalue", + "Giorgio", + "Giovani", + "github", + "Glauert", + "gmaps", + "Gomes", + "grav", + "hemis", + "hgtprs", + "hgtsfc", + "HIRESW", + "hspace", + "ICONEU", + "idxmax", + "imageio", + "imread", + "intc", + "interp", + "Interquartile", + "intp", + "ipywidgets", + "isbijective", + "isin", + "jsonpickle", + "jupyter", + "Karman", + "linalg", + "linestyle", + "linewidth", + "loadtxt", + "LSODA", + "lvhaack", + "Mandioca", + "mathutils", + "maxdepth", + "mbar", + "meshgrid", + "Metrum", + "mult", + "Mumma", + "NASADEM", + "NDAP", + "ndarray", + "NDRT", + "NETCDF", + "newlinestring", + "newmultigeometry", + "newpolygon", + "nfev", + "NOAA", + "NOAA's", + "noaaruc", + "num2pydate", + "outerboundaryis", + "planform", + "polystyle", + "powerseries", + "Projeto", + "prometheus", + "pytz", + "Rdot", + "referece", + "relativetoground", + "reynolds", + "ROABs", + "rocketpy", + "rstride", + "rtol", + "rucsoundings", + "rwork", + "savetxt", + "savgol", + "scilimits", + "searchsorted", + "seealso", + "simplekml", + "SIRGAS", + "somgl", + "Somigliana", + "SRTM", + "SRTMGL", + "subintervals", + "ticklabel", + "timezonefinder", + "tmpprs", + "toctree", + "trapz", + "TRHEDDS", + "triggerfunc", + "twinx", + "udot", + "ufunc", + "ugrdprs", + "USGS", + "uwyo", + "vectorize", + "vgrdprs", + "viridis", + "vmax", + "vmin", + "vonkarman", + "Weibull", + "windrose", + "wireframe", + "wspace", + "xlabel", + "xlim", + "xticks", + "ylabel", + "ylim", + "zdir", + "zlabel", + "zlim" + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc386a24..dd67af47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,10 @@ Attention: The newest changes should be on top --> ### Changed -- +- ENH: Insert apogee state into solution list during flight simulation [#638](https://github.com/RocketPy-Team/RocketPy/pull/638) +- ENH: Environment class major refactor may 2024 [#605](https://github.com/RocketPy-Team/RocketPy/pull/605) +- MNT: Refactors the code to adopt pylint [#621](https://github.com/RocketPy-Team/RocketPy/pull/621) +- MNT: Refactor AeroSurfaces [#634](https://github.com/RocketPy-Team/RocketPy/pull/634) ### Fixed diff --git a/Makefile b/Makefile index 143d27d81..07c620ade 100644 --- a/Makefile +++ b/Makefile @@ -23,14 +23,21 @@ install: pip install -r requirements-optional.txt pip install -e . +format: isort black + isort: isort --profile black rocketpy/ tests/ docs/ black: black rocketpy/ tests/ docs/ - + +lint: flake8 pylint + +flake8: + flake8 rocketpy/ tests/ + pylint: - -pylint rocketpy tests --output=.pylint-report.txt + -pylint rocketpy/ tests/ --output=.pylint-report.txt build-docs: cd docs && $(PYTHON) -m pip install -r requirements.txt && make html diff --git a/docs/development/testing.rst b/docs/development/testing.rst index 444178a6a..68ee91517 100644 --- a/docs/development/testing.rst +++ b/docs/development/testing.rst @@ -216,7 +216,7 @@ Consider the following integration test: # give it at least 5 times to try to download the file example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=URL) - assert example_plain_env.all_info() == None + assert example_plain_env.all_info() is None assert abs(example_plain_env.pressure(0) - 93600.0) < 1e-8 assert ( abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0) diff --git a/pyproject.toml b/pyproject.toml index 426be231b..5e541a8ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,9 @@ line-length = 88 include = '\.py$|\.ipynb$' skip-string-normalization = true +[tool.isort] +profile = "black" + [tool.coverage.report] # Regexes for lines to exclude from consideration exclude_also = [ @@ -71,7 +74,16 @@ exclude_also = [ [tool.flake8] max-line-length = 88 max-module-lines = 3000 -ignore = ['E203', 'W503'] +ignore = [ + 'W503', # conflicts with black (line break before binary operator) + 'E203', # conflicts with black (whitespace before ':') + 'E501', # ignored now because it is hard to fix the whole code (line too long) + 'E266', # this is pointless (too many leading '#' for block comment) + 'F401', # too many errors on __init__.py files (imported but unused) + 'E722', # pylint already checks for bare except + 'E226', # black does not adjust errors like this + 'E731', # pylint already checks for this (lambda functions) +] exclude = [ '.git,__pycache__', ] diff --git a/rocketpy/environment/__init__.py b/rocketpy/environment/__init__.py index 77accd3fa..13f86c51d 100644 --- a/rocketpy/environment/__init__.py +++ b/rocketpy/environment/__init__.py @@ -1,2 +1,9 @@ +"""The rocketpy.environment module is responsible for the Atmospheric and Earth +models. The methods and classes not listed in the __all__ variable will be +considered private and should be used with caution. +""" + from .environment import Environment from .environment_analysis import EnvironmentAnalysis + +__all__ = ["Environment", "EnvironmentAnalysis"] diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 8c70612b9..5d352d929 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1,42 +1,52 @@ +# pylint: disable=too-many-public-methods, too-many-instance-attributes import bisect import json import re import warnings from collections import namedtuple -from datetime import datetime, timedelta, timezone +from datetime import datetime +import netCDF4 import numpy as np -import numpy.ma as ma import pytz -import requests - -from ..mathutils.function import Function, funcify_method -from ..plots.environment_plots import _EnvironmentPlots -from ..prints.environment_prints import _EnvironmentPrints -from ..tools import exponential_backoff - -try: - import netCDF4 -except ImportError: - has_netCDF4 = False - warnings.warn( - "Unable to load netCDF4. NetCDF files and ``OPeNDAP`` will not be imported.", - ImportWarning, - ) -else: - has_netCDF4 = True - - -def requires_netCDF4(func): - def wrapped_func(*args, **kwargs): - if has_netCDF4: - func(*args, **kwargs) - else: - raise ImportError( - "This feature requires netCDF4 to be installed. Install it with `pip install netCDF4`" - ) - return wrapped_func +from rocketpy.environment.fetchers import ( + fetch_atmospheric_data_from_windy, + fetch_gefs_ensemble, + fetch_gfs_file_return_dataset, + fetch_hiresw_file_return_dataset, + fetch_nam_file_return_dataset, + fetch_noaaruc_sounding, + fetch_open_elevation, + fetch_rap_file_return_dataset, + fetch_wyoming_sounding, +) +from rocketpy.environment.tools import ( + calculate_wind_heading, + calculate_wind_speed, + convert_wind_heading_to_direction, + find_latitude_index, + find_longitude_index, + find_time_index, +) +from rocketpy.environment.tools import geodesic_to_utm as geodesic_to_utm_tools +from rocketpy.environment.tools import ( + get_elevation_data_from_dataset, + get_final_date_from_time_array, + get_initial_date_from_time_array, + get_interval_date_from_time_array, + get_pressure_levels_from_file, + mask_and_clean_dataset, +) +from rocketpy.environment.tools import utm_to_geodesic as utm_to_geodesic_tools +from rocketpy.environment.weather_model_mapping import WeatherModelMapping +from rocketpy.mathutils.function import NUMERICAL_TYPES, Function, funcify_method +from rocketpy.plots.environment_plots import _EnvironmentPlots +from rocketpy.prints.environment_prints import _EnvironmentPrints +from rocketpy.tools import ( + bilinear_interpolation, + geopotential_height_to_geometric_height, +) class Environment: @@ -57,8 +67,7 @@ class Environment: Launch site longitude. Environment.datum : string The desired reference ellipsoid model, the following options are - available: "SAD69", "WGS84", "NAD83", and "SIRGAS2000". The default - is "SIRGAS2000". + available: "SAD69", "WGS84", "NAD83", and "SIRGAS2000". Environment.initial_east : float Launch site East UTM coordinate Environment.initial_north : float @@ -94,7 +103,7 @@ class Environment: True if the user already set a topographic profile. False otherwise. Environment.max_expected_height : float Maximum altitude in meters to keep weather data. The altitude must be - above sea level (ASL). Especially useful for controlling plottings. + above sea level (ASL). Especially useful for controlling plots. Can be altered as desired by doing `max_expected_height = number`. Environment.pressure_ISA : Function Air pressure in Pa as a function of altitude as defined by the @@ -345,25 +354,91 @@ def __init__( ------- None """ - # Initialize constants - self.earth_radius = 6.3781 * (10**6) - self.air_gas_constant = 287.05287 # in J/K/Kg - self.standard_g = 9.80665 - - # Initialize launch site details - self.elevation = elevation - self.set_elevation(elevation) - self._max_expected_height = max_expected_height + # Initialize constants and atmospheric variables + self.__initialize_empty_variables() + self.__initialize_constants() + self.__initialize_elevation_and_max_height(elevation, max_expected_height) # Initialize plots and prints objects self.prints = _EnvironmentPrints(self) self.plots = _EnvironmentPlots(self) - # Initialize atmosphere + # Set the atmosphere model to the standard atmosphere self.set_atmospheric_model("standard_atmosphere") - # Save date - if date != None: + # Initialize date, latitude, longitude, and Earth geometry + self.__initialize_date(date, timezone) + self.set_location(latitude, longitude) + self.__initialize_earth_geometry(datum) + self.__initialize_utm_coordinates() + + # Set the gravity model + self.gravity = self.set_gravity_model(gravity) + + def __initialize_constants(self): + """Sets some important constants and atmospheric variables.""" + self.earth_radius = 6.3781 * (10**6) + self.air_gas_constant = 287.05287 # in J/K/kg + self.standard_g = 9.80665 + self.__weather_model_map = WeatherModelMapping() + self.__atm_type_file_to_function_map = { + ("Forecast", "GFS"): fetch_gfs_file_return_dataset, + ("Forecast", "NAM"): fetch_nam_file_return_dataset, + ("Forecast", "RAP"): fetch_rap_file_return_dataset, + ("Forecast", "HIRESW"): fetch_hiresw_file_return_dataset, + ("Ensemble", "GEFS"): fetch_gefs_ensemble, + # ("Ensemble", "CMC"): fetch_cmc_ensemble, + } + self.__standard_atmosphere_layers = { + "geopotential_height": [ # in geopotential m + -2e3, + 0, + 11e3, + 20e3, + 32e3, + 47e3, + 51e3, + 71e3, + 80e3, + ], + "temperature": [ # in K + 301.15, + 288.15, + 216.65, + 216.65, + 228.65, + 270.65, + 270.65, + 214.65, + 196.65, + ], + "beta": [-6.5e-3, -6.5e-3, 0, 1e-3, 2.8e-3, 0, -2.8e-3, -2e-3, 0], # in K/m + "pressure": [ # in Pa + 1.27774e5, + 1.01325e5, + 2.26320e4, + 5.47487e3, + 8.680164e2, + 1.10906e2, + 6.69384e1, + 3.95639e0, + 8.86272e-2, + ], + } + + def __initialize_empty_variables(self): + self.atmospheric_model_file = str() + self.atmospheric_model_dict = {} + + def __initialize_elevation_and_max_height(self, elevation, max_expected_height): + """Saves the elevation and the maximum expected height.""" + self.elevation = elevation + self.set_elevation(elevation) + self._max_expected_height = max_expected_height + + def __initialize_date(self, date, timezone): + """Saves the date and configure timezone.""" + if date is not None: self.set_date(date, timezone) else: self.date = None @@ -371,45 +446,185 @@ def __init__( self.local_date = None self.timezone = None - # Initialize Earth geometry and save datum + def __initialize_earth_geometry(self, datum): + """Initialize Earth geometry, save datum and Recalculate Earth Radius""" self.datum = datum self.ellipsoid = self.set_earth_geometry(datum) + self.earth_radius = self.calculate_earth_radius( + lat=self.latitude, + semi_major_axis=self.ellipsoid.semi_major_axis, + flattening=self.ellipsoid.flattening, + ) - # Save latitude and longitude - self.latitude = latitude - self.longitude = longitude - if latitude != None and longitude != None: - self.set_location(latitude, longitude) - else: - self.latitude, self.longitude = None, None - - # Store launch site coordinates referenced to UTM projection system - if self.latitude > -80 and self.latitude < 84: - convert = self.geodesic_to_utm( + def __initialize_utm_coordinates(self): + """Store launch site coordinates referenced to UTM projection system.""" + if -80 < self.latitude < 84: + ( + self.initial_east, + self.initial_north, + self.initial_utm_zone, + self.initial_utm_letter, + self.initial_hemisphere, + self.initial_ew, + ) = self.geodesic_to_utm( lat=self.latitude, lon=self.longitude, flattening=self.ellipsoid.flattening, semi_major_axis=self.ellipsoid.semi_major_axis, ) + else: + # pragma: no cover + warnings.warn( + "UTM coordinates are not available for latitudes " + "above 84 or below -80 degrees. The UTM conversions will fail." + ) + self.initial_north = None + self.initial_east = None + self.initial_utm_zone = None + self.initial_utm_letter = None + self.initial_hemisphere = None + self.initial_ew = None - self.initial_north = convert[1] - self.initial_east = convert[0] - self.initial_utm_zone = convert[2] - self.initial_utm_letter = convert[3] - self.initial_hemisphere = convert[4] - self.initial_ew = convert[5] + # Auxiliary private setters. - # Set gravity model - self.gravity = self.set_gravity_model(gravity) + def __set_pressure_function(self, source): + self.pressure = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Pressure (Pa)", + interpolation="linear", + ) - # Recalculate Earth Radius (meters) - self.earth_radius = self.calculate_earth_radius( - lat=self.latitude, - semi_major_axis=self.ellipsoid.semi_major_axis, - flattening=self.ellipsoid.flattening, + def __set_barometric_height_function(self, source): + self.barometric_height = Function( + source, + inputs="Pressure (Pa)", + outputs="Height Above Sea Level (m)", + interpolation="linear", + extrapolation="natural", + ) + if callable(self.barometric_height.source): + # discretize to speed up flight simulation + self.barometric_height.set_discrete( + 0, + self.max_expected_height, + 100, + extrapolation="constant", + mutate_self=True, + ) + + def __set_temperature_function(self, source): + self.temperature = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Temperature (K)", + interpolation="linear", + ) + + def __set_wind_velocity_x_function(self, source): + self.wind_velocity_x = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Velocity X (m/s)", + interpolation="linear", + ) + + def __set_wind_velocity_y_function(self, source): + self.wind_velocity_y = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Velocity Y (m/s)", + interpolation="linear", + ) + + def __set_wind_speed_function(self, source): + self.wind_speed = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Speed (m/s)", + interpolation="linear", + ) + + def __set_wind_direction_function(self, source): + self.wind_direction = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Direction (Deg True)", + interpolation="linear", + ) + + def __set_wind_heading_function(self, source): + self.wind_heading = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Heading (Deg True)", + interpolation="linear", ) - return None + def __reset_barometric_height_function(self): + # NOTE: this assumes self.pressure and max_expected_height are already set. + self.barometric_height = self.pressure.inverse_function() + if callable(self.barometric_height.source): + # discretize to speed up flight simulation + self.barometric_height.set_discrete( + 0, + self.max_expected_height, + 100, + extrapolation="constant", + mutate_self=True, + ) + self.barometric_height.set_inputs("Pressure (Pa)") + self.barometric_height.set_outputs("Height Above Sea Level (m)") + + def __reset_wind_speed_function(self): + # NOTE: assume wind_velocity_x and wind_velocity_y as Function objects + self.wind_speed = (self.wind_velocity_x**2 + self.wind_velocity_y**2) ** 0.5 + self.wind_speed.set_inputs("Height Above Sea Level (m)") + self.wind_speed.set_outputs("Wind Speed (m/s)") + self.wind_speed.set_title("Wind Speed Profile") + + # commented because I never finished, leave it for future implementation + # def __reset_wind_heading_function(self): + # NOTE: this assumes wind_u and wind_v as numpy arrays with same length. + # TODO: should we implement arctan2 in the Function class? + # self.wind_heading = calculate_wind_heading( + # self.wind_velocity_x, self.wind_velocity_y + # ) + # self.wind_heading.set_inputs("Height Above Sea Level (m)") + # self.wind_heading.set_outputs("Wind Heading (Deg True)") + # self.wind_heading.set_title("Wind Heading Profile") + + def __reset_wind_direction_function(self): + self.wind_direction = convert_wind_heading_to_direction(self.wind_heading) + self.wind_direction.set_inputs("Height Above Sea Level (m)") + self.wind_direction.set_outputs("Wind Direction (Deg True)") + self.wind_direction.set_title("Wind Direction Profile") + + # Validators (used to verify an attribute is being set correctly.) + + def __validate_dictionary(self, file, dictionary): + # removed CMC until it is fixed. + available_models = ["GFS", "NAM", "RAP", "HIRESW", "GEFS", "ERA5"] + if isinstance(dictionary, str): + dictionary = self.__weather_model_map.get(dictionary) + elif file in available_models: + dictionary = self.__weather_model_map.get(file) + if not isinstance(dictionary, dict): + raise TypeError( + "Please specify a dictionary or choose a valid model from the " + f"following list: {available_models}" + ) + + return dictionary + + def __validate_datetime(self): + if self.datetime_date is None: + raise ValueError( + "Please specify the launch date and time using the " + "Environment.set_date() method." + ) + + # Define setters def set_date(self, date, timezone="UTC"): """Set date and time of launch and update weather conditions if @@ -472,11 +687,11 @@ def set_date(self, date, timezone="UTC"): # Store date and configure time zone self.timezone = timezone tz = pytz.timezone(self.timezone) - if type(date) != datetime: + if not isinstance(date, datetime): local_date = datetime(*date) else: local_date = date - if local_date.tzinfo == None: + if local_date.tzinfo is None: local_date = tz.localize(local_date) self.date = date self.local_date = local_date @@ -484,15 +699,14 @@ def set_date(self, date, timezone="UTC"): # Update atmospheric conditions if atmosphere type is Forecast, # Reanalysis or Ensemble - try: - if self.atmospheric_model_type in ["Forecast", "Reanalysis", "Ensemble"]: - self.set_atmospheric_model( - self.atmospheric_model_file, self.atmospheric_model_dict - ) - except AttributeError: - pass - - return None + if hasattr(self, "atmospheric_model_type") and self.atmospheric_model_type in [ + "Forecast", + "Reanalysis", + "Ensemble", + ]: + self.set_atmospheric_model( + self.atmospheric_model_file, self.atmospheric_model_dict + ) def set_location(self, latitude, longitude): """Set latitude and longitude of launch and update atmospheric @@ -510,13 +724,24 @@ def set_location(self, latitude, longitude): ------- None """ + + if not isinstance(latitude, NUMERICAL_TYPES) and isinstance( + longitude, NUMERICAL_TYPES + ): + # pragma: no cover + raise TypeError("Latitude and Longitude must be numbers!") + # Store latitude and longitude self.latitude = latitude self.longitude = longitude # Update atmospheric conditions if atmosphere type is Forecast, # Reanalysis or Ensemble - if self.atmospheric_model_type in ["Forecast", "Reanalysis", "Ensemble"]: + if hasattr(self, "atmospheric_model_type") and self.atmospheric_model_type in [ + "Forecast", + "Reanalysis", + "Ensemble", + ]: self.set_atmospheric_model( self.atmospheric_model_file, self.atmospheric_model_dict ) @@ -598,7 +823,7 @@ def max_expected_height(self): @max_expected_height.setter def max_expected_height(self, value): if value < self.elevation: - raise ValueError( + raise ValueError( # pragma: no cover "Max expected height cannot be lower than the surface elevation" ) self._max_expected_height = value @@ -666,32 +891,16 @@ def set_elevation(self, elevation="Open-Elevation"): ------- None """ - if elevation != "Open-Elevation" and elevation != "SRTM": - self.elevation = float(elevation) - # elif elevation == "SRTM" and self.latitude != None and self.longitude != None: - # # Trigger the authentication flow. - # #ee.Authenticate() - # # Initialize the library. - # ee.Initialize() - - # # Calculate elevation - # dem = ee.Image('USGS/SRTMGL1_003') - # xy = ee.Geometry.Point([self.longitude, self.latitude]) - # elev = dem.sample(xy, 30).first().get('elevation').getInfo() - - # self.elevation = elev - - elif self.latitude is not None and self.longitude is not None: - self.elevation = float(self.__fetch_open_elevation()) - print("Elevation received: ", self.elevation) + if elevation not in ["Open-Elevation", "SRTM"]: + # NOTE: this is assuming the elevation is a number (i.e. float, int, etc.) + self.elevation = elevation else: - raise ValueError( - "Latitude and longitude must be set to use" - " Open-Elevation API. See Environment.set_location." - ) + self.elevation = fetch_open_elevation(self.latitude, self.longitude) + print("Elevation received: ", self.elevation) - @requires_netCDF4 - def set_topographic_profile(self, type, file, dictionary="netCDF4", crs=None): + def set_topographic_profile( # pylint: disable=redefined-builtin, unused-argument + self, type, file, dictionary="netCDF4", crs=None + ): """[UNDER CONSTRUCTION] Defines the Topographic profile, importing data from previous downloaded files. Mainly data from the Shuttle Radar Topography Mission (SRTM) and NASA Digital Elevation Model will be used @@ -728,18 +937,14 @@ def set_topographic_profile(self, type, file, dictionary="netCDF4", crs=None): print("Region covered by the Topographical file: ") print( - "Latitude from {:.6f}° to {:.6f}°".format( - self.elev_lat_array[-1], self.elev_lat_array[0] - ) + f"Latitude from {self.elev_lat_array[-1]:.6f}° to " + f"{self.elev_lat_array[0]:.6f}°" ) print( - "Longitude from {:.6f}° to {:.6f}°".format( - self.elev_lon_array[0], self.elev_lon_array[-1] - ) + f"Longitude from {self.elev_lon_array[0]:.6f}° to " + f"{self.elev_lon_array[-1]:.6f}°" ) - return None - def get_elevation_from_topographic_profile(self, lat, lon): """Function which receives as inputs the coordinates of a point and finds its elevation in the provided Topographic Profile. @@ -756,11 +961,12 @@ def get_elevation_from_topographic_profile(self, lat, lon): elevation : float | int Elevation provided by the topographic data, in meters. """ - if self.topographic_profile_activated == False: - print( - "You must define a Topographic profile first, please use the method Environment.set_topographic_profile()" + # TODO: refactor this method. pylint: disable=too-many-statements + if self.topographic_profile_activated is False: + raise ValueError( # pragma: no cover + "You must define a Topographic profile first, please use the " + "Environment.set_topographic_profile() method first." ) - return None # Find latitude index # Check if reversed or sorted @@ -783,9 +989,8 @@ def get_elevation_from_topographic_profile(self, lat, lon): # Check if latitude value is inside the grid if lat_index == 0 or lat_index == len(self.elev_lat_array): raise ValueError( - "Latitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - lat, self.elev_lat_array[0], self.elev_lat_array[-1] - ) + f"Latitude {lat} not inside region covered by file, which is from " + f"{self.elev_lat_array[0]} to {self.elev_lat_array[-1]}." ) # Find longitude index @@ -816,9 +1021,8 @@ def get_elevation_from_topographic_profile(self, lat, lon): # Check if longitude value is inside the grid if lon_index == 0 or lon_index == len(self.elev_lon_array): raise ValueError( - "Longitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - lon, self.elev_lon_array[0], self.elev_lon_array[-1] - ) + f"Longitude {lon} not inside region covered by file, which is from " + f"{self.elev_lon_array[0]} to {self.elev_lon_array[-1]}." ) # Get the elevation @@ -826,9 +1030,9 @@ def get_elevation_from_topographic_profile(self, lat, lon): return elevation - def set_atmospheric_model( + def set_atmospheric_model( # pylint: disable=too-many-statements self, - type, + type, # pylint: disable=redefined-builtin file=None, dictionary=None, pressure=None, @@ -946,7 +1150,7 @@ def set_atmospheric_model( .. seealso:: To activate other ensemble forecasts see - ``Environment.selectEnsembleMemberMember``. + ``Environment.select_ensemble_member``. - ``custom_atmosphere``: sets pressure, temperature, wind-u and wind-v profiles given though the pressure, temperature, wind-u and @@ -965,19 +1169,17 @@ def set_atmospheric_model( .. note:: - Time referece for the Forecasts are: + Time reference for the Forecasts are: - ``GFS``: `Global` - 0.25deg resolution - Updates every 6 hours, forecast for 81 points spaced by 3 hours - - ``FV3``: `Global` - 0.25deg resolution - Updates every 6 - hours, forecast for 129 points spaced by 3 hours - ``RAP``: `Regional USA` - 0.19deg resolution - Updates hourly, forecast for 40 points spaced hourly - ``NAM``: `Regional CONUS Nest` - 5 km resolution - Updates every 6 hours, forecast for 21 points spaced by 3 hours - If type is ``Ensemble``, this parameter can also be either ``GEFS``, - or ``CMC`` for the latest of these ensembles. + If type is ``Ensemble``, this parameter can also be ``GEFS`` + for the latest of this ensemble. .. note:: @@ -986,8 +1188,9 @@ def set_atmospheric_model( - GEFS: Global, bias-corrected, 0.5deg resolution, 21 forecast members, Updates every 6 hours, forecast for 65 points spaced by 4 hours - - CMC: Global, 0.5deg resolution, 21 forecast members, Updates - every 12 hours, forecast for 65 points spaced by 4 hours + - CMC (currently not available): Global, 0.5deg resolution, 21 \ + forecast members, Updates every 12 hours, forecast for 65 \ + points spaced by 4 hours If type is ``Windy``, this parameter can be either ``GFS``, ``ECMWF``, ``ICON`` or ``ICONEU``. Default in this case is ``ECMWF``. @@ -1086,286 +1289,42 @@ def set_atmospheric_model( # Save atmospheric model type self.atmospheric_model_type = type - # Handle each case + # Handle each case # TODO: use match case when python 3.9 is no longer supported if type == "standard_atmosphere": self.process_standard_atmosphere() elif type == "wyoming_sounding": self.process_wyoming_sounding(file) - # Save file - self.atmospheric_model_file = file elif type == "NOAARucSounding": self.process_noaaruc_sounding(file) - # Save file - self.atmospheric_model_file = file - elif type == "Forecast" or type == "Reanalysis": - # Process default forecasts if requested - if file == "GFS": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Attempt to get latest forecast - time_attempt = datetime.utcnow() - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=6 * attempt_count) - file = "https://nomads.ncep.noaa.gov/dods/gfs_0p25/gfs{:04d}{:02d}{:02d}/gfs_0p25_{:02d}z".format( - time_attempt.year, - time_attempt.month, - time_attempt.day, - 6 * (time_attempt.hour // 6), - ) - try: - self.process_forecast_reanalysis(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for GFS through " + file - ) - elif file == "FV3": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Attempt to get latest forecast - time_attempt = datetime.utcnow() - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=6 * attempt_count) - file = "https://nomads.ncep.noaa.gov/dods/gfs_0p25_parafv3/gfs{:04d}{:02d}{:02d}/gfs_0p25_parafv3_{:02d}z".format( - time_attempt.year, - time_attempt.month, - time_attempt.day, - 6 * (time_attempt.hour // 6), - ) - try: - self.process_forecast_reanalysis(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for FV3 through " + file - ) - elif file == "NAM": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Attempt to get latest forecast - time_attempt = datetime.utcnow() - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=6 * attempt_count) - file = "https://nomads.ncep.noaa.gov/dods/nam/nam{:04d}{:02d}{:02d}/nam_conusnest_{:02d}z".format( - time_attempt.year, - time_attempt.month, - time_attempt.day, - 6 * (time_attempt.hour // 6), - ) - try: - self.process_forecast_reanalysis(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for NAM through " + file - ) - elif file == "RAP": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Attempt to get latest forecast - time_attempt = datetime.utcnow() - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=1 * attempt_count) - file = "https://nomads.ncep.noaa.gov/dods/rap/rap{:04d}{:02d}{:02d}/rap_{:02d}z".format( - time_attempt.year, - time_attempt.month, - time_attempt.day, - time_attempt.hour, - ) - try: - self.process_forecast_reanalysis(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for RAP through " + file - ) - # Process other forecasts or reanalysis - else: - # Check if default dictionary was requested - if dictionary == "ECMWF": - dictionary = { - "time": "time", - "latitude": "latitude", - "longitude": "longitude", - "level": "level", - "temperature": "t", - "surface_geopotential_height": None, - "geopotential_height": None, - "geopotential": "z", - "u_wind": "u", - "v_wind": "v", - } - elif dictionary == "NOAA": - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - elif dictionary is None: - raise TypeError( - "Please specify a dictionary or choose a default one such as ECMWF or NOAA." - ) - # Process forecast or reanalysis - self.process_forecast_reanalysis(file, dictionary) - # Save dictionary and file - self.atmospheric_model_file = file - self.atmospheric_model_dict = dictionary - elif type == "Ensemble": - # Process default forecasts if requested - if file == "GEFS": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", - "surface_geopotential_height": None, - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Attempt to get latest forecast - self.__fetch_gefs_ensemble(dictionary) - - elif file == "CMC": - # Define dictionary - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", - "surface_geopotential_height": None, - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - self.__fetch_cmc_ensemble(dictionary) - # Process other forecasts or reanalysis - else: - # Check if default dictionary was requested - if dictionary == "ECMWF": - dictionary = { - "time": "time", - "latitude": "latitude", - "longitude": "longitude", - "level": "level", - "ensemble": "number", - "temperature": "t", - "surface_geopotential_height": None, - "geopotential_height": None, - "geopotential": "z", - "u_wind": "u", - "v_wind": "v", - } - elif dictionary == "NOAA": - dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", - "surface_geopotential_height": None, - "geopotential_height": "hgtprs", - "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - # Process forecast or reanalysis - self.process_ensemble(file, dictionary) - # Save dictionary and file - self.atmospheric_model_file = file - self.atmospheric_model_dict = dictionary elif type == "custom_atmosphere": self.process_custom_atmosphere(pressure, temperature, wind_u, wind_v) elif type == "Windy": self.process_windy_atmosphere(file) - else: - raise ValueError("Unknown model type.") + elif type in ["Forecast", "Reanalysis", "Ensemble"]: + dictionary = self.__validate_dictionary(file, dictionary) + fetch_function = self.__atm_type_file_to_function_map.get((type, file)) - # Calculate air density - self.calculate_density_profile() + # Fetches the dataset using OpenDAP protocol or uses the file path + dataset = fetch_function() if fetch_function is not None else file - # Calculate speed of sound - self.calculate_speed_of_sound_profile() + if type in ["Forecast", "Reanalysis"]: + self.process_forecast_reanalysis(dataset, dictionary) + else: + self.process_ensemble(dataset, dictionary) + else: + raise ValueError(f"Unknown model type '{type}'.") # pragma: no cover - # Update dynamic viscosity - self.calculate_dynamic_viscosity() + if type not in ["Ensemble"]: + # Ensemble already computed these values + self.calculate_density_profile() + self.calculate_speed_of_sound_profile() + self.calculate_dynamic_viscosity() - return None + # Save dictionary and file + self.atmospheric_model_file = file + self.atmospheric_model_dict = dictionary + + # Atmospheric model processing methods def process_standard_atmosphere(self): """Sets pressure and temperature profiles corresponding to the @@ -1377,49 +1336,20 @@ def process_standard_atmosphere(self): ------- None """ - # Load international standard atmosphere - self.load_international_standard_atmosphere() - # Save temperature, pressure and wind profiles self.pressure = self.pressure_ISA self.barometric_height = self.barometric_height_ISA - self.temperature = self.temperature_ISA - self.wind_direction = Function( - 0, - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - 0, - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - 0, - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - 0, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - 0, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) - # Set maximum expected height - self.max_expected_height = 80000 + # Set wind profiles to zero + self.__set_wind_direction_function(0) + self.__set_wind_heading_function(0) + self.__set_wind_velocity_x_function(0) + self.__set_wind_velocity_y_function(0) + self.__set_wind_speed_function(0) - return None + # 80k meters is the limit of the standard atmosphere + self.max_expected_height = 80000 def process_custom_atmosphere( self, pressure=None, temperature=None, wind_u=0, wind_v=0 @@ -1505,17 +1435,9 @@ def process_custom_atmosphere( self.barometric_height = self.barometric_height_ISA else: # Use custom input - self.pressure = Function( - pressure, - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) - self.barometric_height = self.pressure.inverse_function().set_discrete( - 0, max_expected_height, 100, extrapolation="constant" - ) - self.barometric_height.set_inputs("Pressure (Pa)") - self.barometric_height.set_outputs("Height Above Sea Level (m)") + self.__set_pressure_function(pressure) + self.__reset_barometric_height_function() + # Check maximum height of custom pressure input if not callable(self.pressure.source): max_expected_height = max(self.pressure[-1, 0], max_expected_height) @@ -1525,79 +1447,34 @@ def process_custom_atmosphere( # Use standard atmosphere self.temperature = self.temperature_ISA else: - self.temperature = Function( - temperature, - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) + self.__set_temperature_function(temperature) # Check maximum height of custom temperature input if not callable(self.temperature.source): max_expected_height = max(self.temperature[-1, 0], max_expected_height) # Save wind profile - self.wind_velocity_x = Function( - wind_u, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - wind_v, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) + self.__set_wind_velocity_x_function(wind_u) + self.__set_wind_velocity_y_function(wind_v) # Check maximum height of custom wind input if not callable(self.wind_velocity_x.source): max_expected_height = max(self.wind_velocity_x[-1, 0], max_expected_height) - def wind_heading_func(h): - return ( - np.arctan2( - self.wind_velocity_x.get_value_opt(h), - self.wind_velocity_y.get_value_opt(h), - ) - * (180 / np.pi) - % 360 + def wind_heading_func(h): # TODO: create another custom reset for heading + return calculate_wind_heading( + self.wind_velocity_x.get_value_opt(h), + self.wind_velocity_y.get_value_opt(h), ) - self.wind_heading = Function( - wind_heading_func, - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) + self.__set_wind_heading_function(wind_heading_func) - def wind_direction(h): - return (wind_heading_func(h) - 180) % 360 + self.__reset_wind_direction_function() + self.__reset_wind_speed_function() - self.wind_direction = Function( - wind_direction, - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - - def wind_speed(h): - return np.sqrt( - self.wind_velocity_x.get_value_opt(h) ** 2 - + self.wind_velocity_y.get_value_opt(h) ** 2 - ) - - self.wind_speed = Function( - wind_speed, - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - - # Save maximum expected height self.max_expected_height = max_expected_height - return None - - def process_windy_atmosphere(self, model="ECMWF"): + def process_windy_atmosphere( + self, model="ECMWF" + ): # pylint: disable=too-many-statements """Process data from Windy.com to retrieve atmospheric forecast data. Parameters @@ -1609,7 +1486,15 @@ def process_windy_atmosphere(self, model="ECMWF"): model. """ - response = self.__fetch_atmospheric_data_from_windy(model) + if model.lower() not in ["ecmwf", "gfs", "icon", "iconeu"]: + raise ValueError( + f"Invalid model '{model}'. " + "Valid options are 'ECMWF', 'GFS', 'ICON' or 'ICONEU'." + ) + + response = fetch_atmospheric_data_from_windy( + self.latitude, self.longitude, model + ) # Determine time index from model time_array = np.array(response["data"]["hours"]) @@ -1624,99 +1509,40 @@ def process_windy_atmosphere(self, model="ECMWF"): ) # Process geopotential height array - geopotential_height_array = np.array( - [response["data"][f"gh-{pL}h"][time_index] for pL in pressure_levels] - ) - # Convert geopotential height to geometric altitude (ASL) - R = self.earth_radius - altitude_array = R * geopotential_height_array / (R - geopotential_height_array) - - # Process temperature array (in Kelvin) - temperature_array = np.array( - [response["data"][f"temp-{pL}h"][time_index] for pL in pressure_levels] - ) - - # Process wind-u and wind-v array (in m/s) - wind_u_array = np.array( - [response["data"][f"wind_u-{pL}h"][time_index] for pL in pressure_levels] - ) - wind_v_array = np.array( - [response["data"][f"wind_v-{pL}h"][time_index] for pL in pressure_levels] - ) + ( + geopotential_height_array, + altitude_array, + temperature_array, + wind_u_array, + wind_v_array, + ) = self.__parse_windy_file(response, time_index, pressure_levels) # Determine wind speed, heading and direction - wind_speed_array = np.sqrt(wind_u_array**2 + wind_v_array**2) - wind_heading_array = ( - np.arctan2(wind_u_array, wind_v_array) * (180 / np.pi) % 360 - ) - wind_direction_array = (wind_heading_array - 180) % 360 + wind_speed_array = calculate_wind_speed(wind_u_array, wind_v_array) + wind_heading_array = calculate_wind_heading(wind_u_array, wind_v_array) + wind_direction_array = convert_wind_heading_to_direction(wind_heading_array) # Combine all data into big array - data_array = np.ma.column_stack( - [ - 100 * pressure_levels, # Convert hPa to Pa - altitude_array, - temperature_array, - wind_u_array, - wind_v_array, - wind_heading_array, - wind_direction_array, - wind_speed_array, - ] + data_array = mask_and_clean_dataset( + 100 * pressure_levels, # Convert hPa to Pa + altitude_array, + temperature_array, + wind_u_array, + wind_v_array, + wind_heading_array, + wind_direction_array, + wind_speed_array, ) # Save atmospheric data - self.pressure = Function( - data_array[:, (1, 0)], - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) - # Linearly extrapolate pressure to ground level - bar_height = data_array[:, (0, 1)] - self.barometric_height = Function( - bar_height, - inputs="Pressure (Pa)", - outputs="Height Above Sea Level (m)", - interpolation="linear", - extrapolation="natural", - ) - self.temperature = Function( - data_array[:, (1, 2)], - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) - self.wind_direction = Function( - data_array[:, (1, 6)], - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - data_array[:, (1, 5)], - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - data_array[:, (1, 7)], - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - data_array[:, (1, 3)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - data_array[:, (1, 4)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) + self.__set_temperature_function(data_array[:, (1, 2)]) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) # Save maximum expected height self.max_expected_height = max(altitude_array[0], altitude_array[-1]) @@ -1725,15 +1551,15 @@ def process_windy_atmosphere(self, model="ECMWF"): self.elevation = float(response["header"]["elevation"]) # Compute info data - self.atmospheric_model_init_date = netCDF4.num2date( - time_array[0], units=time_units + self.atmospheric_model_init_date = get_initial_date_from_time_array( + time_array, time_units ) - self.atmospheric_model_end_date = netCDF4.num2date( - time_array[-1], units=time_units + self.atmospheric_model_end_date = get_final_date_from_time_array( + time_array, time_units + ) + self.atmospheric_model_interval = get_interval_date_from_time_array( + time_array, time_units ) - self.atmospheric_model_interval = netCDF4.num2date( - (time_array[-1] - time_array[0]) / (len(time_array) - 1), units=time_units - ).hour self.atmospheric_model_init_lat = self.latitude self.atmospheric_model_end_lat = self.latitude self.atmospheric_model_init_lon = self.longitude @@ -1748,7 +1574,37 @@ def process_windy_atmosphere(self, model="ECMWF"): self.time_array = time_array self.height = altitude_array - def process_wyoming_sounding(self, file): + def __parse_windy_file(self, response, time_index, pressure_levels): + geopotential_height_array = np.array( + [response["data"][f"gh-{pL}h"][time_index] for pL in pressure_levels] + ) + # Convert geopotential height to geometric altitude (ASL) + altitude_array = geopotential_height_to_geometric_height( + geopotential_height_array, self.earth_radius + ) + + # Process temperature array (in Kelvin) + temperature_array = np.array( + [response["data"][f"temp-{pL}h"][time_index] for pL in pressure_levels] + ) + + # Process wind-u and wind-v array (in m/s) + wind_u_array = np.array( + [response["data"][f"wind_u-{pL}h"][time_index] for pL in pressure_levels] + ) + wind_v_array = np.array( + [response["data"][f"wind_v-{pL}h"][time_index] for pL in pressure_levels] + ) + + return ( + geopotential_height_array, + altitude_array, + temperature_array, + wind_u_array, + wind_v_array, + ) + + def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements """Import and process the upper air sounding data from `Wyoming Upper Air Soundings` database given by the url in file. Sets pressure, temperature, wind-u, wind-v profiles and surface elevation. @@ -1770,7 +1626,7 @@ def process_wyoming_sounding(self, file): None """ # Request Wyoming Sounding from file url - response = self.__fetch_wyoming_sounding(file) + response = fetch_wyoming_sounding(file) # Process Wyoming Sounding by finding data table and station info response_split_text = re.split("(<.{0,1}PRE>)", response.text) @@ -1779,86 +1635,42 @@ def process_wyoming_sounding(self, file): # Transform data table into np array data_array = [] - for line in data_table.split("\n")[ - 5:-1 - ]: # Split data table into lines and remove header and footer + for line in data_table.split("\n")[5:-1]: + # Split data table into lines and remove header and footer columns = re.split(" +", line) # Split line into columns - if ( - len(columns) == 12 - ): # 12 is the number of column entries when all entries are given + # 12 is the number of column entries when all entries are given + if len(columns) == 12: data_array.append(columns[1:]) data_array = np.array(data_array, dtype=float) # Retrieve pressure from data array data_array[:, 0] = 100 * data_array[:, 0] # Converts hPa to Pa - self.pressure = Function( - data_array[:, (1, 0)], - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) - # Linearly extrapolate pressure to ground level - bar_height = data_array[:, (0, 1)] - self.barometric_height = Function( - bar_height, - inputs="Pressure (Pa)", - outputs="Height Above Sea Level (m)", - interpolation="linear", - extrapolation="natural", - ) + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) # Retrieve temperature from data array data_array[:, 2] = data_array[:, 2] + 273.15 # Converts C to K - self.temperature = Function( - data_array[:, (1, 2)], - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) + self.__set_temperature_function(data_array[:, (1, 2)]) # Retrieve wind-u and wind-v from data array - data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6 # Converts Knots to m/s - data_array[:, 5] = ( - data_array[:, 6] + 180 - ) % 360 # Convert wind direction to wind heading + ## Converts Knots to m/s + data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6 + ## Convert wind direction to wind heading + data_array[:, 5] = (data_array[:, 6] + 180) % 360 data_array[:, 3] = data_array[:, 7] * np.sin(data_array[:, 5] * np.pi / 180) data_array[:, 4] = data_array[:, 7] * np.cos(data_array[:, 5] * np.pi / 180) # Convert geopotential height to geometric height - R = self.earth_radius - data_array[:, 1] = R * data_array[:, 1] / (R - data_array[:, 1]) + data_array[:, 1] = geopotential_height_to_geometric_height( + data_array[:, 1], self.earth_radius + ) # Save atmospheric data - self.wind_direction = Function( - data_array[:, (1, 6)], - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - data_array[:, (1, 5)], - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - data_array[:, (1, 7)], - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - data_array[:, (1, 3)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - data_array[:, (1, 4)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) # Retrieve station elevation from station info station_elevation_text = station_info.split("\n")[6] @@ -1871,9 +1683,7 @@ def process_wyoming_sounding(self, file): # Save maximum expected height self.max_expected_height = data_array[-1, 1] - return None - - def process_noaaruc_sounding(self, file): + def process_noaaruc_sounding(self, file): # pylint: disable=too-many-statements """Import and process the upper air sounding data from `NOAA Ruc Soundings` database (https://rucsoundings.noaa.gov/) given as ASCII GSD format pages passed by its url to the file parameter. Sets @@ -1896,7 +1706,7 @@ def process_noaaruc_sounding(self, file): None """ # Request NOAA Ruc Sounding from file url - response = self.__fetch_noaaruc_sounding(file) + response = fetch_noaaruc_sounding(file) # Split response into lines lines = response.text.split("\n") @@ -1915,146 +1725,85 @@ def process_noaaruc_sounding(self, file): # No elevation data available pass - # Extract pressure as a function of height pressure_array = [] barometric_height_array = [] - for line in lines: - # Split line into columns - columns = re.split(" +", line)[1:] - if len(columns) >= 6: - if columns[0] in ["4", "5", "6", "7", "8", "9"]: - # Convert columns to floats - columns = np.array(columns, dtype=float) - # Select relevant columns - columns = columns[[2, 1]] - # Check if values exist - if max(columns) != 99999: - # Save value - pressure_array.append(columns) - barometric_height_array.append([columns[1], columns[0]]) - pressure_array = np.array(pressure_array) - barometric_height_array = np.array(barometric_height_array) - - # Extract temperature as a function of height temperature_array = [] - for line in lines: - # Split line into columns - columns = re.split(" +", line)[1:] - if len(columns) >= 6: - if columns[0] in ["4", "5", "6", "7", "8", "9"]: - # Convert columns to floats - columns = np.array(columns, dtype=float) - # Select relevant columns - columns = columns[[2, 3]] - # Check if values exist - if max(columns) != 99999: - # Save value - temperature_array.append(columns) - temperature_array = np.array(temperature_array) - - # Extract wind speed and direction as a function of height wind_speed_array = [] wind_direction_array = [] + for line in lines: # Split line into columns columns = re.split(" +", line)[1:] - if len(columns) >= 6: - if columns[0] in ["4", "5", "6", "7", "8", "9"]: - # Convert columns to floats - columns = np.array(columns, dtype=float) - # Select relevant columns - columns = columns[[2, 5, 6]] - # Check if values exist - if max(columns) != 99999: - # Save value - wind_direction_array.append(columns[[0, 1]]) - wind_speed_array.append(columns[[0, 2]]) + if len(columns) < 6: + # skip lines with less than 6 columns + continue + if columns[0] in ["4", "5", "6", "7", "8", "9"]: + # Convert columns to floats + columns = np.array(columns, dtype=float) + # Select relevant columns + altitude, pressure, temperature, wind_direction, wind_speed = columns[ + [2, 1, 3, 5, 6] + ] + # Check for missing values + if altitude == 99999: + continue + # Save values only if they are not missing + if pressure != 99999: + pressure_array.append([altitude, pressure]) + barometric_height_array.append([pressure, altitude]) + if temperature != 99999: + temperature_array.append([altitude, temperature]) + if wind_direction != 99999: + wind_direction_array.append([altitude, wind_direction]) + if wind_speed != 99999: + wind_speed_array.append([altitude, wind_speed]) + + # Convert lists to arrays + pressure_array = np.array(pressure_array) + barometric_height_array = np.array(barometric_height_array) + temperature_array = np.array(temperature_array) wind_speed_array = np.array(wind_speed_array) wind_direction_array = np.array(wind_direction_array) # Converts 10*hPa to Pa and save values pressure_array[:, 1] = 10 * pressure_array[:, 1] - self.pressure = Function( - pressure_array, - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) + self.__set_pressure_function(pressure_array) # Converts 10*hPa to Pa and save values barometric_height_array[:, 0] = 10 * barometric_height_array[:, 0] - self.barometric_height = Function( - barometric_height_array, - inputs="Pressure (Pa)", - outputs="Height Above Sea Level (m)", - interpolation="linear", - extrapolation="natural", - ) + self.__set_barometric_height_function(barometric_height_array) - # Convert 10*C to K and save values - temperature_array[:, 1] = ( - temperature_array[:, 1] / 10 + 273.15 - ) # Converts C to K - self.temperature = Function( - temperature_array, - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) + # Convert C to K and save values + temperature_array[:, 1] = temperature_array[:, 1] / 10 + 273.15 + self.__set_temperature_function(temperature_array) # Process wind-u and wind-v - wind_speed_array[:, 1] = ( - wind_speed_array[:, 1] * 1.852 / 3.6 - ) # Converts Knots to m/s + # Converts Knots to m/s + wind_speed_array[:, 1] = wind_speed_array[:, 1] * 1.852 / 3.6 wind_heading_array = wind_direction_array[:, :] * 1 - wind_heading_array[:, 1] = ( - wind_direction_array[:, 1] + 180 - ) % 360 # Convert wind direction to wind heading + # Convert wind direction to wind heading + wind_heading_array[:, 1] = (wind_direction_array[:, 1] + 180) % 360 wind_u = wind_speed_array[:, :] * 1 wind_v = wind_speed_array[:, :] * 1 wind_u[:, 1] = wind_speed_array[:, 1] * np.sin( - wind_heading_array[:, 1] * np.pi / 180 + np.deg2rad(wind_heading_array[:, 1]) ) wind_v[:, 1] = wind_speed_array[:, 1] * np.cos( - wind_heading_array[:, 1] * np.pi / 180 + np.deg2rad(wind_heading_array[:, 1]) ) # Save wind data - self.wind_direction = Function( - wind_direction_array, - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - wind_heading_array, - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - wind_speed_array, - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - wind_u, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - wind_v, - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) + self.__set_wind_direction_function(wind_direction_array) + self.__set_wind_heading_function(wind_heading_array) + self.__set_wind_speed_function(wind_speed_array) + self.__set_wind_velocity_x_function(wind_u) + self.__set_wind_velocity_y_function(wind_v) # Save maximum expected height self.max_expected_height = pressure_array[-1, 0] - @requires_netCDF4 - def process_forecast_reanalysis(self, file, dictionary): + def process_forecast_reanalysis( + self, file, dictionary + ): # pylint: disable=too-many-locals,too-many-statements """Import and process atmospheric data from weather forecasts and reanalysis given as ``netCDF`` or ``OPeNDAP`` files. Sets pressure, temperature, wind-u and wind-v @@ -2112,132 +1861,36 @@ def process_forecast_reanalysis(self, file, dictionary): None """ # Check if date, lat and lon are known - if self.datetime_date is None: - raise TypeError( - "Please specify Date (array-like) when " - "initializing this Environment. " - "Alternatively, use the Environment.set_date" - " method." - ) - if self.latitude is None: - raise TypeError( - "Please specify Location (lat, lon). when " - "initializing this Environment. " - "Alternatively, use the Environment." - "set_location method." - ) + self.__validate_datetime() # Read weather file - weather_data = netCDF4.Dataset(file) + if isinstance(file, str): + data = netCDF4.Dataset(file) + else: + data = file # Get time, latitude and longitude data from file - time_array = weather_data.variables[dictionary["time"]] - lon_array = weather_data.variables[dictionary["longitude"]][:].tolist() - lat_array = weather_data.variables[dictionary["latitude"]][:].tolist() + time_array = data.variables[dictionary["time"]] + lon_list = data.variables[dictionary["longitude"]][:].tolist() + lat_list = data.variables[dictionary["latitude"]][:].tolist() - # Find time index - time_index = netCDF4.date2index( - self.datetime_date, time_array, calendar="gregorian", select="nearest" - ) - # Convert times do dates and numbers - input_time_num = netCDF4.date2num( - self.datetime_date, time_array.units, calendar="gregorian" - ) - file_time_num = time_array[time_index] - file_time_date = netCDF4.num2date( - time_array[time_index], time_array.units, calendar="gregorian" - ) - # Check if time is inside range supplied by file - if time_index == 0 and input_time_num < file_time_num: - raise ValueError( - "Chosen launch time is not available in the provided file, which starts at {:}.".format( - file_time_date - ) - ) - elif time_index == len(time_array) - 1 and input_time_num > file_time_num: - raise ValueError( - "Chosen launch time is not available in the provided file, which ends at {:}.".format( - file_time_date - ) - ) - # Check if time is exactly equal to one in the file - if input_time_num != file_time_num: - warnings.warn( - "Exact chosen launch time is not available in the provided file, using {:} UTC instead.".format( - file_time_date - ) - ) - - # Find longitude index - # Determine if file uses -180 to 180 or 0 to 360 - if lon_array[0] < 0 or lon_array[-1] < 0: - # Convert input to -180 - 180 - lon = ( - self.longitude if self.longitude < 180 else -180 + self.longitude % 180 - ) - else: - # Convert input to 0 - 360 - lon = self.longitude % 360 - # Check if reversed or sorted - if lon_array[0] < lon_array[-1]: - # Deal with sorted lon_array - lon_index = bisect.bisect(lon_array, lon) - else: - # Deal with reversed lon_array - lon_array.reverse() - lon_index = len(lon_array) - bisect.bisect_left(lon_array, lon) - lon_array.reverse() - # Take care of longitude value equal to maximum longitude in the grid - if lon_index == len(lon_array) and lon_array[lon_index - 1] == lon: - lon_index = lon_index - 1 - # Check if longitude value is inside the grid - if lon_index == 0 or lon_index == len(lon_array): - raise ValueError( - "Longitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - lon, lon_array[0], lon_array[-1] - ) - ) - - # Find latitude index - # Check if reversed or sorted - if lat_array[0] < lat_array[-1]: - # Deal with sorted lat_array - lat_index = bisect.bisect(lat_array, self.latitude) - else: - # Deal with reversed lat_array - lat_array.reverse() - lat_index = len(lat_array) - bisect.bisect_left(lat_array, self.latitude) - lat_array.reverse() - # Take care of latitude value equal to maximum longitude in the grid - if lat_index == len(lat_array) and lat_array[lat_index - 1] == self.latitude: - lat_index = lat_index - 1 - # Check if latitude value is inside the grid - if lat_index == 0 or lat_index == len(lat_array): - raise ValueError( - "Latitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - self.latitude, lat_array[0], lat_array[-1] - ) - ) + # Find time, latitude and longitude indexes + time_index = find_time_index(self.datetime_date, time_array) + lon, lon_index = find_longitude_index(self.longitude, lon_list) + _, lat_index = find_latitude_index(self.latitude, lat_list) # Get pressure level data from file - try: - levels = ( - 100 * weather_data.variables[dictionary["level"]][:] - ) # Convert mbar to Pa - except: - raise ValueError( - "Unable to read pressure levels from file. Check file and dictionary." - ) + levels = get_pressure_levels_from_file(data, dictionary) # Get geopotential data from file try: - geopotentials = weather_data.variables[dictionary["geopotential_height"]][ + geopotentials = data.variables[dictionary["geopotential_height"]][ time_index, :, (lat_index - 1, lat_index), (lon_index - 1, lon_index) ] - except: + except KeyError: try: geopotentials = ( - weather_data.variables[dictionary["geopotential"]][ + data.variables[dictionary["geopotential"]][ time_index, :, (lat_index - 1, lat_index), @@ -2245,212 +1898,147 @@ def process_forecast_reanalysis(self, file, dictionary): ] / self.standard_g ) - except: + except KeyError as e: raise ValueError( "Unable to read geopotential height" " nor geopotential from file. At least" " one of them is necessary. Check " " file and dictionary." - ) + ) from e # Get temperature from file try: - temperatures = weather_data.variables[dictionary["temperature"]][ + temperatures = data.variables[dictionary["temperature"]][ time_index, :, (lat_index - 1, lat_index), (lon_index - 1, lon_index) ] - except: + except Exception as e: raise ValueError( "Unable to read temperature from file. Check file and dictionary." - ) + ) from e # Get wind data from file try: - wind_us = weather_data.variables[dictionary["u_wind"]][ + wind_us = data.variables[dictionary["u_wind"]][ time_index, :, (lat_index - 1, lat_index), (lon_index - 1, lon_index) ] - except: + except KeyError as e: raise ValueError( "Unable to read wind-u component. Check file and dictionary." - ) + ) from e try: - wind_vs = weather_data.variables[dictionary["v_wind"]][ + wind_vs = data.variables[dictionary["v_wind"]][ time_index, :, (lat_index - 1, lat_index), (lon_index - 1, lon_index) ] - except: + except KeyError as e: raise ValueError( "Unable to read wind-v component. Check file and dictionary." - ) + ) from e # Prepare for bilinear interpolation x, y = self.latitude, lon - x1, y1 = lat_array[lat_index - 1], lon_array[lon_index - 1] - x2, y2 = lat_array[lat_index], lon_array[lon_index] - - # Determine geopotential in lat, lon - f_x1_y1 = geopotentials[:, 0, 0] - f_x1_y2 = geopotentials[:, 0, 1] - f_x2_y1 = geopotentials[:, 1, 0] - f_x2_y2 = geopotentials[:, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - height = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine temperature in lat, lon - f_x1_y1 = temperatures[:, 0, 0] - f_x1_y2 = temperatures[:, 0, 1] - f_x2_y1 = temperatures[:, 1, 0] - f_x2_y2 = temperatures[:, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - temperature = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine wind u in lat, lon - f_x1_y1 = wind_us[:, 0, 0] - f_x1_y2 = wind_us[:, 0, 1] - f_x2_y1 = wind_us[:, 1, 0] - f_x2_y2 = wind_us[:, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - wind_u = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine wind v in lat, lon - f_x1_y1 = wind_vs[:, 0, 0] - f_x1_y2 = wind_vs[:, 0, 1] - f_x2_y1 = wind_vs[:, 1, 0] - f_x2_y2 = wind_vs[:, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - wind_v = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 + x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1] + x2, y2 = lat_list[lat_index], lon_list[lon_index] + + # Determine properties in lat, lon + height = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + geopotentials[:, 0, 0], + geopotentials[:, 0, 1], + geopotentials[:, 1, 0], + geopotentials[:, 1, 1], + ) + temper = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + temperatures[:, 0, 0], + temperatures[:, 0, 1], + temperatures[:, 1, 0], + temperatures[:, 1, 1], + ) + wind_u = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + wind_us[:, 0, 0], + wind_us[:, 0, 1], + wind_us[:, 1, 0], + wind_us[:, 1, 1], + ) + wind_v = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + wind_vs[:, 0, 0], + wind_vs[:, 0, 1], + wind_vs[:, 1, 0], + wind_vs[:, 1, 1], + ) # Determine wind speed, heading and direction - wind_speed = np.sqrt(wind_u**2 + wind_v**2) - wind_heading = np.arctan2(wind_u, wind_v) * (180 / np.pi) % 360 - wind_direction = (wind_heading - 180) % 360 + wind_speed = calculate_wind_speed(wind_u, wind_v) + wind_heading = calculate_wind_heading(wind_u, wind_v) + wind_direction = convert_wind_heading_to_direction(wind_heading) # Convert geopotential height to geometric height - R = self.earth_radius - height = R * height / (R - height) + height = geopotential_height_to_geometric_height(height, self.earth_radius) # Combine all data into big array - data_array = np.ma.column_stack( - [ - levels, - height, - temperature, - wind_u, - wind_v, - wind_heading, - wind_direction, - wind_speed, - ] + data_array = mask_and_clean_dataset( + levels, + height, + temper, + wind_u, + wind_v, + wind_speed, + wind_heading, + wind_direction, ) - - # Remove lines with masked content - if np.any(data_array.mask): - data_array = np.ma.compress_rows(data_array) - warnings.warn( - "Some values were missing from this weather dataset, therefore, certain pressure levels were removed." - ) # Save atmospheric data - self.pressure = Function( - data_array[:, (1, 0)], - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) - # Linearly extrapolate pressure to ground level - bar_height = data_array[:, (0, 1)] - self.barometric_height = Function( - bar_height, - inputs="Pressure (Pa)", - outputs="Height Above Sea Level (m)", - interpolation="linear", - extrapolation="natural", - ) - self.temperature = Function( - data_array[:, (1, 2)], - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) - self.wind_direction = Function( - data_array[:, (1, 6)], - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - data_array[:, (1, 5)], - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - data_array[:, (1, 7)], - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - data_array[:, (1, 3)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - data_array[:, (1, 4)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) + self.__set_temperature_function(data_array[:, (1, 2)]) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) # Save maximum expected height self.max_expected_height = max(height[0], height[-1]) # Get elevation data from file if dictionary["surface_geopotential_height"] is not None: - try: - elevations = weather_data.variables[ - dictionary["surface_geopotential_height"] - ][time_index, (lat_index - 1, lat_index), (lon_index - 1, lon_index)] - f_x1_y1 = elevations[0, 0] - f_x1_y2 = elevations[0, 1] - f_x2_y1 = elevations[1, 0] - f_x2_y2 = elevations[1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ( - (x - x1) / (x2 - x1) - ) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ( - (x - x1) / (x2 - x1) - ) * f_x2_y2 - self.elevation = float( - ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - ) - except: - raise ValueError( - "Unable to read surface elevation data. Check file and dictionary." - ) + self.elevation = get_elevation_data_from_dataset( + dictionary, data, time_index, lat_index, lon_index, x, y, x1, x2, y1, y2 + ) # Compute info data - self.atmospheric_model_init_date = netCDF4.num2date( - time_array[0], time_array.units, calendar="gregorian" - ) - self.atmospheric_model_end_date = netCDF4.num2date( - time_array[-1], time_array.units, calendar="gregorian" - ) - self.atmospheric_model_interval = netCDF4.num2date( - (time_array[-1] - time_array[0]) / (len(time_array) - 1), - time_array.units, - calendar="gregorian", - ).hour - self.atmospheric_model_init_lat = lat_array[0] - self.atmospheric_model_end_lat = lat_array[-1] - self.atmospheric_model_init_lon = lon_array[0] - self.atmospheric_model_end_lon = lon_array[-1] + self.atmospheric_model_init_date = get_initial_date_from_time_array(time_array) + self.atmospheric_model_end_date = get_final_date_from_time_array(time_array) + self.atmospheric_model_interval = get_interval_date_from_time_array(time_array) + self.atmospheric_model_init_lat = lat_list[0] + self.atmospheric_model_end_lat = lat_list[-1] + self.atmospheric_model_init_lon = lon_list[0] + self.atmospheric_model_end_lon = lon_list[-1] # Save debugging data - self.lat_array = lat_array - self.lon_array = lon_array + self.lat_array = lat_list + self.lon_array = lon_list self.lon_index = lon_index self.lat_index = lat_index self.geopotentials = geopotentials @@ -2462,12 +2050,11 @@ def process_forecast_reanalysis(self, file, dictionary): self.height = height # Close weather data - weather_data.close() - - return None + data.close() - @requires_netCDF4 - def process_ensemble(self, file, dictionary): + def process_ensemble( + self, file, dictionary + ): # pylint: disable=too-many-locals,too-many-statements """Import and process atmospheric data from weather ensembles given as ``netCDF`` or ``OPeNDAP`` files. Sets pressure, temperature, wind-u and wind-v profiles and surface elevation obtained from a weather @@ -2486,12 +2073,12 @@ def process_ensemble(self, file, dictionary): rectangular grid sorted in either ascending or descending order of latitude and longitude. By default the first ensemble forecast is activated. To activate other ensemble forecasts see - ``Environment.selectEnsembleMemberMember()``. + ``Environment.select_ensemble_member()``. Parameters ---------- file : string - String containing path to local ``netCDF`` file or URL of an + String containing path to local ``.nc`` file or URL of an ``OPeNDAP`` file, such as NOAA's NOMAD or UCAR TRHEDDS server. dictionary : dictionary Specifies the dictionary to be used when reading ``netCDF`` and @@ -2519,137 +2106,41 @@ def process_ensemble(self, file, dictionary): "v_wind": "vgrdprs", } - Returns - ------- - None + Notes + ----- + See the ``rocketpy.environment.weather_model_mapping`` for some + dictionary examples. """ # Check if date, lat and lon are known - if self.datetime_date is None: - raise TypeError( - "Please specify Date (array-like) when " - "initializing this Environment. " - "Alternatively, use the Environment.set_date" - " method." - ) - if self.latitude is None: - raise TypeError( - "Please specify Location (lat, lon). when " - "initializing this Environment. " - "Alternatively, use the Environment." - "set_location method." - ) - - # Read weather file - weather_data = netCDF4.Dataset(file) - - # Get time, latitude and longitude data from file - time_array = weather_data.variables[dictionary["time"]] - lon_array = weather_data.variables[dictionary["longitude"]][:].tolist() - lat_array = weather_data.variables[dictionary["latitude"]][:].tolist() - - # Find time index - time_index = netCDF4.date2index( - self.datetime_date, time_array, calendar="gregorian", select="nearest" - ) - # Convert times do dates and numbers - input_time_num = netCDF4.date2num( - self.datetime_date, time_array.units, calendar="gregorian" - ) - file_time_num = time_array[time_index] - file_time_date = netCDF4.num2date( - time_array[time_index], time_array.units, calendar="gregorian" - ) - # Check if time is inside range supplied by file - if time_index == 0 and input_time_num < file_time_num: - raise ValueError( - "Chosen launch time is not available in the provided file, which starts at {:}.".format( - file_time_date - ) - ) - elif time_index == len(time_array) - 1 and input_time_num > file_time_num: - raise ValueError( - "Chosen launch time is not available in the provided file, which ends at {:}.".format( - file_time_date - ) - ) - # Check if time is exactly equal to one in the file - if input_time_num != file_time_num: - warnings.warn( - "Exact chosen launch time is not available in the provided file, using {:} UTC instead.".format( - file_time_date - ) - ) - - # Find longitude index - # Determine if file uses -180 to 180 or 0 to 360 - if lon_array[0] < 0 or lon_array[-1] < 0: - # Convert input to -180 - 180 - lon = ( - self.longitude if self.longitude < 180 else -180 + self.longitude % 180 - ) - else: - # Convert input to 0 - 360 - lon = self.longitude % 360 - # Check if reversed or sorted - if lon_array[0] < lon_array[-1]: - # Deal with sorted lon_array - lon_index = bisect.bisect(lon_array, lon) - else: - # Deal with reversed lon_array - lon_array.reverse() - lon_index = len(lon_array) - bisect.bisect_left(lon_array, lon) - lon_array.reverse() - # Take care of longitude value equal to maximum longitude in the grid - if lon_index == len(lon_array) and lon_array[lon_index - 1] == lon: - lon_index = lon_index - 1 - # Check if longitude value is inside the grid - if lon_index == 0 or lon_index == len(lon_array): - raise ValueError( - "Longitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - lon, lon_array[0], lon_array[-1] - ) - ) + self.__validate_datetime() - # Find latitude index - # Check if reversed or sorted - if lat_array[0] < lat_array[-1]: - # Deal with sorted lat_array - lat_index = bisect.bisect(lat_array, self.latitude) - else: - # Deal with reversed lat_array - lat_array.reverse() - lat_index = len(lat_array) - bisect.bisect_left(lat_array, self.latitude) - lat_array.reverse() - # Take care of latitude value equal to maximum longitude in the grid - if lat_index == len(lat_array) and lat_array[lat_index - 1] == self.latitude: - lat_index = lat_index - 1 - # Check if latitude value is inside the grid - if lat_index == 0 or lat_index == len(lat_array): - raise ValueError( - "Latitude {:f} not inside region covered by file, which is from {:f} to {:f}.".format( - self.latitude, lat_array[0], lat_array[-1] - ) - ) + # Read weather file + if isinstance(file, str): + data = netCDF4.Dataset(file) + else: + data = file + + # Get time, latitude and longitude data from file + time_array = data.variables[dictionary["time"]] + lon_list = data.variables[dictionary["longitude"]][:].tolist() + lat_list = data.variables[dictionary["latitude"]][:].tolist() + + # Find time, latitude and longitude indexes + time_index = find_time_index(self.datetime_date, time_array) + lon, lon_index = find_longitude_index(self.longitude, lon_list) + _, lat_index = find_latitude_index(self.latitude, lat_list) # Get ensemble data from file try: - num_members = len(weather_data.variables[dictionary["ensemble"]][:]) - except: + num_members = len(data.variables[dictionary["ensemble"]][:]) + except KeyError as e: raise ValueError( "Unable to read ensemble data from file. Check file and dictionary." - ) + ) from e # Get pressure level data from file - try: - levels = ( - 100 * weather_data.variables[dictionary["level"]][:] - ) # Convert mbar to Pa - except: - raise ValueError( - "Unable to read pressure levels from file. Check file and dictionary." - ) + levels = get_pressure_levels_from_file(data, dictionary) - ## inverse_dictionary = {v: k for k, v in dictionary.items()} param_dictionary = { "time": time_index, @@ -2658,115 +2149,119 @@ def process_ensemble(self, file, dictionary): "latitude": (lat_index - 1, lat_index), "longitude": (lon_index - 1, lon_index), } - ## + + # Get dimensions + try: + dimensions = data.variables[dictionary["geopotential_height"]].dimensions[:] + except KeyError: + dimensions = data.variables[dictionary["geopotential"]].dimensions[:] + + # Get params + params = tuple(param_dictionary[inverse_dictionary[dim]] for dim in dimensions) # Get geopotential data from file try: - dimensions = weather_data.variables[ - dictionary["geopotential_height"] - ].dimensions[:] - params = tuple( - [param_dictionary[inverse_dictionary[dim]] for dim in dimensions] - ) - geopotentials = weather_data.variables[dictionary["geopotential_height"]][ - params - ] - except: + geopotentials = data.variables[dictionary["geopotential_height"]][params] + except KeyError: try: - dimensions = weather_data.variables[ - dictionary["geopotential"] - ].dimensions[:] - params = tuple( - [param_dictionary[inverse_dictionary[dim]] for dim in dimensions] - ) geopotentials = ( - weather_data.variables[dictionary["geopotential"]][params] - / self.standard_g + data.variables[dictionary["geopotential"]][params] / self.standard_g ) - except: + except KeyError as e: raise ValueError( - "Unable to read geopotential height" - " nor geopotential from file. At least" - " one of them is necessary. Check " - " file and dictionary." - ) + "Unable to read geopotential height nor geopotential from file. " + "At least one of them is necessary. Check file and dictionary." + ) from e # Get temperature from file try: - temperatures = weather_data.variables[dictionary["temperature"]][params] - except: + temperatures = data.variables[dictionary["temperature"]][params] + except KeyError as e: raise ValueError( "Unable to read temperature from file. Check file and dictionary." - ) + ) from e # Get wind data from file try: - wind_us = weather_data.variables[dictionary["u_wind"]][params] - except: + wind_us = data.variables[dictionary["u_wind"]][params] + except KeyError as e: raise ValueError( "Unable to read wind-u component. Check file and dictionary." - ) + ) from e try: - wind_vs = weather_data.variables[dictionary["v_wind"]][params] - except: + wind_vs = data.variables[dictionary["v_wind"]][params] + except KeyError as e: raise ValueError( "Unable to read wind-v component. Check file and dictionary." - ) + ) from e # Prepare for bilinear interpolation x, y = self.latitude, lon - x1, y1 = lat_array[lat_index - 1], lon_array[lon_index - 1] - x2, y2 = lat_array[lat_index], lon_array[lon_index] - - # Determine geopotential in lat, lon - f_x1_y1 = geopotentials[:, :, 0, 0] - f_x1_y2 = geopotentials[:, :, 0, 1] - f_x2_y1 = geopotentials[:, :, 1, 0] - f_x2_y2 = geopotentials[:, :, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - height = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine temperature in lat, lon - f_x1_y1 = temperatures[:, :, 0, 0] - f_x1_y2 = temperatures[:, :, 0, 1] - f_x2_y1 = temperatures[:, :, 1, 0] - f_x2_y2 = temperatures[:, :, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - temperature = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine wind u in lat, lon - f_x1_y1 = wind_us[:, :, 0, 0] - f_x1_y2 = wind_us[:, :, 0, 1] - f_x2_y1 = wind_us[:, :, 1, 0] - f_x2_y2 = wind_us[:, :, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - wind_u = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - - # Determine wind v in lat, lon - f_x1_y1 = wind_vs[:, :, 0, 0] - f_x1_y2 = wind_vs[:, :, 0, 1] - f_x2_y1 = wind_vs[:, :, 1, 0] - f_x2_y2 = wind_vs[:, :, 1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ((x - x1) / (x2 - x1)) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ((x - x1) / (x2 - x1)) * f_x2_y2 - wind_v = ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 + x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1] + x2, y2 = lat_list[lat_index], lon_list[lon_index] + + # Determine properties in lat, lon + height = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + geopotentials[:, :, 0, 0], + geopotentials[:, :, 0, 1], + geopotentials[:, :, 1, 0], + geopotentials[:, :, 1, 1], + ) + temper = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + temperatures[:, :, 0, 0], + temperatures[:, :, 0, 1], + temperatures[:, :, 1, 0], + temperatures[:, :, 1, 1], + ) + wind_u = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + wind_us[:, :, 0, 0], + wind_us[:, :, 0, 1], + wind_us[:, :, 1, 0], + wind_us[:, :, 1, 1], + ) + wind_v = bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + wind_vs[:, :, 0, 0], + wind_vs[:, :, 0, 1], + wind_vs[:, :, 1, 0], + wind_vs[:, :, 1, 1], + ) # Determine wind speed, heading and direction - wind_speed = np.sqrt(wind_u**2 + wind_v**2) - wind_heading = np.arctan2(wind_u, wind_v) * (180 / np.pi) % 360 - wind_direction = (wind_heading - 180) % 360 + wind_speed = calculate_wind_speed(wind_u, wind_v) + wind_heading = calculate_wind_heading(wind_u, wind_v) + wind_direction = convert_wind_heading_to_direction(wind_heading) # Convert geopotential height to geometric height - R = self.earth_radius - height = R * height / (R - height) + height = geopotential_height_to_geometric_height(height, self.earth_radius) # Save ensemble data self.level_ensemble = levels self.height_ensemble = height - self.temperature_ensemble = temperature + self.temperature_ensemble = temper self.wind_u_ensemble = wind_u self.wind_v_ensemble = wind_v self.wind_heading_ensemble = wind_heading @@ -2779,48 +2274,22 @@ def process_ensemble(self, file, dictionary): # Get elevation data from file if dictionary["surface_geopotential_height"] is not None: - try: - elevations = weather_data.variables[ - dictionary["surface_geopotential_height"] - ][time_index, (lat_index - 1, lat_index), (lon_index - 1, lon_index)] - f_x1_y1 = elevations[0, 0] - f_x1_y2 = elevations[0, 1] - f_x2_y1 = elevations[1, 0] - f_x2_y2 = elevations[1, 1] - f_x_y1 = ((x2 - x) / (x2 - x1)) * f_x1_y1 + ( - (x - x1) / (x2 - x1) - ) * f_x2_y1 - f_x_y2 = ((x2 - x) / (x2 - x1)) * f_x1_y2 + ( - (x - x1) / (x2 - x1) - ) * f_x2_y2 - self.elevation = float( - ((y2 - y) / (y2 - y1)) * f_x_y1 + ((y - y1) / (y2 - y1)) * f_x_y2 - ) - except: - raise ValueError( - "Unable to read surface elevation data. Check file and dictionary." - ) + self.elevation = get_elevation_data_from_dataset( + dictionary, data, time_index, lat_index, lon_index, x, y, x1, x2, y1, y2 + ) # Compute info data - self.atmospheric_model_init_date = netCDF4.num2date( - time_array[0], time_array.units, calendar="gregorian" - ) - self.atmospheric_model_end_date = netCDF4.num2date( - time_array[-1], time_array.units, calendar="gregorian" - ) - self.atmospheric_model_interval = netCDF4.num2date( - (time_array[-1] - time_array[0]) / (len(time_array) - 1), - time_array.units, - calendar="gregorian", - ).hour - self.atmospheric_model_init_lat = lat_array[0] - self.atmospheric_model_end_lat = lat_array[-1] - self.atmospheric_model_init_lon = lon_array[0] - self.atmospheric_model_end_lon = lon_array[-1] + self.atmospheric_model_init_date = get_initial_date_from_time_array(time_array) + self.atmospheric_model_end_date = get_final_date_from_time_array(time_array) + self.atmospheric_model_interval = get_interval_date_from_time_array(time_array) + self.atmospheric_model_init_lat = lat_list[0] + self.atmospheric_model_end_lat = lat_list[-1] + self.atmospheric_model_init_lon = lon_list[0] + self.atmospheric_model_end_lon = lon_list[-1] # Save debugging data - self.lat_array = lat_array - self.lon_array = lon_array + self.lat_array = lat_list + self.lon_array = lon_list self.lon_index = lon_index self.lat_index = lat_index self.geopotentials = geopotentials @@ -2832,30 +2301,34 @@ def process_ensemble(self, file, dictionary): self.height = height # Close weather data - weather_data.close() - - return None + data.close() def select_ensemble_member(self, member=0): - """Activates ensemble member, meaning that all atmospheric variables - read from the Environment instance will correspond to the desired + """Activates the specified ensemble member, ensuring all atmospheric + variables read from the Environment instance correspond to the selected ensemble member. Parameters - --------- - member : int - Ensemble member to be activated. Starts from 0. + ---------- + member : int, optional + The ensemble member to activate. Index starts from 0. Default is 0. - Returns - ------- - None + Raises + ------ + ValueError + If the specified ensemble member index is out of range. + + Notes + ----- + The first ensemble member (index 0) is activated by default when loading + an ensemble model. This member typically represents a control member + that is generated without perturbations. Other ensemble members are + generated by perturbing the control member. """ # Verify ensemble member if member >= self.num_ensemble_members: raise ValueError( - "Please choose member from 0 to {:d}".format( - self.num_ensemble_members - 1 - ) + f"Please choose member from 0 to {self.num_ensemble_members - 1}" ) # Read ensemble member @@ -2869,171 +2342,76 @@ def select_ensemble_member(self, member=0): wind_speed = self.wind_speed_ensemble[member, :] # Combine all data into big array - data_array = np.ma.column_stack( - [ - levels, - height, - temperature, - wind_u, - wind_v, - wind_heading, - wind_direction, - wind_speed, - ] + data_array = mask_and_clean_dataset( + levels, + height, + temperature, + wind_u, + wind_v, + wind_heading, + wind_direction, + wind_speed, ) - # Remove lines with masked content - if np.any(data_array.mask): - data_array = np.ma.compress_rows(data_array) - warnings.warn( - "Some values were missing from this weather dataset, therefore, certain pressure levels were removed." - ) - # Save atmospheric data - self.pressure = Function( - data_array[:, (1, 0)], - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - interpolation="linear", - ) - # Linearly extrapolate pressure to ground level - bar_height = data_array[:, (0, 1)] - self.barometric_height = Function( - bar_height, - inputs="Pressure (Pa)", - outputs="Height Above Sea Level (m)", - interpolation="linear", - extrapolation="natural", - ) - self.temperature = Function( - data_array[:, (1, 2)], - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", - ) - self.wind_direction = Function( - data_array[:, (1, 6)], - inputs="Height Above Sea Level (m)", - outputs="Wind Direction (Deg True)", - interpolation="linear", - ) - self.wind_heading = Function( - data_array[:, (1, 5)], - inputs="Height Above Sea Level (m)", - outputs="Wind Heading (Deg True)", - interpolation="linear", - ) - self.wind_speed = Function( - data_array[:, (1, 7)], - inputs="Height Above Sea Level (m)", - outputs="Wind Speed (m/s)", - interpolation="linear", - ) - self.wind_velocity_x = Function( - data_array[:, (1, 3)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity X (m/s)", - interpolation="linear", - ) - self.wind_velocity_y = Function( - data_array[:, (1, 4)], - inputs="Height Above Sea Level (m)", - outputs="Wind Velocity Y (m/s)", - interpolation="linear", - ) - - # Save maximum expected height + self.__set_pressure_function(data_array[:, (1, 0)]) + self.__set_barometric_height_function(data_array[:, (0, 1)]) + self.__set_temperature_function(data_array[:, (1, 2)]) + self.__set_wind_velocity_x_function(data_array[:, (1, 3)]) + self.__set_wind_velocity_y_function(data_array[:, (1, 4)]) + self.__set_wind_heading_function(data_array[:, (1, 5)]) + self.__set_wind_direction_function(data_array[:, (1, 6)]) + self.__set_wind_speed_function(data_array[:, (1, 7)]) + + # Save other attributes self.max_expected_height = max(height[0], height[-1]) - - # Save ensemble member self.ensemble_member = member - # Update air density + # Update air density, speed of sound and dynamic viscosity self.calculate_density_profile() - - # Update speed of sound self.calculate_speed_of_sound_profile() - - # Update dynamic viscosity self.calculate_dynamic_viscosity() - return None - - def load_international_standard_atmosphere(self): + def load_international_standard_atmosphere(self): # pragma: no cover """Defines the pressure and temperature profile functions set by `ISO 2533` for the International Standard atmosphere and saves them as ``Environment.pressure_ISA`` and ``Environment.temperature_ISA``. - Returns - ------- - None + Notes + ----- + This method is **deprecated** and will be removed in version 1.6.0. You + can access `Environment.pressure_ISA` and `Environment.temperature_ISA` + directly without the need to call this method. """ - # Define international standard atmosphere layers - geopotential_height = [ - -2e3, - 0, - 11e3, - 20e3, - 32e3, - 47e3, - 51e3, - 71e3, - 80e3, - ] # in geopotential m - temperature = [ - 301.15, - 288.15, - 216.65, - 216.65, - 228.65, - 270.65, - 270.65, - 214.65, - 196.65, - ] # in K - beta = [ - -6.5e-3, - -6.5e-3, - 0, - 1e-3, - 2.8e-3, - 0, - -2.8e-3, - -2e-3, - 0, - ] # Temperature gradient in K/m - pressure = [ - 1.27774e5, - 1.01325e5, - 2.26320e4, - 5.47487e3, - 8.680164e2, - 1.10906e2, - 6.69384e1, - 3.95639e0, - 8.86272e-2, - ] # in Pa - - # Convert geopotential height to geometric height - ER = self.earth_radius - height = [ER * H / (ER - H) for H in geopotential_height] - - # Save international standard atmosphere temperature profile - self.temperature_ISA = Function( - np.column_stack([height, temperature]), - inputs="Height Above Sea Level (m)", - outputs="Temperature (K)", - interpolation="linear", + warnings.warn( + "load_international_standard_atmosphere() is deprecated in version " + "1.5.0 and will be removed in version 1.7.0. This method is no longer " + "needed as the International Standard Atmosphere is already calculated " + "when the Environment object is created.", + DeprecationWarning, ) - # Get gravity and R + @funcify_method("Height Above Sea Level (m)", "Pressure (Pa)", "spline", "linear") + def pressure_ISA(self): + """Pressure, in Pa, as a function of height above sea level as defined + by the `International Standard Atmosphere ISO 2533`.""" + # Retrieve lists + pressure = self.__standard_atmosphere_layers["pressure"] + geopotential_height = self.__standard_atmosphere_layers["geopotential_height"] + temperature = self.__standard_atmosphere_layers["temperature"] + beta = self.__standard_atmosphere_layers["beta"] + + # Get constants + earth_radius = self.earth_radius g = self.standard_g R = self.air_gas_constant # Create function to compute pressure at a given geometric height def pressure_function(h): + """Computes the pressure at a given geometric height h using the + International Standard Atmosphere model.""" # Convert geometric to geopotential height - H = ER * h / (ER + h) + H = earth_radius * h / (earth_radius + h) # Check if height is within bounds, return extrapolated value if not if H < -2000: @@ -3045,43 +2423,55 @@ def pressure_function(h): layer = bisect.bisect(geopotential_height, H) - 1 # Retrieve layer base geopotential height, temp, beta and pressure - Hb = geopotential_height[layer] - Tb = temperature[layer] - Pb = pressure[layer] + base_geopotential_height = geopotential_height[layer] + base_temperature = temperature[layer] + base_pressure = pressure[layer] B = beta[layer] # Compute pressure if B != 0: - P = Pb * (1 + (B / Tb) * (H - Hb)) ** (-g / (B * R)) + P = base_pressure * ( + 1 + (B / base_temperature) * (H - base_geopotential_height) + ) ** (-g / (B * R)) else: - T = Tb + B * (H - Hb) - P = Pb * np.exp(-(H - Hb) * (g / (R * T))) - - # Return answer + T = base_temperature + B * (H - base_geopotential_height) + P = base_pressure * np.exp( + -(H - base_geopotential_height) * (g / (R * T)) + ) return P - # Save international standard atmosphere pressure profile - self.pressure_ISA = Function( - pressure_function, - inputs="Height Above Sea Level (m)", - outputs="Pressure (Pa)", - ) - - # Discretize Function to speed up the trajectory simulation. - self.barometric_height_ISA = self.pressure_ISA.inverse_function().set_discrete( - pressure[-1], pressure[0], 100, extrapolation="constant" - ) - self.barometric_height_ISA.set_inputs("Pressure (Pa)") - self.barometric_height_ISA.set_outputs("Height Above Sea Level (m)") + # Discretize this Function to speed up the trajectory simulation + altitudes = np.linspace(0, 80000, 100) # TODO: should be -2k instead of 0 + pressures = [pressure_function(h) for h in altitudes] + + return np.column_stack([altitudes, pressures]) + + @funcify_method("Pressure (Pa)", "Height Above Sea Level (m)") + def barometric_height_ISA(self): + """Returns the inverse function of the ``pressure_ISA`` function.""" + return self.pressure_ISA.inverse_function() + + @funcify_method("Height Above Sea Level (m)", "Temperature (K)", "linear") + def temperature_ISA(self): + """Air temperature, in K, as a function of altitude as defined by the + `International Standard Atmosphere ISO 2533`.""" + temperature = self.__standard_atmosphere_layers["temperature"] + geopotential_height = self.__standard_atmosphere_layers["geopotential_height"] + altitude_asl = [ + geopotential_height_to_geometric_height(h, self.earth_radius) + for h in geopotential_height + ] + return np.column_stack([altitude_asl, temperature]) def calculate_density_profile(self): - """Compute the density of the atmosphere as a function of - height by using the formula rho = P/(RT). This function is - automatically called whenever a new atmospheric model is set. + r"""Compute the density of the atmosphere as a function of + height. This function is automatically called whenever a new atmospheric + model is set. - Returns - ------- - None + Notes + ----- + 1. The density is calculated as: + .. math:: \rho = \frac{P}{RT} Examples -------- @@ -3099,7 +2489,7 @@ def calculate_density_profile(self): >>> env = Environment() >>> env.calculate_density_profile() >>> float(env.density(1000)) - 1.1116193933422585 + 1.1115112430077818 """ # Retrieve pressure P, gas constant R and temperature T P = self.pressure @@ -3115,17 +2505,15 @@ def calculate_density_profile(self): # Save calculated density self.density = D - return None - def calculate_speed_of_sound_profile(self): - """Compute the speed of sound in the atmosphere as a function - of height by using the formula a = sqrt(gamma*R*T). This - function is automatically called whenever a new atmospheric - model is set. + r"""Compute the speed of sound in the atmosphere as a function + of height. This function is automatically called whenever a new + atmospheric model is set. - Returns - ------- - None + Notes + ----- + 1. The speed of sound is calculated as: + .. math:: a = \sqrt{\gamma \cdot R \cdot T} """ # Retrieve gas constant R and temperature T R = self.air_gas_constant @@ -3141,18 +2529,20 @@ def calculate_speed_of_sound_profile(self): # Save calculated speed of sound self.speed_of_sound = a - return None - def calculate_dynamic_viscosity(self): - """Compute the dynamic viscosity of the atmosphere as a function of - height by using the formula given in ISO 2533 u = B*T^(1.5)/(T+S). - This function is automatically called whenever a new atmospheric model is set. - Warning: This equation is invalid for very high or very low temperatures - and under conditions occurring at altitudes above 90 km. + r"""Compute the dynamic viscosity of the atmosphere as a function of + height by using the formula given in ISO 2533. This function is + automatically called whenever a new atmospheric model is set. - Returns - ------- - None + Notes + ----- + 1. The dynamic viscosity is calculated as: + .. math:: + \mu = \frac{B \cdot T^{1.5}}{(T + S)} + + where `B` and `S` are constants, and `T` is the temperature. + 2. This equation is invalid for very high or very low temperatures. + 3. Also invalid under conditions occurring at altitudes above 90 km. """ # Retrieve temperature T and set constants T = self.temperature @@ -3168,8 +2558,6 @@ def calculate_dynamic_viscosity(self): # Save calculated density self.dynamic_viscosity = u - return None - def add_wind_gust(self, wind_gust_x, wind_gust_y): """Adds a function to the current stored wind profile, in order to simulate a wind gust. @@ -3184,20 +2572,10 @@ def add_wind_gust(self, wind_gust_x, wind_gust_y): Callable, function of altitude, which will be added to the y velocity of the current stored wind profile. If float is given, it will be considered as a constant function in altitude. - - Returns - ------- - None """ # Recalculate wind_velocity_x and wind_velocity_y - self.wind_velocity_x = self.wind_velocity_x + wind_gust_x - self.wind_velocity_y = self.wind_velocity_y + wind_gust_y - - # Reset wind_velocity_x and wind_velocity_y details - self.wind_velocity_x.set_inputs("Height (m)") - self.wind_velocity_x.set_outputs("Wind Velocity X (m/s)") - self.wind_velocity_y.set_inputs("Height (m)") - self.wind_velocity_y.set_outputs("Wind Velocity Y (m/s)") + self.__set_wind_velocity_x_function(self.wind_velocity_x + wind_gust_x) + self.__set_wind_velocity_y_function(self.wind_velocity_y + wind_gust_y) # Reset wind heading and velocity magnitude self.wind_heading = Function( @@ -3223,206 +2601,32 @@ def add_wind_gust(self, wind_gust_x, wind_gust_y): ) def info(self): - """Prints most important data and graphs available about the - Environment. - - Return - ------ - None - """ - + """Prints important data and graphs available about the Environment.""" self.prints.all() self.plots.info() - return None def all_info(self): - """Prints out all data and graphs available about the Environment. - - Returns - ------- - None - """ - + """Prints out all data and graphs available about the Environment.""" self.prints.all() self.plots.all() - return None - - def all_plot_info_returned(self): - """Returns a dictionary with all plot information available about the Environment. - - Returns - ------ - plot_info : Dict - Dict of data relevant to plot externally - - Warning - ------- - Deprecated in favor of `utilities.get_instance_attributes`. - - """ - warnings.warn( - "The method 'all_plot_info_returned' is deprecated as of version " - + "1.2 and will be removed in version 1.4 " - + "Use 'utilities.get_instance_attributes' instead.", - DeprecationWarning, - ) - - grid = np.linspace(self.elevation, self.max_expected_height) - plot_info = dict( - grid=[i for i in grid], - wind_speed=[self.wind_speed(i) for i in grid], - wind_direction=[self.wind_direction(i) for i in grid], - speed_of_sound=[self.speed_of_sound(i) for i in grid], - density=[self.density(i) for i in grid], - wind_vel_x=[self.wind_velocity_x(i) for i in grid], - wind_vel_y=[self.wind_velocity_y(i) for i in grid], - pressure=[self.pressure(i) / 100 for i in grid], - temperature=[self.temperature(i) for i in grid], - ) - if self.atmospheric_model_type != "Ensemble": - return plot_info - current_member = self.ensemble_member - # List for each ensemble - plot_info["ensemble_wind_velocity_x"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_wind_velocity_x"].append( - [self.wind_velocity_x(i) for i in grid] - ) - plot_info["ensemble_wind_velocity_y"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_wind_velocity_y"].append( - [self.wind_velocity_y(i) for i in grid] - ) - plot_info["ensemble_wind_speed"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_wind_speed"].append([self.wind_speed(i) for i in grid]) - plot_info["ensemble_wind_direction"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_wind_direction"].append( - [self.wind_direction(i) for i in grid] - ) - plot_info["ensemble_pressure"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_pressure"].append([self.pressure(i) for i in grid]) - plot_info["ensemble_temperature"] = [] - for i in range(self.num_ensemble_members): - self.select_ensemble_member(i) - plot_info["ensemble_temperature"].append( - [self.temperature(i) for i in grid] - ) - - # Clean up - self.select_ensemble_member(current_member) - return plot_info - - def all_info_returned(self): - """Returns as dicts all data available about the Environment. - - Returns - ------ - info : Dict - Information relevant about the Environment class. - - Warning - ------- - Deprecated in favor of `utilities.get_instance_attributes`. - - """ - warnings.warn( - "The method 'all_info_returned' is deprecated as of version " - + "1.2 and will be removed in version 1.4 " - + "Use 'utilities.get_instance_attributes' instead.", - DeprecationWarning, - ) - - # Dictionary creation, if not commented follows the SI - info = dict( - grav=self.gravity, - elevation=self.elevation, - model_type=self.atmospheric_model_type, - model_type_max_expected_height=self.max_expected_height, - wind_speed=self.wind_speed(self.elevation), - wind_direction=self.wind_direction(self.elevation), - wind_heading=self.wind_heading(self.elevation), - surface_pressure=self.pressure(self.elevation) / 100, # in hPa - surface_temperature=self.temperature(self.elevation), - surface_air_density=self.density(self.elevation), - surface_speed_of_sound=self.speed_of_sound(self.elevation), - ) - if self.datetime_date != None: - info["launch_date"] = self.datetime_date.strftime("%Y-%d-%m %H:%M:%S") - if self.latitude != None and self.longitude != None: - info["lat"] = self.latitude - info["lon"] = self.longitude - if info["model_type"] in ["Forecast", "Reanalysis", "Ensemble"]: - info["init_date"] = self.atmospheric_model_init_date.strftime( - "%Y-%d-%m %H:%M:%S" - ) - info["endDate"] = self.atmospheric_model_end_date.strftime( - "%Y-%d-%m %H:%M:%S" - ) - info["interval"] = self.atmospheric_model_interval - info["init_lat"] = self.atmospheric_model_init_lat - info["end_lat"] = self.atmospheric_model_end_lat - info["init_lon"] = self.atmospheric_model_init_lon - info["end_lon"] = self.atmospheric_model_end_lon - if info["model_type"] == "Ensemble": - info["num_ensemble_members"] = self.num_ensemble_members - info["selected_ensemble_member"] = self.ensemble_member - return info - + # TODO: Create a better .json format and allow loading a class from it. def export_environment(self, filename="environment"): """Export important attributes of Environment class to a ``.json`` file, - saving all the information needed to recreate the same environment using - custom_atmosphere. + saving the information needed to recreate the same environment using + the ``custom_atmosphere`` model. Parameters ---------- filename : string The name of the file to be saved, without the extension. - - Return - ------ - None """ + pressure = self.pressure.source + temperature = self.temperature.source + wind_x = self.wind_velocity_x.source + wind_y = self.wind_velocity_y.source - try: - atmospheric_model_file = self.atmospheric_model_file - atmospheric_model_dict = self.atmospheric_model_dict - except AttributeError: - atmospheric_model_file = "" - atmospheric_model_dict = "" - - try: - height = self.height - atmospheric_model_pressure_profile = ma.getdata( - self.pressure.get_source()(height) - ).tolist() - atmospheric_model_wind_velocity_x_profile = ma.getdata( - self.wind_velocity_x.get_source()(height) - ).tolist() - atmospheric_model_wind_velocity_y_profile = ma.getdata( - self.wind_velocity_y.get_source()(height) - ).tolist() - - except AttributeError: - atmospheric_model_pressure_profile = ( - "Height Above Sea Level (m) was not provided" - ) - atmospheric_model_wind_velocity_x_profile = ( - "Height Above Sea Level (m) was not provided" - ) - atmospheric_model_wind_velocity_y_profile = ( - "Height Above Sea Level (m) was not provided" - ) - - self.export_env_dictionary = { + export_env_dictionary = { "gravity": self.gravity(self.elevation), "date": [ self.datetime_date.year, @@ -3437,42 +2641,30 @@ def export_environment(self, filename="environment"): "timezone": self.timezone, "max_expected_height": float(self.max_expected_height), "atmospheric_model_type": self.atmospheric_model_type, - "atmospheric_model_file": atmospheric_model_file, - "atmospheric_model_dict": atmospheric_model_dict, - "atmospheric_model_pressure_profile": atmospheric_model_pressure_profile, - "atmospheric_model_temperature_profile": ma.getdata( - self.temperature.get_source() - ).tolist(), - "atmospheric_model_wind_velocity_x_profile": atmospheric_model_wind_velocity_x_profile, - "atmospheric_model_wind_velocity_y_profile": atmospheric_model_wind_velocity_y_profile, + "atmospheric_model_file": self.atmospheric_model_file, + "atmospheric_model_dict": self.atmospheric_model_dict, + "atmospheric_model_pressure_profile": pressure, + "atmospheric_model_temperature_profile": temperature, + "atmospheric_model_wind_velocity_x_profile": wind_x, + "atmospheric_model_wind_velocity_y_profile": wind_y, } - f = open(filename + ".json", "w") - - # write json object to file - f.write( - json.dumps( - self.export_env_dictionary, sort_keys=False, indent=4, default=str - ) - ) - - # close file - f.close() - print("Your Environment file was saved, check it out: " + filename + ".json") + with open(filename + ".json", "w") as f: + json.dump(export_env_dictionary, f, sort_keys=False, indent=4, default=str) print( - "You can use it in the future by using the custom_atmosphere atmospheric model." + f"Your Environment file was saved at '{filename}.json'. You can use " + "it in the future by using the custom_atmosphere atmospheric model." ) - return None - def set_earth_geometry(self, datum): """Sets the Earth geometry for the ``Environment`` class based on the - datum provided. + provided datum. Parameters ---------- datum : str - The datum to be used for the Earth geometry. + The datum to be used for the Earth geometry. The following options + are supported: 'SIRGAS2000', 'SAD69', 'NAD83', 'WGS84'. Returns ------- @@ -3488,114 +2680,12 @@ def set_earth_geometry(self, datum): } try: return ellipsoid[datum] - except KeyError: + except KeyError as e: + available_datums = ', '.join(ellipsoid.keys()) raise AttributeError( - f"The reference system {datum} for Earth geometry " "is not recognized." - ) - - # Auxiliary functions - Fetching Data from 3rd party APIs - - @exponential_backoff(max_attempts=3, base_delay=1, max_delay=60) - def __fetch_open_elevation(self): - print("Fetching elevation from open-elevation.com...") - request_url = ( - "https://api.open-elevation.com/api/v1/lookup?locations" - f"={self.latitude},{self.longitude}" - ) - try: - response = requests.get(request_url) - except Exception as e: - raise RuntimeError("Unable to reach Open-Elevation API servers.") - results = response.json()["results"] - return results[0]["elevation"] - - @exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) - def __fetch_atmospheric_data_from_windy(self, model): - model = model.lower() - if model[-1] == "u": # case iconEu - model = "".join([model[:4], model[4].upper(), model[4 + 1 :]]) - url = ( - f"https://node.windy.com/forecast/meteogram/{model}/" - f"{self.latitude}/{self.longitude}/?step=undefined" - ) - try: - response = requests.get(url).json() - except Exception as e: - if model == "iconEu": - raise ValueError( - "Could not get a valid response for Icon-EU from Windy. " - "Check if the coordinates are set inside Europe." - ) - return response - - @exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) - def __fetch_wyoming_sounding(self, file): - response = requests.get(file) - if response.status_code != 200: - raise ImportError(f"Unable to load {file}.") - if len(re.findall("Can't get .+ Observations at", response.text)): - raise ValueError( - re.findall("Can't get .+ Observations at .+", response.text)[0] - + " Check station number and date." - ) - if response.text == "Invalid OUTPUT: specified\n": - raise ValueError( - "Invalid OUTPUT: specified. Make sure the output is Text: List." - ) - return response - - @exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) - def __fetch_noaaruc_sounding(self, file): - response = requests.get(file) - if response.status_code != 200 or len(response.text) < 10: - raise ImportError("Unable to load " + file + ".") - - @exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) - def __fetch_gefs_ensemble(self, dictionary): - time_attempt = datetime.now(tz=timezone.utc) - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=6 * attempt_count) - file = ( - f"https://nomads.ncep.noaa.gov/dods/gens_bc/gens" - f"{time_attempt.year:04d}{time_attempt.month:02d}" - f"{time_attempt.day:02d}/" - f"gep_all_{6 * (time_attempt.hour // 6):02d}z" - ) - try: - self.process_ensemble(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for GEFS through " + file - ) - - @exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) - def __fetch_cmc_ensemble(self, dictionary): - # Attempt to get latest forecast - time_attempt = datetime.now(tz=timezone.utc) - success = False - attempt_count = 0 - while not success and attempt_count < 10: - time_attempt -= timedelta(hours=12 * attempt_count) - file = ( - f"https://nomads.ncep.noaa.gov/dods/cmcens/" - f"cmcens{time_attempt.year:04d}{time_attempt.month:02d}" - f"{time_attempt.day:02d}/" - f"cmcens_all_{12 * (time_attempt.hour // 12):02d}z" - ) - try: - self.process_ensemble(file, dictionary) - success = True - except OSError: - attempt_count += 1 - if not success: - raise RuntimeError( - "Unable to load latest weather data for CMC through " + file - ) + f"The reference system '{datum}' is not recognized. Please use one of " + f"the following recognized datum: {available_datums}" + ) from e # Auxiliary functions - Geodesic Coordinates @@ -3641,93 +2731,14 @@ def geodesic_to_utm( EW : string Returns "W" for western hemisphere and "E" for eastern hemisphere """ - - # Calculate the central meridian of UTM zone - if lon != 0: - signal = lon / abs(lon) - if signal > 0: - aux = lon - 3 - aux = aux * signal - div = aux // 6 - lon_mc = div * 6 + 3 - EW = "E" - else: - aux = lon + 3 - aux = aux * signal - div = aux // 6 - lon_mc = (div * 6 + 3) * signal - EW = "W" - else: - lon_mc = 3 - EW = "W|E" - - # Evaluate the hemisphere and determine the N coordinate at the Equator - if lat < 0: - N0 = 10000000 - hemis = "S" - else: - N0 = 0 - hemis = "N" - - # Convert the input lat and lon to radians - lat = lat * np.pi / 180 - lon = lon * np.pi / 180 - lon_mc = lon_mc * np.pi / 180 - - # Evaluate reference parameters - K0 = 1 - 1 / 2500 - e2 = 2 * flattening - flattening**2 - e2lin = e2 / (1 - e2) - - # Evaluate auxiliary parameters - A = e2 * e2 - B = A * e2 - C = np.sin(2 * lat) - D = np.sin(4 * lat) - E = np.sin(6 * lat) - F = (1 - e2 / 4 - 3 * A / 64 - 5 * B / 256) * lat - G = (3 * e2 / 8 + 3 * A / 32 + 45 * B / 1024) * C - H = (15 * A / 256 + 45 * B / 1024) * D - I = (35 * B / 3072) * E - - # Evaluate other reference parameters - n = semi_major_axis / ((1 - e2 * (np.sin(lat) ** 2)) ** 0.5) - t = np.tan(lat) ** 2 - c = e2lin * (np.cos(lat) ** 2) - ag = (lon - lon_mc) * np.cos(lat) - m = semi_major_axis * (F - G + H - I) - - # Evaluate new auxiliary parameters - J = (1 - t + c) * ag * ag * ag / 6 - K = (5 - 18 * t + t * t + 72 * c - 58 * e2lin) * (ag**5) / 120 - L = (5 - t + 9 * c + 4 * c * c) * ag * ag * ag * ag / 24 - M = (61 - 58 * t + t * t + 600 * c - 330 * e2lin) * (ag**6) / 720 - - # Evaluate the final coordinates - x = 500000 + K0 * n * (ag + J + K) - y = N0 + K0 * (m + n * np.tan(lat) * (ag * ag / 2 + L + M)) - - # Convert the output lat and lon to degrees - lat = lat * 180 / np.pi - lon = lon * 180 / np.pi - lon_mc = lon_mc * 180 / np.pi - - # Calculate the UTM zone number - utm_zone = int((lon_mc + 183) / 6) - - # Calculate the UTM zone letter - letters = "CDEFGHJKLMNPQRSTUVWXX" - utm_letter = letters[int(80 + lat) >> 3] - - return x, y, utm_zone, utm_letter, hemis, EW + return geodesic_to_utm_tools(lat, lon, semi_major_axis, flattening) @staticmethod def utm_to_geodesic( x, y, utm_zone, hemis, semi_major_axis=6378137.0, flattening=1 / 298.257223563 ): """Function to convert UTM coordinates to geodesic coordinates - (i.e. latitude and longitude). The latitude should be between -80° - and 84° + (i.e. latitude and longitude). Parameters ---------- @@ -3757,82 +2768,21 @@ def utm_to_geodesic( lon : float latitude of the analyzed point """ - - if hemis == "N": - y = y + 10000000 - - # Calculate the Central Meridian from the UTM zone number - central_meridian = utm_zone * 6 - 183 # degrees - - # Calculate reference values - K0 = 1 - 1 / 2500 - e2 = 2 * flattening - flattening**2 - e2lin = e2 / (1 - e2) - e1 = (1 - (1 - e2) ** 0.5) / (1 + (1 - e2) ** 0.5) - - # Calculate auxiliary values - A = e2 * e2 - B = A * e2 - C = e1 * e1 - D = e1 * C - E = e1 * D - - m = (y - 10000000) / K0 - mi = m / (semi_major_axis * (1 - e2 / 4 - 3 * A / 64 - 5 * B / 256)) - - # Calculate other auxiliary values - F = (3 * e1 / 2 - 27 * D / 32) * np.sin(2 * mi) - G = (21 * C / 16 - 55 * E / 32) * np.sin(4 * mi) - H = (151 * D / 96) * np.sin(6 * mi) - - lat1 = mi + F + G + H - c1 = e2lin * (np.cos(lat1) ** 2) - t1 = np.tan(lat1) ** 2 - n1 = semi_major_axis / ((1 - e2 * (np.sin(lat1) ** 2)) ** 0.5) - quoc = (1 - e2 * np.sin(lat1) * np.sin(lat1)) ** 3 - r1 = semi_major_axis * (1 - e2) / (quoc**0.5) - d = (x - 500000) / (n1 * K0) - - # Calculate other auxiliary values - I = (5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * e2lin) * d * d * d * d / 24 - J = ( - (61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * e2lin - 3 * c1 * c1) - * (d**6) - / 720 - ) - K = d - (1 + 2 * t1 + c1) * d * d * d / 6 - L = ( - (5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * e2lin + 24 * t1 * t1) - * (d**5) - / 120 - ) - - # Finally calculate the coordinates in lat/lot - lat = lat1 - (n1 * np.tan(lat1) / r1) * (d * d / 2 - I + J) - lon = central_meridian * np.pi / 180 + (K + L) / np.cos(lat1) - - # Convert final lat/lon to Degrees - lat = lat * 180 / np.pi - lon = lon * 180 / np.pi - - return lat, lon + return utm_to_geodesic_tools(x, y, utm_zone, hemis, semi_major_axis, flattening) @staticmethod def calculate_earth_radius( lat, semi_major_axis=6378137.0, flattening=1 / 298.257223563 ): - """Simple function to calculate the Earth Radius at a specific latitude - based on ellipsoidal reference model (datum). The earth radius here is + """Function to calculate the Earth's radius at a specific latitude + based on ellipsoidal reference model. The Earth radius here is assumed as the distance between the ellipsoid's center of gravity and a - point on ellipsoid surface at the desired - Pay attention: The ellipsoid is an approximation for the earth model and - will obviously output an estimate of the perfect distance between - earth's relief and its center of gravity. + point on ellipsoid surface at the desired latitude. Parameters ---------- lat : float - latitude in which the Earth radius will be calculated + latitude at which the Earth radius will be calculated semi_major_axis : float The semi-major axis of the ellipsoid used to represent the Earth, must be given in meters (default is 6,378,137.0 m, which corresponds @@ -3845,7 +2795,13 @@ def calculate_earth_radius( Returns ------- radius : float - Earth Radius at the desired latitude in meters + Earth radius at the desired latitude, in meters + + Notes + ----- + The ellipsoid is an approximation for the Earth model and + will result in an estimate of the perfect distance between + Earth's relief and its center of gravity. """ semi_minor_axis = semi_major_axis * (1 - flattening) @@ -3868,23 +2824,30 @@ def calculate_earth_radius( @staticmethod def decimal_degrees_to_arc_seconds(angle): - """Function to convert an angle in decimal degrees to deg/min/sec. - Converts (°) to (° ' ") + """Function to convert an angle in decimal degrees to degrees, arc + minutes and arc seconds. Parameters ---------- angle : float - The angle that you need convert to deg/min/sec. Must be given in - decimal degrees. + The angle that you need convert. Must be given in decimal degrees. Returns ------- - degrees : float + degrees : int The degrees. arc_minutes : int The arc minutes. 1 arc-minute = (1/60)*degree arc_seconds : float The arc Seconds. 1 arc-second = (1/3600)*degree + + Examples + -------- + Convert 45.5 degrees to degrees, arc minutes and arc seconds: + + >>> from rocketpy import Environment + >>> Environment.decimal_degrees_to_arc_seconds(45.5) + (45, 30, 0.0) """ sign = -1 if angle < 0 else 1 degrees = int(abs(angle)) * sign @@ -3892,3 +2855,13 @@ def decimal_degrees_to_arc_seconds(angle): arc_minutes = int(remainder * 60) arc_seconds = (remainder * 60 - arc_minutes) * 60 return degrees, arc_minutes, arc_seconds + + +if __name__ == "__main__": + import doctest + + results = doctest.testmod() + if results.failed < 1: + print(f"All the {results.attempted} tests passed!") + else: + print(f"{results.failed} out of {results.attempted} tests failed.") diff --git a/rocketpy/environment/environment_analysis.py b/rocketpy/environment/environment_analysis.py index da6fde364..6b917d88a 100644 --- a/rocketpy/environment/environment_analysis.py +++ b/rocketpy/environment/environment_analysis.py @@ -2,6 +2,7 @@ import copy import datetime import json +import warnings from collections import defaultdict from functools import cached_property @@ -26,7 +27,7 @@ # TODO: the average_wind_speed_profile_by_hour and similar methods could be more abstract than currently are -class EnvironmentAnalysis: +class EnvironmentAnalysis: # pylint: disable=too-many-public-methods """Class for analyzing the environment. List of properties currently implemented: @@ -92,7 +93,7 @@ class EnvironmentAnalysis: average max wind gust, and average day wind rose. """ - def __init__( + def __init__( # pylint: disable=too-many-statements self, start_date, end_date, @@ -209,7 +210,6 @@ def __init__( forecast_args = forecast_args or {"type": "Forecast", "file": "GFS"} env.set_atmospheric_model(**forecast_args) self.forecast[hour] = env - return None # Private, auxiliary methods @@ -244,7 +244,6 @@ def __check_requirements(self): "Given the above errors, some methods may not work. Please run " + "'pip install rocketpy[env_analysis]' to install extra requirements." ) - return None def __init_surface_dictionary(self): # Create dictionary of file variable names to process surface data @@ -426,8 +425,6 @@ def __check_coordinates_inside_grid( raise ValueError( f"Latitude and longitude pair {(self.latitude, self.longitude)} is outside the grid available in the given file, which is defined by {(lat_array[0], lon_array[0])} and {(lat_array[-1], lon_array[-1])}." ) - else: - return None def __localize_input_dates(self): if self.start_date.tzinfo is None: @@ -445,7 +442,7 @@ def __find_preferred_timezone(self): tf.timezone_at(lng=self.longitude, lat=self.latitude) ) except ImportError: - print( + warnings.warning( # pragma: no cover "'timezonefinder' not installed, defaulting to UTC." + " Install timezonefinder to get local time zone." + " To do so, run 'pip install timezonefinder'" @@ -478,8 +475,6 @@ def __init_data_parsing_units(self): # Create a variable to store updated units when units are being updated self.updated_units = self.current_units.copy() - return None - def __init_unit_system(self): """Initialize preferred units for output (SI, metric or imperial).""" if self.unit_system_string == "metric": @@ -552,10 +547,9 @@ def __set_unit_system(self, unit_system="metric"): # Update current units self.current_units = self.updated_units.copy() - return None - # General properties + # pylint: disable=too-many-locals, too-many-statements @cached_property def __parse_pressure_level_data(self): """ @@ -642,9 +636,9 @@ def __parse_pressure_level_data(self): ) # Check if date is within analysis range - if not (self.start_date <= date_time < self.end_date): + if not self.start_date <= date_time < self.end_date: continue - if not (self.start_hour <= date_time.hour < self.end_hour): + if not self.start_hour <= date_time.hour < self.end_hour: continue # Make sure keys exist if date_string not in dictionary: @@ -814,7 +808,7 @@ def pressure_level_lon1(self): return self.__parse_pressure_level_data[4] @cached_property - def __parse_surface_data(self): + def __parse_surface_data(self): # pylint: disable=too-many-statements """ Parse surface data from a weather file. Currently only supports files from ECMWF. @@ -883,9 +877,9 @@ def __parse_surface_data(self): ) # Check if date is within analysis range - if not (self.start_date <= date_time < self.end_date): + if not self.start_date <= date_time < self.end_date: continue - if not (self.start_hour <= date_time.hour < self.end_hour): + if not self.start_hour <= date_time.hour < self.end_hour: continue # Make sure keys exist @@ -999,7 +993,11 @@ def converted_pressure_level_data(self): converted_dict = copy.deepcopy(self.original_pressure_level_data) # Loop through dates - for date in self.original_pressure_level_data: + for ( + date + ) in ( + self.original_pressure_level_data + ): # pylint: disable=consider-using-dict-items # Loop through hours for hour in self.original_pressure_level_data[date]: # Loop through variables @@ -1061,7 +1059,9 @@ def converted_surface_data(self): converted_dict = copy.deepcopy(self.original_surface_data) # Loop through dates - for date in self.original_surface_data: + for ( + date + ) in self.original_surface_data: # pylint: disable=consider-using-dict-items # Loop through hours for hour in self.original_surface_data[date]: # Loop through variables @@ -1090,7 +1090,7 @@ def hours(self): List with all the hours available in the dataset. """ hours = list( - set( + set( # pylint: disable=consider-using-set-comprehension [ int(hour) for day_dict in self.converted_surface_data.values() @@ -1275,7 +1275,7 @@ def precipitation_per_day(self): List with total precipitation for each day in the dataset. """ return [ - sum([day_dict[hour]["total_precipitation"] for hour in day_dict.keys()]) + sum(day_dict[hour]["total_precipitation"] for hour in day_dict.keys()) for day_dict in self.converted_surface_data.values() ] @@ -1514,9 +1514,9 @@ def record_max_surface_wind_speed(self): Record maximum wind speed at surface level. """ max_speed = float("-inf") - for hour in self.surface_wind_speed_by_hour.keys(): - speed = max(self.surface_wind_speed_by_hour[hour]) - if speed > max_speed: + for speeds in self.surface_wind_speed_by_hour.values(): + speed = max(speeds) + if speed > max_speed: # pylint: disable=consider-using-max-builtin max_speed = speed return max_speed @@ -1532,9 +1532,9 @@ def record_min_surface_wind_speed(self): Record minimum wind speed at surface level. """ min_speed = float("inf") - for hour in self.surface_wind_speed_by_hour.keys(): - speed = max(self.surface_wind_speed_by_hour[hour]) - if speed < min_speed: + for speeds in self.surface_wind_speed_by_hour.values(): + speed = min(speeds) + if speed < min_speed: # pylint: disable=consider-using-min-builtin min_speed = speed return min_speed @@ -2134,7 +2134,7 @@ def surface_wind_gust_by_hour(self): # Pressure level data @cached_property - def altitude_AGL_range(self): + def altitude_AGL_range(self): # pylint: disable=invalid-name """The altitude range for the pressure level data. The minimum altitude is always 0, and the maximum altitude is the maximum altitude of the pressure level data, or the maximum expected altitude if it is set. @@ -2147,7 +2147,7 @@ def altitude_AGL_range(self): is the minimum altitude, and the second element is the maximum. """ min_altitude = 0 - if self.max_expected_altitude == None: + if self.max_expected_altitude is None: max_altitudes = [ np.max(day_dict[hour]["wind_speed"].source[-1, 0]) for day_dict in self.original_pressure_level_data.values() @@ -2159,7 +2159,7 @@ def altitude_AGL_range(self): return min_altitude, max_altitude @cached_property - def altitude_list(self, points=200): + def altitude_list(self, points=200): # pylint: disable=property-with-parameters """A list of altitudes, from 0 to the maximum altitude of the pressure level data, or the maximum expected altitude if it is set. The list is cached so that the computation is only done once. Units are kept as they @@ -2583,11 +2583,8 @@ def max_average_temperature_at_altitude(self): Maximum average temperature. """ max_temp = float("-inf") - for hour in self.average_temperature_profile_by_hour.keys(): - max_temp = max( - max_temp, - np.max(self.average_temperature_profile_by_hour[hour][0]), - ) + for temp_profile in self.average_temperature_profile_by_hour.values(): + max_temp = max(max_temp, np.max(temp_profile[0])) return max_temp @cached_property @@ -2603,11 +2600,8 @@ def min_average_temperature_at_altitude(self): Minimum average temperature. """ min_temp = float("inf") - for hour in self.average_temperature_profile_by_hour.keys(): - min_temp = min( - min_temp, - np.min(self.average_temperature_profile_by_hour[hour][0]), - ) + for temp_profile in self.average_temperature_profile_by_hour.values(): + min_temp = min(min_temp, np.min(temp_profile[0])) return min_temp @cached_property @@ -2624,11 +2618,8 @@ def max_average_wind_speed_at_altitude(self): Maximum average wind speed. """ max_wind_speed = float("-inf") - for hour in self.average_wind_speed_profile_by_hour.keys(): - max_wind_speed = max( - max_wind_speed, - np.max(self.average_wind_speed_profile_by_hour[hour][0]), - ) + for wind_speed_profile in self.average_wind_speed_profile_by_hour.values(): + max_wind_speed = max(max_wind_speed, np.max(wind_speed_profile[0])) return max_wind_speed # Pressure level data - Average values @@ -2772,7 +2763,6 @@ def info(self): self.prints.all() self.plots.info() - return None def all_info(self): """Prints out all data and graphs available. @@ -2785,8 +2775,6 @@ def all_info(self): self.prints.all() self.plots.all() - return None - def export_mean_profiles(self, filename="export_env_analysis"): """ Exports the mean profiles of the weather data to a file in order to it @@ -2808,30 +2796,30 @@ def export_mean_profiles(self, filename="export_env_analysis"): flipped_wind_x_dict = {} flipped_wind_y_dict = {} - for hour in self.average_temperature_profile_by_hour.keys(): + for hour, temp_profile in self.average_temperature_profile_by_hour.items(): flipped_temperature_dict[hour] = np.column_stack( - ( - self.average_temperature_profile_by_hour[hour][1], - self.average_temperature_profile_by_hour[hour][0], - ) + (temp_profile[1], temp_profile[0]) ).tolist() + + for hour, pressure_profile in self.average_pressure_profile_by_hour.items(): flipped_pressure_dict[hour] = np.column_stack( - ( - self.average_pressure_profile_by_hour[hour][1], - self.average_pressure_profile_by_hour[hour][0], - ) + (pressure_profile[1], pressure_profile[0]) ).tolist() + + for ( + hour, + wind_x_profile, + ) in self.average_wind_velocity_x_profile_by_hour.items(): flipped_wind_x_dict[hour] = np.column_stack( - ( - self.average_wind_velocity_x_profile_by_hour[hour][1], - self.average_wind_velocity_x_profile_by_hour[hour][0], - ) + (wind_x_profile[1], wind_x_profile[0]) ).tolist() + + for ( + hour, + wind_y_profile, + ) in self.average_wind_velocity_y_profile_by_hour.items(): flipped_wind_y_dict[hour] = np.column_stack( - ( - self.average_wind_velocity_y_profile_by_hour[hour][1], - self.average_wind_velocity_y_profile_by_hour[hour][0], - ) + (wind_y_profile[1], wind_y_profile[0]) ).tolist() self.export_dictionary = { @@ -2852,29 +2840,19 @@ def export_mean_profiles(self, filename="export_env_analysis"): "atmospheric_model_wind_velocity_y_profile": flipped_wind_y_dict, } - # Convert to json - f = open(filename + ".json", "w") - - # write json object to file - f.write( - json.dumps(self.export_dictionary, sort_keys=False, indent=4, default=str) - ) - - # close file - f.close() - print( - "Your Environment Analysis file was saved, check it out: " - + filename - + ".json" - ) + with open(filename + ".json", "w") as f: + f.write( + json.dumps( + self.export_dictionary, sort_keys=False, indent=4, default=str + ) + ) print( - "You can use it in the future by using the customAtmosphere atmospheric model." + f"Your Environment Analysis file was saved, check it out: {filename}.json\n" + "You can use it to set a `customAtmosphere` atmospheric model" ) - return None - @classmethod - def load(self, filename="env_analysis_dict"): + def load(cls, filename="env_analysis_dict"): """Load a previously saved Environment Analysis file. Example: EnvA = EnvironmentAnalysis.load("filename"). @@ -2886,10 +2864,9 @@ def load(self, filename="env_analysis_dict"): Returns ------- EnvironmentAnalysis object - """ jsonpickle = import_optional_dependency("jsonpickle") - encoded_class = open(filename).read() + encoded_class = open(filename).read() # pylint: disable=consider-using-with return jsonpickle.decode(encoded_class) def save(self, filename="env_analysis_dict"): @@ -2907,9 +2884,7 @@ def save(self, filename="env_analysis_dict"): """ jsonpickle = import_optional_dependency("jsonpickle") encoded_class = jsonpickle.encode(self) - file = open(filename, "w") + file = open(filename, "w") # pylint: disable=consider-using-with file.write(encoded_class) file.close() print("Your Environment Analysis file was saved, check it out: " + filename) - - return None diff --git a/rocketpy/environment/fetchers.py b/rocketpy/environment/fetchers.py new file mode 100644 index 000000000..6ba566145 --- /dev/null +++ b/rocketpy/environment/fetchers.py @@ -0,0 +1,432 @@ +"""This module contains auxiliary functions for fetching data from various +third-party APIs. As this is a recent module (introduced in v1.2.0), some +functions may be changed without notice in future feature releases. +""" + +import re +import time +from datetime import datetime, timedelta, timezone + +import netCDF4 +import requests + +from rocketpy.tools import exponential_backoff + + +@exponential_backoff(max_attempts=3, base_delay=1, max_delay=60) +def fetch_open_elevation(lat, lon): + """Fetches elevation data from the Open-Elevation API at a given latitude + and longitude. + + Parameters + ---------- + lat : float + The latitude of the location. + lon : float + The longitude of the location. + + Returns + ------- + float + The elevation at the given latitude and longitude in meters. + + Raises + ------ + RuntimeError + If there is a problem reaching the Open-Elevation API servers. + """ + print(f"Fetching elevation from open-elevation.com for lat={lat}, lon={lon}...") + request_url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}" + try: + response = requests.get(request_url) + results = response.json()["results"] + return results[0]["elevation"] + except ( + requests.exceptions.RequestException, + requests.exceptions.JSONDecodeError, + ) as e: + raise RuntimeError("Unable to reach Open-Elevation API servers.") from e + + +@exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) +def fetch_atmospheric_data_from_windy(lat, lon, model): + """Fetches atmospheric data from Windy.com API for a given latitude and + longitude, using a specific model. + + Parameters + ---------- + lat : float + The latitude of the location. + lon : float + The longitude of the location. + model : str + The atmospheric model to use. Options are: ecmwf, GFS, ICON or ICONEU. + + Returns + ------- + dict + A dictionary containing the atmospheric data retrieved from the API. + """ + model = model.lower() + if model[-1] == "u": # case iconEu + model = "".join([model[:4], model[4].upper(), model[5:]]) + + url = ( + f"https://node.windy.com/forecast/meteogram/{model}/{lat}/{lon}/" + "?step=undefined" + ) + + try: + response = requests.get(url).json() + if "data" not in response.keys(): + raise ValueError( + f"Could not get a valid response for '{model}' from Windy. " + "Check if the coordinates are set inside the model's domain." + ) + except requests.exceptions.RequestException as e: + if model == "iconEu": + raise ValueError( + "Could not get a valid response for Icon-EU from Windy. " + "Check if the coordinates are set inside Europe." + ) from e + + return response + + +def fetch_gfs_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest GFS (Global Forecast System) dataset from the NOAA's + GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The GFS dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for GFS. + """ + time_attempt = datetime.now(tz=timezone.utc) + attempt_count = 0 + dataset = None + + # TODO: the code below is trying to determine the hour of the latest available + # forecast by trial and error. This is not the best way to do it. We should + # actually check the NOAA website for the latest forecast time. Refactor needed. + while attempt_count < max_attempts: + time_attempt -= timedelta(hours=6) # GFS updates every 6 hours + file_url = ( + f"https://nomads.ncep.noaa.gov/dods/gfs_0p25/gfs" + f"{time_attempt.year:04d}{time_attempt.month:02d}" + f"{time_attempt.day:02d}/" + f"gfs_0p25_{6 * (time_attempt.hour // 6):02d}z" + ) + try: + # Attempts to create a dataset from the file using OpenDAP protocol. + dataset = netCDF4.Dataset(file_url) + return dataset + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + if dataset is None: + raise RuntimeError( + "Unable to load latest weather data for GFS through " + file_url + ) + + +def fetch_nam_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest NAM (North American Mesoscale) dataset from the NOAA's + GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The NAM dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for NAM. + """ + # Attempt to get latest forecast + time_attempt = datetime.now(tz=timezone.utc) + attempt_count = 0 + dataset = None + + while attempt_count < max_attempts: + time_attempt -= timedelta(hours=6) # NAM updates every 6 hours + file = ( + f"https://nomads.ncep.noaa.gov/dods/nam/nam{time_attempt.year:04d}" + f"{time_attempt.month:02d}{time_attempt.day:02d}/" + f"nam_conusnest_{6 * (time_attempt.hour // 6):02d}z" + ) + try: + # Attempts to create a dataset from the file using OpenDAP protocol. + dataset = netCDF4.Dataset(file) + return dataset + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + if dataset is None: + raise RuntimeError("Unable to load latest weather data for NAM through " + file) + + +def fetch_rap_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest RAP (Rapid Refresh) dataset from the NOAA's GrADS data + server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The RAP dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for RAP. + """ + # Attempt to get latest forecast + time_attempt = datetime.now(tz=timezone.utc) + attempt_count = 0 + dataset = None + + while attempt_count < max_attempts: + time_attempt -= timedelta(hours=1) # RAP updates every hour + file = ( + f"https://nomads.ncep.noaa.gov/dods/rap/rap{time_attempt.year:04d}" + f"{time_attempt.month:02d}{time_attempt.day:02d}/" + f"rap_{time_attempt.hour:02d}z" + ) + try: + # Attempts to create a dataset from the file using OpenDAP protocol. + dataset = netCDF4.Dataset(file) + return dataset + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + if dataset is None: + raise RuntimeError("Unable to load latest weather data for RAP through " + file) + + +def fetch_hiresw_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest HiResW (High-Resolution Window) dataset from the NOAA's + GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The HiResW dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for HiResW. + """ + # Attempt to get latest forecast + time_attempt = datetime.now(tz=timezone.utc) + attempt_count = 0 + dataset = None + + today = datetime.now(tz=timezone.utc) + date_info = (today.year, today.month, today.day, 12) # Hour given in UTC time + + while attempt_count < max_attempts: + time_attempt -= timedelta(hours=12) + date_info = ( + time_attempt.year, + time_attempt.month, + time_attempt.day, + 12, + ) # Hour given in UTC time + date_string = f"{date_info[0]:04d}{date_info[1]:02d}{date_info[2]:02d}" + file = ( + f"https://nomads.ncep.noaa.gov/dods/hiresw/hiresw{date_string}/" + "hiresw_conusarw_12z" + ) + try: + # Attempts to create a dataset from the file using OpenDAP protocol. + dataset = netCDF4.Dataset(file) + return dataset + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + if dataset is None: + raise RuntimeError( + "Unable to load latest weather data for HiResW through " + file + ) + + +@exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) +def fetch_wyoming_sounding(file): + """Fetches sounding data from a specified file using the Wyoming Weather + Web. + + Parameters + ---------- + file : str + The URL of the file to fetch. + + Returns + ------- + str + The content of the fetched file. + + Raises + ------ + ImportError + If unable to load the specified file. + ValueError + If the response indicates the specified station or date is invalid. + ValueError + If the response indicates the output format is invalid. + """ + response = requests.get(file) + if response.status_code != 200: + raise ImportError(f"Unable to load {file}.") # pragma: no cover + if len(re.findall("Can't get .+ Observations at", response.text)): + raise ValueError( + re.findall("Can't get .+ Observations at .+", response.text)[0] + + " Check station number and date." + ) + if response.text == "Invalid OUTPUT: specified\n": + raise ValueError( + "Invalid OUTPUT: specified. Make sure the output is Text: List." + ) + return response + + +@exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) +def fetch_noaaruc_sounding(file): + """Fetches sounding data from a specified file using the NOAA RUC soundings. + + Parameters + ---------- + file : str + The URL of the file to fetch. + + Returns + ------- + str + The content of the fetched file. + + Raises + ------ + ImportError + If unable to load the specified file or the file content is too short. + """ + response = requests.get(file) + if response.status_code != 200 or len(response.text) < 10: + raise ImportError("Unable to load " + file + ".") + return response + + +@exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) +def fetch_gefs_ensemble(): + """Fetches the latest GEFS (Global Ensemble Forecast System) dataset from + the NOAA's GrADS data server using the OpenDAP protocol. + + Returns + ------- + netCDF4.Dataset + The GEFS dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for GEFS. + """ + time_attempt = datetime.now(tz=timezone.utc) + success = False + attempt_count = 0 + while not success and attempt_count < 10: + time_attempt -= timedelta(hours=6 * attempt_count) # GEFS updates every 6 hours + file = ( + f"https://nomads.ncep.noaa.gov/dods/gens_bc/gens" + f"{time_attempt.year:04d}{time_attempt.month:02d}" + f"{time_attempt.day:02d}/" + f"gep_all_{6 * (time_attempt.hour // 6):02d}z" + ) + try: + dataset = netCDF4.Dataset(file) + success = True + return dataset + except OSError: + attempt_count += 1 + time.sleep(2**attempt_count) + if not success: + raise RuntimeError( + "Unable to load latest weather data for GEFS through " + file + ) + + +@exponential_backoff(max_attempts=5, base_delay=2, max_delay=60) +def fetch_cmc_ensemble(): + """Fetches the latest CMC (Canadian Meteorological Centre) ensemble dataset + from the NOAA's GrADS data server using the OpenDAP protocol. + + Returns + ------- + netCDF4.Dataset + The CMC ensemble dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for CMC. + """ + # Attempt to get latest forecast + time_attempt = datetime.now(tz=timezone.utc) + success = False + attempt_count = 0 + while not success and attempt_count < 10: + time_attempt -= timedelta( + hours=12 * attempt_count + ) # CMC updates every 12 hours + file = ( + f"https://nomads.ncep.noaa.gov/dods/cmcens/" + f"cmcens{time_attempt.year:04d}{time_attempt.month:02d}" + f"{time_attempt.day:02d}/" + f"cmcensspr_{12 * (time_attempt.hour // 12):02d}z" + ) + try: + dataset = netCDF4.Dataset(file) + success = True + return dataset + except OSError: + attempt_count += 1 + time.sleep(2**attempt_count) + if not success: + raise RuntimeError("Unable to load latest weather data for CMC through " + file) diff --git a/rocketpy/environment/tools.py b/rocketpy/environment/tools.py new file mode 100644 index 000000000..dfa2698a1 --- /dev/null +++ b/rocketpy/environment/tools.py @@ -0,0 +1,600 @@ +""""This module contains auxiliary functions for helping with the Environment +classes operations. The functions mainly deal with wind calculations and +interpolation of data from netCDF4 datasets. As this is a recent addition to +the library (introduced in version 1.5.0), some functions may be modified in the +future to improve their performance and usability. +""" + +import bisect +import warnings + +import netCDF4 +import numpy as np + +from rocketpy.tools import bilinear_interpolation + +## Wind data functions + + +def calculate_wind_heading(u, v): + """Calculates the wind heading from the u and v components of the wind. + + Parameters + ---------- + u : float + The velocity of the wind in the u (or x) direction. It can be either + positive or negative values. + v : float + The velocity of the wind in the v (or y) direction. It can be either + positive or negative values. + + Returns + ------- + float + The wind heading in degrees, ranging from 0 to 360 degrees. + + Examples + -------- + >>> from rocketpy.environment.tools import calculate_wind_heading + >>> calculate_wind_heading(1, 0) + np.float64(90.0) + >>> calculate_wind_heading(0, 1) + np.float64(0.0) + >>> calculate_wind_heading(3, 3) + np.float64(45.0) + >>> calculate_wind_heading(-3, 3) + np.float64(315.0) + """ + return np.degrees(np.arctan2(u, v)) % 360 + + +def convert_wind_heading_to_direction(wind_heading): + """Converts wind heading to wind direction. The wind direction is the + direction from which the wind is coming from, while the wind heading is the + direction to which the wind is blowing to. + + Parameters + ---------- + wind_heading : float + The wind heading in degrees, ranging from 0 to 360 degrees. + + Returns + ------- + float + The wind direction in degrees, ranging from 0 to 360 degrees. + """ + return (wind_heading - 180) % 360 + + +def calculate_wind_speed(u, v, w=0.0): + """Calculates the wind speed from the u, v, and w components of the wind. + + Parameters + ---------- + u : float + The velocity of the wind in the u (or x) direction. It can be either + positive or negative values. + v : float + The velocity of the wind in the v (or y) direction. It can be either + positive or negative values. + w : float + The velocity of the wind in the w (or z) direction. It can be either + positive or negative values. + + Returns + ------- + float + The wind speed in m/s. + + Examples + -------- + >>> from rocketpy.environment.tools import calculate_wind_speed + >>> calculate_wind_speed(1, 0, 0) + np.float64(1.0) + >>> calculate_wind_speed(0, 1, 0) + np.float64(1.0) + >>> calculate_wind_speed(0, 0, 1) + np.float64(1.0) + >>> calculate_wind_speed(3, 4, 0) + np.float64(5.0) + + The third component of the wind is optional, and if not provided, it is + assumed to be zero. + + >>> calculate_wind_speed(3, 4) + np.float64(5.0) + >>> calculate_wind_speed(3, 4, 0) + np.float64(5.0) + """ + return np.sqrt(u**2 + v**2 + w**2) + + +## These functions are meant to be used with netcdf4 datasets + + +def get_pressure_levels_from_file(data, dictionary): + """Extracts pressure levels from a netCDF4 dataset and converts them to Pa. + + Parameters + ---------- + data : netCDF4.Dataset + The netCDF4 dataset containing the pressure level data. + dictionary : dict + A dictionary mapping variable names to dataset keys. + + Returns + ------- + numpy.ndarray + An array of pressure levels in Pa. + + Raises + ------ + ValueError + If the pressure levels cannot be read from the file. + """ + try: + # Convert mbar to Pa + levels = 100 * data.variables[dictionary["level"]][:] + except KeyError as e: + raise ValueError( + "Unable to read pressure levels from file. Check file and dictionary." + ) from e + return levels + + +def mask_and_clean_dataset(*args): + """Masks and cleans a dataset by removing rows with masked values. + + Parameters + ---------- + *args : numpy.ma.MaskedArray + Variable number of masked arrays to be cleaned. + + Returns + ------- + numpy.ma.MaskedArray + A cleaned array with rows containing masked values removed. + """ + data_array = np.ma.column_stack(list(args)) + + # Remove lines with masked content + if np.any(data_array.mask): + data_array = np.ma.compress_rows(data_array) + warnings.warn( + "Some values were missing from this weather dataset, therefore, " + "certain pressure levels were removed." + ) + + return data_array + + +def find_longitude_index(longitude, lon_list): + """Finds the index of the given longitude in a list of longitudes. + + Parameters + ---------- + longitude : float + The longitude to find in the list. + lon_list : list of float + The list of longitudes. + + Returns + ------- + tuple + A tuple containing the adjusted longitude and its index in the list. + + Raises + ------ + ValueError + If the longitude is not within the range covered by the list. + """ + # Determine if file uses -180 to 180 or 0 to 360 + if lon_list[0] < 0 or lon_list[-1] < 0: + # Convert input to -180 - 180 + lon = longitude if longitude < 180 else -180 + longitude % 180 + else: + # Convert input to 0 - 360 + lon = longitude % 360 + # Check if reversed or sorted + if lon_list[0] < lon_list[-1]: + # Deal with sorted lon_list + lon_index = bisect.bisect(lon_list, lon) + else: + # Deal with reversed lon_list + lon_list.reverse() + lon_index = len(lon_list) - bisect.bisect_left(lon_list, lon) + lon_list.reverse() + # Take care of longitude value equal to maximum longitude in the grid + if lon_index == len(lon_list) and lon_list[lon_index - 1] == lon: + lon_index = lon_index - 1 + # Check if longitude value is inside the grid + if lon_index == 0 or lon_index == len(lon_list): + raise ValueError( + f"Longitude {lon} not inside region covered by file, which is " + f"from {lon_list[0]} to {lon_list[-1]}." + ) + + return lon, lon_index + + +def find_latitude_index(latitude, lat_list): + """Finds the index of the given latitude in a list of latitudes. + + Parameters + ---------- + latitude : float + The latitude to find in the list. + lat_list : list of float + The list of latitudes. + + Returns + ------- + tuple + A tuple containing the latitude and its index in the list. + + Raises + ------ + ValueError + If the latitude is not within the range covered by the list. + """ + # Check if reversed or sorted + if lat_list[0] < lat_list[-1]: + # Deal with sorted lat_list + lat_index = bisect.bisect(lat_list, latitude) + else: + # Deal with reversed lat_list + lat_list.reverse() + lat_index = len(lat_list) - bisect.bisect_left(lat_list, latitude) + lat_list.reverse() + # Take care of latitude value equal to maximum longitude in the grid + if lat_index == len(lat_list) and lat_list[lat_index - 1] == latitude: + lat_index = lat_index - 1 + # Check if latitude value is inside the grid + if lat_index == 0 or lat_index == len(lat_list): + raise ValueError( + f"Latitude {latitude} not inside region covered by file, " + f"which is from {lat_list[0]} to {lat_list[-1]}." + ) + return latitude, lat_index + + +def find_time_index(datetime_date, time_array): + """Finds the index of the given datetime in a netCDF4 time array. + + Parameters + ---------- + datetime_date : datetime.datetime + The datetime to find in the array. + time_array : netCDF4.Variable + The netCDF4 time array. + + Returns + ------- + int + The index of the datetime in the time array. + + Raises + ------ + ValueError + If the datetime is not within the range covered by the time array. + ValueError + If the exact datetime is not available and the nearest datetime is used instead. + """ + time_index = netCDF4.date2index( + datetime_date, time_array, calendar="gregorian", select="nearest" + ) + # Convert times do dates and numbers + input_time_num = netCDF4.date2num( + datetime_date, time_array.units, calendar="gregorian" + ) + file_time_num = time_array[time_index] + file_time_date = netCDF4.num2date( + time_array[time_index], time_array.units, calendar="gregorian" + ) + # Check if time is inside range supplied by file + if time_index == 0 and input_time_num < file_time_num: + raise ValueError( + f"The chosen launch time '{datetime_date.strftime('%Y-%m-%d-%H:')} UTC' is" + " not available in the provided file. Please choose a time within the range" + " of the file, which starts at " + f"'{file_time_date.strftime('%Y-%m-%d-%H')} UTC'." + ) + elif time_index == len(time_array) - 1 and input_time_num > file_time_num: + raise ValueError( + "Chosen launch time is not available in the provided file, " + f"which ends at {file_time_date}." + ) + # Check if time is exactly equal to one in the file + if input_time_num != file_time_num: + warnings.warn( + "Exact chosen launch time is not available in the provided file, " + f"using {file_time_date} UTC instead." + ) + + return time_index + + +def get_elevation_data_from_dataset( + dictionary, data, time_index, lat_index, lon_index, x, y, x1, x2, y1, y2 +): + """Retrieves elevation data from a netCDF4 dataset and applies bilinear + interpolation. + + Parameters + ---------- + dictionary : dict + A dictionary mapping variable names to dataset keys. + data : netCDF4.Dataset + The netCDF4 dataset containing the elevation data. + time_index : int + The time index for the data. + lat_index : int + The latitude index for the data. + lon_index : int + The longitude index for the data. + x : float + The x-coordinate of the point to be interpolated. + y : float + The y-coordinate of the point to be interpolated. + x1 : float + The x-coordinate of the first reference point. + x2 : float + The x-coordinate of the second reference point. + y1 : float + The y-coordinate of the first reference point. + y2 : float + The y-coordinate of the second reference point. + + Returns + ------- + float + The interpolated elevation value at the point (x, y). + + Raises + ------ + ValueError + If the elevation data cannot be read from the file. + """ + try: + elevations = data.variables[dictionary["surface_geopotential_height"]][ + time_index, (lat_index - 1, lat_index), (lon_index - 1, lon_index) + ] + except KeyError as e: + raise ValueError( + "Unable to read surface elevation data. Check file and dictionary." + ) from e + return bilinear_interpolation( + x, + y, + x1, + x2, + y1, + y2, + elevations[0, 0], + elevations[0, 1], + elevations[1, 0], + elevations[1, 1], + ) + + +def get_initial_date_from_time_array(time_array, units=None): + """Returns a datetime object representing the first time in the time array. + + Parameters + ---------- + time_array : netCDF4.Variable + The netCDF4 time array. + units : str, optional + The time units, by default None. + + Returns + ------- + datetime.datetime + A datetime object representing the first time in the time array. + """ + units = units or time_array.units + return netCDF4.num2date(time_array[0], units, calendar="gregorian") + + +def get_final_date_from_time_array(time_array, units=None): + """Returns a datetime object representing the last time in the time array. + + Parameters + ---------- + time_array : netCDF4.Variable + The netCDF4 time array. + units : str, optional + The time units, by default None. + + Returns + ------- + datetime.datetime + A datetime object representing the last time in the time array. + """ + units = units if units is not None else time_array.units + return netCDF4.num2date(time_array[-1], units, calendar="gregorian") + + +def get_interval_date_from_time_array(time_array, units=None): + """Returns the interval between two times in the time array in hours. + + Parameters + ---------- + time_array : netCDF4.Variable + The netCDF4 time array. + units : str, optional + The time units, by default None. If None is set, the units from the + time array are used. + + Returns + ------- + int + The interval in hours between two times in the time array. + """ + units = units or time_array.units + return netCDF4.num2date( + (time_array[-1] - time_array[0]) / (len(time_array) - 1), + units, + calendar="gregorian", + ).hour + + +# Geodesic conversions functions + + +def geodesic_to_utm( + lat, lon, semi_major_axis=6378137.0, flattening=1 / 298.257223563 +): # pylint: disable=too-many-locals,too-many-statements + # NOTE: already documented in the Environment class. + # TODO: deprecated the static method from the environment class, use only this one. + + # Calculate the central meridian of UTM zone + if lon != 0: + signal = lon / abs(lon) + if signal > 0: + aux = lon - 3 + aux = aux * signal + div = aux // 6 + lon_mc = div * 6 + 3 + EW = "E" # pylint: disable=invalid-name + else: + aux = lon + 3 + aux = aux * signal + div = aux // 6 + lon_mc = (div * 6 + 3) * signal + EW = "W" # pylint: disable=invalid-name + else: + lon_mc = 3 + EW = "W|E" # pylint: disable=invalid-name + + # Evaluate the hemisphere and determine the N coordinate at the Equator + if lat < 0: + N0 = 10000000 + hemis = "S" + else: + N0 = 0 + hemis = "N" + + # Convert the input lat and lon to radians + lat = lat * np.pi / 180 + lon = lon * np.pi / 180 + lon_mc = lon_mc * np.pi / 180 + + # Evaluate reference parameters + K0 = 1 - 1 / 2500 + e2 = 2 * flattening - flattening**2 + e2lin = e2 / (1 - e2) + + # Evaluate auxiliary parameters + A = e2 * e2 + B = A * e2 + C = np.sin(2 * lat) + D = np.sin(4 * lat) + E = np.sin(6 * lat) + F = (1 - e2 / 4 - 3 * A / 64 - 5 * B / 256) * lat + G = (3 * e2 / 8 + 3 * A / 32 + 45 * B / 1024) * C + H = (15 * A / 256 + 45 * B / 1024) * D + aux_i = (35 * B / 3072) * E + + # Evaluate other reference parameters + n = semi_major_axis / ((1 - e2 * (np.sin(lat) ** 2)) ** 0.5) + t = np.tan(lat) ** 2 + c = e2lin * (np.cos(lat) ** 2) + ag = (lon - lon_mc) * np.cos(lat) + m = semi_major_axis * (F - G + H - aux_i) + + # Evaluate new auxiliary parameters + J = (1 - t + c) * ag * ag * ag / 6 + K = (5 - 18 * t + t * t + 72 * c - 58 * e2lin) * (ag**5) / 120 + L = (5 - t + 9 * c + 4 * c * c) * ag * ag * ag * ag / 24 + M = (61 - 58 * t + t * t + 600 * c - 330 * e2lin) * (ag**6) / 720 + + # Evaluate the final coordinates + x = 500000 + K0 * n * (ag + J + K) + y = N0 + K0 * (m + n * np.tan(lat) * (ag * ag / 2 + L + M)) + + # Convert the output lat and lon to degrees + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi + lon_mc = lon_mc * 180 / np.pi + + # Calculate the UTM zone number + utm_zone = int((lon_mc + 183) / 6) + + # Calculate the UTM zone letter + letters = "CDEFGHJKLMNPQRSTUVWXX" + utm_letter = letters[int(80 + lat) >> 3] + + return x, y, utm_zone, utm_letter, hemis, EW + + +def utm_to_geodesic( # pylint: disable=too-many-locals,too-many-statements + x, y, utm_zone, hemis, semi_major_axis=6378137.0, flattening=1 / 298.257223563 +): + # NOTE: already documented in the Environment class. + # TODO: deprecated the static method from the environment class, use only this one. + + if hemis == "N": + y = y + 10000000 + + # Calculate the Central Meridian from the UTM zone number + central_meridian = utm_zone * 6 - 183 # degrees + + # Calculate reference values + K0 = 1 - 1 / 2500 + e2 = 2 * flattening - flattening**2 + e2lin = e2 / (1 - e2) + e1 = (1 - (1 - e2) ** 0.5) / (1 + (1 - e2) ** 0.5) + + # Calculate auxiliary values + A = e2 * e2 + B = A * e2 + C = e1 * e1 + D = e1 * C + E = e1 * D + + m = (y - 10000000) / K0 + mi = m / (semi_major_axis * (1 - e2 / 4 - 3 * A / 64 - 5 * B / 256)) + + # Calculate other auxiliary values + F = (3 * e1 / 2 - 27 * D / 32) * np.sin(2 * mi) + G = (21 * C / 16 - 55 * E / 32) * np.sin(4 * mi) + H = (151 * D / 96) * np.sin(6 * mi) + + lat1 = mi + F + G + H + c1 = e2lin * (np.cos(lat1) ** 2) + t1 = np.tan(lat1) ** 2 + n1 = semi_major_axis / ((1 - e2 * (np.sin(lat1) ** 2)) ** 0.5) + quoc = (1 - e2 * np.sin(lat1) * np.sin(lat1)) ** 3 + r1 = semi_major_axis * (1 - e2) / (quoc**0.5) + d = (x - 500000) / (n1 * K0) + + # Calculate other auxiliary values + aux_i = (5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * e2lin) * d * d * d * d / 24 + J = ( + (61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * e2lin - 3 * c1 * c1) + * (d**6) + / 720 + ) + K = d - (1 + 2 * t1 + c1) * d * d * d / 6 + L = (5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * e2lin + 24 * t1 * t1) * (d**5) / 120 + + # Finally calculate the coordinates in lat/lot + lat = lat1 - (n1 * np.tan(lat1) / r1) * (d * d / 2 - aux_i + J) + lon = central_meridian * np.pi / 180 + (K + L) / np.cos(lat1) + + # Convert final lat/lon to Degrees + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi + + return lat, lon + + +if __name__ == "__main__": + import doctest + + results = doctest.testmod() + if results.failed < 1: + print(f"All the {results.attempted} tests passed!") + else: + print(f"{results.failed} out of {results.attempted} tests failed.") diff --git a/rocketpy/environment/weather_model_mapping.py b/rocketpy/environment/weather_model_mapping.py new file mode 100644 index 000000000..7aed6d5e1 --- /dev/null +++ b/rocketpy/environment/weather_model_mapping.py @@ -0,0 +1,126 @@ +class WeatherModelMapping: + """Class to map the weather model variables to the variables used in the + Environment class. + """ + + GFS = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + NAM = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + ECMWF = { + "time": "time", + "latitude": "latitude", + "longitude": "longitude", + "level": "level", + "temperature": "t", + "surface_geopotential_height": None, + "geopotential_height": None, + "geopotential": "z", + "u_wind": "u", + "v_wind": "v", + } + NOAA = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + RAP = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + CMC = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "ensemble": "ens", + "temperature": "tmpprs", + "surface_geopotential_height": None, + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + GEFS = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "ensemble": "ens", + "temperature": "tmpprs", + "surface_geopotential_height": None, + "geopotential_height": "hgtprs", + "geopotential": None, + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + HIRESW = { + "time": "time", + "latitude": "lat", + "longitude": "lon", + "level": "lev", + "temperature": "tmpprs", + "surface_geopotential_height": "hgtsfc", + "geopotential_height": "hgtprs", + "u_wind": "ugrdprs", + "v_wind": "vgrdprs", + } + + def __init__(self): + """Initialize the class, creates a dictionary with all the weather models + available and their respective dictionaries with the variables.""" + + self.all_dictionaries = { + "GFS": self.GFS, + "NAM": self.NAM, + "ECMWF": self.ECMWF, + "NOAA": self.NOAA, + "RAP": self.RAP, + "CMC": self.CMC, + "GEFS": self.GEFS, + "HIRESW": self.HIRESW, + } + + def get(self, model): + try: + return self.all_dictionaries[model] + except KeyError as e: + raise KeyError( + f"Model {model} not found in the WeatherModelMapping. " + f"The available models are: {self.all_dictionaries.keys()}" + ) from e diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index b34c4dd52..55680d199 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """ The mathutils/function.py is a rocketpy module totally dedicated to function operations, including interpolation, extrapolation, integration, differentiation and more. This is a core class of our package, and should be maintained @@ -34,7 +35,7 @@ EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} -class Function: +class Function: # pylint: disable=too-many-public-methods """Class converts a python function or a data sequence into an object which can be handled more naturally, enabling easy interpolation, extrapolation, plotting and algebra. @@ -168,7 +169,7 @@ def set_outputs(self, outputs): self.__outputs__ = self.__validate_outputs(outputs) return self - def set_source(self, source): + def set_source(self, source): # pylint: disable=too-many-statements """Sets the data source for the function, defining how the function produces output from a given input. @@ -336,7 +337,7 @@ def set_extrapolation(self, method="constant"): self.__set_extrapolation_func() return self - def __set_interpolation_func(self): + def __set_interpolation_func(self): # pylint: disable=too-many-statements """Defines interpolation function used by the Function. Each interpolation method has its own function with exception of shepard, which has its interpolation/extrapolation function defined in @@ -345,7 +346,9 @@ def __set_interpolation_func(self): interpolation = INTERPOLATION_TYPES[self.__interpolation__] if interpolation == 0: # linear - def linear_interpolation(x, x_min, x_max, x_data, y_data, coeffs): + def linear_interpolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument x_interval = bisect_left(x_data, x) x_left = x_data[x_interval - 1] y_left = y_data[x_interval - 1] @@ -357,14 +360,18 @@ def linear_interpolation(x, x_min, x_max, x_data, y_data, coeffs): elif interpolation == 1: # polynomial - def polynomial_interpolation(x, x_min, x_max, x_data, y_data, coeffs): + def polynomial_interpolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument return np.sum(coeffs * x ** np.arange(len(coeffs))) self._interpolation_func = polynomial_interpolation elif interpolation == 2: # akima - def akima_interpolation(x, x_min, x_max, x_data, y_data, coeffs): + def akima_interpolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument x_interval = bisect_left(x_data, x) x_interval = x_interval if x_interval != 0 else 1 a = coeffs[4 * x_interval - 4 : 4 * x_interval] @@ -374,7 +381,9 @@ def akima_interpolation(x, x_min, x_max, x_data, y_data, coeffs): elif interpolation == 3: # spline - def spline_interpolation(x, x_min, x_max, x_data, y_data, coeffs): + def spline_interpolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument x_interval = bisect_left(x_data, x) x_interval = max(x_interval, 1) a = coeffs[:, x_interval - 1] @@ -386,7 +395,7 @@ def spline_interpolation(x, x_min, x_max, x_data, y_data, coeffs): elif interpolation == 4: # shepard does not use interpolation function self._interpolation_func = None - def __set_extrapolation_func(self): + def __set_extrapolation_func(self): # pylint: disable=too-many-statements """Defines extrapolation function used by the Function. Each extrapolation method has its own function. The function is stored in the attribute _extrapolation_func.""" @@ -398,14 +407,18 @@ def __set_extrapolation_func(self): elif extrapolation == 0: # zero - def zero_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def zero_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument return 0 self._extrapolation_func = zero_extrapolation elif extrapolation == 1: # natural if interpolation == 0: # linear - def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def natural_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument x_interval = 1 if x < x_min else -1 x_left = x_data[x_interval - 1] y_left = y_data[x_interval - 1] @@ -415,18 +428,24 @@ def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): elif interpolation == 1: # polynomial - def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def natural_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument return np.sum(coeffs * x ** np.arange(len(coeffs))) elif interpolation == 2: # akima - def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def natural_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument a = coeffs[:4] if x < x_min else coeffs[-4:] return a[3] * x**3 + a[2] * x**2 + a[1] * x + a[0] elif interpolation == 3: # spline - def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def natural_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument if x < x_min: a = coeffs[:, 0] x = x - x_data[0] @@ -438,7 +457,9 @@ def natural_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): self._extrapolation_func = natural_extrapolation elif extrapolation == 2: # constant - def constant_extrapolation(x, x_min, x_max, x_data, y_data, coeffs): + def constant_extrapolation( + x, x_min, x_max, x_data, y_data, coeffs + ): # pylint: disable=unused-argument return y_data[0] if x < x_min else y_data[-1] self._extrapolation_func = constant_extrapolation @@ -1170,7 +1191,7 @@ def plot(self, *args, **kwargs): elif self.__dom_dim__ == 2: self.plot_2d(*args, **kwargs) else: - print("Error: Only functions with 1D or 2D domains are plottable!") + print("Error: Only functions with 1D or 2D domains can be plotted.") def plot1D(self, *args, **kwargs): """Deprecated method, use Function.plot_1d instead.""" @@ -1182,7 +1203,7 @@ def plot1D(self, *args, **kwargs): ) return self.plot_1d(*args, **kwargs) - def plot_1d( + def plot_1d( # pylint: disable=too-many-statements self, lower=None, upper=None, @@ -1275,7 +1296,7 @@ def plot2D(self, *args, **kwargs): ) return self.plot_2d(*args, **kwargs) - def plot_2d( + def plot_2d( # pylint: disable=too-many-statements self, lower=None, upper=None, @@ -1364,7 +1385,6 @@ def plot_2d( ) z_min, z_max = z.min(), z.max() color_map = plt.colormaps[cmap] - norm = plt.Normalize(z_min, z_max) # Plot function if disp_type == "surface": @@ -1399,7 +1419,7 @@ def plot_2d( plt.show() @staticmethod - def compare_plots( + def compare_plots( # pylint: disable=too-many-statements plot_list, lower=None, upper=None, @@ -1520,7 +1540,7 @@ def compare_plots( ax.scatter(points[0], points[1], marker="o") # Setup legend - if any([plot[1] for plot in plots]): + if any(plot[1] for plot in plots): ax.legend(loc="best", shadow=True) # Turn on grid and set title and axis @@ -1735,7 +1755,6 @@ def __ge__(self, other): "Comparison not supported between two instances of " "the Function class with callable sources." ) from exc - return None def __le__(self, other): """Less than or equal to comparison operator. It can be used to @@ -1789,7 +1808,6 @@ def __le__(self, other): "Comparison not supported between two instances of " "the Function class with callable sources." ) from exc - return None def __gt__(self, other): """Greater than comparison operator. It can be used to compare a @@ -1836,7 +1854,7 @@ def __lt__(self, other): return ~self.__ge__(other) # Define all possible algebraic operations - def __add__(self, other): + def __add__(self, other): # pylint: disable=too-many-statements """Sums a Function object and 'other', returns a new Function object which gives the result of the sum. Only implemented for 1D domains. @@ -2043,7 +2061,7 @@ def __rmul__(self, other): """ return self * other - def __truediv__(self, other): + def __truediv__(self, other): # pylint: disable=too-many-statements """Divides a Function object and returns a new Function object which gives the result of the division. Only implemented for 1D domains. @@ -2153,7 +2171,7 @@ def __rtruediv__(self, other): elif callable(other): return Function(lambda x: (other(x) / self.get_value_opt(x))) - def __pow__(self, other): + def __pow__(self, other): # pylint: disable=too-many-statements """Raises a Function object to the power of 'other' and returns a new Function object which gives the result. Only implemented for 1D domains. @@ -2282,7 +2300,24 @@ def __matmul__(self, other): """ return self.compose(other) - def integral(self, a, b, numerical=False): + def __mod__(self, other): + """Operator % as an alias for modulo operation.""" + if callable(self.source): + return Function(lambda x: self.source(x) % other) + elif isinstance(self.source, np.ndarray) and isinstance(other, NUMERICAL_TYPES): + return Function( + np.column_stack((self.x_array, self.y_array % other)), + self.__inputs__, + self.__outputs__, + self.__interpolation__, + self.__extrapolation__, + ) + raise NotImplementedError( + "Modulo operation not implemented for operands of type " + f"'{type(self)}' and '{type(other)}'." + ) + + def integral(self, a, b, numerical=False): # pylint: disable=too-many-statements """Evaluate a definite integral of a 1-D Function in the interval from a to b. @@ -2471,7 +2506,7 @@ def differentiate_complex_step(self, x, dx=1e-200, order=1): return float(self.get_value_opt(x + dx * 1j).imag / dx) else: raise NotImplementedError( - "Only 1st order derivatives are supported yet. " "Set order=1." + "Only 1st order derivatives are supported yet. Set order=1." ) def identity_function(self): @@ -2596,8 +2631,8 @@ def isbijective(self): return len(distinct_map) == len(x_data_distinct) == len(y_data_distinct) else: raise TypeError( - "Only Functions whose source is a list of points can be " - "checked for bijectivity." + "`isbijective()` method only supports Functions whose " + "source is an array." ) def is_strictly_bijective(self): @@ -2649,8 +2684,8 @@ def is_strictly_bijective(self): return np.all(y_data_diff >= 0) or np.all(y_data_diff <= 0) else: raise TypeError( - "Only Functions whose source is a list of points can be " - "checked for bijectivity." + "`is_strictly_bijective()` method only supports Functions " + "whose source is an array." ) def inverse_function(self, approx_func=None, tol=1e-4): @@ -2660,8 +2695,9 @@ def inverse_function(self, approx_func=None, tol=1e-4): and only if F is bijective. Makes the domain the range and the range the domain. - If the Function is given by a list of points, its bijectivity is - checked and an error is raised if it is not bijective. + If the Function is given by a list of points, the method + `is_strictly_bijective()` is called and an error is raised if the + Function is not bijective. If the Function is given by a function, its bijection is not checked and may lead to inaccuracies outside of its bijective region. @@ -2920,7 +2956,7 @@ def __is_single_element_array(var): return isinstance(var, np.ndarray) and var.size == 1 # Input validators - def __validate_source(self, source): + def __validate_source(self, source): # pylint: disable=too-many-statements """Used to validate the source parameter for creating a Function object. Parameters @@ -2965,7 +3001,7 @@ def __validate_source(self, source): "Could not read the csv or txt file to create Function source." ) from e - if isinstance(source, list) or isinstance(source, np.ndarray): + if isinstance(source, (list, np.ndarray)): # Triggers an error if source is not a list of numbers source = np.array(source, dtype=np.float64) @@ -3016,7 +3052,7 @@ def __validate_inputs(self, inputs): ) if self.__dom_dim__ > 1: if inputs is None: - return [f"Input {i+1}" for i in range(self.__dom_dim__)] + return [f"Input {i + 1}" for i in range(self.__dom_dim__)] if isinstance(inputs, list): if len(inputs) == self.__dom_dim__ and all( isinstance(i, str) for i in inputs @@ -3206,7 +3242,7 @@ def calc_output(func, inputs): ) -def funcify_method(*args, **kwargs): +def funcify_method(*args, **kwargs): # pylint: disable=too-many-statements """Decorator factory to wrap methods as Function objects and save them as cached properties. diff --git a/rocketpy/mathutils/vector_matrix.py b/rocketpy/mathutils/vector_matrix.py index 332e1b680..03d2d5b51 100644 --- a/rocketpy/mathutils/vector_matrix.py +++ b/rocketpy/mathutils/vector_matrix.py @@ -335,11 +335,11 @@ def element_wise(self, operation): def dot(self, other): """Dot product between two R3 vectors.""" - return self.__matmul__(other) + return self @ other def cross(self, other): """Cross product between two R3 vectors.""" - return self.__xor__(other) + return self ^ other def proj(self, other): """Scalar projection of R3 vector self onto R3 vector other. @@ -613,18 +613,12 @@ def transpose(self): @cached_property def det(self): """Matrix determinant.""" - return self.__abs__() + return abs(self) @cached_property - def is_diagonal(self, tol=1e-6): + def is_diagonal(self): """Boolean indicating if matrix is diagonal. - Parameters - ---------- - tol : float, optional - Tolerance used to determine if non-diagonal elements are negligible. - Defaults to 1e-6. - Returns ------- bool @@ -647,7 +641,7 @@ def is_diagonal(self, tol=1e-6): for i, j in product(range(3), range(3)): if i == j: continue - if abs(self[i, j]) > tol: + if abs(self[i, j]) > 1e-6: return False return True @@ -918,7 +912,7 @@ def dot(self, other): -------- Matrix.__matmul__ """ - return self.__matmul__(other) + return self @ (other) def __str__(self): return ( diff --git a/rocketpy/motors/fluid.py b/rocketpy/motors/fluid.py index c93cf8079..4be124ec3 100644 --- a/rocketpy/motors/fluid.py +++ b/rocketpy/motors/fluid.py @@ -38,7 +38,6 @@ def __post_init__(self): # Initialize plots and prints object self.prints = _FluidPrints(self) self.plots = _FluidPlots(self) - return None def __repr__(self): """Representation method. diff --git a/rocketpy/motors/hybrid_motor.py b/rocketpy/motors/hybrid_motor.py index 557333fe7..aa223668f 100644 --- a/rocketpy/motors/hybrid_motor.py +++ b/rocketpy/motors/hybrid_motor.py @@ -181,7 +181,7 @@ class HybridMotor(Motor): 'akima' and 'linear'. Default is "linear". """ - def __init__( + def __init__( # pylint: disable=too-many-arguments self, thrust_source, dry_mass, @@ -359,7 +359,6 @@ class Function. Thrust units are Newtons. # Initialize plots and prints object self.prints = _HybridMotorPrints(self) self.plots = _HybridMotorPlots(self) - return None @funcify_method("Time (s)", "Exhaust velocity (m/s)") def exhaust_velocity(self): @@ -608,7 +607,6 @@ def info(self): """Prints out basic data about the Motor.""" self.prints.all() self.plots.thrust() - return None def all_info(self): """Prints out all data and graphs available about the Motor. @@ -619,4 +617,3 @@ def all_info(self): """ self.prints.all() self.plots.all() - return None diff --git a/rocketpy/motors/liquid_motor.py b/rocketpy/motors/liquid_motor.py index 7314e11ba..3674c33de 100644 --- a/rocketpy/motors/liquid_motor.py +++ b/rocketpy/motors/liquid_motor.py @@ -2,11 +2,7 @@ import numpy as np -from rocketpy.mathutils.function import ( - Function, - funcify_method, - reset_funcified_methods, -) +from rocketpy.mathutils.function import funcify_method, reset_funcified_methods from rocketpy.tools import parallel_axis_theorem_from_com from ..plots.liquid_motor_plots import _LiquidMotorPlots @@ -251,7 +247,6 @@ class Function. Thrust units are Newtons. # Initialize plots and prints object self.prints = _LiquidMotorPrints(self) self.plots = _LiquidMotorPlots(self) - return None @funcify_method("Time (s)", "Exhaust Velocity (m/s)") def exhaust_velocity(self): @@ -474,7 +469,6 @@ def info(self): """Prints out basic data about the Motor.""" self.prints.all() self.plots.thrust() - return None def all_info(self): """Prints out all data and graphs available about the Motor. @@ -485,4 +479,3 @@ def all_info(self): """ self.prints.all() self.plots.all() - return None diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 9429da88e..3268c9279 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -11,6 +11,7 @@ from ..tools import parallel_axis_theorem_from_com, tuple_handler +# pylint: disable=too-many-public-methods class Motor(ABC): """Abstract class to specify characteristics and useful operations for motors. Cannot be instantiated. @@ -146,6 +147,7 @@ class Motor(ABC): 'akima' and 'linear'. Default is "linear". """ + # pylint: disable=too-many-statements def __init__( self, thrust_source, @@ -311,7 +313,6 @@ class Function. Thrust units are Newtons. # Initialize plots and prints object self.prints = _MotorPrints(self) self.plots = _MotorPlots(self) - return None @property def burn_time(self): @@ -379,7 +380,6 @@ def exhaust_velocity(self): Tanks's mass flow rates. Therefore the exhaust velocity is generally variable, being the ratio of the motor thrust by the mass flow rate. """ - pass @funcify_method("Time (s)", "Total mass (kg)") def total_mass(self): @@ -458,7 +458,6 @@ def propellant_initial_mass(self): float Propellant initial mass in kg. """ - pass @funcify_method("Time (s)", "Motor center of mass (m)") def center_of_mass(self): @@ -488,7 +487,6 @@ def center_of_propellant_mass(self): Function Position of the propellant center of mass as a function of time. """ - pass @funcify_method("Time (s)", "Inertia I_11 (kg m²)") def I_11(self): @@ -695,7 +693,6 @@ def propellant_I_11(self): ---------- .. [1] https://en.wikipedia.org/wiki/Moment_of_inertia#Inertia_tensor """ - pass @property @abstractmethod @@ -718,7 +715,6 @@ def propellant_I_22(self): ---------- .. [1] https://en.wikipedia.org/wiki/Moment_of_inertia#Inertia_tensor """ - pass @property @abstractmethod @@ -741,7 +737,6 @@ def propellant_I_33(self): ---------- .. [1] https://en.wikipedia.org/wiki/Moment_of_inertia#Inertia_tensor """ - pass @property @abstractmethod @@ -768,7 +763,6 @@ def propellant_I_12(self): ---------- .. [1] https://en.wikipedia.org/wiki/Moment_of_inertia#Inertia_tensor """ - pass @property @abstractmethod @@ -795,7 +789,6 @@ def propellant_I_13(self): ---------- https://en.wikipedia.org/wiki/Moment_of_inertia """ - pass @property @abstractmethod @@ -822,7 +815,6 @@ def propellant_I_23(self): ---------- https://en.wikipedia.org/wiki/Moment_of_inertia """ - pass @staticmethod def reshape_thrust_curve(thrust, new_burn_time, total_impulse): @@ -973,7 +965,7 @@ def import_eng(file_name): comments.append(re.findall(r";.*", line)[0]) line = re.sub(r";.*", "", line) if line.strip(): - if description == []: + if not description: # Extract description description = line.strip().split(" ") else: @@ -1033,8 +1025,6 @@ def get_attr_value(obj, attr_name, multiplier=1): # Write last line file.write(f"{self.thrust.source[-1, 0]:.4f} {0:.3f}\n") - return None - def info(self): """Prints out a summary of the data and graphs available about the Motor. @@ -1042,14 +1032,12 @@ def info(self): # Print motor details self.prints.all() self.plots.thrust() - return None @abstractmethod def all_info(self): """Prints out all data and graphs available about the Motor.""" self.prints.all() self.plots.all() - return None class GenericMotor(Motor): @@ -1196,7 +1184,6 @@ def __init__( # Initialize plots and prints object self.prints = _MotorPrints(self) self.plots = _MotorPlots(self) - return None @cached_property def propellant_initial_mass(self): @@ -1331,7 +1318,6 @@ def all_info(self): # Print motor details self.prints.all() self.plots.all() - return None class EmptyMotor: @@ -1339,6 +1325,7 @@ class EmptyMotor: # TODO: This is a temporary solution. It should be replaced by a class that # inherits from the abstract Motor class. Currently cannot be done easily. + # pylint: disable=too-many-statements def __init__(self): """Initializes an empty motor with no mass and no thrust. @@ -1377,4 +1364,3 @@ def __init__(self): self.I_12 = Function(0) self.I_13 = Function(0) self.I_23 = Function(0) - return None diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py index 4c55c1ee4..d693eda28 100644 --- a/rocketpy/motors/solid_motor.py +++ b/rocketpy/motors/solid_motor.py @@ -181,6 +181,7 @@ class SolidMotor(Motor): 'akima' and 'linear'. Default is "linear". """ + # pylint: disable=too-many-arguments def __init__( self, thrust_source, @@ -339,7 +340,6 @@ class Function. Thrust units are Newtons. # Initialize plots and prints object self.prints = _SolidMotorPrints(self) self.plots = _SolidMotorPlots(self) - return None @funcify_method("Time (s)", "Mass (kg)") def propellant_mass(self): @@ -448,6 +448,7 @@ def center_of_propellant_mass(self): center_of_mass = np.full_like(time_source, self.grains_center_of_mass_position) return np.column_stack((time_source, center_of_mass)) + # pylint: disable=too-many-arguments, too-many-statements def evaluate_geometry(self): """Calculates grain inner radius and grain height as a function of time by assuming that every propellant mass burnt is exhausted. In order to @@ -466,7 +467,7 @@ def evaluate_geometry(self): t_span = t[0], t[-1] density = self.grain_density - rO = self.grain_outer_radius + grain_outer_radius = self.grain_outer_radius n_grain = self.grain_number # Define system of differential equations @@ -475,12 +476,20 @@ def geometry_dot(t, y): volume_diff = self.mass_flow_rate(t) / (n_grain * density) # Compute state vector derivative - rI, h = y - burn_area = 2 * np.pi * (rO**2 - rI**2 + rI * h) - rI_dot = -volume_diff / burn_area - h_dot = -2 * rI_dot + grain_inner_radius, grain_height = y + burn_area = ( + 2 + * np.pi + * ( + grain_outer_radius**2 + - grain_inner_radius**2 + + grain_inner_radius * grain_height + ) + ) + grain_inner_radius_derivative = -volume_diff / burn_area + grain_height_derivative = -2 * grain_inner_radius_derivative - return [rI_dot, h_dot] + return [grain_inner_radius_derivative, grain_height_derivative] # Define jacobian of the system of differential equations def geometry_jacobian(t, y): @@ -488,16 +497,35 @@ def geometry_jacobian(t, y): volume_diff = self.mass_flow_rate(t) / (n_grain * density) # Compute jacobian - rI, h = y - factor = volume_diff / (2 * np.pi * (rO**2 - rI**2 + rI * h) ** 2) - drI_dot_drI = factor * (h - 2 * rI) - drI_dot_dh = factor * rI - dh_dot_drI = -2 * drI_dot_drI - dh_dot_dh = -2 * drI_dot_dh + grain_inner_radius, grain_height = y + factor = volume_diff / ( + 2 + * np.pi + * ( + grain_outer_radius**2 + - grain_inner_radius**2 + + grain_inner_radius * grain_height + ) + ** 2 + ) + inner_radius_derivative_wrt_inner_radius = factor * ( + grain_height - 2 * grain_inner_radius + ) + inner_radius_derivative_wrt_height = factor * grain_inner_radius + height_derivative_wrt_inner_radius = ( + -2 * inner_radius_derivative_wrt_inner_radius + ) + height_derivative_wrt_height = -2 * inner_radius_derivative_wrt_height - return [[drI_dot_drI, drI_dot_dh], [dh_dot_drI, dh_dot_dh]] + return [ + [ + inner_radius_derivative_wrt_inner_radius, + inner_radius_derivative_wrt_height, + ], + [height_derivative_wrt_inner_radius, height_derivative_wrt_height], + ] - def terminate_burn(t, y): + def terminate_burn(t, y): # pylint: disable=unused-argument end_function = (self.grain_outer_radius - y[0]) * y[1] return end_function @@ -536,8 +564,6 @@ def terminate_burn(t, y): reset_funcified_methods(self) - return None - @funcify_method("Time (s)", "burn area (m²)") def burn_area(self): """Calculates the BurnArea of the grain for each time. Assuming that @@ -707,11 +733,8 @@ def info(self): """Prints out basic data about the SolidMotor.""" self.prints.all() self.plots.thrust() - return None def all_info(self): """Prints out all data and graphs available about the SolidMotor.""" self.prints.all() self.plots.all() - - return None diff --git a/rocketpy/motors/tank.py b/rocketpy/motors/tank.py index d5df51b84..6fabaa341 100644 --- a/rocketpy/motors/tank.py +++ b/rocketpy/motors/tank.py @@ -145,7 +145,6 @@ def fluid_mass(self): Function Mass of the tank as a function of time. Units in kg. """ - pass @property @abstractmethod @@ -160,7 +159,6 @@ def net_mass_flow_rate(self): Function Net mass flow rate of the tank as a function of time. """ - pass @property @abstractmethod @@ -175,7 +173,6 @@ def fluid_volume(self): Function Volume of the fluid as a function of time. """ - pass @property @abstractmethod @@ -188,7 +185,6 @@ def liquid_volume(self): Function Volume of the liquid as a function of time. """ - pass @property @abstractmethod @@ -201,7 +197,6 @@ def gas_volume(self): Function Volume of the gas as a function of time. """ - pass @property @abstractmethod @@ -216,7 +211,6 @@ def liquid_height(self): Function Height of the ullage as a function of time. """ - pass @property @abstractmethod @@ -231,7 +225,6 @@ def gas_height(self): Function Height of the ullage as a function of time. """ - pass @property @abstractmethod @@ -244,7 +237,6 @@ def liquid_mass(self): Function Mass of the liquid as a function of time. """ - pass @property @abstractmethod @@ -257,7 +249,6 @@ def gas_mass(self): Function Mass of the gas as a function of time. """ - pass @funcify_method("Time (s)", "Center of mass of liquid (m)") def liquid_center_of_mass(self): @@ -609,7 +600,8 @@ def __init__( ) # Discretize input flow if needed - self.discretize_flow() if discretize else None + if discretize: + self.discretize_flow() # Check if the tank is overfilled or underfilled self._check_volume_bounds() @@ -890,7 +882,8 @@ def __init__( self.ullage = Function(ullage, "Time (s)", "Volume (m³)", "linear") # Discretize input if needed - self.discretize_ullage() if discretize else None + if discretize: + self.discretize_ullage() # Check if the tank is overfilled or underfilled self._check_volume_bounds() @@ -1083,8 +1076,8 @@ def __init__( # Define liquid level function self.liquid_level = Function(liquid_height, "Time (s)", "height (m)", "linear") - # Discretize input if needed - self.discretize_liquid_height() if discretize else None + if discretize: + self.discretize_liquid_height() # Check if the tank is overfilled or underfilled self._check_height_bounds() @@ -1298,8 +1291,8 @@ def __init__( self.liquid_mass = Function(liquid_mass, "Time (s)", "Mass (kg)", "linear") self.gas_mass = Function(gas_mass, "Time (s)", "Mass (kg)", "linear") - # Discretize input if needed - self.discretize_masses() if discretize else None + if discretize: + self.discretize_masses() # Check if the tank is overfilled or underfilled self._check_volume_bounds() diff --git a/rocketpy/motors/tank_geometry.py b/rocketpy/motors/tank_geometry.py index 2eb7bd27e..fb8102228 100644 --- a/rocketpy/motors/tank_geometry.py +++ b/rocketpy/motors/tank_geometry.py @@ -1,3 +1,5 @@ +from functools import cached_property + import numpy as np from ..mathutils.function import Function, PiecewiseFunction, funcify_method @@ -11,8 +13,6 @@ cache = lru_cache(maxsize=None) -from functools import cached_property - class TankGeometry: """Class to define the geometry of a tank. It is used to calculate the @@ -59,24 +59,23 @@ class TankGeometry: TankGeometry.volume Function. """ - def __init__(self, geometry_dict=dict()): + def __init__(self, geometry_dict=None): """Initialize TankGeometry class. Parameters ---------- - geometry_dict : dict, optional + geometry_dict : Union[dict, None], optional Dictionary containing the geometry of the tank. The geometry is calculated by a PiecewiseFunction. Hence, the dict keys are disjoint tuples containing the lower and upper bounds of the domain of the corresponding Function, while the values correspond to the radius function from an axis of symmetry. """ - self.geometry = geometry_dict + self.geometry = geometry_dict or {} # Initialize plots and prints object self.prints = _TankGeometryPrints(self) self.plots = _TankGeometryPlots(self) - return None @property def geometry(self): @@ -100,7 +99,7 @@ def geometry(self, geometry_dict): geometry_dict : dict Dictionary containing the geometry of the tank. """ - self._geometry = dict() + self._geometry = {} for domain, function in geometry_dict.items(): self.add_geometry(domain, function) @@ -354,7 +353,7 @@ class inherits from the TankGeometry class. See the TankGeometry class for more information on its attributes and methods. """ - def __init__(self, radius, height, spherical_caps=False, geometry_dict=dict()): + def __init__(self, radius, height, spherical_caps=False, geometry_dict=None): """Initialize CylindricalTank class. The zero reference point of the cylinder is its center (i.e. half of its height). Therefore the its height coordinate span is (-height/2, height/2). @@ -369,9 +368,10 @@ def __init__(self, radius, height, spherical_caps=False, geometry_dict=dict()): If True, the tank will have spherical caps at the top and bottom with the same radius as the cylindrical part. If False, the tank will have flat caps at the top and bottom. Defaults to False. - geometry_dict : dict, optional + geometry_dict : Union[dict, None], optional Dictionary containing the geometry of the tank. See TankGeometry. """ + geometry_dict = geometry_dict or {} super().__init__(geometry_dict) self.height = height self.has_caps = False @@ -420,7 +420,7 @@ class SphericalTank(TankGeometry): inherits from the TankGeometry class. See the TankGeometry class for more information on its attributes and methods.""" - def __init__(self, radius, geometry_dict=dict()): + def __init__(self, radius, geometry_dict=None): """Initialize SphericalTank class. The zero reference point of the sphere is its center (i.e. half of its height). Therefore, its height coordinate ranges between (-radius, radius). @@ -429,8 +429,9 @@ def __init__(self, radius, geometry_dict=dict()): ---------- radius : float Radius of the spherical tank. - geometry_dict : dict, optional + geometry_dict : Union[dict, None], optional Dictionary containing the geometry of the tank. See TankGeometry. """ + geometry_dict = geometry_dict or {} super().__init__(geometry_dict) self.add_geometry((-radius, radius), lambda h: (radius**2 - h**2) ** 0.5) diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index 57d48d78b..c242973b3 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -21,7 +21,6 @@ def __init__(self, aero_surface): None """ self.aero_surface = aero_surface - return None @abstractmethod def draw(self): @@ -37,7 +36,6 @@ class for more information on how this plot is made. None """ self.aero_surface.cl() - return None def all(self): """Plots all aero surface plots. @@ -48,28 +46,12 @@ def all(self): """ self.draw() self.lift() - return None class _NoseConePlots(_AeroSurfacePlots): """Class that contains all nosecone plots. This class inherits from the _AeroSurfacePlots class.""" - def __init__(self, nosecone): - """Initialize the class - - Parameters - ---------- - nosecone : rocketpy.AeroSurface.NoseCone - Nosecone object to be plotted - - Returns - ------- - None - """ - super().__init__(nosecone) - return None - def draw(self): """Draw the nosecone shape along with some important information, including the center line and the center of pressure position. @@ -82,7 +64,7 @@ def draw(self): nosecone_x, nosecone_y = self.aero_surface.shape_vec # Figure creation and set up - fig_ogive, ax = plt.subplots() + _, ax = plt.subplots() ax.set_xlim(-0.05, self.aero_surface.length * 1.02) # Horizontal size ax.set_ylim( -self.aero_surface.base_radius * 1.05, self.aero_surface.base_radius * 1.05 @@ -140,30 +122,14 @@ def draw(self): ax.set_ylabel("Radius") ax.set_title(self.aero_surface.kind + " Nose Cone") ax.legend(bbox_to_anchor=(1, -0.2)) - # Show Plot + plt.show() - return None class _FinsPlots(_AeroSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the _AeroSurfacePlots class.""" - def __init__(self, fin_set): - """Initialize the class - - Parameters - ---------- - fin_set : rocketpy.AeroSurface.fin_set - fin_set object to be plotted - - Returns - ------- - None - """ - super().__init__(fin_set) - return None - @abstractmethod def draw(self): pass @@ -180,7 +146,6 @@ def airfoil(self): if self.aero_surface.airfoil: print("Airfoil lift curve:") self.aero_surface.airfoil_cl.plot_1d(force_data=True) - return None def roll(self): """Plots the roll parameters of the fin set. @@ -193,7 +158,6 @@ def roll(self): # TODO: lacks a title in the plots self.aero_surface.roll_parameters[0]() self.aero_surface.roll_parameters[1]() - return None def lift(self): """Plots the lift coefficient of the aero surface as a function of Mach @@ -210,7 +174,6 @@ class for more information on how this plot is made. Also, this method self.aero_surface.cl() self.aero_surface.clalpha_single_fin() self.aero_surface.clalpha_multiple_fins() - return None def all(self): """Plots all available fin plots. @@ -223,16 +186,12 @@ def all(self): self.airfoil() self.roll() self.lift() - return None class _TrapezoidalFinsPlots(_FinsPlots): """Class that contains all trapezoidal fin plots.""" - def __init__(self, fin_set): - super().__init__(fin_set) - return None - + # pylint: disable=too-many-statements def draw(self): """Draw the fin shape along with some important information, including the center line, the quarter line and the center of pressure position. @@ -348,16 +307,12 @@ def draw(self): plt.tight_layout() plt.show() - return None class _EllipticalFinsPlots(_FinsPlots): """Class that contains all elliptical fin plots.""" - def __init__(self, fin_set): - super().__init__(fin_set) - return None - + # pylint: disable=too-many-statements def draw(self): """Draw the fin shape along with some important information. These being: the center line and the center of pressure position. @@ -424,49 +379,18 @@ def draw(self): plt.tight_layout() plt.show() - return None - class _TailPlots(_AeroSurfacePlots): """Class that contains all tail plots.""" - def __init__(self, tail): - """Initialize the class - - Parameters - ---------- - tail : rocketpy.AeroSurface.Tail - Tail object to be plotted - - Returns - ------- - None - """ - super().__init__(tail) - return None - def draw(self): # This will de done in the future - return None + pass class _AirBrakesPlots(_AeroSurfacePlots): """Class that contains all air brakes plots.""" - def __init__(self, air_brakes): - """Initialize the class - - Parameters - ---------- - air_brakes : rocketpy.AeroSurface.air_brakes - AirBrakes object to be plotted - - Returns - ------- - None - """ - super().__init__(air_brakes) - def drag_coefficient_curve(self): """Plots the drag coefficient curve of the air_brakes.""" if self.aero_surface.clamp is True: diff --git a/rocketpy/plots/compare/compare.py b/rocketpy/plots/compare/compare.py index 24e06f1b9..b4c87ad08 100644 --- a/rocketpy/plots/compare/compare.py +++ b/rocketpy/plots/compare/compare.py @@ -40,8 +40,7 @@ def __init__(self, object_list): self.object_list = object_list - return None - + # pylint: disable=too-many-statements def create_comparison_figure( self, y_attributes, @@ -121,36 +120,38 @@ def create_comparison_figure( # Adding the plots to each subplot if x_attributes: - for object in self.object_list: + for obj in self.object_list: for i in range(n_plots): try: ax[i].plot( - object.__getattribute__(x_attributes[i])[:, 1], - object.__getattribute__(y_attributes[i])[:, 1], - label=object.name, + getattr(obj, x_attributes[i])[:, 1], + getattr(obj, y_attributes[i])[:, 1], + label=obj.name, ) except IndexError: ax[i].plot( - object.__getattribute__(x_attributes[i]), - object.__getattribute__(y_attributes[i])[:, 1], - label=object.name, + getattr(obj, x_attributes[i]), + getattr(obj, y_attributes[i])[:, 1], + label=obj.name, ) - except AttributeError: + except AttributeError as e: raise AttributeError( f"Invalid attribute {y_attributes[i]} or {x_attributes[i]}." - ) + ) from e else: # Adding the plots to each subplot - for object in self.object_list: + for obj in self.object_list: for i in range(n_plots): try: ax[i].plot( - object.__getattribute__(y_attributes[i])[:, 0], - object.__getattribute__(y_attributes[i])[:, 1], - label=object.name, + getattr(obj, y_attributes[i])[:, 0], + getattr(obj, y_attributes[i])[:, 1], + label=obj.name, ) - except AttributeError: - raise AttributeError(f"Invalid attribute {y_attributes[i]}.") + except AttributeError as e: + raise AttributeError( + f"Invalid attribute {y_attributes[i]}." + ) from e for i, subplot in enumerate(ax): # Set the labels for the x and y axis @@ -167,7 +168,6 @@ def create_comparison_figure( # Find the two closest integers to the square root of the number of object_list # to be used as the number of columns and rows of the legend n_cols_legend = int(round(len(self.object_list) ** 0.5)) - n_rows_legend = int(round(len(self.object_list) / n_cols_legend)) # Set the legend if legend: # Add a global legend to the figure diff --git a/rocketpy/plots/compare/compare_flights.py b/rocketpy/plots/compare/compare_flights.py index e443898fc..740548b5d 100644 --- a/rocketpy/plots/compare/compare_flights.py +++ b/rocketpy/plots/compare/compare_flights.py @@ -1,10 +1,12 @@ +# TODO: remove this disable once the code is refactored +# pylint: disable=nested-min-max import matplotlib.pyplot as plt import numpy as np from .compare import Compare -class CompareFlights(Compare): +class CompareFlights(Compare): # pylint: disable=too-many-public-methods """A class to compare the results of multiple flights. Parameters @@ -46,8 +48,6 @@ def __init__(self, flights): self.apogee_time = apogee_time self.flights = self.object_list - return None - def __process_xlim(self, x_lim): """Function to process the x_lim key word argument. It is simply a logic to check if the string "apogee" is used as an item for the tuple, @@ -95,7 +95,6 @@ def __process_savefig(self, filename, fig): print("Plot saved to file: " + filename) else: plt.show() - return None def __process_legend(self, legend, fig): """Function to add a legend to the plot, if the legend key word @@ -115,7 +114,6 @@ def __process_legend(self, legend, fig): """ if legend: fig.legend() - return None def positions( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None @@ -169,8 +167,6 @@ def positions( # otherwise self.__process_savefig(filename, fig) - return None - def velocities( self, figsize=(7, 10 * 4 / 3), @@ -225,11 +221,8 @@ def velocities( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def stream_velocities( self, figsize=(7, 10 * 4 / 3), @@ -295,11 +288,8 @@ def stream_velocities( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def accelerations( self, figsize=(7, 10 * 4 / 3), @@ -359,11 +349,8 @@ def accelerations( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def euler_angles( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None ): @@ -417,11 +404,8 @@ def euler_angles( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def quaternions( self, figsize=(7, 10 * 4 / 3), @@ -481,11 +465,8 @@ def quaternions( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def attitude_angles( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None ): @@ -539,11 +520,8 @@ def attitude_angles( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def angular_velocities( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None ): @@ -597,11 +575,8 @@ def angular_velocities( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def angular_accelerations( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None ): @@ -655,11 +630,8 @@ def angular_accelerations( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def aerodynamic_forces( self, figsize=(7, 10 * 2 / 3), @@ -717,11 +689,8 @@ def aerodynamic_forces( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def aerodynamic_moments( self, figsize=(7, 10 * 2 / 3), @@ -779,11 +748,8 @@ def aerodynamic_moments( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def energies( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None ): @@ -837,11 +803,8 @@ def energies( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def powers( self, figsize=(7, 10 * 2 / 3), @@ -882,7 +845,6 @@ def powers( # Check if key word is used for x_limit x_lim = self.__process_xlim(x_lim) - # Create the figure fig, _ = super().create_comparison_figure( y_attributes=["thrust_power", "drag_power"], n_rows=2, @@ -896,11 +858,8 @@ def powers( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def rail_buttons_forces( self, figsize=(7, 10 * 4 / 3), @@ -965,11 +924,8 @@ def rail_buttons_forces( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def angles_of_attack( self, figsize=(7, 10 * 1 / 3), @@ -1024,11 +980,8 @@ def angles_of_attack( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def fluid_mechanics( self, figsize=(7, 10 * 4 / 3), @@ -1093,14 +1046,11 @@ def fluid_mechanics( y_lim=y_lim, ) - # Saving the plot to a file if a filename is provided, showing the plot otherwise self.__process_savefig(filename, fig) - return None - def stability_margin( self, figsize=(7, 10), x_lim=None, y_lim=None, legend=True, filename=None - ): + ): # pylint: disable=unused-argument """Plots the stability margin of the rocket for the different flights. The stability margin here is different than the static margin, it is the difference between the center of pressure and the center of gravity of @@ -1134,8 +1084,6 @@ def stability_margin( print("This method is not implemented yet") - return None - def attitude_frequency( self, figsize=(7, 10 * 4 / 3), @@ -1143,7 +1091,7 @@ def attitude_frequency( y_lim=None, legend=True, filename=None, - ): + ): # pylint: disable=unused-argument """Plots the frequency of the attitude of the rocket for the different flights. @@ -1175,10 +1123,8 @@ def attitude_frequency( print("This method is not implemented yet") - return None - @staticmethod - def compare_trajectories_3d( + def compare_trajectories_3d( # pylint: disable=too-many-statements flights, names_list=None, figsize=(7, 7), legend=None, filename=None ): """Creates a trajectory plot combining the trajectories listed. @@ -1281,8 +1227,6 @@ def compare_trajectories_3d( else: plt.show() - return None - def trajectories_3d(self, figsize=(7, 7), legend=None, filename=None): """Creates a trajectory plot that is the combination of the trajectories of the Flight objects passed via a Python list. @@ -1314,8 +1258,6 @@ def trajectories_3d(self, figsize=(7, 7), legend=None, filename=None): figsize=figsize, ) - return None - def __retrieve_trajectories(self): """Retrieve trajectories from Flight objects. @@ -1344,10 +1286,10 @@ def __retrieve_trajectories(self): x = flight.x[:, 1] y = flight.y[:, 1] z = flight.altitude[:, 1] - except AttributeError: + except AttributeError as e: raise AttributeError( - "Flight object {} does not have a trajectory.".format(flight.name) - ) + f"Flight object '{flight.name}' does not have a trajectory." + ) from e flights.append([x, y, z]) names_list.append(flight.name) return flights, names_list @@ -1393,9 +1335,7 @@ def trajectories_2d(self, plane="xy", figsize=(7, 7), legend=None, filename=None func(flights, names_list, figsize, legend, filename) - return None - - def __plot_xy( + def __plot_xy( # pylint: disable=too-many-statements self, flights, names_list, figsize=(7, 7), legend=None, filename=None ): """Creates a 2D trajectory plot in the X-Y plane that is the combination @@ -1456,9 +1396,7 @@ def __plot_xy( # Save figure self.__process_savefig(filename, fig) - return None - - def __plot_xz( + def __plot_xz( # pylint: disable=too-many-statements self, flights, names_list, figsize=(7, 7), legend=None, filename=None ): """Creates a 2D trajectory plot in the X-Z plane that is the combination @@ -1522,9 +1460,7 @@ def __plot_xz( else: plt.show() - return None - - def __plot_yz( + def __plot_yz( # pylint: disable=too-many-statements self, flights, names_list, figsize=(7, 7), legend=None, filename=None ): """Creates a 2D trajectory plot in the Y-Z plane that is the combination @@ -1585,8 +1521,6 @@ def __plot_yz( # Save figure self.__process_savefig(filename, fig) - return None - def all(self): """Prints out all data and graphs available about the Flight. @@ -1634,5 +1568,3 @@ def all(self): self.fluid_mechanics() self.attitude_frequency() - - return None diff --git a/rocketpy/plots/environment_analysis_plots.py b/rocketpy/plots/environment_analysis_plots.py index 26727aba9..0b2c28990 100644 --- a/rocketpy/plots/environment_analysis_plots.py +++ b/rocketpy/plots/environment_analysis_plots.py @@ -1,7 +1,6 @@ import matplotlib.pyplot as plt import matplotlib.ticker as mtick import numpy as np -from matplotlib import pyplot as plt from matplotlib.animation import FuncAnimation from matplotlib.animation import PillowWriter as ImageWriter from scipy import stats @@ -13,7 +12,7 @@ # TODO: `wind_speed_limit` and `clear_range_limits` and should be numbers, not booleans -class _EnvironmentAnalysisPlots: +class _EnvironmentAnalysisPlots: # pylint: disable=too-many-public-methods """Class that holds plot methods for EnvironmentAnalysis class. Attributes @@ -45,8 +44,6 @@ def __init__(self, env_analysis): self.surface_level_dict = self.env_analysis.converted_surface_data self.pressure_level_dict = self.env_analysis.converted_pressure_level_data - return None - def __beaufort_wind_scale(self, units, max_wind_speed=None): """Returns a list of bins equivalent to the Beaufort wind scale in the desired unit system. @@ -118,8 +115,6 @@ def wind_gust_distribution(self): plt.legend() plt.show() - return None - def surface10m_wind_speed_distribution(self, wind_speed_limit=False): """Get all values of sustained surface wind speed (for every date and hour available) and plot a single distribution. Expected result is a @@ -179,9 +174,9 @@ def surface10m_wind_speed_distribution(self, wind_speed_limit=False): plt.legend() plt.show() - return None - - def average_surface_temperature_evolution(self): + def average_surface_temperature_evolution( + self, + ): # pylint: disable=too-many-statements """Plots average temperature progression throughout the day, including sigma contours. @@ -236,7 +231,7 @@ def average_surface_temperature_evolution(self): # Format plot plt.gca().xaxis.set_major_locator(plt.MaxNLocator(integer=True)) plt.gca().xaxis.set_major_formatter( - lambda x, pos: "{0:02.0f}:{1:02.0f}".format(*divmod(x * 60, 60)) + lambda x, pos: f"{int(x):02}:{int((x * 60) % 60):02}" ) plt.autoscale(enable=True, axis="x", tight=True) plt.xlabel("Time (hours)") @@ -245,9 +240,10 @@ def average_surface_temperature_evolution(self): plt.grid(alpha=0.25) plt.legend() plt.show() - return None - def average_surface10m_wind_speed_evolution(self, wind_speed_limit=False): + def average_surface10m_wind_speed_evolution( + self, wind_speed_limit=False + ): # pylint: disable=too-many-statements """Plots average surface wind speed progression throughout the day, including sigma contours. @@ -281,7 +277,7 @@ def average_surface10m_wind_speed_evolution(self, wind_speed_limit=False): # Plot average wind speed along day for hour_entries in self.surface_level_dict.values(): plt.plot( - [x for x in self.env_analysis.hours], + list(self.env_analysis.hours), [ ( val["surface10m_wind_velocity_x"] ** 2 @@ -317,7 +313,7 @@ def average_surface10m_wind_speed_evolution(self, wind_speed_limit=False): # Format plot plt.gca().xaxis.set_major_locator(plt.MaxNLocator(integer=True)) plt.gca().xaxis.set_major_formatter( - lambda x, pos: "{0:02.0f}:{1:02.0f}".format(*divmod(x * 60, 60)) + lambda x, pos: f"{int(x):02}:{int((x * 60) % 60):02}" ) plt.autoscale(enable=True, axis="x", tight=True) @@ -340,9 +336,9 @@ def average_surface10m_wind_speed_evolution(self, wind_speed_limit=False): plt.legend() plt.show() - return None - - def average_surface100m_wind_speed_evolution(self): + def average_surface100m_wind_speed_evolution( + self, + ): # pylint: disable=too-many-statements """Plots average surface wind speed progression throughout the day, including sigma contours. @@ -404,7 +400,7 @@ def average_surface100m_wind_speed_evolution(self): # Format plot plt.gca().xaxis.set_major_locator(plt.MaxNLocator(integer=True)) plt.gca().xaxis.set_major_formatter( - lambda x, pos: "{0:02.0f}:{1:02.0f}".format(*divmod(x * 60, 60)) + lambda x, pos: f"{int(x):02}:{int((x * 60) % 60):02}" ) plt.autoscale(enable=True, axis="x", tight=True) plt.xlabel("Time (hours)") @@ -413,7 +409,6 @@ def average_surface100m_wind_speed_evolution(self): plt.grid(alpha=0.25) plt.legend() plt.show() - return None # Average profiles plots (pressure level data) @@ -517,8 +512,6 @@ def average_wind_speed_profile(self, clear_range_limits=False): ) plt.show() - return None - def average_wind_velocity_xy_profile(self, clear_range_limits=False): """Average wind X and wind Y for all datetimes available. The X component is the wind speed in the direction of East, and the Y component is the @@ -581,8 +574,6 @@ def average_wind_velocity_xy_profile(self, clear_range_limits=False): plt.grid() plt.show() - return None - def average_wind_heading_profile(self, clear_range_limits=False): """Average wind heading for all datetimes available. @@ -635,7 +626,6 @@ def average_wind_heading_profile(self, clear_range_limits=False): plt.title("Average Wind heading Profile") plt.legend() plt.show() - return None def average_pressure_profile(self, clear_range_limits=False): """Average pressure profile for all datetimes available. The plot also @@ -724,7 +714,6 @@ def average_pressure_profile(self, clear_range_limits=False): max(np.percentile(self.env_analysis.pressure_profiles_list, 99.85, axis=0)), ) plt.show() - return None def average_temperature_profile(self, clear_range_limits=False): """Average temperature profile for all datetimes available. The plot @@ -781,7 +770,7 @@ def average_temperature_profile(self, clear_range_limits=False): plt.autoscale(enable=True, axis="y", tight=True) if clear_range_limits: - x_min, xmax, ymax, ymin = plt.axis() + x_min, xmax, _, _ = plt.axis() plt.fill_between( [x_min, xmax], 0.7 @@ -821,8 +810,6 @@ def average_temperature_profile(self, clear_range_limits=False): ) plt.show() - return None - # Wind roses (surface level data) @staticmethod @@ -897,9 +884,7 @@ def average_wind_rose_specific_hour(self, hour, fig=None): ) plt.show() - return None - - def average_wind_rose_grid(self): + def average_wind_rose_grid(self): # pylint: disable=too-many-statements """Plot wind roses for all hours of a day, in a grid like plot. Returns @@ -966,7 +951,6 @@ def average_wind_rose_grid(self): ) plt.bbox_inches = "tight" plt.show() - return None def animate_average_wind_rose(self, figsize=(5, 5), filename="wind_rose.gif"): """Animates the wind_rose of an average day. The inputs of a wind_rose @@ -988,12 +972,11 @@ def animate_average_wind_rose(self, figsize=(5, 5), filename="wind_rose.gif"): Image : ipywidgets.widget_media.Image """ widgets = import_optional_dependency("ipywidgets") - metadata = dict( - title="windrose", - artist="windrose", - comment="""Made with windrose - http://www.github.com/scls19fr/windrose""", - ) + metadata = { + "title": "windrose", + "artist": "windrose", + "comment": """Made with windrose\nhttp://www.github.com/scls19fr/windrose""", + } writer = ImageWriter(fps=1, metadata=metadata) fig = plt.figure(facecolor="w", edgecolor="w", figsize=figsize) with writer.saving(fig, filename, 100): @@ -1027,7 +1010,7 @@ def animate_average_wind_rose(self, figsize=(5, 5), filename="wind_rose.gif"): # More plots and animations - def wind_gust_distribution_grid(self): + def wind_gust_distribution_grid(self): # pylint: disable=too-many-statements """Plots shown in the animation of how the wind gust distribution varies throughout the day. @@ -1098,9 +1081,7 @@ def wind_gust_distribution_grid(self): fig.supylabel("Probability") plt.show() - return None - - def animate_wind_gust_distribution(self): + def animate_wind_gust_distribution(self): # pylint: disable=too-many-statements """Animation of how the wind gust distribution varies throughout the day. Each frame is a histogram of the wind gust distribution for a specific hour. @@ -1195,7 +1176,9 @@ def update(frame): plt.close(fig) return HTML(animation.to_jshtml()) - def surface_wind_speed_distribution_grid(self, wind_speed_limit=False): + def surface_wind_speed_distribution_grid( + self, wind_speed_limit=False + ): # pylint: disable=too-many-statements """Plots shown in the animation of how the sustained surface wind speed distribution varies throughout the day. The plots are histograms of the wind speed distribution for a specific hour. The plots are arranged in a @@ -1294,9 +1277,9 @@ def surface_wind_speed_distribution_grid(self, wind_speed_limit=False): fig.supylabel("Probability") plt.show() - return None - - def animate_surface_wind_speed_distribution(self, wind_speed_limit=False): + def animate_surface_wind_speed_distribution( + self, wind_speed_limit=False + ): # pylint: disable=too-many-statements """Animation of how the sustained surface wind speed distribution varies throughout the day. Each frame is a histogram of the wind speed distribution for a specific hour. @@ -1418,7 +1401,9 @@ def update(frame): plt.close(fig) return HTML(animation.to_jshtml()) - def wind_speed_profile_grid(self, clear_range_limits=False): + def wind_speed_profile_grid( + self, clear_range_limits=False + ): # pylint: disable=too-many-statements """Creates a grid of plots with the wind profile over the average day. Each subplot represents a different hour of the day. @@ -1510,9 +1495,9 @@ def wind_speed_profile_grid(self, clear_range_limits=False): fig.supylabel(f"Altitude AGL ({self.env_analysis.unit_system['length']})") plt.show() - return None - - def wind_heading_profile_grid(self, clear_range_limits=False): + def wind_heading_profile_grid( + self, clear_range_limits=False + ): # pylint: disable=too-many-statements """Creates a grid of plots with the wind heading profile over the average day. Each subplot represents a different hour of the day. @@ -1599,9 +1584,9 @@ def wind_heading_profile_grid(self, clear_range_limits=False): fig.supylabel(f"Altitude AGL ({self.env_analysis.unit_system['length']})") plt.show() - return None - - def animate_wind_speed_profile(self, clear_range_limits=False): + def animate_wind_speed_profile( + self, clear_range_limits=False + ): # pylint: disable=too-many-statements """Animation of how wind profile evolves throughout an average day. Parameters @@ -1681,7 +1666,9 @@ def update(frame): plt.close(fig) return HTML(animation.to_jshtml()) - def animate_wind_heading_profile(self, clear_range_limits=False): + def animate_wind_heading_profile( + self, clear_range_limits=False + ): # pylint: disable=too-many-statements """Animation of how the wind heading profile evolves throughout an average day. Each frame is a different hour of the day. @@ -1775,8 +1762,6 @@ def all_animations(self): self.animate_wind_heading_profile(clear_range_limits=True) self.animate_wind_speed_profile() - return None - def all_plots(self): """Plots all the available plots together, this avoids having animations @@ -1798,8 +1783,6 @@ def all_plots(self): self.wind_speed_profile_grid() self.wind_heading_profile_grid() - return None - def info(self): """Plots only the most important plots together. This method simply invokes the `wind_gust_distribution`, `average_wind_speed_profile`, @@ -1815,8 +1798,6 @@ def info(self): self.wind_speed_profile_grid() self.wind_heading_profile_grid() - return None - def all(self): """Plots all the available plots and animations together. This method simply invokes the `all_plots` and `all_animations` methods. @@ -1827,5 +1808,3 @@ def all(self): """ self.all_plots() self.all_animations() - - return None diff --git a/rocketpy/plots/environment_plots.py b/rocketpy/plots/environment_plots.py index 9e29ec21a..39fb9548e 100644 --- a/rocketpy/plots/environment_plots.py +++ b/rocketpy/plots/environment_plots.py @@ -30,7 +30,6 @@ def __init__(self, environment): # Create height grid self.grid = np.linspace(environment.elevation, environment.max_expected_height) self.environment = environment - return None def __wind(self, ax): """Adds wind speed and wind direction graphs to the same axis. @@ -195,8 +194,6 @@ def gravity_model(self): plt.show() - return None - def atmospheric_model(self): """Plots all atmospheric model graphs available. This includes wind speed and wind direction, density and speed of sound, wind u and wind v, @@ -229,8 +226,7 @@ def atmospheric_model(self): plt.subplots_adjust(wspace=0.5, hspace=0.3) plt.show() - return None - + # pylint: disable=too-many-statements def ensemble_member_comparison(self): """Plots ensemble member comparisons. It requires that the environment model has been set as Ensemble. @@ -330,8 +326,6 @@ def ensemble_member_comparison(self): # Clean up self.environment.select_ensemble_member(current_member) - return None - def info(self): """Plots a summary of the atmospheric model, including wind speed and wind direction, density and speed of sound. This is important for the @@ -353,7 +347,6 @@ def info(self): plt.subplots_adjust(wspace=0.5) plt.show() - return None def all(self): """Prints out all graphs available about the Environment. This includes @@ -376,5 +369,3 @@ def all(self): if self.environment.atmospheric_model_type == "Ensemble": print("\n\nEnsemble Members Comparison") self.ensemble_member_comparison() - - return None diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 1209b4cd6..74caaeb33 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -32,7 +32,6 @@ def __init__(self, flight): None """ self.flight = flight - return None @cached_property def first_event_time(self): @@ -53,7 +52,7 @@ def first_event_time_index(self): else: return -1 - def trajectory_3d(self): + def trajectory_3d(self): # pylint: disable=too-many-statements """Plot a 3D graph of the trajectory Returns @@ -124,16 +123,14 @@ def trajectory_3d(self): ax1.set_box_aspect(None, zoom=0.95) # 95% for label adjustment plt.show() - def linear_kinematics_data(self): + def linear_kinematics_data(self): # pylint: disable=too-many-statements """Prints out all Kinematics graphs available about the Flight Returns ------- None """ - - # Velocity and acceleration plots - fig2 = plt.figure(figsize=(9, 12)) + plt.figure(figsize=(9, 12)) ax1 = plt.subplot(414) ax1.plot(self.flight.vx[:, 0], self.flight.vx[:, 1], color="#ff7f0e") @@ -197,9 +194,8 @@ def linear_kinematics_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - def attitude_data(self): + def attitude_data(self): # pylint: disable=too-many-statements """Prints out all Angular position graphs available about the Flight Returns @@ -208,7 +204,7 @@ def attitude_data(self): """ # Angular position plots - fig3 = plt.figure(figsize=(9, 12)) + _ = plt.figure(figsize=(9, 12)) ax1 = plt.subplot(411) ax1.plot(self.flight.e0[:, 0], self.flight.e0[:, 1], label="$e_0$") @@ -249,8 +245,6 @@ def attitude_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - def flight_path_angle_data(self): """Prints out Flight path and Rocket Attitude angle graphs available about the Flight @@ -259,10 +253,7 @@ def flight_path_angle_data(self): ------- None """ - - # Path, Attitude and Lateral Attitude Angle - # Angular position plots - fig5 = plt.figure(figsize=(9, 6)) + plt.figure(figsize=(9, 6)) ax1 = plt.subplot(211) ax1.plot( @@ -296,9 +287,7 @@ def flight_path_angle_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - - def angular_kinematics_data(self): + def angular_kinematics_data(self): # pylint: disable=too-many-statements """Prints out all Angular velocity and acceleration graphs available about the Flight @@ -306,9 +295,7 @@ def angular_kinematics_data(self): ------- None """ - - # Angular velocity and acceleration plots - fig4 = plt.figure(figsize=(9, 9)) + plt.figure(figsize=(9, 9)) ax1 = plt.subplot(311) ax1.plot(self.flight.w1[:, 0], self.flight.w1[:, 1], color="#ff7f0e") ax1.set_xlim(0, self.first_event_time) @@ -366,9 +353,7 @@ def angular_kinematics_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - - def rail_buttons_forces(self): + def rail_buttons_forces(self): # pylint: disable=too-many-statements """Prints out all Rail Buttons Forces graphs available about the Flight. Returns @@ -380,7 +365,7 @@ def rail_buttons_forces(self): elif self.flight.out_of_rail_time_index == 0: print("No rail phase was found. Skipping rail button plots.") else: - fig6 = plt.figure(figsize=(9, 6)) + plt.figure(figsize=(9, 6)) ax1 = plt.subplot(211) ax1.plot( @@ -450,18 +435,15 @@ def rail_buttons_forces(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - def aerodynamic_forces(self): + def aerodynamic_forces(self): # pylint: disable=too-many-statements """Prints out all Forces and Moments graphs available about the Flight Returns ------- None """ - - # Aerodynamic force and moment plots - fig7 = plt.figure(figsize=(9, 12)) + plt.figure(figsize=(9, 12)) ax1 = plt.subplot(411) ax1.plot( @@ -534,9 +516,7 @@ def aerodynamic_forces(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - - def energy_data(self): + def energy_data(self): # pylint: disable=too-many-statements """Prints out all Energy components graphs available about the Flight Returns @@ -544,7 +524,7 @@ def energy_data(self): None """ - fig8 = plt.figure(figsize=(9, 9)) + plt.figure(figsize=(9, 9)) ax1 = plt.subplot(411) ax1.plot( @@ -647,9 +627,7 @@ def energy_data(self): plt.subplots_adjust(hspace=1) plt.show() - return None - - def fluid_mechanics_data(self): + def fluid_mechanics_data(self): # pylint: disable=too-many-statements """Prints out a summary of the Fluid Mechanics graphs available about the Flight @@ -657,9 +635,7 @@ def fluid_mechanics_data(self): ------- None """ - - # Trajectory Fluid Mechanics Plots - fig10 = plt.figure(figsize=(9, 12)) + plt.figure(figsize=(9, 12)) ax1 = plt.subplot(411) ax1.plot(self.flight.mach_number[:, 0], self.flight.mach_number[:, 1]) @@ -714,9 +690,7 @@ def fluid_mechanics_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - - def stability_and_control_data(self): + def stability_and_control_data(self): # pylint: disable=too-many-statements """Prints out Rocket Stability and Control parameters graphs available about the Flight @@ -725,7 +699,7 @@ def stability_and_control_data(self): None """ - fig9 = plt.figure(figsize=(9, 6)) + plt.figure(figsize=(9, 6)) ax1 = plt.subplot(211) ax1.plot(self.flight.stability_margin[:, 0], self.flight.stability_margin[:, 1]) @@ -795,8 +769,6 @@ def stability_and_control_data(self): plt.subplots_adjust(hspace=0.5) plt.show() - return None - def pressure_rocket_altitude(self): """Plots out pressure at rocket's altitude. @@ -818,8 +790,6 @@ def pressure_rocket_altitude(self): plt.show() - return None - def pressure_signals(self): """Plots out all Parachute Trigger Pressure Signals. This function can be called also for plot pressure data for flights @@ -845,9 +815,7 @@ def pressure_signals(self): else: print("\nRocket has no parachutes. No parachute plots available") - return None - - def all(self): + def all(self): # pylint: disable=too-many-statements """Prints out all plots available about the Flight. Returns @@ -888,5 +856,3 @@ def all(self): print("\n\nRocket and Parachute Pressure Plots\n") self.pressure_rocket_altitude() self.pressure_signals() - - return None diff --git a/rocketpy/plots/fluid_plots.py b/rocketpy/plots/fluid_plots.py index ed8221494..b0292a8c6 100644 --- a/rocketpy/plots/fluid_plots.py +++ b/rocketpy/plots/fluid_plots.py @@ -23,8 +23,6 @@ def __init__(self, fluid): self.fluid = fluid - return None - def all(self): """Prints out all graphs available about the Fluid. It simply calls all the other plotter methods in this class. @@ -33,5 +31,3 @@ def all(self): ------ None """ - - return None diff --git a/rocketpy/plots/hybrid_motor_plots.py b/rocketpy/plots/hybrid_motor_plots.py index 4c1eb20b7..21a415986 100644 --- a/rocketpy/plots/hybrid_motor_plots.py +++ b/rocketpy/plots/hybrid_motor_plots.py @@ -13,20 +13,6 @@ class _HybridMotorPlots(_MotorPlots): """ - def __init__(self, hybrid_motor): - """Initializes _MotorClass class. - - Parameters - ---------- - hybrid_motor : HybridMotor - Instance of the HybridMotor class - - Returns - ------- - None - """ - super().__init__(hybrid_motor) - def grain_inner_radius(self, lower_limit=None, upper_limit=None): """Plots grain_inner_radius of the hybrid_motor as a function of time. diff --git a/rocketpy/plots/liquid_motor_plots.py b/rocketpy/plots/liquid_motor_plots.py index 68b37405b..3e5e7703b 100644 --- a/rocketpy/plots/liquid_motor_plots.py +++ b/rocketpy/plots/liquid_motor_plots.py @@ -13,20 +13,6 @@ class _LiquidMotorPlots(_MotorPlots): """ - def __init__(self, liquid_motor): - """Initializes _MotorClass class. - - Parameters - ---------- - liquid_motor : LiquidMotor - Instance of the LiquidMotor class - - Returns - ------- - None - """ - super().__init__(liquid_motor) - def draw(self): """Draw a representation of the LiquidMotor. diff --git a/rocketpy/plots/monte_carlo_plots.py b/rocketpy/plots/monte_carlo_plots.py index 587f98b11..2264597da 100644 --- a/rocketpy/plots/monte_carlo_plots.py +++ b/rocketpy/plots/monte_carlo_plots.py @@ -1,6 +1,6 @@ import matplotlib.pyplot as plt -from ..tools import generate_monte_carlo_ellipses +from ..tools import generate_monte_carlo_ellipses, import_optional_dependency class _MonteCarloPlots: @@ -9,6 +9,7 @@ class _MonteCarloPlots: def __init__(self, monte_carlo): self.monte_carlo = monte_carlo + # pylint: disable=too-many-statements def ellipses( self, image=None, @@ -42,20 +43,16 @@ def ellipses( None """ + imageio = import_optional_dependency("imageio") + # Import background map if image is not None: try: - from imageio import imread - - img = imread(image) - except ImportError: - raise ImportError( - "The 'imageio' package is required to add background images. Please install it." - ) - except FileNotFoundError: + img = imageio.imread(image) + except FileNotFoundError as e: raise FileNotFoundError( "The image file was not found. Please check the path." - ) + ) from e impact_ellipses, apogee_ellipses, apogee_x, apogee_y, impact_x, impact_y = ( generate_monte_carlo_ellipses(self.monte_carlo.results) diff --git a/rocketpy/plots/motor_plots.py b/rocketpy/plots/motor_plots.py index 3a8f604c5..39cdcaeb6 100644 --- a/rocketpy/plots/motor_plots.py +++ b/rocketpy/plots/motor_plots.py @@ -289,6 +289,7 @@ def _generate_combustion_chamber( ) return patch + # pylint: disable=too-many-statements def _generate_grains(self, translate=(0, 0)): """Generates a list of patches that represent the grains of the motor. Each grain is a polygon with 4 vertices mirrored in the x axis. The top diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 012f025e7..f86da9f64 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -32,8 +32,6 @@ def __init__(self, rocket): self.rocket = rocket - return None - def total_mass(self): """Plots total mass of the rocket as a function of time. @@ -44,8 +42,6 @@ def total_mass(self): self.rocket.total_mass() - return None - def reduced_mass(self): """Plots reduced mass of the rocket as a function of time. @@ -56,8 +52,6 @@ def reduced_mass(self): self.rocket.reduced_mass() - return None - def static_margin(self): """Plots static margin of the rocket as a function of time. @@ -68,8 +62,6 @@ def static_margin(self): self.rocket.static_margin() - return None - def stability_margin(self): """Plots static margin of the rocket as a function of time. @@ -86,8 +78,6 @@ def stability_margin(self): alpha=1, ) - return None - def power_on_drag(self): """Plots power on drag of the rocket as a function of time. @@ -105,8 +95,6 @@ def power_on_drag(self): self.rocket.power_on_drag() - return None - def power_off_drag(self): """Plots power off drag of the rocket as a function of time. @@ -124,8 +112,7 @@ def power_off_drag(self): self.rocket.power_off_drag() - return None - + # pylint: disable=too-many-statements def drag_curves(self): """Plots power off and on drag curves of the rocket as a function of time. @@ -151,7 +138,7 @@ def drag_curves(self): [self.rocket.power_off_drag.source(x) for x in x_power_drag_off] ) - fig, ax = plt.subplots() + _, ax = plt.subplots() ax.plot(x_power_drag_on, y_power_drag_on, label="Power on Drag") ax.plot( x_power_drag_off, y_power_drag_off, label="Power off Drag", linestyle="--" @@ -178,8 +165,6 @@ def thrust_to_weight(self): lower=0, upper=self.rocket.motor.burn_out_time ) - return None - def draw(self, vis_args=None): """Draws the rocket in a matplotlib figure. @@ -362,16 +347,14 @@ def _draw_tubes(self, ax, drawn_surfaces, vis_args): if isinstance(surface, Tail): continue # Else goes to the end of the surface - else: - x_tube = [position, last_x] - y_tube = [radius, radius] - y_tube_negated = [-radius, -radius] + x_tube = [position, last_x] + y_tube = [radius, radius] + y_tube_negated = [-radius, -radius] else: # If it is not the last surface, the tube goes to the beginning # of the next surface - next_surface, next_position, next_radius, next_last_x = drawn_surfaces[ - i + 1 - ] + # [next_surface, next_position, next_radius, next_last_x] + next_position = drawn_surfaces[i + 1][1] x_tube = [last_x, next_position] y_tube = [radius, radius] y_tube_negated = [-radius, -radius] @@ -418,7 +401,9 @@ def _draw_motor(self, last_radius, last_x, ax, vis_args): self._draw_nozzle_tube(last_radius, last_x, nozzle_position, ax, vis_args) - def _generate_motor_patches(self, total_csys, ax, vis_args): + def _generate_motor_patches( + self, total_csys, ax, vis_args + ): # pylint: disable=unused-argument """Generates motor patches for drawing""" motor_patches = [] @@ -603,5 +588,3 @@ def all(self): print("\nThrust-to-Weight Plot") print("-" * 40) self.thrust_to_weight() - - return None diff --git a/rocketpy/plots/solid_motor_plots.py b/rocketpy/plots/solid_motor_plots.py index 36e7f76a1..57af737b8 100644 --- a/rocketpy/plots/solid_motor_plots.py +++ b/rocketpy/plots/solid_motor_plots.py @@ -13,21 +13,6 @@ class _SolidMotorPlots(_MotorPlots): """ - def __init__(self, solid_motor): - """Initializes _MotorClass class. - - Parameters - ---------- - solid_motor : SolidMotor - Instance of the SolidMotor class - - Returns - ------- - None - """ - - super().__init__(solid_motor) - def grain_inner_radius(self, lower_limit=None, upper_limit=None): """Plots grain_inner_radius of the solid_motor as a function of time. diff --git a/rocketpy/plots/tank_geometry_plots.py b/rocketpy/plots/tank_geometry_plots.py index 884343bff..d9bf141bf 100644 --- a/rocketpy/plots/tank_geometry_plots.py +++ b/rocketpy/plots/tank_geometry_plots.py @@ -23,19 +23,14 @@ def __init__(self, tank_geometry): self.tank_geometry = tank_geometry - return None - def radius(self, upper=None, lower=None): self.tank_geometry.radius.plot(lower, upper) - return None def area(self, upper=None, lower=None): self.tank_geometry.area.plot(lower, upper) - return None def volume(self, upper=None, lower=None): self.tank_geometry.volume.plot(lower, upper) - return None def all(self): """Prints out all graphs available about the TankGeometry. It simply calls @@ -48,4 +43,3 @@ def all(self): self.radius() self.area() self.volume() - return None diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index f02021704..7c0541eb2 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -30,8 +30,6 @@ def __init__(self, tank): self.name = tank.name self.geometry = tank.geometry - return None - def _generate_tank(self, translate=(0, 0), csys=1): """Generates a matplotlib patch object that represents the tank. @@ -76,7 +74,7 @@ def draw(self): ------- None """ - fig, ax = plt.subplots(facecolor="#EEEEEE") + _, ax = plt.subplots(facecolor="#EEEEEE") ax.add_patch(self._generate_tank()) @@ -100,5 +98,3 @@ def all(self): ------- None """ - - return None diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py index 9a971babe..7cc87c28f 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -4,7 +4,6 @@ class _AeroSurfacePrints(ABC): def __init__(self, aero_surface): self.aero_surface = aero_surface - return None def identity(self): """Prints the identity of the aero surface. @@ -13,11 +12,10 @@ def identity(self): ------- None """ - print(f"Identification of the AeroSurface:") - print(f"----------------------------------") + print("Identification of the AeroSurface:") + print("----------------------------------") print(f"Name: {self.aero_surface.name}") print(f"Python Class: {str(self.aero_surface.__class__)}\n") - return None @abstractmethod def geometry(self): @@ -30,15 +28,17 @@ def lift(self): ------- None """ - print(f"Lift information of the AeroSurface:") - print(f"-----------------------------------") + print("Lift information of the AeroSurface:") + print("-----------------------------------") print( - f"Center of Pressure position in local coordinates: ({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, {self.aero_surface.cpz:.3f})" + "Center of Pressure position in local coordinates: " + f"({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, " + f"{self.aero_surface.cpz:.3f})" ) print( - f"Lift coefficient derivative at Mach 0 and AoA 0: {self.aero_surface.clalpha(0):.3f} 1/rad\n" + "Lift coefficient derivative at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha(0):.3f} 1/rad\n" ) - return None def all(self): """Prints all information of the aero surface. @@ -50,27 +50,11 @@ def all(self): self.identity() self.geometry() self.lift() - return None class _NoseConePrints(_AeroSurfacePrints): """Class that contains all nosecone prints.""" - def __init__(self, nosecone): - """Initialize the class - - Parameters - ---------- - nosecone : rocketpy.AeroSurface.NoseCone - Nosecone object to be printed - - Returns - ------- - None - """ - super().__init__(nosecone) - return None - def geometry(self): """Prints the geometric information of the nosecone. @@ -78,35 +62,20 @@ def geometry(self): ------- None """ - print(f"Geometric information of NoseCone:") - print(f"----------------------------------") + print("Geometric information of NoseCone:") + print("----------------------------------") print(f"Length: {self.aero_surface.length:.3f} m") print(f"Kind: {self.aero_surface.kind}") print(f"Base radius: {self.aero_surface.base_radius:.3f} m") print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m") print(f"Reference radius ratio: {self.aero_surface.radius_ratio:.3f}\n") - return None class _FinsPrints(_AeroSurfacePrints): - def __init__(self, fin_set): - """Initialize the class - - Parameters - ---------- - fin_set : rocketpy.AeroSurface.fin_set - fin_set object to be printed - - Returns - ------- - None - """ - super().__init__(fin_set) - return None def geometry(self): - print(f"Geometric information of the fin set:") - print(f"-------------------------------------") + print("Geometric information of the fin set:") + print("-------------------------------------") print(f"Number of fins: {self.aero_surface.n}") print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m") try: @@ -116,13 +85,13 @@ def geometry(self): print(f"Root chord: {self.aero_surface.root_chord:.3f} m") print(f"Span: {self.aero_surface.span:.3f} m") print( - f"Cant angle: {self.aero_surface.cant_angle:.3f} ° or {self.aero_surface.cant_angle_rad:.3f} rad" + f"Cant angle: {self.aero_surface.cant_angle:.3f} ° or " + f"{self.aero_surface.cant_angle_rad:.3f} rad" ) print(f"Longitudinal section area: {self.aero_surface.Af:.3f} m²") print(f"Aspect ratio: {self.aero_surface.AR:.3f} ") print(f"Gamma_c: {self.aero_surface.gamma_c:.3f} m") print(f"Mean aerodynamic chord: {self.aero_surface.Yma:.3f} m\n") - return None def airfoil(self): """Prints out airfoil related information of the fin set. @@ -132,15 +101,16 @@ def airfoil(self): None """ if self.aero_surface.airfoil: - print(f"Airfoil information:") - print(f"--------------------") + print("Airfoil information:") + print("--------------------") print( - f"Number of points defining the lift curve: {len(self.aero_surface.airfoil_cl.x_array)}" + "Number of points defining the lift curve: " + f"{len(self.aero_surface.airfoil_cl.x_array)}" ) print( - f"Lift coefficient derivative at Mach 0 and AoA 0: {self.aero_surface.clalpha(0):.5f} 1/rad\n" + "Lift coefficient derivative at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha(0):.5f} 1/rad\n" ) - return None def roll(self): """Prints out information about roll parameters @@ -150,18 +120,19 @@ def roll(self): ------- None """ - print(f"Roll information of the fin set:") - print(f"--------------------------------") + print("Roll information of the fin set:") + print("--------------------------------") print( f"Geometric constant: {self.aero_surface.roll_geometrical_constant:.3f} m" ) print( - f"Damping interference factor: {self.aero_surface.roll_damping_interference_factor:.3f} rad" + "Damping interference factor: " + f"{self.aero_surface.roll_damping_interference_factor:.3f} rad" ) print( - f"Forcing interference factor: {self.aero_surface.roll_forcing_interference_factor:.3f} rad\n" + "Forcing interference factor: " + f"{self.aero_surface.roll_forcing_interference_factor:.3f} rad\n" ) - return None def lift(self): """Prints out information about lift parameters @@ -171,21 +142,25 @@ def lift(self): ------- None """ - print(f"Lift information of the fin set:") - print(f"--------------------------------") + print("Lift information of the fin set:") + print("--------------------------------") print( - f"Lift interference factor: {self.aero_surface.lift_interference_factor:.3f} m" + "Lift interference factor: " + f"{self.aero_surface.lift_interference_factor:.3f} m" ) print( - f"Center of Pressure position in local coordinates: ({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, {self.aero_surface.cpz:.3f})" + "Center of Pressure position in local coordinates: " + f"({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, " + f"{self.aero_surface.cpz:.3f})" ) print( - f"Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: {self.aero_surface.clalpha_single_fin(0):.3f}" + "Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha_single_fin(0):.3f}" ) print( - f"Lift Coefficient derivative (fin set) at Mach 0 and AoA 0: {self.aero_surface.clalpha_multiple_fins(0):.3f}" + "Lift Coefficient derivative (fin set) at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha_multiple_fins(0):.3f}" ) - return None def all(self): """Prints all information of the fin set. @@ -199,63 +174,19 @@ def all(self): self.airfoil() self.roll() self.lift() - return None class _TrapezoidalFinsPrints(_FinsPrints): - def __init__(self, fin_set): - """Initialize the class - - Parameters - ---------- - fin_set : rocketpy.AeroSurface.fin_set - fin_set object to be printed - - Returns - ------- - None - """ - super().__init__(fin_set) - return None + """Class that contains all trapezoidal fins prints.""" class _EllipticalFinsPrints(_FinsPrints): """Class that contains all elliptical fins prints.""" - def __init__(self, fin_set): - """Initialize the class - - Parameters - ---------- - fin_set : rocketpy.AeroSurface.fin_set - fin_set object to be printed - - Returns - ------- - None - """ - super().__init__(fin_set) - return None - class _TailPrints(_AeroSurfacePrints): """Class that contains all tail prints.""" - def __init__(self, tail): - """Initialize the class - - Parameters - ---------- - tail : rocketpy.AeroSurface.Tail - Tail object to be printed - - Returns - ------- - None - """ - super().__init__(tail) - return None - def geometry(self): """Prints the geometric information of the tail. @@ -263,42 +194,35 @@ def geometry(self): ------- None """ - print(f"Geometric information of the Tail:") - print(f"----------------------------------") + print("Geometric information of the Tail:") + print("----------------------------------") print(f"Top radius: {self.aero_surface.top_radius:.3f} m") print(f"Bottom radius: {self.aero_surface.bottom_radius:.3f} m") print(f"Reference radius: {2*self.aero_surface.rocket_radius:.3f} m") print(f"Length: {self.aero_surface.length:.3f} m") print(f"Slant length: {self.aero_surface.slant_length:.3f} m") print(f"Surface area: {self.aero_surface.surface_area:.6f} m²\n") - return None class _RailButtonsPrints(_AeroSurfacePrints): """Class that contains all rail buttons prints.""" - def __init__(self, rail_buttons): - super().__init__(rail_buttons) - return None - def geometry(self): - print(f"Geometric information of the RailButtons:") - print(f"-----------------------------------------") + print("Geometric information of the RailButtons:") + print("-----------------------------------------") print( - f"Distance from one button to the other: {self.aero_surface.buttons_distance:.3f} m" + "Distance from one button to the other: " + f"{self.aero_surface.buttons_distance:.3f} m" ) print( - f"Angular position of the buttons: {self.aero_surface.angular_position:.3f} deg\n" + "Angular position of the buttons: " + f"{self.aero_surface.angular_position:.3f} deg\n" ) - return None class _AirBrakesPrints(_AeroSurfacePrints): """Class that contains all air_brakes prints. Not yet implemented.""" - def __init__(self, air_brakes): - super().__init__(air_brakes) - def geometry(self): pass diff --git a/rocketpy/prints/environment_analysis_prints.py b/rocketpy/prints/environment_analysis_prints.py index 390274bff..96db7608d 100644 --- a/rocketpy/prints/environment_analysis_prints.py +++ b/rocketpy/prints/environment_analysis_prints.py @@ -1,3 +1,5 @@ +# pylint: disable=missing-function-docstring, line-too-long, # TODO: fix this. + import numpy as np from ..units import convert_units @@ -15,7 +17,6 @@ class _EnvironmentAnalysisPrints: def __init__(self, env_analysis): self.env_analysis = env_analysis - return None def dataset(self): print("Dataset Information: ") @@ -55,13 +56,12 @@ def dataset(self): self.env_analysis.pressure_level_lon1, "°\n", ) - return None def launch_site(self): # Print launch site details print("Launch Site Details") - print("Launch Site Latitude: {:.5f}°".format(self.env_analysis.latitude)) - print("Launch Site Longitude: {:.5f}°".format(self.env_analysis.longitude)) + print(f"Launch Site Latitude: {self.env_analysis.latitude:.5f}°") + print(f"Launch Site Longitude: {self.env_analysis.longitude:.5f}°") print( f"Surface Elevation (from surface data file): {self.env_analysis.converted_elevation:.1f} {self.env_analysis.unit_system['length']}" ) @@ -72,7 +72,6 @@ def launch_site(self): self.env_analysis.unit_system["length"], "\n", ) - return None def pressure(self): print("Pressure Information") @@ -88,7 +87,6 @@ def pressure(self): print( f"Average Pressure at {convert_units(30000, 'ft', self.env_analysis.unit_system['length']):.0f} {self.env_analysis.unit_system['length']}: {self.env_analysis.average_pressure_at_30000ft:.2f} ± {self.env_analysis.std_pressure_at_1000ft:.2f} {self.env_analysis.unit_system['pressure']}\n" ) - return None def temperature(self): print("Temperature Information") @@ -104,7 +102,6 @@ def temperature(self): print( f"Average Daily Minimum Temperature: {self.env_analysis.average_min_temperature:.2f} {self.env_analysis.unit_system['temperature']}\n" ) - return None def wind_speed(self): print( @@ -137,7 +134,6 @@ def wind_speed(self): print( f"Average Daily Minimum Wind Speed: {self.env_analysis.average_min_surface_100m_wind_speed:.2f} {self.env_analysis.unit_system['wind_speed']}\n" ) - return None def wind_gust(self): print("Wind Gust Information") @@ -147,7 +143,6 @@ def wind_gust(self): print( f"Average Daily Maximum Wind Gust: {self.env_analysis.average_max_wind_gust:.2f} {self.env_analysis.unit_system['wind_speed']}\n" ) - return None def precipitation(self): print("Precipitation Information") @@ -160,7 +155,6 @@ def precipitation(self): print( f"Average Precipitation in a day: {np.mean(self.env_analysis.precipitation_per_day):.1f} {self.env_analysis.unit_system['precipitation']}\n" ) - return None def cloud_coverage(self): print("Cloud Base Height Information") @@ -173,7 +167,6 @@ def cloud_coverage(self): print( f"Percentage of Days Without Clouds: {100*self.env_analysis.percentage_of_days_with_no_cloud_coverage:.1f} %\n" ) - return None def all(self): self.dataset() @@ -184,4 +177,3 @@ def all(self): self.wind_gust() self.precipitation() self.cloud_coverage() - return None diff --git a/rocketpy/prints/environment_prints.py b/rocketpy/prints/environment_prints.py index 9968c984b..f95998899 100644 --- a/rocketpy/prints/environment_prints.py +++ b/rocketpy/prints/environment_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.environment = environment - return None def gravity_details(self): """Prints gravity details. @@ -40,9 +39,9 @@ def gravity_details(self): print("\nGravity Details\n") print(f"Acceleration of gravity at surface level: {surface_gravity:9.4f} m/s²") print( - f"Acceleration of gravity at {max_expected_height/1000:7.3f} km (ASL): {ceiling_gravity:.4f} m/s²" + f"Acceleration of gravity at {max_expected_height / 1000:7.3f} " + f"km (ASL): {ceiling_gravity:.4f} m/s²\n" ) - return None def launch_site_details(self): """Prints launch site details. @@ -54,7 +53,7 @@ def launch_site_details(self): print("\nLaunch Site Details\n") time_format = "%Y-%m-%d %H:%M:%S" if ( - self.environment.datetime_date != None + self.environment.datetime_date is not None and "UTC" not in self.environment.timezone ): print( @@ -64,32 +63,30 @@ def launch_site_details(self): self.environment.local_date.strftime(time_format), self.environment.timezone, ) - elif self.environment.datetime_date != None: + elif self.environment.datetime_date is not None: print( "Launch Date:", self.environment.datetime_date.strftime(time_format), "UTC", ) - if self.environment.latitude != None and self.environment.longitude != None: - print("Launch Site Latitude: {:.5f}°".format(self.environment.latitude)) - print("Launch Site Longitude: {:.5f}°".format(self.environment.longitude)) - print("Reference Datum: " + self.environment.datum) - print( - "Launch Site UTM coordinates: {:.2f} ".format(self.environment.initial_east) - + self.environment.initial_ew - + " {:.2f} ".format(self.environment.initial_north) - + self.environment.initial_hemisphere - ) - print( - "Launch Site UTM zone:", - str(self.environment.initial_utm_zone) - + self.environment.initial_utm_letter, - ) - print( - "Launch Site Surface Elevation: {:.1f} m".format(self.environment.elevation) - ) - - return None + if ( + self.environment.latitude is not None + and self.environment.longitude is not None + ): + print(f"Launch Site Latitude: {self.environment.latitude:.5f}°") + print(f"Launch Site Longitude: {self.environment.longitude:.5f}°") + print(f"Reference Datum: {self.environment.datum}") + if self.environment.initial_east: + print( + f"Launch Site UTM coordinates: {self.environment.initial_east:.2f} " + f"{self.environment.initial_ew} {self.environment.initial_north:.2f} " + f"{self.environment.initial_hemisphere}" + ) + print( + f"Launch Site UTM zone: {self.environment.initial_utm_zone}" + f"{self.environment.initial_utm_letter}" + ) + print(f"Launch Site Surface Elevation: {self.environment.elevation:.1f} m\n") def atmospheric_model_details(self): """Prints atmospheric model details. @@ -102,34 +99,31 @@ def atmospheric_model_details(self): model_type = self.environment.atmospheric_model_type print("Atmospheric Model Type:", model_type) print( - model_type - + " Maximum Height: {:.3f} km".format( - self.environment.max_expected_height / 1000 - ) + f"{model_type} Maximum Height: " + f"{self.environment.max_expected_height / 1000:.3f} km" ) if model_type in ["Forecast", "Reanalysis", "Ensemble"]: # Determine time period - initDate = self.environment.atmospheric_model_init_date - endDate = self.environment.atmospheric_model_end_date + init_date = self.environment.atmospheric_model_init_date + end_date = self.environment.atmospheric_model_end_date interval = self.environment.atmospheric_model_interval - print(model_type + " Time Period: From ", initDate, " to ", endDate, " UTC") - print(model_type + " Hour Interval:", interval, " hrs") + print(f"{model_type} Time Period: from {init_date} to {end_date} utc") + print(f"{model_type} Hour Interval: {interval} hrs") # Determine latitude and longitude range - initLat = self.environment.atmospheric_model_init_lat - endLat = self.environment.atmospheric_model_end_lat - initLon = self.environment.atmospheric_model_init_lon - endLon = self.environment.atmospheric_model_end_lon - print(model_type + " Latitude Range: From ", initLat, "° To ", endLat, "°") - print(model_type + " Longitude Range: From ", initLon, "° To ", endLon, "°") + init_lat = self.environment.atmospheric_model_init_lat + end_lat = self.environment.atmospheric_model_end_lat + init_lon = self.environment.atmospheric_model_init_lon + end_lon = self.environment.atmospheric_model_end_lon + print(f"{model_type} Latitude Range: From {init_lat}° to {end_lat}°") + print(f"{model_type} Longitude Range: From {init_lon}° to {end_lon}°") if model_type == "Ensemble": - print("Number of Ensemble Members:", self.environment.num_ensemble_members) print( - "Selected Ensemble Member:", - self.environment.ensemble_member, - " (Starts from 0)", + f"Number of Ensemble Members: {self.environment.num_ensemble_members}" + ) + print( + f"Selected Ensemble Member: {self.environment.ensemble_member} " + "(Starts from 0)\n" ) - - return None def atmospheric_conditions(self): """Prints atmospheric conditions. @@ -139,49 +133,25 @@ def atmospheric_conditions(self): None """ print("\nSurface Atmospheric Conditions\n") - print( - "Surface Wind Speed: {:.2f} m/s".format( - self.environment.wind_speed(self.environment.elevation) - ) - ) - print( - "Surface Wind Direction: {:.2f}°".format( - self.environment.wind_direction(self.environment.elevation) - ) - ) - print( - "Surface Wind Heading: {:.2f}°".format( - self.environment.wind_heading(self.environment.elevation) - ) - ) - print( - "Surface Pressure: {:.2f} hPa".format( - self.environment.pressure(self.environment.elevation) / 100 - ) - ) - print( - "Surface Temperature: {:.2f} K".format( - self.environment.temperature(self.environment.elevation) - ) - ) - print( - "Surface Air Density: {:.3f} kg/m³".format( - self.environment.density(self.environment.elevation) - ) - ) - print( - "Surface Speed of Sound: {:.2f} m/s".format( - self.environment.speed_of_sound(self.environment.elevation) - ) - ) - - return None + wind_speed = self.environment.wind_speed(self.environment.elevation) + wind_direction = self.environment.wind_direction(self.environment.elevation) + wind_heading = self.environment.wind_heading(self.environment.elevation) + pressure = self.environment.pressure(self.environment.elevation) / 100 + temperature = self.environment.temperature(self.environment.elevation) + air_density = self.environment.density(self.environment.elevation) + speed_of_sound = self.environment.speed_of_sound(self.environment.elevation) + print(f"Surface Wind Speed: {wind_speed:.2f} m/s") + print(f"Surface Wind Direction: {wind_direction:.2f}°") + print(f"Surface Wind Heading: {wind_heading:.2f}°") + print(f"Surface Pressure: {pressure:.2f} hPa") + print(f"Surface Temperature: {temperature:.2f} K") + print(f"Surface Air Density: {air_density:.3f} kg/m³") + print(f"Surface Speed of Sound: {speed_of_sound:.2f} m/s\n") def print_earth_details(self): """ Function to print information about the Earth Model used in the Environment Class - """ print("\nEarth Model Details\n") earth_radius = self.environment.earth_radius @@ -193,8 +163,6 @@ def print_earth_details(self): print(f"Semi-minor Axis: {semi_minor_axis/1000:.2f} km") print(f"Flattening: {flattening:.4f}\n") - return None - def all(self): """Prints all print methods about the Environment. @@ -202,23 +170,8 @@ def all(self): ------- None """ - - # Print gravity details self.gravity_details() - print() - - # Print launch site details self.launch_site_details() - print() - - # Print atmospheric model details self.atmospheric_model_details() - print() - - # Print atmospheric conditions self.atmospheric_conditions() - print() - self.print_earth_details() - - return None diff --git a/rocketpy/prints/fluid_prints.py b/rocketpy/prints/fluid_prints.py index 80eefa24e..a90aac229 100644 --- a/rocketpy/prints/fluid_prints.py +++ b/rocketpy/prints/fluid_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.fluid = fluid - return None def all(self): """Prints out all data available about the Fluid. @@ -33,5 +32,3 @@ def all(self): ------- None """ - - return None diff --git a/rocketpy/prints/hybrid_motor_prints.py b/rocketpy/prints/hybrid_motor_prints.py index 76dd1b6be..4dcd7b113 100644 --- a/rocketpy/prints/hybrid_motor_prints.py +++ b/rocketpy/prints/hybrid_motor_prints.py @@ -27,7 +27,6 @@ def __init__( None """ self.hybrid_motor = hybrid_motor - return None def nozzle_details(self): """Prints out all data available about the Nozzle. @@ -40,10 +39,11 @@ def nozzle_details(self): print("Nozzle Details") print(f"Outlet Radius: {self.hybrid_motor.nozzle_radius} m") print(f"Throat Radius: {self.hybrid_motor.solid.throat_radius} m") - print(f"Outlet Area: {np.pi*self.hybrid_motor.nozzle_radius**2:.6f} m²") - print(f"Throat Area: {np.pi*self.hybrid_motor.solid.throat_radius**2:.6f} m²") + print(f"Outlet Area: {np.pi * self.hybrid_motor.nozzle_radius ** 2:.6f} m²") + print( + f"Throat Area: {np.pi * self.hybrid_motor.solid.throat_radius ** 2:.6f} m²" + ) print(f"Position: {self.hybrid_motor.nozzle_position} m\n") - return None def grain_details(self): """Prints out all data available about the Grain. @@ -52,35 +52,18 @@ def grain_details(self): ------- None """ - # Print grain details print("Grain Details") - print("Number of Grains: " + str(self.hybrid_motor.solid.grain_number)) - print("Grain Spacing: " + str(self.hybrid_motor.solid.grain_separation) + " m") - print("Grain Density: " + str(self.hybrid_motor.solid.grain_density) + " kg/m3") - print( - "Grain Outer Radius: " - + str(self.hybrid_motor.solid.grain_outer_radius) - + " m" - ) + print(f"Number of Grains: {self.hybrid_motor.solid.grain_number}") + print(f"Grain Spacing: {self.hybrid_motor.solid.grain_separation} m") + print(f"Grain Density: {self.hybrid_motor.solid.grain_density} kg/m3") + print(f"Grain Outer Radius: {self.hybrid_motor.solid.grain_outer_radius} m") print( "Grain Inner Radius: " - + str(self.hybrid_motor.solid.grain_initial_inner_radius) - + " m" + f"{self.hybrid_motor.solid.grain_initial_inner_radius} m" ) - print( - "Grain Height: " + str(self.hybrid_motor.solid.grain_initial_height) + " m" - ) - print( - "Grain Volume: " - + "{:.3f}".format(self.hybrid_motor.solid.grain_initial_volume) - + " m3" - ) - print( - "Grain Mass: " - + "{:.3f}".format(self.hybrid_motor.solid.grain_initial_mass) - + " kg\n" - ) - return None + print(f"Grain Height: {self.hybrid_motor.solid.grain_initial_height} m") + print(f"Grain Volume: {self.hybrid_motor.solid.grain_initial_volume:.3f} m3") + print(f"Grain Mass: {self.hybrid_motor.solid.grain_initial_mass:.3f} kg\n") def motor_details(self): """Prints out all data available about the HybridMotor. @@ -89,39 +72,19 @@ def motor_details(self): ------- None """ - # Print motor details print("Motor Details") - print("Total Burning Time: " + str(self.hybrid_motor.burn_duration) + " s") - print( - "Total Propellant Mass: " - + "{:.3f}".format(self.hybrid_motor.propellant_initial_mass) - + " kg" - ) - print( - "Average Propellant Exhaust Velocity: " - + "{:.3f}".format( - self.hybrid_motor.exhaust_velocity.average(*self.hybrid_motor.burn_time) - ) - + " m/s" - ) - print( - "Average Thrust: " - + "{:.3f}".format(self.hybrid_motor.average_thrust) - + " N" - ) + print(f"Total Burning Time: {self.hybrid_motor.burn_duration} s") print( - "Maximum Thrust: " - + str(self.hybrid_motor.max_thrust) - + " N at " - + str(self.hybrid_motor.max_thrust_time) - + " s after ignition." + f"Total Propellant Mass: {self.hybrid_motor.propellant_initial_mass:.3f} kg" ) + avg = self.hybrid_motor.exhaust_velocity.average(*self.hybrid_motor.burn_time) + print(f"Average Propellant Exhaust Velocity: {avg:.3f} m/s") + print(f"Average Thrust: {self.hybrid_motor.average_thrust:.3f} N") print( - "Total Impulse: " - + "{:.3f}".format(self.hybrid_motor.total_impulse) - + " Ns\n" + f"Maximum Thrust: {self.hybrid_motor.max_thrust} N at " + f"{self.hybrid_motor.max_thrust_time} s after ignition." ) - return None + print(f"Total Impulse: {self.hybrid_motor.total_impulse:.3f} Ns\n") def all(self): """Prints out all data available about the HybridMotor. @@ -134,5 +97,3 @@ def all(self): self.nozzle_details() self.grain_details() self.motor_details() - - return None diff --git a/rocketpy/prints/liquid_motor_prints.py b/rocketpy/prints/liquid_motor_prints.py index 608e07faa..fb493ed0a 100644 --- a/rocketpy/prints/liquid_motor_prints.py +++ b/rocketpy/prints/liquid_motor_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.liquid_motor = liquid_motor - return None def nozzle_details(self): """Prints out all data available about the Nozzle. @@ -35,7 +34,6 @@ def nozzle_details(self): """ print("Nozzle Details") print("Nozzle Radius: " + str(self.liquid_motor.nozzle_radius) + " m\n") - return None def motor_details(self): """Prints out all data available about the motor. @@ -45,37 +43,18 @@ def motor_details(self): None """ print("Motor Details") - print("Total Burning Time: " + str(self.liquid_motor.burn_duration) + " s") + print(f"Total Burning Time: {self.liquid_motor.burn_duration} s") print( - "Total Propellant Mass: " - + "{:.3f}".format(self.liquid_motor.propellant_initial_mass) - + " kg" + f"Total Propellant Mass: {self.liquid_motor.propellant_initial_mass:.3f} kg" ) + avg = self.liquid_motor.exhaust_velocity.average(*self.liquid_motor.burn_time) + print(f"Average Propellant Exhaust Velocity: {avg:.3f} m/s") + print(f"Average Thrust: {self.liquid_motor.average_thrust:.3f} N") print( - "Average Propellant Exhaust Velocity: " - + "{:.3f}".format( - self.liquid_motor.exhaust_velocity.average(*self.liquid_motor.burn_time) - ) - + " m/s" + f"Maximum Thrust: {self.liquid_motor.max_thrust} N at " + f"{self.liquid_motor.max_thrust_time} s after ignition." ) - print( - "Average Thrust: " - + "{:.3f}".format(self.liquid_motor.average_thrust) - + " N" - ) - print( - "Maximum Thrust: " - + str(self.liquid_motor.max_thrust) - + " N at " - + str(self.liquid_motor.max_thrust_time) - + " s after ignition." - ) - print( - "Total Impulse: " - + "{:.3f}".format(self.liquid_motor.total_impulse) - + " Ns\n" - ) - return None + print(f"Total Impulse: {self.liquid_motor.total_impulse:.3f} Ns\n") def all(self): """Prints out all data available about the LiquidMotor. @@ -86,4 +65,3 @@ def all(self): """ self.nozzle_details() self.motor_details() - return None diff --git a/rocketpy/prints/motor_prints.py b/rocketpy/prints/motor_prints.py index 7f84f10ac..d9b7fbc98 100644 --- a/rocketpy/prints/motor_prints.py +++ b/rocketpy/prints/motor_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.motor = motor - return None def motor_details(self): """Print Motor details. @@ -35,19 +34,12 @@ def motor_details(self): """ print("Motor Details") print("Total Burning Time: " + str(self.motor.burn_out_time) + " s") - print( - "Total Propellant Mass: " - + "{:.3f}".format(self.motor.propellant_initial_mass) - + " kg" - ) + print(f"Total Propellant Mass: {self.motor.propellant_initial_mass:.3f} kg") print( "Average Propellant Exhaust Velocity: " - + "{:.3f}".format( - self.motor.exhaust_velocity.average(*self.motor.burn_time) - ) - + " m/s" + f"{self.motor.exhaust_velocity.average(*self.motor.burn_time):.3f} m/s" ) - print("Average Thrust: " + "{:.3f}".format(self.motor.average_thrust) + " N") + print(f"Average Thrust: {self.motor.average_thrust:.3f} N") print( "Maximum Thrust: " + str(self.motor.max_thrust) @@ -55,8 +47,7 @@ def motor_details(self): + str(self.motor.max_thrust_time) + " s after ignition." ) - print("Total Impulse: " + "{:.3f}".format(self.motor.total_impulse) + " Ns\n") - return None + print(f"Total Impulse: {self.motor.total_impulse:.3f} Ns\n") def all(self): """Prints out all data available about the Motor. @@ -66,4 +57,3 @@ def all(self): None """ self.motor_details() - return None diff --git a/rocketpy/prints/parachute_prints.py b/rocketpy/prints/parachute_prints.py index 316f41486..f7cbc07c5 100644 --- a/rocketpy/prints/parachute_prints.py +++ b/rocketpy/prints/parachute_prints.py @@ -25,8 +25,6 @@ def __init__(self, parachute): """ self.parachute = parachute - return None - def trigger(self): """Prints trigger information. @@ -53,8 +51,6 @@ def trigger(self): f"Time between ejection signal is triggered and the parachute is fully opened: {self.parachute.lag:.1f} s\n" ) - return None - def noise(self): # Not implemented yet pass @@ -68,8 +64,6 @@ def all(self): """ print("\nParachute Details\n") - print(self.parachute.__str__()) + print(str(self.parachute)) self.trigger() self.noise() - - return None diff --git a/rocketpy/prints/rocket_prints.py b/rocketpy/prints/rocket_prints.py index 615bb55ac..7ad42fd7b 100644 --- a/rocketpy/prints/rocket_prints.py +++ b/rocketpy/prints/rocket_prints.py @@ -32,9 +32,7 @@ def inertia_details(self): print("\nInertia Details\n") print(f"Rocket Mass: {self.rocket.mass:.3f} kg (without motor)") print(f"Rocket Dry Mass: {self.rocket.dry_mass:.3f} kg (with unloaded motor)") - print( - f"Rocket Loaded Mass: {self.rocket.total_mass(0):.3f} kg (with loaded motor)" - ) + print(f"Rocket Loaded Mass: {self.rocket.total_mass(0):.3f} kg") print( f"Rocket Inertia (with unloaded motor) 11: {self.rocket.dry_I_11:.3f} kg*m2" ) @@ -62,48 +60,36 @@ def rocket_geometrical_parameters(self): None """ print("\nGeometrical Parameters\n") - print("Rocket Maximum Radius: " + str(self.rocket.radius) + " m") - print("Rocket Frontal Area: " + "{:.6f}".format(self.rocket.area) + " m2") + print(f"Rocket Maximum Radius: {self.rocket.radius} m") + print(f"Rocket Frontal Area: {self.rocket.area:.6f} m2") + print("\nRocket Distances") + distance = abs( + self.rocket.center_of_mass_without_motor + - self.rocket.center_of_dry_mass_position + ) print( "Rocket Center of Dry Mass - Center of Mass without Motor: " - + "{:.3f} m".format( - abs( - self.rocket.center_of_mass_without_motor - - self.rocket.center_of_dry_mass_position - ) - ) + f"{distance:.3f} m" ) - print( - "Rocket Center of Dry Mass - Nozzle Exit: " - + "{:.3f} m".format( - abs( - self.rocket.center_of_dry_mass_position - - self.rocket.nozzle_position - ) - ) + distance = abs( + self.rocket.center_of_dry_mass_position - self.rocket.nozzle_position + ) + print(f"Rocket Center of Dry Mass - Nozzle Exit: {distance:.3f} m") + distance = abs( + self.rocket.center_of_propellant_position(0) + - self.rocket.center_of_dry_mass_position ) print( - "Rocket Center of Dry Mass - Center of Propellant Mass: " - + "{:.3f} m".format( - abs( - self.rocket.center_of_propellant_position(0) - - self.rocket.center_of_dry_mass_position - ) - ) + f"Rocket Center of Dry Mass - Center of Propellant Mass: {distance:.3f} m" + ) + distance = abs( + self.rocket.center_of_mass(0) - self.rocket.center_of_dry_mass_position ) print( - "Rocket Center of Mass - Rocket Loaded Center of Mass: " - + "{:.3f} m\n".format( - abs( - self.rocket.center_of_mass(0) - - self.rocket.center_of_dry_mass_position - ) - ) + f"Rocket Center of Mass - Rocket Loaded Center of Mass: {distance:.3f} m\n" ) - return None - def rocket_aerodynamics_quantities(self): """Print rocket aerodynamics quantities. @@ -117,11 +103,8 @@ def rocket_aerodynamics_quantities(self): # ref_factor corrects lift for different reference areas ref_factor = (surface.rocket_radius / self.rocket.radius) ** 2 print( - name - + " Lift Coefficient Derivative: {:.3f}".format( - ref_factor * surface.clalpha(0) - ) - + "/rad" + f"{name} Lift Coefficient Derivative: " + f"{ref_factor * surface.clalpha(0):.3f}/rad" ) print("\nCenter of Pressure\n") @@ -129,11 +112,8 @@ def rocket_aerodynamics_quantities(self): name = surface.name cpz = surface.cp[2] # relative to the user defined coordinate system print( - name - + " Center of Pressure position: {:.3f}".format( - position - self.rocket._csys * cpz - ) - + " m" + f"{name} Center of Pressure position: " + f"{position - self.rocket._csys * cpz:.3f} m" ) print("\nStability\n") print( @@ -143,27 +123,18 @@ def rocket_aerodynamics_quantities(self): f"Center of Pressure position (time=0): {self.rocket.cp_position(0):.3f} m" ) print( - "Initial Static Margin (mach=0, time=0): " - + "{:.3f}".format(self.rocket.static_margin(0)) - + " c" + f"Initial Static Margin (mach=0, time=0): " + f"{self.rocket.static_margin(0):.3f} c" ) print( - "Final Static Margin (mach=0, time=burn_out): " - + "{:.3f}".format( - self.rocket.static_margin(self.rocket.motor.burn_out_time) - ) - + " c" + f"Final Static Margin (mach=0, time=burn_out): " + f"{self.rocket.static_margin(self.rocket.motor.burn_out_time):.3f} c" ) print( - "Rocket Center of Mass (time=0) - Center of Pressure (mach=0): " - + "{:.3f}".format( - abs(self.rocket.center_of_mass(0) - self.rocket.cp_position(0)) - ) - + " m\n" + 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" ) - return None - def parachute_data(self): """Print parachute data. @@ -173,7 +144,6 @@ def parachute_data(self): """ for chute in self.rocket.parachutes: chute.all_info() - return None def all(self): """Prints all print methods about the Environment. @@ -182,16 +152,7 @@ def all(self): ------- None """ - # Print inertia details self.inertia_details() - - # Print rocket geometrical parameters self.rocket_geometrical_parameters() - - # Print rocket aerodynamics quantities self.rocket_aerodynamics_quantities() - - # Print parachute data self.parachute_data() - - return None diff --git a/rocketpy/prints/solid_motor_prints.py b/rocketpy/prints/solid_motor_prints.py index 9156eaae7..c37a9b69e 100644 --- a/rocketpy/prints/solid_motor_prints.py +++ b/rocketpy/prints/solid_motor_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.solid_motor = solid_motor - return None def nozzle_details(self): """Prints out all data available about the SolidMotor nozzle. @@ -33,10 +32,9 @@ def nozzle_details(self): ------- None """ - # Print nozzle details print("Nozzle Details") - print("Nozzle Radius: " + str(self.solid_motor.nozzle_radius) + " m") - print("Nozzle Throat Radius: " + str(self.solid_motor.throat_radius) + " m\n") + print(f"Nozzle Radius: {self.solid_motor.nozzle_radius} m") + print(f"Nozzle Throat Radius: {self.solid_motor.throat_radius} m\n") def grain_details(self): """Prints out all data available about the SolidMotor grain. @@ -45,29 +43,15 @@ def grain_details(self): ------- None """ - - # Print grain details print("Grain Details") - print("Number of Grains: " + str(self.solid_motor.grain_number)) - print("Grain Spacing: " + str(self.solid_motor.grain_separation) + " m") - print("Grain Density: " + str(self.solid_motor.grain_density) + " kg/m3") - print("Grain Outer Radius: " + str(self.solid_motor.grain_outer_radius) + " m") - print( - "Grain Inner Radius: " - + str(self.solid_motor.grain_initial_inner_radius) - + " m" - ) - print("Grain Height: " + str(self.solid_motor.grain_initial_height) + " m") - print( - "Grain Volume: " - + "{:.3f}".format(self.solid_motor.grain_initial_volume) - + " m3" - ) - print( - "Grain Mass: " - + "{:.3f}".format(self.solid_motor.grain_initial_mass) - + " kg\n" - ) + print(f"Number of Grains: {self.solid_motor.grain_number}") + print(f"Grain Spacing: {self.solid_motor.grain_separation} m") + print(f"Grain Density: {self.solid_motor.grain_density} kg/m3") + print(f"Grain Outer Radius: {self.solid_motor.grain_outer_radius} m") + print(f"Grain Inner Radius: {self.solid_motor.grain_initial_inner_radius} m") + print(f"Grain Height: {self.solid_motor.grain_initial_height} m") + print(f"Grain Volume: {self.solid_motor.grain_initial_volume:.3f} m3") + print(f"Grain Mass: {self.solid_motor.grain_initial_mass:.3f} kg\n") def motor_details(self): """Prints out all data available about the SolidMotor. @@ -76,37 +60,19 @@ def motor_details(self): ------- None """ - - # Print motor details print("Motor Details") print("Total Burning Time: " + str(self.solid_motor.burn_duration) + " s") print( - "Total Propellant Mass: " - + "{:.3f}".format(self.solid_motor.propellant_initial_mass) - + " kg" - ) - print( - "Average Propellant Exhaust Velocity: " - + "{:.3f}".format( - self.solid_motor.exhaust_velocity.average(*self.solid_motor.burn_time) - ) - + " m/s" - ) - print( - "Average Thrust: " + "{:.3f}".format(self.solid_motor.average_thrust) + " N" - ) - print( - "Maximum Thrust: " - + str(self.solid_motor.max_thrust) - + " N at " - + str(self.solid_motor.max_thrust_time) - + " s after ignition." + f"Total Propellant Mass: {self.solid_motor.propellant_initial_mass:.3f} kg" ) + average = self.solid_motor.exhaust_velocity.average(*self.solid_motor.burn_time) + print(f"Average Propellant Exhaust Velocity: {average:.3f} m/s") + print(f"Average Thrust: {self.solid_motor.average_thrust:.3f} N") print( - "Total Impulse: " - + "{:.3f}".format(self.solid_motor.total_impulse) - + " Ns\n" + f"Maximum Thrust: {self.solid_motor.max_thrust} N " + f"at {self.solid_motor.max_thrust_time} s after ignition." ) + print(f"Total Impulse: {self.solid_motor.total_impulse:.3f} Ns\n") def all(self): """Prints out all data available about the SolidMotor. @@ -118,4 +84,3 @@ def all(self): self.nozzle_details() self.grain_details() self.motor_details() - return None diff --git a/rocketpy/prints/tank_geometry_prints.py b/rocketpy/prints/tank_geometry_prints.py index 3b58c1bcb..6ca7be9ab 100644 --- a/rocketpy/prints/tank_geometry_prints.py +++ b/rocketpy/prints/tank_geometry_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.tank_geometry = tank_geometry - return None def geometry(self): """Prints out the geometry of the tank. @@ -33,13 +32,12 @@ def geometry(self): ------- None """ - print(f"Tank Geometry:") + print("Tank Geometry:") print(f"Average radius {self.tank_geometry.average_radius:.3f} m") print(f"Bottom: {self.tank_geometry.bottom} m") print(f"Top: {self.tank_geometry.top} m") print(f"Total height: {self.tank_geometry.total_height} m") print(f"Total volume: {self.tank_geometry.total_volume:.6f} m^3\n") - return None def all(self): """Prints out all data available about the TankGeometry. @@ -49,4 +47,3 @@ def all(self): None """ self.geometry() - return None diff --git a/rocketpy/prints/tank_prints.py b/rocketpy/prints/tank_prints.py index 9f3a648b1..41732c6cf 100644 --- a/rocketpy/prints/tank_prints.py +++ b/rocketpy/prints/tank_prints.py @@ -24,7 +24,6 @@ def __init__( None """ self.tank = tank - return None def all(self): """Prints out all data available about the Tank. @@ -33,4 +32,3 @@ def all(self): ------- None """ - return None diff --git a/rocketpy/rocket/aero_surface.py b/rocketpy/rocket/aero_surface.py deleted file mode 100644 index 26be5cdcd..000000000 --- a/rocketpy/rocket/aero_surface.py +++ /dev/null @@ -1,2179 +0,0 @@ -import warnings -from abc import ABC, abstractmethod - -import numpy as np -from scipy.optimize import fsolve - -from ..mathutils.function import Function -from ..plots.aero_surface_plots import ( - _AirBrakesPlots, - _EllipticalFinsPlots, - _NoseConePlots, - _TailPlots, - _TrapezoidalFinsPlots, -) -from ..prints.aero_surface_prints import ( - _AirBrakesPrints, - _EllipticalFinsPrints, - _NoseConePrints, - _RailButtonsPrints, - _TailPrints, - _TrapezoidalFinsPrints, -) - -# TODO: all the evaluate_shape() methods need tests and documentation - - -class AeroSurface(ABC): - """Abstract class used to define aerodynamic surfaces.""" - - def __init__(self, name): - self.cpx = 0 - self.cpy = 0 - self.cpz = 0 - self.name = name - return None - - # Defines beta parameter - @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 - """ - pass - - @abstractmethod - def evaluate_lift_coefficient(self): - """Evaluates the lift coefficient curve of the aerodynamic surface. - - Returns - ------- - None - """ - pass - - @abstractmethod - def evaluate_geometrical_parameters(self): - """Evaluates the geometrical parameters of the aerodynamic surface. - - Returns - ------- - None - """ - pass - - @abstractmethod - def info(self): - """Prints and plots summarized information of the aerodynamic surface. - - Returns - ------- - None - """ - pass - - @abstractmethod - def all_info(self): - """Prints and plots all the available information of the aero surface. - - Returns - ------- - None - """ - pass - - -class NoseCone(AeroSurface): - """Keeps nose cone information. - - Note - ---- - The local coordinate system has the origin at the tip of the nose cone - and the Z axis along the longitudinal axis of symmetry, positive - downwards (top -> bottom). - - Attributes - ---------- - NoseCone.length : float - Nose cone length. Has units of length and must be given in meters. - NoseCone.kind : string - Nose cone kind. Can be "conical", "ogive", "elliptical", "tangent", - "von karman", "parabolic", "powerseries" or "lvhaack". - NoseCone.bluffness : float - Ratio between the radius of the circle on the tip of the ogive and the - radius of the base of the ogive. Currently only used for the nose cone's - drawing. Must be between 0 and 1. Default is None, which means that the - nose cone will not have a sphere on the tip. If a value is given, the - nose cone's length will be slightly reduced because of the addition of - the sphere. Must be None or 0 if a "powerseries" nose cone kind is - specified. - NoseCone.rocket_radius : float - The reference rocket radius used for lift coefficient normalization, - in meters. - NoseCone.base_radius : float - Nose cone base radius. Has units of length and must be given in meters. - NoseCone.radius_ratio : float - Ratio between the nose cone base radius and the rocket radius. Has no - units. If base radius is not given, the ratio between base radius and - rocket radius is assumed as 1, meaning that the nose cone has the same - radius as the rocket. If base radius is given, the ratio between base - radius and rocket radius is calculated and used for lift calculation. - NoseCone.power : float - Factor that controls the bluntness of the shape for a power series - nose cone. Must be between 0 and 1. It is ignored when other nose - cone types are used. - NoseCone.name : string - Nose cone name. Has no impact in simulation, as it is only used to - display data in a more organized matter. - NoseCone.cp : tuple - Tuple with the x, y and z local coordinates of the nose cone center of - pressure. Has units of length and is given in meters. - NoseCone.cpx : float - Nose cone local center of pressure x coordinate. Has units of length and - is given in meters. - NoseCone.cpy : float - Nose cone local center of pressure y coordinate. Has units of length and - is given in meters. - NoseCone.cpz : float - 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. - NoseCone.clalpha : float - Lift 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. - NoseCone.prints : prints.aero_surface_prints._NoseConePrints - This contains all the prints methods. Use help(NoseCone.prints) to know - more about it. - """ - - def __init__( - self, - length, - kind, - base_radius=None, - bluffness=None, - rocket_radius=None, - power=None, - name="Nose Cone", - ): - """Initializes the nose cone. It is used to define the nose cone - length, kind, center of pressure and lift coefficient curve. - - Parameters - ---------- - length : float - Nose cone length. Has units of length and must be given in meters. - kind : string - Nose cone kind. Can be "conical", "ogive", "elliptical", "tangent", - "von karman", "parabolic", "powerseries" or "lvhaack". If - "powerseries" is used, the "power" argument must be assigned to a - value between 0 and 1. - base_radius : float, optional - Nose cone base radius. Has units of length and must be given in - meters. If not given, the ratio between ``base_radius`` and - ``rocket_radius`` will be assumed as 1. - bluffness : float, optional - Ratio between the radius of the circle on the tip of the ogive and - the radius of the base of the ogive. Currently only used for the - nose cone's drawing. Must be between 0 and 1. Default is None, which - means that the nose cone will not have a sphere on the tip. If a - value is given, the nose cone's length will be reduced to account - for the addition of the sphere at the tip. Must be None or 0 if a - "powerseries" nose cone kind is specified. - rocket_radius : int, float, optional - The reference rocket radius used for lift coefficient normalization. - If not given, the ratio between ``base_radius`` and - ``rocket_radius`` will be assumed as 1. - power : float, optional - Factor that controls the bluntness of the shape for a power series - nose cone. Must be between 0 and 1. It is ignored when other nose - cone types are used. - name : str, optional - Nose cone name. Has no impact in simulation, as it is only used to - display data in a more organized matter. - - Returns - ------- - None - """ - super().__init__(name) - - self._rocket_radius = rocket_radius - self._base_radius = base_radius - self._length = length - if bluffness is not None: - if bluffness > 1 or bluffness < 0: - raise ValueError( - f"Bluffness ratio of {bluffness} is out of range. It must be between 0 and 1." - ) - self._bluffness = bluffness - if kind == "powerseries": - # Checks if bluffness is not being used - if (self.bluffness is not None) and (self.bluffness != 0): - raise ValueError( - "Parameter 'bluffness' must be None or 0 when using a nose cone kind 'powerseries'." - ) - - if power is None: - raise ValueError( - "Parameter 'power' cannot be None when using a nose cone kind 'powerseries'." - ) - - if power > 1 or power <= 0: - raise ValueError( - f"Power value of {power} is out of range. It must be between 0 and 1." - ) - self._power = power - self.kind = kind - - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() - - self.plots = _NoseConePlots(self) - self.prints = _NoseConePrints(self) - - return None - - @property - def rocket_radius(self): - return self._rocket_radius - - @rocket_radius.setter - def rocket_radius(self, value): - self._rocket_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_lift_coefficient() - self.evaluate_nose_shape() - - @property - def base_radius(self): - return self._base_radius - - @base_radius.setter - def base_radius(self, value): - self._base_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_lift_coefficient() - self.evaluate_nose_shape() - - @property - def length(self): - return self._length - - @length.setter - def length(self, value): - self._length = value - self.evaluate_center_of_pressure() - self.evaluate_nose_shape() - - @property - def power(self): - return self._power - - @power.setter - def power(self, value): - if value is not None: - if value > 1 or value <= 0: - raise ValueError( - f"Power value of {value} is out of range. It must be between 0 and 1." - ) - self._power = value - self.evaluate_k() - self.evaluate_center_of_pressure() - self.evaluate_nose_shape() - - @property - def kind(self): - return self._kind - - @kind.setter - def kind(self, value): - # Analyzes nosecone type - # Sets the k for Cp calculation - # Sets the function which creates the respective curve - self._kind = value - value = (value.replace(" ", "")).lower() - - if value == "conical": - self.k = 2 / 3 - self.y_nosecone = Function(lambda x: x * self.base_radius / self.length) - - elif value == "lvhaack": - self.k = 0.563 - theta = lambda x: np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) - self.y_nosecone = Function( - lambda x: self.base_radius - * (theta(x) - np.sin(2 * theta(x)) / 2 + (np.sin(theta(x)) ** 3) / 3) - ** (0.5) - / (np.pi**0.5) - ) - - elif value in ["tangent", "tangentogive", "ogive"]: - rho = (self.base_radius**2 + self.length**2) / (2 * self.base_radius) - volume = np.pi * ( - self.length * rho**2 - - (self.length**3) / 3 - - (rho - self.base_radius) * rho**2 * np.arcsin(self.length / rho) - ) - area = np.pi * self.base_radius**2 - self.k = 1 - volume / (area * self.length) - self.y_nosecone = Function( - lambda x: np.sqrt(rho**2 - (min(x - self.length, 0)) ** 2) - + (self.base_radius - rho) - ) - - elif value == "elliptical": - self.k = 1 / 3 - self.y_nosecone = Function( - lambda x: self.base_radius - * np.sqrt(1 - ((x - self.length) / self.length) ** 2) - ) - - elif value == "vonkarman": - self.k = 0.5 - theta = lambda x: np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) - self.y_nosecone = Function( - lambda x: self.base_radius - * (theta(x) - np.sin(2 * theta(x)) / 2) ** (0.5) - / (np.pi**0.5) - ) - elif value == "parabolic": - self.k = 0.5 - self.y_nosecone = Function( - lambda x: self.base_radius - * ((2 * x / self.length - (x / self.length) ** 2) / (2 - 1)) - ) - elif value == "powerseries": - self.k = (2 * self.power) / ((2 * self.power) + 1) - self.y_nosecone = Function( - lambda x: self.base_radius * np.power(x / self.length, self.power) - ) - else: - raise ValueError( - f"Nose Cone kind '{self.kind}' not found, " - + "please use one of the following Nose Cone kinds:" - + '\n\t"conical"' - + '\n\t"ogive"' - + '\n\t"lvhaack"' - + '\n\t"tangent"' - + '\n\t"vonkarman"' - + '\n\t"elliptical"' - + '\n\t"powerseries"' - + '\n\t"parabolic"\n' - ) - - self.evaluate_center_of_pressure() - self.evaluate_geometrical_parameters() - self.evaluate_nose_shape() - - @property - def bluffness(self): - return self._bluffness - - @bluffness.setter - def bluffness(self, value): - # prevents from setting bluffness on "powerseries" nose cones - if self.kind == "powerseries": - # Checks if bluffness is not being used - if (value is not None) and (value != 0): - raise ValueError( - "Parameter 'bluffness' must be None or 0 when using a nose cone kind 'powerseries'." - ) - if value is not None: - if value > 1 or value < 0: - raise ValueError( - f"Bluffness ratio of {value} is out of range. It must be between 0 and 1." - ) - self._bluffness = value - self.evaluate_nose_shape() - - def evaluate_geometrical_parameters(self): - """Calculates and saves nose cone's radius ratio. - - Returns - ------- - None - """ - - # If base radius is not given, the ratio between base radius and - # rocket radius is assumed as 1, meaning that the nose cone has the - # same radius as the rocket - if self.base_radius is None and self.rocket_radius is not None: - self.radius_ratio = 1 - self.base_radius = self.rocket_radius - elif self.base_radius is not None and self.rocket_radius is None: - self.radius_ratio = 1 - self.rocket_radius = self.base_radius - # If base radius is given, the ratio between base radius and rocket - # radius is calculated - elif self.base_radius is not None and self.rocket_radius is not None: - self.radius_ratio = self.base_radius / self.rocket_radius - else: - raise ValueError( - "Either base radius or rocket radius must be given to calculate the nose cone radius ratio." - ) - - self.fineness_ratio = self.length / (2 * self.base_radius) - return None - - def evaluate_nose_shape(self): - """Calculates and saves nose cone's shape as lists and re-evaluates the - nose cone's length for a given bluffness ratio. The shape is saved as - two vectors, one for the x coordinates and one for the y coordinates. - - Returns - ------- - None - """ - # Constants - n = 127 # Points on the final curve. - p = 3 # Density modifier. Greater n makes more points closer to 0. n=1 -> points equally spaced. - - # Calculate a function to find the tangential intersection point between the circle and nosecone curve. - def find_x_intercept(x): - return x + self.y_nosecone(x) * self.y_nosecone.differentiate_complex_step( - x - ) - - # Calculate a function to find the radius of the nosecone curve - def find_radius(x): - return (self.y_nosecone(x) ** 2 + (x - find_x_intercept(x)) ** 2) ** 0.5 - - # Check bluffness circle and choose whether to use it or not - if self.bluffness is None or self.bluffness == 0: - # Set up parameters to continue without bluffness - r_circle, circle_center, x_init = 0, 0, 0 - else: - # Calculate circle radius - r_circle = self.bluffness * self.base_radius - if self.kind == "elliptical": - # Calculate a function to set up a circle at the starting position to test bluffness - def test_circle(x): - return np.sqrt(r_circle**2 - (x - r_circle) ** 2) - - # Check if bluffness circle is too small - if test_circle(1e-03) <= self.y_nosecone(1e-03): - # Raise a warning - warnings.warn( - "WARNING: The chosen bluffness ratio is too small for " - "the selected nose cone category, thereby the effective " - "bluffness will be 0." - ) - # Set up parameters to continue without bluffness - r_circle, circle_center, x_init = 0, 0, 0 - else: - # Find the intersection point between circle and nosecone curve - x_init = fsolve(lambda x: find_radius(x[0]) - r_circle, r_circle)[0] - circle_center = find_x_intercept(x_init) - else: - # Find the intersection point between circle and nosecone curve - x_init = fsolve(lambda x: find_radius(x[0]) - r_circle, r_circle)[0] - circle_center = find_x_intercept(x_init) - - # Calculate a function to create the circle at the correct position - def create_circle(x): - return abs(r_circle**2 - (x - circle_center) ** 2) ** 0.5 - - # Define a function for the final shape of the curve with a circle at the tip - def final_shape(x): - return self.y_nosecone(x) if x >= x_init else create_circle(x) - - # Vectorize the final_shape function - final_shape_vec = np.vectorize(final_shape) - - # Create the vectors X and Y with the points of the curve - nosecone_x = (self.length - (circle_center - r_circle)) * ( - np.linspace(0, 1, n) ** p - ) - nosecone_y = final_shape_vec(nosecone_x + (circle_center - r_circle)) - - # Evaluate final geometry parameters - self.shape_vec = [nosecone_x, nosecone_y] - if abs(nosecone_x[-1] - self.length) >= 0.001: # 1 milimiter - self._length = nosecone_x[-1] - print( - "Due to the chosen bluffness ratio, the nose cone length was reduced to m.".format( - self.length - ) - ) - self.fineness_ratio = self.length / (2 * self.base_radius) - - return None - - def evaluate_lift_coefficient(self): - """Calculates and returns nose cone's lift coefficient. - The lift coefficient is saved and returned. This function - also calculates and saves its lift coefficient derivative. - - Returns - ------- - None - """ - # Calculate clalpha - # clalpha is currently a constant, meaning it is independent of Mach - # number. This is only valid for subsonic speeds. - # It must be set as a Function because it will be called and treated - # as a function of mach in the simulation. - self.clalpha = Function( - lambda mach: 2 / self._beta(mach) * 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", - ) - return None - - def evaluate_k(self): - """Updates the self.k attribute used to compute the center of - pressure when using "powerseries" nose cones. - - Returns - ------- - None - """ - if self.kind == "powerseries": - self.k = (2 * self.power) / ((2 * self.power) + 1) - return None - - def evaluate_center_of_pressure(self): - """Calculates and returns the center of pressure of the nose cone in - local coordinates. The center of pressure position is saved and stored - as a tuple. Local coordinate origin is found at the tip of the nose - cone. - - Returns - ------- - self.cp : tuple - Tuple containing cpx, cpy, cpz. - """ - - self.cpz = self.k * self.length - self.cpy = 0 - self.cpx = 0 - self.cp = (self.cpx, self.cpy, self.cpz) - return self.cp - - def draw(self): - """Draw the fin shape along with some important information, including - the center line, the quarter line and the center of pressure position. - - Returns - ------- - None - """ - self.plots.draw() - - def info(self): - """Prints and plots summarized information of the nose cone. - - Return - ------ - None - """ - self.prints.geometry() - self.prints.lift() - return None - - def all_info(self): - """Prints and plots all the available information of the nose cone. - - Returns - ------- - None - """ - self.prints.all() - self.plots.all() - return None - - -class Fins(AeroSurface): - """Abstract class that holds common methods for the fin classes. - Cannot be instantiated. - - Note - ---- - Local coordinate system: Z axis along the longitudinal axis of symmetry, - positive downwards (top -> bottom). Origin located at the top of the root - chord. - - Attributes - ---------- - Fins.n : int - Number of fins in fin set. - Fins.rocket_radius : float - The reference rocket radius used for lift coefficient normalization, - in meters. - Fins.airfoil : tuple - Tuple of two items. First is the airfoil lift curve. - Second is the unit of the curve (radians or degrees). - Fins.cant_angle : float - Fins cant angle with respect to the rocket centerline, in degrees. - Fins.changing_attribute_dict : dict - Dictionary that stores the name and the values of the attributes that - may be changed during a simulation. Useful for control systems. - Fins.cant_angle_rad : float - Fins cant angle with respect to the rocket centerline, in radians. - Fins.root_chord : float - Fin root chord in meters. - Fins.tip_chord : float - Fin tip chord in meters. - Fins.span : float - Fin span in meters. - Fins.name : string - Name of fin set. - Fins.sweep_length : float - Fins sweep length in meters. By sweep length, understand the axial - distance between the fin root leading edge and the fin tip leading edge - measured parallel to the rocket centerline. - Fins.sweep_angle : float - Fins sweep angle with respect to the rocket centerline. Must - be given in degrees. - Fins.d : float - Reference diameter of the rocket. Has units of length and is given - in meters. - Fins.ref_area : float - Reference area of the rocket. - Fins.Af : float - Area of the longitudinal section of each fin in the set. - Fins.AR : float - Aspect ratio of each fin in the set. - Fins.gamma_c : float - Fin mid-chord sweep angle. - Fins.Yma : float - Span wise position of the mean aerodynamic chord. - Fins.roll_geometrical_constant : float - Geometrical constant used in roll calculations. - Fins.tau : float - Geometrical relation used to simplify lift and roll calculations. - Fins.lift_interference_factor : float - Factor of Fin-Body interference in the lift coefficient. - Fins.cp : tuple - Tuple with the x, y and z local coordinates of the fin set center of - pressure. Has units of length and is given in meters. - Fins.cpx : float - Fin set local center of pressure x coordinate. Has units of length and - is given in meters. - Fins.cpy : float - Fin set local center of pressure y coordinate. Has units of length and - is given in meters. - Fins.cpz : float - 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. - Fins.clalpha : float - Lift 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. - """ - - def __init__( - self, - n, - root_chord, - span, - rocket_radius, - cant_angle=0, - airfoil=None, - name="Fins", - ): - """Initialize Fins class. - - Parameters - ---------- - n : int - Number of fins, from 2 to infinity. - root_chord : int, float - Fin root chord in meters. - span : int, float - Fin span in meters. - rocket_radius : int, float - Reference rocket radius used for lift coefficient normalization. - cant_angle : int, float, optional - Fins cant angle with respect to the rocket centerline. Must - be given in degrees. - airfoil : tuple, optional - Default is null, in which case fins will be treated as flat plates. - Otherwise, if tuple, fins will be considered as airfoils. The - tuple's first item specifies the airfoil's lift coefficient - by angle of attack and must be either a .csv, .txt, ndarray - or callable. The .csv and .txt files can contain a single line - header and the first column must specify the angle of attack, while - the second column must specify the lift coefficient. The - ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] - where x0 is the angle of attack and y0 is the lift coefficient. - If callable, it should take an angle of attack as input and - return the lift coefficient at that angle of attack. - The tuple's second item is the unit of the angle of attack, - accepting either "radians" or "degrees". - name : str - Name of fin set. - - Returns - ------- - None - """ - - super().__init__(name) - - # Compute auxiliary geometrical parameters - d = 2 * rocket_radius - ref_area = np.pi * rocket_radius**2 # Reference area - - # Store values - self._n = n - self._rocket_radius = rocket_radius - self._airfoil = airfoil - self._cant_angle = cant_angle - self._root_chord = root_chord - self._span = span - self.name = name - self.d = d - self.ref_area = ref_area # Reference area - - return None - - @property - def n(self): - return self._n - - @n.setter - def n(self, value): - self._n = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def root_chord(self): - return self._root_chord - - @root_chord.setter - def root_chord(self, value): - self._root_chord = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def span(self): - return self._span - - @span.setter - def span(self, value): - self._span = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def rocket_radius(self): - return self._rocket_radius - - @rocket_radius.setter - def rocket_radius(self, value): - self._rocket_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def cant_angle(self): - return self._cant_angle - - @cant_angle.setter - def cant_angle(self, value): - self._cant_angle = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def airfoil(self): - return self._airfoil - - @airfoil.setter - def airfoil(self, value): - self._airfoil = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - def evaluate_lift_coefficient(self): - """Calculates and returns the fin set's lift coefficient. - The lift coefficient is saved and returned. This function - also calculates and saves the lift coefficient derivative - for a single fin and the lift coefficient derivative for - a number of n fins corrected for Fin-Body interference. - - Returns - ------- - None - """ - if not self.airfoil: - # Defines clalpha2D as 2*pi for planar fins - clalpha2D_incompressible = 2 * np.pi - else: - # Defines clalpha2D as the derivative of the lift coefficient curve - # for the specific airfoil - self.airfoil_cl = Function( - self.airfoil[0], - interpolation="linear", - ) - - # Differentiating at alpha = 0 to get cl_alpha - clalpha2D_incompressible = self.airfoil_cl.differentiate_complex_step( - x=1e-3, dx=1e-3 - ) - - # Convert to radians if needed - if self.airfoil[1] == "degrees": - clalpha2D_incompressible *= 180 / np.pi - - # Correcting for compressible flow (apply Prandtl-Glauert correction) - clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach)) - - # Diederich's Planform Correlation Parameter - FD = 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) - - # Lift coefficient derivative for a single fin - self.clalpha_single_fin = Function( - lambda mach: ( - clalpha2D(mach) - * FD(mach) - * (self.Af / self.ref_area) - * np.cos(self.gamma_c) - ) - / (2 + FD(mach) * np.sqrt(1 + (2 / FD(mach)) ** 2)), - "Mach", - "Lift coefficient derivative for a single fin", - ) - - # Lift coefficient derivative for a number of n fins corrected for Fin-Body interference - self.clalpha_multiple_fins = ( - self.lift_interference_factor - * self.__fin_num_correction(self.n) - * self.clalpha_single_fin - ) # Function of mach number - self.clalpha_multiple_fins.set_inputs("Mach") - self.clalpha_multiple_fins.set_outputs( - "Lift coefficient derivative for {:.0f} fins".format(self.n) - ) - self.clalpha = self.clalpha_multiple_fins - - # Calculates clalpha * alpha - self.cl = Function( - lambda alpha, mach: alpha * self.clalpha_multiple_fins(mach), - ["Alpha (rad)", "Mach"], - "Lift coefficient", - ) - - return self.cl - - def evaluate_roll_parameters(self): - """Calculates and returns the fin set's roll coefficients. - The roll coefficients are saved in a list. - - Returns - ------- - self.roll_parameters : list - List containing the roll moment lift coefficient, the - roll moment damping coefficient and the cant angle in - radians - """ - - self.cant_angle_rad = np.radians(self.cant_angle) - - clf_delta = ( - self.roll_forcing_interference_factor - * self.n - * (self.Yma + self.rocket_radius) - * self.clalpha_single_fin - / self.d - ) # Function of mach number - clf_delta.set_inputs("Mach") - clf_delta.set_outputs("Roll moment forcing coefficient derivative") - cld_omega = ( - 2 - * self.roll_damping_interference_factor - * self.n - * self.clalpha_single_fin - * np.cos(self.cant_angle_rad) - * self.roll_geometrical_constant - / (self.ref_area * self.d**2) - ) # Function of mach number - cld_omega.set_inputs("Mach") - cld_omega.set_outputs("Roll moment damping coefficient derivative") - self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad] - return self.roll_parameters - - # Defines number of fins factor - def __fin_num_correction(_, n): - """Calculates a correction factor for the lift coefficient of multiple - fins. - The specifics values are documented at: - Niskanen, S. (2013). “OpenRocket technical documentation”. - In: Development of an Open Source model rocket simulation software. - - Parameters - ---------- - n : int - Number of fins. - - Returns - ------- - Corrector factor : int - Factor that accounts for the number of fins. - """ - corrector_factor = [2.37, 2.74, 2.99, 3.24] - if n >= 5 and n <= 8: - return corrector_factor[n - 5] - else: - return n / 2 - - def draw(self): - """Draw the fin shape along with some important information, including - the center line, the quarter line and the center of pressure position. - - Returns - ------- - None - """ - self.plots.draw() - return None - - -class TrapezoidalFins(Fins): - """Class that defines and holds information for a trapezoidal fin set. - - This class inherits from the Fins class. - - Note - ---- - Local coordinate system: Z axis along the longitudinal axis of symmetry, - positive downwards (top -> bottom). Origin located at the top of the root - chord. - - See Also - -------- - Fins - - Attributes - ---------- - TrapezoidalFins.n : int - Number of fins in fin set. - TrapezoidalFins.rocket_radius : float - The reference rocket radius used for lift coefficient normalization, in - meters. - TrapezoidalFins.airfoil : tuple - Tuple of two items. First is the airfoil lift curve. - Second is the unit of the curve (radians or degrees). - TrapezoidalFins.cant_angle : float - Fins cant angle with respect to the rocket centerline, in degrees. - TrapezoidalFins.changing_attribute_dict : dict - Dictionary that stores the name and the values of the attributes that - may be changed during a simulation. Useful for control systems. - TrapezoidalFins.cant_angle_rad : float - Fins cant angle with respect to the rocket centerline, in radians. - TrapezoidalFins.root_chord : float - Fin root chord in meters. - TrapezoidalFins.tip_chord : float - Fin tip chord in meters. - TrapezoidalFins.span : float - Fin span in meters. - TrapezoidalFins.name : string - Name of fin set. - TrapezoidalFins.sweep_length : float - Fins sweep length in meters. By sweep length, understand the axial - distance between the fin root leading edge and the fin tip leading edge - measured parallel to the rocket centerline. - TrapezoidalFins.sweep_angle : float - Fins sweep angle with respect to the rocket centerline. Must - be given in degrees. - TrapezoidalFins.d : float - Reference diameter of the rocket, in meters. - TrapezoidalFins.ref_area : float - Reference area of the rocket, in m². - TrapezoidalFins.Af : float - Area of the longitudinal section of each fin in the set. - TrapezoidalFins.AR : float - Aspect ratio of each fin in the set - TrapezoidalFins.gamma_c : float - Fin mid-chord sweep angle. - TrapezoidalFins.Yma : float - Span wise position of the mean aerodynamic chord. - TrapezoidalFins.roll_geometrical_constant : float - Geometrical constant used in roll calculations. - TrapezoidalFins.tau : float - Geometrical relation used to simplify lift and roll calculations. - TrapezoidalFins.lift_interference_factor : float - Factor of Fin-Body interference in the lift coefficient. - TrapezoidalFins.cp : tuple - Tuple with the x, y and z local coordinates of the fin set center of - pressure. Has units of length and is given in meters. - TrapezoidalFins.cpx : float - Fin set local center of pressure x coordinate. Has units of length and - is given in meters. - TrapezoidalFins.cpy : float - Fin set local center of pressure y coordinate. Has units of length and - is given in meters. - TrapezoidalFins.cpz : float - 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. - TrapezoidalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. - """ - - def __init__( - self, - n, - root_chord, - tip_chord, - span, - rocket_radius, - cant_angle=0, - sweep_length=None, - sweep_angle=None, - airfoil=None, - name="Fins", - ): - """Initialize TrapezoidalFins class. - - Parameters - ---------- - n : int - Number of fins, from 2 to infinity. - root_chord : int, float - Fin root chord in meters. - tip_chord : int, float - Fin tip chord in meters. - span : int, float - Fin span in meters. - rocket_radius : int, float - Reference radius to calculate lift coefficient, in meters. - cant_angle : int, float, optional - Fins cant angle with respect to the rocket centerline. Must - be given in degrees. - sweep_length : int, float, optional - Fins sweep length in meters. By sweep length, understand the axial - distance between the fin root leading edge and the fin tip leading - edge measured parallel to the rocket centerline. If not given, the - sweep length is assumed to be equal the root chord minus the tip - chord, in which case the fin is a right trapezoid with its base - perpendicular to the rocket's axis. Cannot be used in conjunction - with sweep_angle. - sweep_angle : int, float, optional - Fins sweep angle with respect to the rocket centerline. Must - be given in degrees. If not given, the sweep angle is automatically - calculated, in which case the fin is assumed to be a right trapezoid - with its base perpendicular to the rocket's axis. - Cannot be used in conjunction with sweep_length. - airfoil : tuple, optional - Default is null, in which case fins will be treated as flat plates. - Otherwise, if tuple, fins will be considered as airfoils. The - tuple's first item specifies the airfoil's lift coefficient - by angle of attack and must be either a .csv, .txt, ndarray - or callable. The .csv and .txt files can contain a single line - header and the first column must specify the angle of attack, while - the second column must specify the lift coefficient. The - ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] - where x0 is the angle of attack and y0 is the lift coefficient. - If callable, it should take an angle of attack as input and - return the lift coefficient at that angle of attack. - The tuple's second item is the unit of the angle of attack, - accepting either "radians" or "degrees". - name : str - Name of fin set. - - Returns - ------- - None - """ - - super().__init__( - n, - root_chord, - span, - rocket_radius, - cant_angle, - airfoil, - name, - ) - - # Check if sweep angle or sweep length is given - if sweep_length is not None and sweep_angle is not None: - raise ValueError("Cannot use sweep_length and sweep_angle together") - elif sweep_angle is not None: - sweep_length = np.tan(sweep_angle * np.pi / 180) * span - elif sweep_length is None: - sweep_length = root_chord - tip_chord - else: - # Sweep length is given - pass - - self._tip_chord = tip_chord - self._sweep_length = sweep_length - self._sweep_angle = sweep_angle - - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - self.prints = _TrapezoidalFinsPrints(self) - self.plots = _TrapezoidalFinsPlots(self) - - @property - def tip_chord(self): - return self._tip_chord - - @tip_chord.setter - def tip_chord(self, value): - self._tip_chord = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def sweep_angle(self): - return self._sweep_angle - - @sweep_angle.setter - def sweep_angle(self, value): - self._sweep_angle = value - self._sweep_length = np.tan(value * np.pi / 180) * self.span - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def sweep_length(self): - return self._sweep_length - - @sweep_length.setter - def sweep_length(self, value): - self._sweep_length = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - def evaluate_center_of_pressure(self): - """Calculates and returns the center of pressure of the fin set in local - coordinates. The center of pressure position is saved and stored as a - tuple. - - Returns - ------- - None - """ - # Center of pressure position in local coordinates - cpz = (self.sweep_length / 3) * ( - (self.root_chord + 2 * self.tip_chord) / (self.root_chord + self.tip_chord) - ) + (1 / 6) * ( - self.root_chord - + self.tip_chord - - self.root_chord * self.tip_chord / (self.root_chord + self.tip_chord) - ) - self.cpx = 0 - self.cpy = 0 - self.cpz = cpz - self.cp = (self.cpx, self.cpy, self.cpz) - return None - - def evaluate_geometrical_parameters(self): - """Calculates and saves fin set's geometrical parameters such as the - fins' area, aspect ratio and parameters for roll movement. - - Returns - ------- - None - """ - - Yr = self.root_chord + self.tip_chord - Af = Yr * self.span / 2 # Fin area - AR = 2 * self.span**2 / Af # Fin aspect ratio - gamma_c = np.arctan( - (self.sweep_length + 0.5 * self.tip_chord - 0.5 * self.root_chord) - / (self.span) - ) - Yma = ( - (self.span / 3) * (self.root_chord + 2 * self.tip_chord) / Yr - ) # Span wise coord of mean aero chord - - # Fin–body interference correction parameters - tau = (self.span + self.rocket_radius) / self.rocket_radius - lift_interference_factor = 1 + 1 / tau - λ = self.tip_chord / self.root_chord - - # Parameters for Roll Moment. - # Documented at: https://github.com/RocketPy-Team/RocketPy/blob/master/docs/technical/aerodynamics/Roll_Equations.pdf - roll_geometrical_constant = ( - (self.root_chord + 3 * self.tip_chord) * self.span**3 - + 4 - * (self.root_chord + 2 * self.tip_chord) - * self.rocket_radius - * self.span**2 - + 6 * (self.root_chord + self.tip_chord) * self.span * self.rocket_radius**2 - ) / 12 - roll_damping_interference_factor = 1 + ( - ((tau - λ) / (tau)) - ((1 - λ) / (tau - 1)) * np.log(tau) - ) / ( - ((tau + 1) * (tau - λ)) / (2) - ((1 - λ) * (tau**3 - 1)) / (3 * (tau - 1)) - ) - roll_forcing_interference_factor = (1 / np.pi**2) * ( - (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) - + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) - + ((tau**2 + 1) ** 2) - / (tau**2 * (tau - 1) ** 2) - * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 - - (4 * (tau + 1)) - / (tau * (tau - 1)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) - ) - - # Store values - self.Yr = Yr - self.Af = Af # Fin area - self.AR = AR # Aspect Ratio - self.gamma_c = gamma_c # Mid chord angle - self.Yma = Yma # Span wise coord of mean aero chord - self.roll_geometrical_constant = roll_geometrical_constant - self.tau = tau - self.lift_interference_factor = lift_interference_factor - self.λ = λ - self.roll_damping_interference_factor = roll_damping_interference_factor - self.roll_forcing_interference_factor = roll_forcing_interference_factor - - self.evaluate_shape() - return None - - def evaluate_shape(self): - if self.sweep_length: - points = [ - (0, 0), - (self.sweep_length, self.span), - (self.sweep_length + self.tip_chord, self.span), - (self.root_chord, 0), - ] - else: - points = [ - (0, 0), - (self.root_chord - self.tip_chord, self.span), - (self.root_chord, self.span), - (self.root_chord, 0), - ] - - x_array, y_array = zip(*points) - self.shape_vec = [np.array(x_array), np.array(y_array)] - - return None - - def info(self): - self.prints.geometry() - self.prints.lift() - return None - - def all_info(self): - self.prints.all() - self.plots.all() - return None - - -class EllipticalFins(Fins): - """Class that defines and holds information for an elliptical fin set. - - This class inherits from the Fins class. - - Note - ---- - Local coordinate system: Z axis along the longitudinal axis of symmetry, - positive downwards (top -> bottom). Origin located at the top of the root - chord. - - See Also - -------- - Fins - - Attributes - ---------- - EllipticalFins.n : int - Number of fins in fin set. - EllipticalFins.rocket_radius : float - The reference rocket radius used for lift coefficient normalization, in - meters. - EllipticalFins.airfoil : tuple - Tuple of two items. First is the airfoil lift curve. - Second is the unit of the curve (radians or degrees) - EllipticalFins.cant_angle : float - Fins cant angle with respect to the rocket centerline, in degrees. - EllipticalFins.changing_attribute_dict : dict - Dictionary that stores the name and the values of the attributes that - may be changed during a simulation. Useful for control systems. - EllipticalFins.cant_angle_rad : float - Fins cant angle with respect to the rocket centerline, in radians. - EllipticalFins.root_chord : float - Fin root chord in meters. - EllipticalFins.span : float - Fin span in meters. - EllipticalFins.name : string - Name of fin set. - EllipticalFins.sweep_length : float - Fins sweep length in meters. By sweep length, understand the axial - distance between the fin root leading edge and the fin tip leading edge - measured parallel to the rocket centerline. - EllipticalFins.sweep_angle : float - Fins sweep angle with respect to the rocket centerline. Must - be given in degrees. - EllipticalFins.d : float - Reference diameter of the rocket, in meters. - EllipticalFins.ref_area : float - Reference area of the rocket. - EllipticalFins.Af : float - Area of the longitudinal section of each fin in the set. - EllipticalFins.AR : float - Aspect ratio of each fin in the set. - EllipticalFins.gamma_c : float - Fin mid-chord sweep angle. - EllipticalFins.Yma : float - Span wise position of the mean aerodynamic chord. - EllipticalFins.roll_geometrical_constant : float - Geometrical constant used in roll calculations. - EllipticalFins.tau : float - Geometrical relation used to simplify lift and roll calculations. - EllipticalFins.lift_interference_factor : float - Factor of Fin-Body interference in the lift coefficient. - EllipticalFins.cp : tuple - Tuple with the x, y and z local coordinates of the fin set center of - pressure. Has units of length and is given in meters. - EllipticalFins.cpx : float - Fin set local center of pressure x coordinate. Has units of length and - is given in meters. - EllipticalFins.cpy : float - Fin set local center of pressure y coordinate. Has units of length and - is given in meters. - EllipticalFins.cpz : float - 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. - EllipticalFins.clalpha : float - Lift coefficient slope. Has units of 1/rad. - """ - - def __init__( - self, - n, - root_chord, - span, - rocket_radius, - cant_angle=0, - airfoil=None, - name="Fins", - ): - """Initialize EllipticalFins class. - - Parameters - ---------- - n : int - Number of fins, from 2 to infinity. - root_chord : int, float - Fin root chord in meters. - span : int, float - Fin span in meters. - rocket_radius : int, float - Reference radius to calculate lift coefficient, in meters. - cant_angle : int, float, optional - Fins cant angle with respect to the rocket centerline. Must - be given in degrees. - sweep_length : int, float, optional - Fins sweep length in meters. By sweep length, understand the axial - distance between the fin root leading edge and the fin tip leading - edge measured parallel to the rocket centerline. If not given, the - sweep length is assumed to be equal the root chord minus the tip - chord, in which case the fin is a right trapezoid with its base - perpendicular to the rocket's axis. Cannot be used in conjunction - with sweep_angle. - sweep_angle : int, float, optional - Fins sweep angle with respect to the rocket centerline. Must - be given in degrees. If not given, the sweep angle is automatically - calculated, in which case the fin is assumed to be a right trapezoid - with its base perpendicular to the rocket's axis. - Cannot be used in conjunction with sweep_length. - airfoil : tuple, optional - Default is null, in which case fins will be treated as flat plates. - Otherwise, if tuple, fins will be considered as airfoils. The - tuple's first item specifies the airfoil's lift coefficient - by angle of attack and must be either a .csv, .txt, ndarray - or callable. The .csv and .txt files can contain a single line - header and the first column must specify the angle of attack, while - the second column must specify the lift coefficient. The - ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] - where x0 is the angle of attack and y0 is the lift coefficient. - If callable, it should take an angle of attack as input and - return the lift coefficient at that angle of attack. - The tuple's second item is the unit of the angle of attack, - accepting either "radians" or "degrees". - name : str - Name of fin set. - - Returns - ------- - None - """ - - super().__init__( - n, - root_chord, - span, - rocket_radius, - cant_angle, - airfoil, - name, - ) - - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - self.prints = _EllipticalFinsPrints(self) - self.plots = _EllipticalFinsPlots(self) - - return None - - def evaluate_center_of_pressure(self): - """Calculates and returns the center of pressure of the fin set in local - coordinates. The center of pressure position is saved and stored as a - tuple. - - Returns - ------- - None - """ - # Center of pressure position in local coordinates - cpz = 0.288 * self.root_chord - self.cpx = 0 - self.cpy = 0 - self.cpz = cpz - self.cp = (self.cpx, self.cpy, self.cpz) - return None - - def evaluate_geometrical_parameters(self): - """Calculates and saves fin set's geometrical parameters such as the - fins' area, aspect ratio and parameters for roll movement. - - Returns - ------- - None - """ - - # Compute auxiliary geometrical parameters - Af = (np.pi * self.root_chord / 2 * self.span) / 2 # Fin area - gamma_c = 0 # Zero for elliptical fins - AR = 2 * self.span**2 / Af # Fin aspect ratio - Yma = ( - self.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64) - ) # Span wise coord of mean aero chord - roll_geometrical_constant = ( - self.root_chord - * self.span - * ( - 3 * np.pi * self.span**2 - + 32 * self.rocket_radius * self.span - + 12 * np.pi * self.rocket_radius**2 - ) - / 48 - ) - - # Fin–body interference correction parameters - tau = (self.span + self.rocket_radius) / self.rocket_radius - lift_interference_factor = 1 + 1 / tau - if self.span > self.rocket_radius: - roll_damping_interference_factor = 1 + ( - (self.rocket_radius**2) - * ( - 2 - * (self.rocket_radius**2) - * np.sqrt(self.span**2 - self.rocket_radius**2) - * np.log( - ( - 2 - * self.span - * np.sqrt(self.span**2 - self.rocket_radius**2) - + 2 * self.span**2 - ) - / self.rocket_radius - ) - - 2 - * (self.rocket_radius**2) - * np.sqrt(self.span**2 - self.rocket_radius**2) - * np.log(2 * self.span) - + 2 * self.span**3 - - np.pi * self.rocket_radius * self.span**2 - - 2 * (self.rocket_radius**2) * self.span - + np.pi * self.rocket_radius**3 - ) - ) / ( - 2 - * (self.span**2) - * (self.span / 3 + np.pi * self.rocket_radius / 4) - * (self.span**2 - self.rocket_radius**2) - ) - elif self.span < self.rocket_radius: - roll_damping_interference_factor = 1 - ( - self.rocket_radius**2 - * ( - 2 * self.span**3 - - np.pi * self.span**2 * self.rocket_radius - - 2 * self.span * self.rocket_radius**2 - + np.pi * self.rocket_radius**3 - + 2 - * self.rocket_radius**2 - * np.sqrt(-self.span**2 + self.rocket_radius**2) - * np.arctan( - (self.span) / (np.sqrt(-self.span**2 + self.rocket_radius**2)) - ) - - np.pi - * self.rocket_radius**2 - * np.sqrt(-self.span**2 + self.rocket_radius**2) - ) - ) / ( - 2 - * self.span - * (-self.span**2 + self.rocket_radius**2) - * (self.span**2 / 3 + np.pi * self.span * self.rocket_radius / 4) - ) - elif self.span == self.rocket_radius: - roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi) - - roll_forcing_interference_factor = (1 / np.pi**2) * ( - (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) - + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) - + ((tau**2 + 1) ** 2) - / (tau**2 * (tau - 1) ** 2) - * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 - - (4 * (tau + 1)) - / (tau * (tau - 1)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) - ) - - # Store values - self.Af = Af # Fin area - self.AR = AR # Fin aspect ratio - self.gamma_c = gamma_c # Mid chord angle - self.Yma = Yma # Span wise coord of mean aero chord - self.roll_geometrical_constant = roll_geometrical_constant - self.tau = tau - self.lift_interference_factor = lift_interference_factor - self.roll_damping_interference_factor = roll_damping_interference_factor - self.roll_forcing_interference_factor = roll_forcing_interference_factor - - self.evaluate_shape() - return None - - def evaluate_shape(self): - angles = np.arange(0, 180, 5) - x_array = self.root_chord / 2 + self.root_chord / 2 * np.cos(np.radians(angles)) - y_array = self.span * np.sin(np.radians(angles)) - self.shape_vec = [x_array, y_array] - return None - - def info(self): - self.prints.geometry() - self.prints.lift() - return None - - def all_info(self): - self.prints.all() - self.plots.all() - return None - - -class Tail(AeroSurface): - """Class that defines a tail. Currently only accepts conical tails. - - Note - ---- - Local coordinate system: Z axis along the longitudinal axis of symmetry, - positive downwards (top -> bottom). Origin located at top of the tail - (generally the portion closest to the rocket's nose). - - Attributes - ---------- - Tail.top_radius : int, float - Radius of the top of the tail. The top radius is defined as the radius - of the transversal section that is closest to the rocket's nose. - Tail.bottom_radius : int, float - Radius of the bottom of the tail. - Tail.length : int, float - Length of the tail. The length is defined as the distance between the - top and bottom of the tail. The length is measured along the rocket's - longitudinal axis. Has the unit of meters. - Tail.rocket_radius: int, float - The reference rocket radius used for lift coefficient normalization in - meters. - Tail.name : str - Name of the tail. Default is 'Tail'. - Tail.cpx : int, float - x local coordinate of the center of pressure of the tail. - Tail.cpy : int, float - y local coordinate of the center of pressure of the tail. - Tail.cpz : int, float - z local coordinate of the center of pressure of the tail. - 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. - Tail.clalpha : float - Lift 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 - along the tail's slant axis. Has the unit of meters. - Tail.surface_area : float - Surface area of the tail. Has the unit of meters squared. - """ - - def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail"): - """Initializes the tail object by computing and storing the most - important values. - - Parameters - ---------- - top_radius : int, float - Radius of the top of the tail. The top radius is defined as the - radius of the transversal section that is closest to the rocket's - nose. - bottom_radius : int, float - Radius of the bottom of the tail. - length : int, float - Length of the tail. - rocket_radius : int, float - The reference rocket radius used for lift coefficient normalization. - name : str - Name of the tail. Default is 'Tail'. - - Returns - ------- - None - """ - super().__init__(name) - - # Store arguments as attributes - self._top_radius = top_radius - self._bottom_radius = bottom_radius - self._length = length - self._rocket_radius = rocket_radius - - # Calculate geometrical parameters - self.evaluate_geometrical_parameters() - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() - - self.plots = _TailPlots(self) - self.prints = _TailPrints(self) - - return None - - @property - def top_radius(self): - return self._top_radius - - @top_radius.setter - def top_radius(self, value): - self._top_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() - - @property - def bottom_radius(self): - return self._bottom_radius - - @bottom_radius.setter - def bottom_radius(self, value): - self._bottom_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() - - @property - def length(self): - return self._length - - @length.setter - def length(self, value): - self._length = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - - @property - def rocket_radius(self): - return self._rocket_radius - - @rocket_radius.setter - def rocket_radius(self, value): - self._rocket_radius = value - self.evaluate_lift_coefficient() - - def evaluate_geometrical_parameters(self): - """Calculates and saves tail's slant length and surface area. - - Returns - ------- - None - """ - # Calculate tail slant length - self.slant_length = np.sqrt( - (self.length) ** 2 + (self.top_radius - self.bottom_radius) ** 2 - ) - # Calculate the surface area of the tail - self.surface_area = ( - np.pi * self.slant_length * (self.top_radius + self.bottom_radius) - ) - self.evaluate_shape() - return None - - def evaluate_shape(self): - # Assuming the tail is a cone, calculate the shape vector - self.shape_vec = [ - np.array([0, self.length]), - np.array([self.top_radius, self.bottom_radius]), - ] - return None - - def evaluate_lift_coefficient(self): - """Calculates and returns tail's lift coefficient. - The lift coefficient is saved and returned. This function - also calculates and saves its lift coefficient derivative. - - Returns - ------- - None - """ - # Calculate clalpha - # clalpha is currently a constant, meaning it is independent of Mach - # number. This is only valid for subsonic speeds. - # It must be set as a Function because it will be called and treated - # as a function of mach in the simulation. - self.clalpha = Function( - lambda mach: 2 - / self._beta(mach) - * ( - (self.bottom_radius / self.rocket_radius) ** 2 - - (self.top_radius / self.rocket_radius) ** 2 - ), - "Mach", - f"Lift coefficient derivative for {self.name}", - ) - self.cl = Function( - lambda alpha, mach: self.clalpha(mach) * alpha, - ["Alpha (rad)", "Mach"], - "Cl", - ) - return None - - def evaluate_center_of_pressure(self): - """Calculates and returns the center of pressure of the tail in local - coordinates. The center of pressure position is saved and stored as a - tuple. - - Returns - ------- - None - """ - # Calculate cp position in local coordinates - r = self.top_radius / self.bottom_radius - cpz = (self.length / 3) * (1 + (1 - r) / (1 - r**2)) - - # Store values as class attributes - self.cpx = 0 - self.cpy = 0 - self.cpz = cpz - self.cp = (self.cpx, self.cpy, self.cpz) - return None - - def info(self): - self.prints.geometry() - self.prints.lift() - return None - - def all_info(self): - self.prints.all() - self.plots.all() - return None - - -class RailButtons(AeroSurface): - """Class that defines a rail button pair or group. - - Attributes - ---------- - RailButtons.buttons_distance : int, float - Distance between the two rail buttons closest to the nozzle. - RailButtons.angular_position : int, float - Angular position of the rail buttons in degrees measured - as the rotation around the symmetry axis of the rocket - relative to one of the other principal axis. - """ - - def __init__(self, buttons_distance, angular_position=45, name="Rail Buttons"): - """Initializes RailButtons Class. - - Parameters - ---------- - buttons_distance : int, float - Distance between the first and the last rail button in meters. - angular_position : int, float, optional - Angular position of the rail buttons in degrees measured - as the rotation around the symmetry axis of the rocket - relative to one of the other principal axis. - name : string, optional - Name of the rail buttons. Default is "Rail Buttons". - - Returns - ------- - None - - """ - super().__init__(name) - self.buttons_distance = buttons_distance - self.angular_position = angular_position - self.name = name - - self.evaluate_lift_coefficient() - self.evaluate_center_of_pressure() - - self.prints = _RailButtonsPrints(self) - return None - - 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) - return None - - 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", - ) - return None - - 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 - """ - return None - - def info(self): - """Prints out all the information about the Rail Buttons. - - Returns - ------- - None - """ - self.prints.geometry() - return None - - def all_info(self): - """Returns all info of the Rail Buttons. - - Returns - ------- - None - """ - self.prints.all() - return None - - -class AirBrakes(AeroSurface): - """AirBrakes class. Inherits from AeroSurface. - - Attributes - ---------- - AirBrakes.drag_coefficient : Function - Drag coefficient as a function of deployment level and Mach number. - AirBrakes.drag_coefficient_curve : int, float, callable, array, string, Function - Curve that defines the drag coefficient as a function of deployment level - and Mach number. Used as the source of `AirBrakes.drag_coefficient`. - AirBrakes.deployment_level : float - Current deployment level, ranging from 0 to 1. Deployment level is the - fraction of the total airbrake area that is deployed. - AirBrakes.reference_area : int, float - Reference area used to calculate the drag force of the air brakes - from the drag coefficient curve. Units of m^2. - AirBrakes.clamp : bool, optional - If True, the simulation will clamp the deployment level to 0 or 1 if - the deployment level is out of bounds. If False, the simulation will - not clamp the deployment level and will instead raise a warning if - the deployment level is out of bounds. Default is True. - AirBrakes.name : str - Name of the air brakes. - """ - - def __init__( - self, - drag_coefficient_curve, - reference_area, - clamp=True, - override_rocket_drag=False, - deployment_level=0, - name="AirBrakes", - ): - """Initializes the AirBrakes class. - - Parameters - ---------- - drag_coefficient_curve : int, float, callable, array, string, Function - This parameter represents the drag coefficient associated with the - air brakes and/or the entire rocket, depending on the value of - ``override_rocket_drag``. - - - If a constant, it should be an integer or a float representing a - fixed drag coefficient value. - - If a function, it must take two parameters: deployment level and - Mach number, and return the drag coefficient. This function allows - for dynamic computation based on deployment and Mach number. - - If an array, it should be a 2D array with three columns: the first - column for deployment level, the second for Mach number, and the - third for the corresponding drag coefficient. - - If a string, it should be the path to a .csv or .txt file. The - file must contain three columns: the first for deployment level, - the second for Mach number, and the third for the drag - coefficient. - - If a Function, it must take two parameters: deployment level and - Mach number, and return the drag coefficient. - - .. note:: For ``override_rocket_drag = False``, at - deployment level 0, the drag coefficient is assumed to be 0, - independent of the input drag coefficient curve. This means that - the simulation always considers that at a deployment level of 0, - the air brakes are completely retracted and do not contribute to - the drag of the rocket. - - reference_area : int, float - Reference area used to calculate the drag force of the air brakes - from the drag coefficient curve. Units of m^2. - clamp : bool, optional - If True, the simulation will clamp the deployment level to 0 or 1 if - the deployment level is out of bounds. If False, the simulation will - not clamp the deployment level and will instead raise a warning if - the deployment level is out of bounds. Default is True. - override_rocket_drag : bool, optional - If False, the air brakes drag coefficient will be added to the - rocket's power off drag coefficient curve. If True, during the - simulation, the rocket's power off drag will be ignored and the air - brakes drag coefficient will be used for the entire rocket instead. - 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 - is 0. - name : str, optional - Name of the air brakes. Default is "AirBrakes". - - Returns - ------- - None - """ - super().__init__(name) - self.drag_coefficient_curve = drag_coefficient_curve - self.drag_coefficient = Function( - drag_coefficient_curve, - inputs=["Deployment Level", "Mach"], - outputs="Drag Coefficient", - ) - self.reference_area = reference_area - self.clamp = clamp - self.override_rocket_drag = override_rocket_drag - self.initial_deployment_level = 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 - if self.clamp: - # Make sure deployment level is between 0 and 1 - value = 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." - ) - self._deployment_level = value - - def _reset(self): - """Resets the air brakes to their initial state. This is ran at the - beginning of each simulation to ensure the air brakes are in the correct - 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. - - Returns - ------- - None - """ - pass - - def info(self): - """Prints and plots summarized information of the aerodynamic surface. - - Returns - ------- - None - """ - self.prints.geometry() - - def all_info(self): - """Prints and plots all information of the aerodynamic surface. - - Returns - ------- - None - """ - self.info() - self.plots.drag_coefficient_curve() diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py new file mode 100644 index 000000000..9d9c68586 --- /dev/null +++ b/rocketpy/rocket/aero_surface/__init__.py @@ -0,0 +1,6 @@ +from rocketpy.rocket.aero_surface.aero_surface import AeroSurface +from rocketpy.rocket.aero_surface.air_brakes import AirBrakes +from rocketpy.rocket.aero_surface.fins import EllipticalFins, Fins, TrapezoidalFins +from rocketpy.rocket.aero_surface.nose_cone import NoseCone +from rocketpy.rocket.aero_surface.rail_buttons import RailButtons +from rocketpy.rocket.aero_surface.tail import Tail diff --git a/rocketpy/rocket/aero_surface/aero_surface.py b/rocketpy/rocket/aero_surface/aero_surface.py new file mode 100644 index 000000000..81b5610e3 --- /dev/null +++ b/rocketpy/rocket/aero_surface/aero_surface.py @@ -0,0 +1,92 @@ +from abc import ABC, abstractmethod + +import numpy as np + + +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 + + @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 + """ diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py new file mode 100644 index 000000000..ee4830808 --- /dev/null +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -0,0 +1,207 @@ +import warnings + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.plots.aero_surface_plots import _AirBrakesPlots +from rocketpy.prints.aero_surface_prints import _AirBrakesPrints + +from .aero_surface import AeroSurface + + +class AirBrakes(AeroSurface): + """AirBrakes class. Inherits from AeroSurface. + + Attributes + ---------- + AirBrakes.drag_coefficient : Function + Drag coefficient as a function of deployment level and Mach number. + AirBrakes.drag_coefficient_curve : int, float, callable, array, string, Function + Curve that defines the drag coefficient as a function of deployment level + and Mach number. Used as the source of `AirBrakes.drag_coefficient`. + AirBrakes.deployment_level : float + Current deployment level, ranging from 0 to 1. Deployment level is the + fraction of the total airbrake area that is deployed. + AirBrakes.reference_area : int, float + Reference area used to calculate the drag force of the air brakes + from the drag coefficient curve. Units of m^2. + AirBrakes.clamp : bool, optional + If True, the simulation will clamp the deployment level to 0 or 1 if + the deployment level is out of bounds. If False, the simulation will + not clamp the deployment level and will instead raise a warning if + the deployment level is out of bounds. Default is True. + AirBrakes.name : str + Name of the air brakes. + """ + + def __init__( + self, + drag_coefficient_curve, + reference_area, + clamp=True, + override_rocket_drag=False, + deployment_level=0, + name="AirBrakes", + ): + """Initializes the AirBrakes class. + + Parameters + ---------- + drag_coefficient_curve : int, float, callable, array, string, Function + This parameter represents the drag coefficient associated with the + air brakes and/or the entire rocket, depending on the value of + ``override_rocket_drag``. + + - If a constant, it should be an integer or a float representing a + fixed drag coefficient value. + - If a function, it must take two parameters: deployment level and + Mach number, and return the drag coefficient. This function allows + for dynamic computation based on deployment and Mach number. + - If an array, it should be a 2D array with three columns: the first + column for deployment level, the second for Mach number, and the + third for the corresponding drag coefficient. + - If a string, it should be the path to a .csv or .txt file. The + file must contain three columns: the first for deployment level, + the second for Mach number, and the third for the drag + coefficient. + - If a Function, it must take two parameters: deployment level and + Mach number, and return the drag coefficient. + + .. note:: For ``override_rocket_drag = False``, at + deployment level 0, the drag coefficient is assumed to be 0, + independent of the input drag coefficient curve. This means that + the simulation always considers that at a deployment level of 0, + the air brakes are completely retracted and do not contribute to + the drag of the rocket. + + reference_area : int, float + Reference area used to calculate the drag force of the air brakes + from the drag coefficient curve. Units of m^2. + clamp : bool, optional + If True, the simulation will clamp the deployment level to 0 or 1 if + the deployment level is out of bounds. If False, the simulation will + not clamp the deployment level and will instead raise a warning if + the deployment level is out of bounds. Default is True. + override_rocket_drag : bool, optional + If False, the air brakes drag coefficient will be added to the + rocket's power off drag coefficient curve. If True, during the + simulation, the rocket's power off drag will be ignored and the air + brakes drag coefficient will be used for the entire rocket instead. + 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 + is 0. + name : str, optional + Name of the air brakes. Default is "AirBrakes". + + Returns + ------- + None + """ + super().__init__(name, reference_area, None) + self.drag_coefficient_curve = drag_coefficient_curve + self.drag_coefficient = Function( + drag_coefficient_curve, + inputs=["Deployment Level", "Mach"], + outputs="Drag Coefficient", + ) + self.clamp = clamp + self.override_rocket_drag = override_rocket_drag + self.initial_deployment_level = 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 + if self.clamp: + # Make sure deployment level is between 0 and 1 + value = 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." + ) + self._deployment_level = value + + def _reset(self): + """Resets the air brakes to their initial state. This is ran at the + beginning of each simulation to ensure the air brakes are in the correct + 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. + + Returns + ------- + None + """ + + def info(self): + """Prints and plots summarized information of the aerodynamic surface. + + Returns + ------- + None + """ + self.prints.geometry() + + def all_info(self): + """Prints and plots all information of the aerodynamic surface. + + Returns + ------- + None + """ + self.info() + self.plots.drag_coefficient_curve() diff --git a/rocketpy/rocket/aero_surface/fins/__init__.py b/rocketpy/rocket/aero_surface/fins/__init__.py new file mode 100644 index 000000000..f1efc603a --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/__init__.py @@ -0,0 +1,3 @@ +from rocketpy.rocket.aero_surface.fins.elliptical_fins import EllipticalFins +from rocketpy.rocket.aero_surface.fins.fins import Fins +from rocketpy.rocket.aero_surface.fins.trapezoidal_fins import TrapezoidalFins diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py new file mode 100644 index 000000000..5622900d6 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py @@ -0,0 +1,316 @@ +import numpy as np + +from rocketpy.plots.aero_surface_plots import _EllipticalFinsPlots +from rocketpy.prints.aero_surface_prints import _EllipticalFinsPrints + +from .fins import Fins + + +class EllipticalFins(Fins): + """Class that defines and holds information for an elliptical fin set. + + This class inherits from the Fins class. + + Note + ---- + Local coordinate system: Z axis along the longitudinal axis of symmetry, + positive downwards (top -> bottom). Origin located at the top of the root + chord. + + See Also + -------- + Fins + + Attributes + ---------- + EllipticalFins.n : int + Number of fins in fin set. + EllipticalFins.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, in + meters. + EllipticalFins.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees) + EllipticalFins.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + EllipticalFins.changing_attribute_dict : dict + Dictionary that stores the name and the values of the attributes that + may be changed during a simulation. Useful for control systems. + EllipticalFins.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + EllipticalFins.root_chord : float + Fin root chord in meters. + EllipticalFins.span : float + Fin span in meters. + EllipticalFins.name : string + Name of fin set. + EllipticalFins.sweep_length : float + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + EllipticalFins.sweep_angle : float + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. + EllipticalFins.d : float + Reference diameter of the rocket, in meters. + EllipticalFins.ref_area : float + Reference area of the rocket. + EllipticalFins.Af : float + Area of the longitudinal section of each fin in the set. + EllipticalFins.AR : float + Aspect ratio of each fin in the set. + EllipticalFins.gamma_c : float + Fin mid-chord sweep angle. + EllipticalFins.Yma : float + Span wise position of the mean aerodynamic chord. + EllipticalFins.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + EllipticalFins.tau : float + Geometrical relation used to simplify lift and roll calculations. + EllipticalFins.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + EllipticalFins.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + EllipticalFins.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + EllipticalFins.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + EllipticalFins.cpz : float + 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. + EllipticalFins.clalpha : float + Lift coefficient slope. Has units of 1/rad. + """ + + def __init__( + self, + n, + root_chord, + span, + rocket_radius, + cant_angle=0, + airfoil=None, + name="Fins", + ): + """Initialize EllipticalFins class. + + Parameters + ---------- + n : int + Number of fins, from 2 to infinity. + root_chord : int, float + Fin root chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference radius to calculate lift coefficient, in meters. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + sweep_length : int, float, optional + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading + edge measured parallel to the rocket centerline. If not given, the + sweep length is assumed to be equal the root chord minus the tip + chord, in which case the fin is a right trapezoid with its base + perpendicular to the rocket's axis. Cannot be used in conjunction + with sweep_angle. + sweep_angle : int, float, optional + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. If not given, the sweep angle is automatically + calculated, in which case the fin is assumed to be a right trapezoid + with its base perpendicular to the rocket's axis. + Cannot be used in conjunction with sweep_length. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of fin set. + + Returns + ------- + None + """ + + super().__init__( + n, + root_chord, + span, + rocket_radius, + cant_angle, + airfoil, + name, + ) + + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + self.prints = _EllipticalFinsPrints(self) + self.plots = _EllipticalFinsPlots(self) + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the fin set in local + coordinates. The center of pressure position is saved and stored as a + tuple. + + Returns + ------- + None + """ + # Center of pressure position in local coordinates + cpz = 0.288 * self.root_chord + self.cpx = 0 + self.cpy = 0 + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements + """Calculates and saves fin set's geometrical parameters such as the + fins' area, aspect ratio and parameters for roll movement. + + Returns + ------- + None + """ + + # Compute auxiliary geometrical parameters + # pylint: disable=invalid-name + Af = (np.pi * self.root_chord / 2 * self.span) / 2 # Fin area + gamma_c = 0 # Zero for elliptical fins + AR = 2 * self.span**2 / Af # Fin aspect ratio + Yma = ( + self.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64) + ) # Span wise coord of mean aero chord + roll_geometrical_constant = ( + self.root_chord + * self.span + * ( + 3 * np.pi * self.span**2 + + 32 * self.rocket_radius * self.span + + 12 * np.pi * self.rocket_radius**2 + ) + / 48 + ) + + # Fin–body interference correction parameters + tau = (self.span + self.rocket_radius) / self.rocket_radius + lift_interference_factor = 1 + 1 / tau + if self.span > self.rocket_radius: + roll_damping_interference_factor = 1 + ( + (self.rocket_radius**2) + * ( + 2 + * (self.rocket_radius**2) + * np.sqrt(self.span**2 - self.rocket_radius**2) + * np.log( + ( + 2 + * self.span + * np.sqrt(self.span**2 - self.rocket_radius**2) + + 2 * self.span**2 + ) + / self.rocket_radius + ) + - 2 + * (self.rocket_radius**2) + * np.sqrt(self.span**2 - self.rocket_radius**2) + * np.log(2 * self.span) + + 2 * self.span**3 + - np.pi * self.rocket_radius * self.span**2 + - 2 * (self.rocket_radius**2) * self.span + + np.pi * self.rocket_radius**3 + ) + ) / ( + 2 + * (self.span**2) + * (self.span / 3 + np.pi * self.rocket_radius / 4) + * (self.span**2 - self.rocket_radius**2) + ) + elif self.span < self.rocket_radius: + roll_damping_interference_factor = 1 - ( + self.rocket_radius**2 + * ( + 2 * self.span**3 + - np.pi * self.span**2 * self.rocket_radius + - 2 * self.span * self.rocket_radius**2 + + np.pi * self.rocket_radius**3 + + 2 + * self.rocket_radius**2 + * np.sqrt(-self.span**2 + self.rocket_radius**2) + * np.arctan( + (self.span) / (np.sqrt(-self.span**2 + self.rocket_radius**2)) + ) + - np.pi + * self.rocket_radius**2 + * np.sqrt(-self.span**2 + self.rocket_radius**2) + ) + ) / ( + 2 + * self.span + * (-self.span**2 + self.rocket_radius**2) + * (self.span**2 / 3 + np.pi * self.span * self.rocket_radius / 4) + ) + else: + roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi) + + roll_forcing_interference_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2) + / (tau**2 * (tau - 1) ** 2) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) + / (tau * (tau - 1)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + # Store values + # pylint: disable=invalid-name + self.Af = Af # Fin area + self.AR = AR # Fin aspect ratio + self.gamma_c = gamma_c # Mid chord angle + self.Yma = Yma # Span wise coord of mean aero chord + self.roll_geometrical_constant = roll_geometrical_constant + self.tau = tau + self.lift_interference_factor = lift_interference_factor + self.roll_damping_interference_factor = roll_damping_interference_factor + self.roll_forcing_interference_factor = roll_forcing_interference_factor + + self.evaluate_shape() + + def evaluate_shape(self): + angles = np.arange(0, 180, 5) + x_array = self.root_chord / 2 + self.root_chord / 2 * np.cos(np.radians(angles)) + y_array = self.span * np.sin(np.radians(angles)) + self.shape_vec = [x_array, y_array] + + def info(self): + self.prints.geometry() + self.prints.lift() + + def all_info(self): + self.prints.all() + self.plots.all() diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py new file mode 100644 index 000000000..2de0176b0 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -0,0 +1,375 @@ +import numpy as np + +from rocketpy.mathutils.function import Function + +from ..aero_surface import AeroSurface + + +class Fins(AeroSurface): + """Abstract class that holds common methods for the fin classes. + Cannot be instantiated. + + Note + ---- + Local coordinate system: Z axis along the longitudinal axis of symmetry, + positive downwards (top -> bottom). Origin located at the top of the root + chord. + + Attributes + ---------- + Fins.n : int + Number of fins in fin set. + Fins.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, + in meters. + Fins.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees). + Fins.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + Fins.changing_attribute_dict : dict + Dictionary that stores the name and the values of the attributes that + may be changed during a simulation. Useful for control systems. + Fins.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + Fins.root_chord : float + Fin root chord in meters. + Fins.tip_chord : float + Fin tip chord in meters. + Fins.span : float + Fin span in meters. + Fins.name : string + Name of fin set. + Fins.sweep_length : float + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + Fins.sweep_angle : float + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. + Fins.d : float + Reference diameter of the rocket. Has units of length and is given + in meters. + Fins.ref_area : float + Reference area of the rocket. + Fins.Af : float + Area of the longitudinal section of each fin in the set. + Fins.AR : float + Aspect ratio of each fin in the set. + Fins.gamma_c : float + Fin mid-chord sweep angle. + Fins.Yma : float + Span wise position of the mean aerodynamic chord. + Fins.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + Fins.tau : float + Geometrical relation used to simplify lift and roll calculations. + Fins.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + Fins.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + Fins.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + Fins.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + Fins.cpz : float + 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. + Fins.clalpha : float + Lift 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. + """ + + def __init__( + self, + n, + root_chord, + span, + rocket_radius, + cant_angle=0, + airfoil=None, + name="Fins", + ): + """Initialize Fins class. + + Parameters + ---------- + n : int + Number of fins, from 2 to infinity. + root_chord : int, float + Fin root chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference rocket radius used for lift coefficient normalization. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of fin set. + + Returns + ------- + None + """ + # Compute auxiliary geometrical parameters + d = 2 * rocket_radius + ref_area = np.pi * rocket_radius**2 # Reference area + + super().__init__(name, ref_area, d) + + # Store values + self._n = n + self._rocket_radius = rocket_radius + self._airfoil = airfoil + self._cant_angle = cant_angle + self._root_chord = root_chord + self._span = span + self.name = name + self.d = d + self.ref_area = ref_area # Reference area + + @property + def n(self): + return self._n + + @n.setter + def n(self, value): + self._n = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def root_chord(self): + return self._root_chord + + @root_chord.setter + def root_chord(self, value): + self._root_chord = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def span(self): + return self._span + + @span.setter + def span(self, value): + self._span = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def rocket_radius(self): + return self._rocket_radius + + @rocket_radius.setter + def rocket_radius(self, value): + self._rocket_radius = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def cant_angle(self): + return self._cant_angle + + @cant_angle.setter + def cant_angle(self, value): + self._cant_angle = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def airfoil(self): + return self._airfoil + + @airfoil.setter + def airfoil(self, value): + self._airfoil = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + def evaluate_lift_coefficient(self): + """Calculates and returns the fin set's lift coefficient. + The lift coefficient is saved and returned. This function + also calculates and saves the lift coefficient derivative + for a single fin and the lift coefficient derivative for + a number of n fins corrected for Fin-Body interference. + + Returns + ------- + None + """ + if not self.airfoil: + # Defines clalpha2D as 2*pi for planar fins + clalpha2D_incompressible = 2 * np.pi + else: + # Defines clalpha2D as the derivative of the lift coefficient curve + # for the specific airfoil + self.airfoil_cl = Function( + self.airfoil[0], + interpolation="linear", + ) + + # Differentiating at alpha = 0 to get cl_alpha + clalpha2D_incompressible = self.airfoil_cl.differentiate_complex_step( + x=1e-3, dx=1e-3 + ) + + # Convert to radians if needed + if self.airfoil[1] == "degrees": + clalpha2D_incompressible *= 180 / np.pi + + # Correcting for compressible flow (apply Prandtl-Glauert correction) + clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach)) + + # Diederich's Planform Correlation Parameter + planform_correlation_parameter = ( + 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) + ) + + # Lift coefficient derivative for a single fin + def lift_source(mach): + return ( + clalpha2D(mach) + * planform_correlation_parameter(mach) + * (self.Af / self.ref_area) + * np.cos(self.gamma_c) + ) / ( + 2 + + planform_correlation_parameter(mach) + * np.sqrt(1 + (2 / planform_correlation_parameter(mach)) ** 2) + ) + + self.clalpha_single_fin = Function( + lift_source, + "Mach", + "Lift coefficient derivative for a single fin", + ) + + # Lift coefficient derivative for n fins corrected with Fin-Body interference + self.clalpha_multiple_fins = ( + self.lift_interference_factor + * self.fin_num_correction(self.n) + * self.clalpha_single_fin + ) # 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" + ) + 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 + + def evaluate_roll_parameters(self): + """Calculates and returns the fin set's roll coefficients. + The roll coefficients are saved in a list. + + Returns + ------- + self.roll_parameters : list + List containing the roll moment lift coefficient, the + roll moment damping coefficient and the cant angle in + radians + """ + + self.cant_angle_rad = np.radians(self.cant_angle) + + clf_delta = ( + self.roll_forcing_interference_factor + * self.n + * (self.Yma + self.rocket_radius) + * self.clalpha_single_fin + / self.d + ) # Function of mach number + clf_delta.set_inputs("Mach") + clf_delta.set_outputs("Roll moment forcing coefficient derivative") + cld_omega = ( + 2 + * self.roll_damping_interference_factor + * self.n + * self.clalpha_single_fin + * np.cos(self.cant_angle_rad) + * self.roll_geometrical_constant + / (self.ref_area * self.d**2) + ) # Function of mach number + cld_omega.set_inputs("Mach") + cld_omega.set_outputs("Roll moment damping coefficient derivative") + self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad] + return self.roll_parameters + + @staticmethod + def fin_num_correction(n): + """Calculates a correction factor for the lift coefficient of multiple + fins. + The specifics values are documented at: + Niskanen, S. (2013). “OpenRocket technical documentation”. + In: Development of an Open Source model rocket simulation software. + + Parameters + ---------- + n : int + Number of fins. + + Returns + ------- + Corrector factor : int + Factor that accounts for the number of fins. + """ + corrector_factor = [2.37, 2.74, 2.99, 3.24] + if 5 <= n <= 8: + return corrector_factor[n - 5] + else: + return n / 2 + + def draw(self): + """Draw the fin shape along with some important information, including + the center line, the quarter line and the center of pressure position. + + Returns + ------- + None + """ + self.plots.draw() diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py new file mode 100644 index 000000000..15caa38e8 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py @@ -0,0 +1,347 @@ +import numpy as np + +from rocketpy.plots.aero_surface_plots import _TrapezoidalFinsPlots +from rocketpy.prints.aero_surface_prints import _TrapezoidalFinsPrints + +from .fins import Fins + + +class TrapezoidalFins(Fins): + """Class that defines and holds information for a trapezoidal fin set. + + This class inherits from the Fins class. + + Note + ---- + Local coordinate system: Z axis along the longitudinal axis of symmetry, + positive downwards (top -> bottom). Origin located at the top of the root + chord. + + See Also + -------- + Fins + + Attributes + ---------- + TrapezoidalFins.n : int + Number of fins in fin set. + TrapezoidalFins.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, in + meters. + TrapezoidalFins.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees). + TrapezoidalFins.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + TrapezoidalFins.changing_attribute_dict : dict + Dictionary that stores the name and the values of the attributes that + may be changed during a simulation. Useful for control systems. + TrapezoidalFins.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + TrapezoidalFins.root_chord : float + Fin root chord in meters. + TrapezoidalFins.tip_chord : float + Fin tip chord in meters. + TrapezoidalFins.span : float + Fin span in meters. + TrapezoidalFins.name : string + Name of fin set. + TrapezoidalFins.sweep_length : float + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + TrapezoidalFins.sweep_angle : float + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. + TrapezoidalFins.d : float + Reference diameter of the rocket, in meters. + TrapezoidalFins.ref_area : float + Reference area of the rocket, in m². + TrapezoidalFins.Af : float + Area of the longitudinal section of each fin in the set. + TrapezoidalFins.AR : float + Aspect ratio of each fin in the set + TrapezoidalFins.gamma_c : float + Fin mid-chord sweep angle. + TrapezoidalFins.Yma : float + Span wise position of the mean aerodynamic chord. + TrapezoidalFins.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + TrapezoidalFins.tau : float + Geometrical relation used to simplify lift and roll calculations. + TrapezoidalFins.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + TrapezoidalFins.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + TrapezoidalFins.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + TrapezoidalFins.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + TrapezoidalFins.cpz : float + 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. + TrapezoidalFins.clalpha : float + Lift coefficient slope. Has units of 1/rad. + """ + + def __init__( + self, + n, + root_chord, + tip_chord, + span, + rocket_radius, + cant_angle=0, + sweep_length=None, + sweep_angle=None, + airfoil=None, + name="Fins", + ): + """Initialize TrapezoidalFins class. + + Parameters + ---------- + n : int + Number of fins, from 2 to infinity. + root_chord : int, float + Fin root chord in meters. + tip_chord : int, float + Fin tip chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference radius to calculate lift coefficient, in meters. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + sweep_length : int, float, optional + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading + edge measured parallel to the rocket centerline. If not given, the + sweep length is assumed to be equal the root chord minus the tip + chord, in which case the fin is a right trapezoid with its base + perpendicular to the rocket's axis. Cannot be used in conjunction + with sweep_angle. + sweep_angle : int, float, optional + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. If not given, the sweep angle is automatically + calculated, in which case the fin is assumed to be a right trapezoid + with its base perpendicular to the rocket's axis. + Cannot be used in conjunction with sweep_length. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of fin set. + + Returns + ------- + None + """ + + super().__init__( + n, + root_chord, + span, + rocket_radius, + cant_angle, + airfoil, + name, + ) + + # Check if sweep angle or sweep length is given + if sweep_length is not None and sweep_angle is not None: + raise ValueError("Cannot use sweep_length and sweep_angle together") + elif sweep_angle is not None: + sweep_length = np.tan(sweep_angle * np.pi / 180) * span + elif sweep_length is None: + sweep_length = root_chord - tip_chord + else: + # Sweep length is given + pass + + self._tip_chord = tip_chord + self._sweep_length = sweep_length + self._sweep_angle = sweep_angle + + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + self.prints = _TrapezoidalFinsPrints(self) + self.plots = _TrapezoidalFinsPlots(self) + + @property + def tip_chord(self): + return self._tip_chord + + @tip_chord.setter + def tip_chord(self, value): + self._tip_chord = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def sweep_angle(self): + return self._sweep_angle + + @sweep_angle.setter + def sweep_angle(self, value): + self._sweep_angle = value + self._sweep_length = np.tan(value * np.pi / 180) * self.span + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def sweep_length(self): + return self._sweep_length + + @sweep_length.setter + def sweep_length(self, value): + self._sweep_length = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the fin set in local + coordinates. The center of pressure position is saved and stored as a + tuple. + + Returns + ------- + None + """ + # Center of pressure position in local coordinates + cpz = (self.sweep_length / 3) * ( + (self.root_chord + 2 * self.tip_chord) / (self.root_chord + self.tip_chord) + ) + (1 / 6) * ( + self.root_chord + + self.tip_chord + - self.root_chord * self.tip_chord / (self.root_chord + self.tip_chord) + ) + self.cpx = 0 + self.cpy = 0 + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements + """Calculates and saves fin set's geometrical parameters such as the + fins' area, aspect ratio and parameters for roll movement. + + Returns + ------- + None + """ + # pylint: disable=invalid-name + Yr = self.root_chord + self.tip_chord + Af = Yr * self.span / 2 # Fin area + AR = 2 * self.span**2 / Af # Fin aspect ratio + gamma_c = np.arctan( + (self.sweep_length + 0.5 * self.tip_chord - 0.5 * self.root_chord) + / (self.span) + ) + Yma = ( + (self.span / 3) * (self.root_chord + 2 * self.tip_chord) / Yr + ) # Span wise coord of mean aero chord + + # Fin–body interference correction parameters + tau = (self.span + self.rocket_radius) / self.rocket_radius + lift_interference_factor = 1 + 1 / tau + lambda_ = self.tip_chord / self.root_chord + + # Parameters for Roll Moment. + # Documented at: https://docs.rocketpy.org/en/latest/technical/ + roll_geometrical_constant = ( + (self.root_chord + 3 * self.tip_chord) * self.span**3 + + 4 + * (self.root_chord + 2 * self.tip_chord) + * self.rocket_radius + * self.span**2 + + 6 * (self.root_chord + self.tip_chord) * self.span * self.rocket_radius**2 + ) / 12 + roll_damping_interference_factor = 1 + ( + ((tau - lambda_) / (tau)) - ((1 - lambda_) / (tau - 1)) * np.log(tau) + ) / ( + ((tau + 1) * (tau - lambda_)) / (2) + - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1)) + ) + roll_forcing_interference_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2) + / (tau**2 * (tau - 1) ** 2) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) + / (tau * (tau - 1)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + # Store values + self.Yr = Yr + self.Af = Af # Fin area + self.AR = AR # Aspect Ratio + self.gamma_c = gamma_c # Mid chord angle + self.Yma = Yma # Span wise coord of mean aero chord + self.roll_geometrical_constant = roll_geometrical_constant + self.tau = tau + self.lift_interference_factor = lift_interference_factor + self.λ = lambda_ # pylint: disable=non-ascii-name + self.roll_damping_interference_factor = roll_damping_interference_factor + self.roll_forcing_interference_factor = roll_forcing_interference_factor + + self.evaluate_shape() + + def evaluate_shape(self): + if self.sweep_length: + points = [ + (0, 0), + (self.sweep_length, self.span), + (self.sweep_length + self.tip_chord, self.span), + (self.root_chord, 0), + ] + else: + points = [ + (0, 0), + (self.root_chord - self.tip_chord, self.span), + (self.root_chord, self.span), + (self.root_chord, 0), + ] + + x_array, y_array = zip(*points) + self.shape_vec = [np.array(x_array), np.array(y_array)] + + def info(self): + self.prints.geometry() + self.prints.lift() + + def all_info(self): + self.prints.all() + self.plots.all() diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py new file mode 100644 index 000000000..8886be8c5 --- /dev/null +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -0,0 +1,521 @@ +import warnings + +import numpy as np +from scipy.optimize import fsolve + +from rocketpy.mathutils.function import Function +from rocketpy.plots.aero_surface_plots import _NoseConePlots +from rocketpy.prints.aero_surface_prints import _NoseConePrints + +from .aero_surface import AeroSurface + + +class NoseCone(AeroSurface): + """Keeps nose cone information. + + Note + ---- + The local coordinate system has the origin at the tip of the nose cone + and the Z axis along the longitudinal axis of symmetry, positive + downwards (top -> bottom). + + Attributes + ---------- + NoseCone.length : float + Nose cone length. Has units of length and must be given in meters. + NoseCone.kind : string + Nose cone kind. Can be "conical", "ogive", "elliptical", "tangent", + "von karman", "parabolic", "powerseries" or "lvhaack". + NoseCone.bluffness : float + Ratio between the radius of the circle on the tip of the ogive and the + radius of the base of the ogive. Currently only used for the nose cone's + drawing. Must be between 0 and 1. Default is None, which means that the + nose cone will not have a sphere on the tip. If a value is given, the + nose cone's length will be slightly reduced because of the addition of + the sphere. Must be None or 0 if a "powerseries" nose cone kind is + specified. + NoseCone.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, + in meters. + NoseCone.base_radius : float + Nose cone base radius. Has units of length and must be given in meters. + NoseCone.radius_ratio : float + Ratio between the nose cone base radius and the rocket radius. Has no + units. If base radius is not given, the ratio between base radius and + rocket radius is assumed as 1, meaning that the nose cone has the same + radius as the rocket. If base radius is given, the ratio between base + radius and rocket radius is calculated and used for lift calculation. + NoseCone.power : float + Factor that controls the bluntness of the shape for a power series + nose cone. Must be between 0 and 1. It is ignored when other nose + cone types are used. + NoseCone.name : string + Nose cone name. Has no impact in simulation, as it is only used to + display data in a more organized matter. + NoseCone.cp : tuple + Tuple with the x, y and z local coordinates of the nose cone center of + pressure. Has units of length and is given in meters. + NoseCone.cpx : float + Nose cone local center of pressure x coordinate. Has units of length and + is given in meters. + NoseCone.cpy : float + Nose cone local center of pressure y coordinate. Has units of length and + is given in meters. + NoseCone.cpz : float + 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. + NoseCone.clalpha : float + Lift 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. + NoseCone.prints : prints.aero_surface_prints._NoseConePrints + This contains all the prints methods. Use help(NoseCone.prints) to know + more about it. + """ + + def __init__( # pylint: disable=too-many-statements + self, + length, + kind, + base_radius=None, + bluffness=None, + rocket_radius=None, + power=None, + name="Nose Cone", + ): + """Initializes the nose cone. It is used to define the nose cone + length, kind, center of pressure and lift coefficient curve. + + Parameters + ---------- + length : float + Nose cone length. Has units of length and must be given in meters. + kind : string + Nose cone kind. Can be "conical", "ogive", "elliptical", "tangent", + "von karman", "parabolic", "powerseries" or "lvhaack". If + "powerseries" is used, the "power" argument must be assigned to a + value between 0 and 1. + base_radius : float, optional + Nose cone base radius. Has units of length and must be given in + meters. If not given, the ratio between ``base_radius`` and + ``rocket_radius`` will be assumed as 1. + bluffness : float, optional + Ratio between the radius of the circle on the tip of the ogive and + the radius of the base of the ogive. Currently only used for the + nose cone's drawing. Must be between 0 and 1. Default is None, which + means that the nose cone will not have a sphere on the tip. If a + value is given, the nose cone's length will be reduced to account + for the addition of the sphere at the tip. Must be None or 0 if a + "powerseries" nose cone kind is specified. + rocket_radius : int, float, optional + The reference rocket radius used for lift coefficient normalization. + If not given, the ratio between ``base_radius`` and + ``rocket_radius`` will be assumed as 1. + power : float, optional + Factor that controls the bluntness of the shape for a power series + nose cone. Must be between 0 and 1. It is ignored when other nose + cone types are used. + name : str, optional + Nose cone name. Has no impact in simulation, as it is only used to + display data in a more organized matter. + + Returns + ------- + None + """ + rocket_radius = rocket_radius or base_radius + super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + + self._rocket_radius = rocket_radius + self._base_radius = base_radius + self._length = length + if bluffness is not None: + if bluffness > 1 or bluffness < 0: + raise ValueError( + f"Bluffness ratio of {bluffness} is out of range. " + "It must be between 0 and 1." + ) + self._bluffness = bluffness + if kind == "powerseries": + # Checks if bluffness is not being used + if (self.bluffness is not None) and (self.bluffness != 0): + raise ValueError( + "Parameter 'bluffness' must be None or 0 when using a nose cone kind 'powerseries'." + ) + + if power is None: + raise ValueError( + "Parameter 'power' cannot be None when using a nose cone kind 'powerseries'." + ) + + if power > 1 or power <= 0: + raise ValueError( + f"Power value of {power} is out of range. It must be between 0 and 1." + ) + self._power = power + self.kind = kind + + self.evaluate_lift_coefficient() + self.evaluate_center_of_pressure() + + self.plots = _NoseConePlots(self) + self.prints = _NoseConePrints(self) + + @property + def rocket_radius(self): + return self._rocket_radius + + @rocket_radius.setter + def rocket_radius(self, value): + self._rocket_radius = value + self.evaluate_geometrical_parameters() + self.evaluate_lift_coefficient() + self.evaluate_nose_shape() + + @property + def base_radius(self): + return self._base_radius + + @base_radius.setter + def base_radius(self, value): + self._base_radius = value + self.evaluate_geometrical_parameters() + self.evaluate_lift_coefficient() + self.evaluate_nose_shape() + + @property + def length(self): + return self._length + + @length.setter + def length(self, value): + self._length = value + self.evaluate_center_of_pressure() + self.evaluate_nose_shape() + + @property + def power(self): + return self._power + + @power.setter + def power(self, value): + if value is not None: + if value > 1 or value <= 0: + raise ValueError( + f"Power value of {value} is out of range. It must be between 0 and 1." + ) + self._power = value + self.evaluate_k() + self.evaluate_center_of_pressure() + self.evaluate_nose_shape() + + @property + def kind(self): + return self._kind + + @kind.setter + def kind(self, value): # pylint: disable=too-many-statements + # Analyzes nosecone type + # Sets the k for Cp calculation + # Sets the function which creates the respective curve + self._kind = value + value = (value.replace(" ", "")).lower() + + if value == "conical": + self.k = 2 / 3 + self.y_nosecone = Function(lambda x: x * self.base_radius / self.length) + + elif value == "lvhaack": + self.k = 0.563 + + def theta(x): + return np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) + + self.y_nosecone = Function( + lambda x: self.base_radius + * (theta(x) - np.sin(2 * theta(x)) / 2 + (np.sin(theta(x)) ** 3) / 3) + ** (0.5) + / (np.pi**0.5) + ) + + elif value in ["tangent", "tangentogive", "ogive"]: + rho = (self.base_radius**2 + self.length**2) / (2 * self.base_radius) + volume = np.pi * ( + self.length * rho**2 + - (self.length**3) / 3 + - (rho - self.base_radius) * rho**2 * np.arcsin(self.length / rho) + ) + area = np.pi * self.base_radius**2 + self.k = 1 - volume / (area * self.length) + self.y_nosecone = Function( + lambda x: np.sqrt(rho**2 - (min(x - self.length, 0)) ** 2) + + (self.base_radius - rho) + ) + + elif value == "elliptical": + self.k = 1 / 3 + self.y_nosecone = Function( + lambda x: self.base_radius + * np.sqrt(1 - ((x - self.length) / self.length) ** 2) + ) + + elif value == "vonkarman": + self.k = 0.5 + + def theta(x): + return np.arccos(1 - 2 * max(min(x / self.length, 1), 0)) + + self.y_nosecone = Function( + lambda x: self.base_radius + * (theta(x) - np.sin(2 * theta(x)) / 2) ** (0.5) + / (np.pi**0.5) + ) + elif value == "parabolic": + self.k = 0.5 + self.y_nosecone = Function( + lambda x: self.base_radius + * ((2 * x / self.length - (x / self.length) ** 2) / (2 - 1)) + ) + elif value == "powerseries": + self.k = (2 * self.power) / ((2 * self.power) + 1) + self.y_nosecone = Function( + lambda x: self.base_radius * np.power(x / self.length, self.power) + ) + else: + raise ValueError( + f"Nose Cone kind '{self.kind}' not found, " + + "please use one of the following Nose Cone kinds:" + + '\n\t"conical"' + + '\n\t"ogive"' + + '\n\t"lvhaack"' + + '\n\t"tangent"' + + '\n\t"vonkarman"' + + '\n\t"elliptical"' + + '\n\t"powerseries"' + + '\n\t"parabolic"\n' + ) + + self.evaluate_center_of_pressure() + self.evaluate_geometrical_parameters() + self.evaluate_nose_shape() + + @property + def bluffness(self): + return self._bluffness + + @bluffness.setter + def bluffness(self, value): + # prevents from setting bluffness on "powerseries" nose cones + if self.kind == "powerseries": + # Checks if bluffness is not being used + if (value is not None) and (value != 0): + raise ValueError( + "Parameter 'bluffness' must be None or 0 when using a nose cone kind 'powerseries'." + ) + if value is not None: + if value > 1 or value < 0: + raise ValueError( + f"Bluffness ratio of {value} is out of range. " + "It must be between 0 and 1." + ) + self._bluffness = value + self.evaluate_nose_shape() + + def evaluate_geometrical_parameters(self): + """Calculates and saves nose cone's radius ratio. + + Returns + ------- + None + """ + + # If base radius is not given, the ratio between base radius and + # rocket radius is assumed as 1, meaning that the nose cone has the + # same radius as the rocket + if self.base_radius is None and self.rocket_radius is not None: + self.radius_ratio = 1 + self.base_radius = self.rocket_radius + elif self.base_radius is not None and self.rocket_radius is None: + self.radius_ratio = 1 + self.rocket_radius = self.base_radius + # If base radius is given, the ratio between base radius and rocket + # radius is calculated + elif self.base_radius is not None and self.rocket_radius is not None: + self.radius_ratio = self.base_radius / self.rocket_radius + else: + raise ValueError( + "Either base radius or rocket radius must be given to " + "calculate the nose cone radius ratio." + ) + + self.fineness_ratio = self.length / (2 * self.base_radius) + + def evaluate_nose_shape(self): # pylint: disable=too-many-statements + """Calculates and saves nose cone's shape as lists and re-evaluates the + nose cone's length for a given bluffness ratio. The shape is saved as + two vectors, one for the x coordinates and one for the y coordinates. + + Returns + ------- + None + """ + number_of_points = 127 + density_modifier = 3 # increase density of points to improve accuracy + + def find_x_intercept(x): + # find the tangential intersection point between the circle and nosec curve + return x + self.y_nosecone(x) * self.y_nosecone.differentiate_complex_step( + x + ) + + # Calculate a function to find the radius of the nosecone curve + def find_radius(x): + return (self.y_nosecone(x) ** 2 + (x - find_x_intercept(x)) ** 2) ** 0.5 + + # Check bluffness circle and choose whether to use it or not + if self.bluffness is None or self.bluffness == 0: + # Set up parameters to continue without bluffness + r_circle, circle_center, x_init = 0, 0, 0 + else: + # Calculate circle radius + r_circle = self.bluffness * self.base_radius + if self.kind == "elliptical": + + def test_circle(x): + # set up a circle at the starting position to test bluffness + return np.sqrt(r_circle**2 - (x - r_circle) ** 2) + + # Check if bluffness circle is too small + if test_circle(1e-03) <= self.y_nosecone(1e-03): + # Raise a warning + warnings.warn( + "WARNING: The chosen bluffness ratio is too small for " + "the selected nose cone category, thereby the effective " + "bluffness will be 0." + ) + # Set up parameters to continue without bluffness + r_circle, circle_center, x_init = 0, 0, 0 + else: + # Find the intersection point between circle and nosecone curve + x_init = fsolve(lambda x: find_radius(x[0]) - r_circle, r_circle)[0] + circle_center = find_x_intercept(x_init) + else: + # Find the intersection point between circle and nosecone curve + x_init = fsolve(lambda x: find_radius(x[0]) - r_circle, r_circle)[0] + circle_center = find_x_intercept(x_init) + + # Calculate a function to create the circle at the correct position + def create_circle(x): + return abs(r_circle**2 - (x - circle_center) ** 2) ** 0.5 + + # Define a function for the final shape of the curve with a circle at the tip + def final_shape(x): + return self.y_nosecone(x) if x >= x_init else create_circle(x) + + # Vectorize the final_shape function + final_shape_vec = np.vectorize(final_shape) + + # Create the vectors X and Y with the points of the curve + nosecone_x = (self.length - (circle_center - r_circle)) * ( + np.linspace(0, 1, number_of_points) ** density_modifier + ) + nosecone_y = final_shape_vec(nosecone_x + (circle_center - r_circle)) + + # Evaluate final geometry parameters + self.shape_vec = [nosecone_x, nosecone_y] + if abs(nosecone_x[-1] - self.length) >= 0.001: # 1 millimeter + self._length = nosecone_x[-1] + print( + "Due to the chosen bluffness ratio, the nose " + f"cone length was reduced to {self.length} m." + ) + self.fineness_ratio = self.length / (2 * self.base_radius) + + def evaluate_lift_coefficient(self): + """Calculates and returns nose cone's lift coefficient. + The lift coefficient is saved and returned. This function + also calculates and saves its lift coefficient derivative. + + Returns + ------- + None + """ + # Calculate clalpha + # clalpha is currently a constant, meaning it is independent of Mach + # number. This is only valid for subsonic speeds. + # It must be set as a Function because it will be called and treated + # as a function of mach in the simulation. + self.clalpha = Function( + lambda mach: 2 / self._beta(mach) * 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", + ) + + def evaluate_k(self): + """Updates the self.k attribute used to compute the center of + pressure when using "powerseries" nose cones. + + Returns + ------- + None + """ + if self.kind == "powerseries": + self.k = (2 * self.power) / ((2 * self.power) + 1) + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the nose cone in + local coordinates. The center of pressure position is saved and stored + as a tuple. Local coordinate origin is found at the tip of the nose + cone. + + Returns + ------- + self.cp : tuple + Tuple containing cpx, cpy, cpz. + """ + + self.cpz = self.k * self.length + self.cpy = 0 + self.cpx = 0 + self.cp = (self.cpx, self.cpy, self.cpz) + return self.cp + + def draw(self): + """Draw the fin shape along with some important information, including + the center line, the quarter line and the center of pressure position. + + Returns + ------- + None + """ + self.plots.draw() + + def info(self): + """Prints and plots summarized information of the nose cone. + + Return + ------ + None + """ + self.prints.geometry() + self.prints.lift() + + def all_info(self): + """Prints and plots all the available information of the nose cone. + + Returns + ------- + None + """ + self.prints.all() + self.plots.all() diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py new file mode 100644 index 000000000..6ffafbb97 --- /dev/null +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -0,0 +1,106 @@ +from rocketpy.mathutils.function import Function +from rocketpy.prints.aero_surface_prints import _RailButtonsPrints + +from .aero_surface import AeroSurface + + +class RailButtons(AeroSurface): + """Class that defines a rail button pair or group. + + Attributes + ---------- + RailButtons.buttons_distance : int, float + Distance between the two rail buttons closest to the nozzle. + RailButtons.angular_position : int, float + Angular position of the rail buttons in degrees measured + as the rotation around the symmetry axis of the rocket + relative to one of the other principal axis. + """ + + def __init__(self, buttons_distance, angular_position=45, name="Rail Buttons"): + """Initializes RailButtons Class. + + Parameters + ---------- + buttons_distance : int, float + Distance between the first and the last rail button in meters. + angular_position : int, float, optional + Angular position of the rail buttons in degrees measured + as the rotation around the symmetry axis of the rocket + relative to one of the other principal axis. + name : string, optional + Name of the rail buttons. Default is "Rail Buttons". + + Returns + ------- + None + + """ + super().__init__(name, None, None) + self.buttons_distance = buttons_distance + self.angular_position = angular_position + self.name = name + + self.evaluate_lift_coefficient() + self.evaluate_center_of_pressure() + + self.prints = _RailButtonsPrints(self) + + 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 info(self): + """Prints out all the information about the Rail Buttons. + + Returns + ------- + None + """ + self.prints.geometry() + + def all_info(self): + """Returns all info of the Rail Buttons. + + Returns + ------- + None + """ + self.prints.all() diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py new file mode 100644 index 000000000..a5c5cf939 --- /dev/null +++ b/rocketpy/rocket/aero_surface/tail.py @@ -0,0 +1,211 @@ +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.plots.aero_surface_plots import _TailPlots +from rocketpy.prints.aero_surface_prints import _TailPrints + +from .aero_surface import AeroSurface + + +class Tail(AeroSurface): + """Class that defines a tail. Currently only accepts conical tails. + + Note + ---- + Local coordinate system: Z axis along the longitudinal axis of symmetry, + positive downwards (top -> bottom). Origin located at top of the tail + (generally the portion closest to the rocket's nose). + + Attributes + ---------- + Tail.top_radius : int, float + Radius of the top of the tail. The top radius is defined as the radius + of the transversal section that is closest to the rocket's nose. + Tail.bottom_radius : int, float + Radius of the bottom of the tail. + Tail.length : int, float + Length of the tail. The length is defined as the distance between the + top and bottom of the tail. The length is measured along the rocket's + longitudinal axis. Has the unit of meters. + Tail.rocket_radius: int, float + The reference rocket radius used for lift coefficient normalization in + meters. + Tail.name : str + Name of the tail. Default is 'Tail'. + Tail.cpx : int, float + x local coordinate of the center of pressure of the tail. + Tail.cpy : int, float + y local coordinate of the center of pressure of the tail. + Tail.cpz : int, float + z local coordinate of the center of pressure of the tail. + 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. + Tail.clalpha : float + Lift 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 + along the tail's slant axis. Has the unit of meters. + Tail.surface_area : float + Surface area of the tail. Has the unit of meters squared. + """ + + def __init__(self, top_radius, bottom_radius, length, rocket_radius, name="Tail"): + """Initializes the tail object by computing and storing the most + important values. + + Parameters + ---------- + top_radius : int, float + Radius of the top of the tail. The top radius is defined as the + radius of the transversal section that is closest to the rocket's + nose. + bottom_radius : int, float + Radius of the bottom of the tail. + length : int, float + Length of the tail. + rocket_radius : int, float + The reference rocket radius used for lift coefficient normalization. + name : str + Name of the tail. Default is 'Tail'. + + Returns + ------- + None + """ + super().__init__(name, np.pi * rocket_radius**2, 2 * rocket_radius) + + self._top_radius = top_radius + self._bottom_radius = bottom_radius + self._length = length + self._rocket_radius = rocket_radius + + self.evaluate_geometrical_parameters() + self.evaluate_lift_coefficient() + self.evaluate_center_of_pressure() + + self.plots = _TailPlots(self) + self.prints = _TailPrints(self) + + @property + def top_radius(self): + return self._top_radius + + @top_radius.setter + def top_radius(self, value): + self._top_radius = value + self.evaluate_geometrical_parameters() + self.evaluate_lift_coefficient() + self.evaluate_center_of_pressure() + + @property + def bottom_radius(self): + return self._bottom_radius + + @bottom_radius.setter + def bottom_radius(self, value): + self._bottom_radius = value + self.evaluate_geometrical_parameters() + self.evaluate_lift_coefficient() + self.evaluate_center_of_pressure() + + @property + def length(self): + return self._length + + @length.setter + def length(self, value): + self._length = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + + @property + def rocket_radius(self): + return self._rocket_radius + + @rocket_radius.setter + def rocket_radius(self, value): + self._rocket_radius = value + self.evaluate_lift_coefficient() + + def evaluate_geometrical_parameters(self): + """Calculates and saves tail's slant length and surface area. + + Returns + ------- + None + """ + self.slant_length = np.sqrt( + (self.length) ** 2 + (self.top_radius - self.bottom_radius) ** 2 + ) + self.surface_area = ( + np.pi * self.slant_length * (self.top_radius + self.bottom_radius) + ) + self.evaluate_shape() + + def evaluate_shape(self): + # Assuming the tail is a cone, calculate the shape vector + self.shape_vec = [ + np.array([0, self.length]), + np.array([self.top_radius, self.bottom_radius]), + ] + + def evaluate_lift_coefficient(self): + """Calculates and returns tail's lift coefficient. + The lift coefficient is saved and returned. This function + also calculates and saves its lift coefficient derivative. + + Returns + ------- + None + """ + # Calculate clalpha + # clalpha is currently a constant, meaning it is independent of Mach + # number. This is only valid for subsonic speeds. + # It must be set as a Function because it will be called and treated + # as a function of mach in the simulation. + self.clalpha = Function( + lambda mach: 2 + / self._beta(mach) + * ( + (self.bottom_radius / self.rocket_radius) ** 2 + - (self.top_radius / self.rocket_radius) ** 2 + ), + "Mach", + f"Lift coefficient derivative for {self.name}", + ) + self.cl = Function( + lambda alpha, mach: self.clalpha(mach) * alpha, + ["Alpha (rad)", "Mach"], + "Cl", + ) + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the tail in local + coordinates. The center of pressure position is saved and stored as a + tuple. + + Returns + ------- + None + """ + # Calculate cp position in local coordinates + r = self.top_radius / self.bottom_radius + cpz = (self.length / 3) * (1 + (1 - r) / (1 - r**2)) + + # Store values as class attributes + self.cpx = 0 + self.cpy = 0 + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + def info(self): + self.prints.geometry() + self.prints.lift() + + def all_info(self): + self.prints.all() + self.plots.all() diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py index 3197db687..5132a315e 100644 --- a/rocketpy/rocket/components.py +++ b/rocketpy/rocket/components.py @@ -144,7 +144,7 @@ def remove(self, component): self._components.pop(index) break else: - raise Exception(f"Component {component} not found in components {self}") + raise ValueError(f"Component {component} not found in components {self}") def pop(self, index=-1): """Pop a component from the list of components. @@ -186,4 +186,3 @@ def sort_by_position(self, reverse=False): None """ self._components.sort(key=lambda x: x.position, reverse=reverse) - return None diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index f96e2a024..f42b5ff6a 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -188,21 +188,21 @@ def __evaluate_trigger_function(self, trigger): elif isinstance(trigger, (int, float)): # The parachute is deployed at a given height - def triggerfunc(p, h, y): + def triggerfunc(p, h, y): # pylint: disable=unused-argument # p = pressure considering parachute noise signal # h = height above ground level considering parachute noise signal # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] - return True if y[5] < 0 and h < trigger else False + return y[5] < 0 and h < trigger self.triggerfunc = triggerfunc elif trigger.lower() == "apogee": # The parachute is deployed at apogee - def triggerfunc(p, h, y): + def triggerfunc(p, h, y): # pylint: disable=unused-argument # p = pressure considering parachute noise signal # h = height above ground level considering parachute noise signal # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] - return True if y[5] < 0 else False + return y[5] < 0 self.triggerfunc = triggerfunc @@ -221,10 +221,7 @@ def __str__(self): string String representation of Parachute class. It is human readable. """ - return "Parachute {} with a cd_s of {:.4f} m2".format( - self.name.title(), - self.cd_s, - ) + return f"Parachute {self.name.title()} with a cd_s of {self.cd_s:.4f} m2" def __repr__(self): """Representation method for the class, useful when debugging.""" @@ -237,11 +234,7 @@ def info(self): """Prints information about the Parachute class.""" self.prints.all() - return None - def all_info(self): """Prints all information about the Parachute class.""" self.info() # self.plots.all() # Parachutes still doesn't have plots - - return None diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 461ea2029..d6dff9113 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -22,6 +22,7 @@ from rocketpy.tools import parallel_axis_theorem_from_com +# pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-instance-attributes class Rocket: """Keeps rocket information. @@ -193,7 +194,7 @@ class Rocket: Rocket's inertia tensor 23 component with unloaded motor,in kg*m^2. """ - def __init__( + def __init__( # pylint: disable=too-many-statements self, radius, mass, @@ -643,25 +644,25 @@ def evaluate_dry_inertias(self): motor_dry_mass = self.motor.dry_mass mass = self.mass - # Compute axes distances - noMCM_to_CDM = ( + # Compute axes distances (CDM: Center of Dry Mass) + center_of_mass_without_motor_to_CDM = ( self.center_of_mass_without_motor - self.center_of_dry_mass_position ) - motorCDM_to_CDM = ( + motor_center_of_dry_mass_to_CDM = ( self.motor_center_of_dry_mass_position - self.center_of_dry_mass_position ) # Compute dry inertias self.dry_I_11 = parallel_axis_theorem_from_com( - self.I_11_without_motor, mass, noMCM_to_CDM + self.I_11_without_motor, mass, center_of_mass_without_motor_to_CDM ) + parallel_axis_theorem_from_com( - self.motor.dry_I_11, motor_dry_mass, motorCDM_to_CDM + self.motor.dry_I_11, motor_dry_mass, motor_center_of_dry_mass_to_CDM ) self.dry_I_22 = parallel_axis_theorem_from_com( - self.I_22_without_motor, mass, noMCM_to_CDM + self.I_22_without_motor, mass, center_of_mass_without_motor_to_CDM ) + parallel_axis_theorem_from_com( - self.motor.dry_I_22, motor_dry_mass, motorCDM_to_CDM + self.motor.dry_I_22, motor_dry_mass, motor_center_of_dry_mass_to_CDM ) self.dry_I_33 = self.I_33_without_motor + self.motor.dry_I_33 @@ -871,7 +872,7 @@ def get_inertia_tensor_derivative_at_time(self, t): ] ) - def add_motor(self, motor, position): + def add_motor(self, motor, position): # pylint: disable=too-many-statements """Adds a motor to the rocket. Parameters @@ -890,11 +891,13 @@ def add_motor(self, motor, position): ------- None """ - if hasattr(self, "motor") and not isinstance(self.motor, EmptyMotor): - print( - "Only one motor per rocket is currently supported. " - + "Overwriting previous motor." - ) + if hasattr(self, "motor"): + # pylint: disable=access-member-before-definition + if not isinstance(self.motor, EmptyMotor): + print( + "Only one motor per rocket is currently supported. " + + "Overwriting previous motor." + ) self.motor = motor self.motor_position = position _ = self._csys * self.motor._csys @@ -1612,7 +1615,6 @@ def draw(self, vis_args=None): https://matplotlib.org/stable/gallery/color/named_colors """ self.plots.draw(vis_args) - return None def info(self): """Prints out a summary of the data and graphs available about diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 5ec2b581a..6b43b18c1 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines import math import warnings from copy import deepcopy @@ -22,7 +23,7 @@ ) -class Flight: +class Flight: # pylint: disable=too-many-public-methods """Keeps all flight information and has a method to simulate flight. Attributes @@ -485,7 +486,7 @@ class Flight: array. """ - def __init__( + def __init__( # pylint: disable=too-many-arguments,too-many-statements self, rocket, environment, @@ -498,7 +499,7 @@ def __init__( max_time_step=np.inf, min_time_step=0, rtol=1e-6, - atol=6 * [1e-3] + 4 * [1e-6] + 3 * [1e-3], + atol=None, time_overshoot=True, verbose=False, name="Flight", @@ -596,7 +597,7 @@ def __init__( self.max_time_step = max_time_step self.min_time_step = min_time_step self.rtol = rtol - self.atol = atol + self.atol = atol or 6 * [1e-3] + 4 * [1e-6] + 3 * [1e-3] self.initial_solution = initial_solution self.time_overshoot = time_overshoot self.terminate_on_apogee = terminate_on_apogee @@ -635,6 +636,7 @@ def __repr__(self): f"name= {self.name})>" ) + # pylint: disable=too-many-nested-blocks, too-many-branches, too-many-locals,too-many-statements def __simulate(self, verbose): """Simulate the flight trajectory.""" for phase_index, phase in self.time_iterator(self.flight_phases): @@ -714,7 +716,7 @@ def __simulate(self, verbose): ): # Remove parachute from flight parachutes self.parachutes.remove(parachute) - # Create flight phase for time after detection and before inflation + # Create phase for time after detection and before inflation # Must only be created if parachute has any lag i = 1 if parachute.lag != 0: @@ -771,25 +773,21 @@ def __simulate(self, verbose): self.solution[-1][3] -= self.env.elevation # Get points y0 = ( - sum([self.solution[-2][i] ** 2 for i in [1, 2, 3]]) + sum(self.solution[-2][i] ** 2 for i in [1, 2, 3]) - self.effective_1rl**2 ) yp0 = 2 * sum( - [ - self.solution[-2][i] * self.solution[-2][i + 3] - for i in [1, 2, 3] - ] + self.solution[-2][i] * self.solution[-2][i + 3] + for i in [1, 2, 3] ) t1 = self.solution[-1][0] - self.solution[-2][0] y1 = ( - sum([self.solution[-1][i] ** 2 for i in [1, 2, 3]]) + sum(self.solution[-1][i] ** 2 for i in [1, 2, 3]) - self.effective_1rl**2 ) yp1 = 2 * sum( - [ - self.solution[-1][i] * self.solution[-1][i + 3] - for i in [1, 2, 3] - ] + self.solution[-1][i] * self.solution[-1][i + 3] + for i in [1, 2, 3] ) # Put elevation back self.solution[-2][3] += self.env.elevation @@ -816,7 +814,7 @@ def __simulate(self, verbose): raise ValueError( "Multiple roots found when solving for rail exit time." ) - elif len(valid_t_root) == 0: + if len(valid_t_root) == 0: raise ValueError( "No valid roots found when solving for rail exit time." ) @@ -840,6 +838,7 @@ def __simulate(self, verbose): phase.solver.status = "finished" # Check for apogee event + # TODO: negative vz doesn't really mean apogee. Improve this. if len(self.apogee_state) == 1 and self.y_sol[5] < 0: # Assume linear vz(t) to detect when vz = 0 t0, vz0 = self.solution[-2][0], self.solution[-2][6] @@ -865,6 +864,10 @@ def __simulate(self, verbose): phase.time_nodes.flush_after(node_index) phase.time_nodes.add_node(self.t, [], []) phase.solver.status = "finished" + elif len(self.solution) > 2: + # adding the apogee state to solution increases accuracy + # we can only do this if the apogee is not the first state + self.solution.insert(-1, [t_root, *self.apogee_state]) # Check for impact event if self.y_sol[2] < self.env.elevation: # Check exactly when it happened using root finding @@ -955,7 +958,8 @@ def __simulate(self, verbose): ): # Remove parachute from flight parachutes self.parachutes.remove(parachute) - # Create flight phase for time after detection and before inflation + # Create phase for time after detection and + # before inflation # Must only be created if parachute has any lag i = 1 if parachute.lag != 0: @@ -1142,7 +1146,8 @@ def __init_controllers(self): if self.time_overshoot: self.time_overshoot = False warnings.warn( - "time_overshoot has been set to False due to the presence of controllers. " + "time_overshoot has been set to False due to the " + "presence of controllers. " ) # reset controllable object to initial state (only airbrakes for now) for air_brakes in self.rocket.air_brakes: @@ -1233,11 +1238,11 @@ def udot_rail1(self, t, u, post_processing=False): """ # Retrieve integration data - x, y, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u + _, _, z, vx, vy, vz, e0, e1, e2, e3, _, _, _ = u # Retrieve important quantities # Mass - M = self.rocket.total_mass.get_value_opt(t) + total_mass_at_t = self.rocket.total_mass.get_value_opt(t) # Get freestream speed free_stream_speed = ( @@ -1254,7 +1259,7 @@ def udot_rail1(self, t, u, post_processing=False): R3 = -0.5 * rho * (free_stream_speed**2) * self.rocket.area * (drag_coeff) # Calculate Linear acceleration - a3 = (R3 + thrust) / M - ( + a3 = (R3 + thrust) / total_mass_at_t - ( e0**2 - e1**2 - e2**2 + e3**2 ) * self.env.gravity.get_value_opt(z) if a3 > 0: @@ -1296,7 +1301,9 @@ def udot_rail2(self, t, u, post_processing=False): # Hey! We will finish this function later, now we just can use u_dot return self.u_dot_generalized(t, u, post_processing=post_processing) - def u_dot(self, t, u, post_processing=False): + def u_dot( + self, t, u, post_processing=False + ): # pylint: disable=too-many-locals,too-many-statements """Calculates derivative of u state vector with respect to time when rocket is flying in 6 DOF motion during ascent out of rail and descent without parachute. @@ -1320,7 +1327,7 @@ def u_dot(self, t, u, post_processing=False): """ # Retrieve integration data - x, y, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u + _, _, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u # Determine lift force and moment R1, R2, M1, M2, M3 = 0, 0, 0, 0, 0 # Determine current behavior @@ -1328,13 +1335,17 @@ def u_dot(self, t, u, post_processing=False): # Motor burning # Retrieve important motor quantities # Inertias - Tz = self.rocket.motor.I_33.get_value_opt(t) - Ti = self.rocket.motor.I_11.get_value_opt(t) - Tzdot = self.rocket.motor.I_33.differentiate(t, dx=1e-6) - Tidot = self.rocket.motor.I_11.differentiate(t, dx=1e-6) + motor_I_33_at_t = self.rocket.motor.I_33.get_value_opt(t) + motor_I_11_at_t = self.rocket.motor.I_11.get_value_opt(t) + motor_I_33_derivative_at_t = self.rocket.motor.I_33.differentiate( + t, dx=1e-6 + ) + motor_I_11_derivative_at_t = self.rocket.motor.I_11.differentiate( + t, dx=1e-6 + ) # Mass - Mtdot = self.rocket.motor.mass_flow_rate.get_value_opt(t) - Mt = self.rocket.motor.propellant_mass.get_value_opt(t) + mass_flow_rate_at_t = self.rocket.motor.mass_flow_rate.get_value_opt(t) + propellant_mass_at_t = self.rocket.motor.propellant_mass.get_value_opt(t) # Thrust thrust = self.rocket.motor.thrust.get_value_opt(t) # Off center moment @@ -1343,20 +1354,27 @@ def u_dot(self, t, u, post_processing=False): else: # Motor stopped # Inertias - Tz, Ti, Tzdot, Tidot = 0, 0, 0, 0 + ( + motor_I_33_at_t, + motor_I_11_at_t, + motor_I_33_derivative_at_t, + motor_I_11_derivative_at_t, + ) = (0, 0, 0, 0) # Mass - Mtdot, Mt = 0, 0 + mass_flow_rate_at_t, propellant_mass_at_t = 0, 0 # thrust thrust = 0 # Retrieve important quantities # Inertias - Rz = self.rocket.dry_I_33 - Ri = self.rocket.dry_I_11 + rocket_dry_I_33 = self.rocket.dry_I_33 + rocket_dry_I_11 = self.rocket.dry_I_11 # Mass - Mr = self.rocket.dry_mass - M = Mt + Mr - mu = (Mt * Mr) / (Mt + Mr) + rocket_dry_mass = self.rocket.dry_mass # already with motor's dry mass + total_mass_at_t = propellant_mass_at_t + rocket_dry_mass + mu = (propellant_mass_at_t * rocket_dry_mass) / ( + propellant_mass_at_t + rocket_dry_mass + ) # Geometry # b = -self.rocket.distance_rocket_propellant b = ( @@ -1368,7 +1386,7 @@ def u_dot(self, t, u, post_processing=False): ) c = self.rocket.nozzle_to_cdm a = self.rocket.com_to_cdm_function.get_value_opt(t) - rN = self.rocket.motor.nozzle_radius + nozzle_radius = self.rocket.motor.nozzle_radius # Prepare transformation matrix a11 = 1 - 2 * (e2**2 + e3**2) a12 = 2 * (e1 * e2 - e0 * e3) @@ -1427,8 +1445,8 @@ def u_dot(self, t, u, post_processing=False): comp_cp = ( position - self.rocket.center_of_dry_mass_position ) * self.rocket._csys - aero_surface.cpz - surface_radius = aero_surface.rocket_radius - reference_area = np.pi * surface_radius**2 + reference_area = aero_surface.reference_area + reference_length = aero_surface.reference_length # Component absolute velocity in body frame comp_vx_b = vx_b + comp_cp * omega2 comp_vy_b = vy_b - comp_cp * omega1 @@ -1475,23 +1493,22 @@ def u_dot(self, t, u, post_processing=False): # Calculates Roll Moment try: clf_delta, cld_omega, cant_angle_rad = aero_surface.roll_parameters - M3f = ( + M3_forcing = ( (1 / 2 * rho * free_stream_speed**2) * reference_area - * 2 - * surface_radius + * reference_length * clf_delta.get_value_opt(free_stream_mach) * cant_angle_rad ) - M3d = ( + M3_damping = ( (1 / 2 * rho * free_stream_speed) * reference_area - * (2 * surface_radius) ** 2 + * (reference_length) ** 2 * cld_omega.get_value_opt(free_stream_mach) * omega3 / 2 ) - M3 += M3f - M3d + M3 += M3_forcing - M3_damping except AttributeError: pass # Off center moment @@ -1502,26 +1519,61 @@ def u_dot(self, t, u, post_processing=False): alpha1 = ( M1 - ( - omega2 * omega3 * (Rz + Tz - Ri - Ti - mu * b**2) + omega2 + * omega3 + * ( + rocket_dry_I_33 + + motor_I_33_at_t + - rocket_dry_I_11 + - motor_I_11_at_t + - mu * b**2 + ) + omega1 * ( - (Tidot + Mtdot * (Mr - 1) * (b / M) ** 2) - - Mtdot * ((rN / 2) ** 2 + (c - b * mu / Mr) ** 2) + ( + motor_I_11_derivative_at_t + + mass_flow_rate_at_t + * (rocket_dry_mass - 1) + * (b / total_mass_at_t) ** 2 + ) + - mass_flow_rate_at_t + * ((nozzle_radius / 2) ** 2 + (c - b * mu / rocket_dry_mass) ** 2) ) ) - ) / (Ri + Ti + mu * b**2) + ) / (rocket_dry_I_11 + motor_I_11_at_t + mu * b**2) alpha2 = ( M2 - ( - omega1 * omega3 * (Ri + Ti + mu * b**2 - Rz - Tz) + omega1 + * omega3 + * ( + rocket_dry_I_11 + + motor_I_11_at_t + + mu * b**2 + - rocket_dry_I_33 + - motor_I_33_at_t + ) + omega2 * ( - (Tidot + Mtdot * (Mr - 1) * (b / M) ** 2) - - Mtdot * ((rN / 2) ** 2 + (c - b * mu / Mr) ** 2) + ( + motor_I_11_derivative_at_t + + mass_flow_rate_at_t + * (rocket_dry_mass - 1) + * (b / total_mass_at_t) ** 2 + ) + - mass_flow_rate_at_t + * ((nozzle_radius / 2) ** 2 + (c - b * mu / rocket_dry_mass) ** 2) ) ) - ) / (Ri + Ti + mu * b**2) - alpha3 = (M3 - omega3 * (Tzdot - Mtdot * (rN**2) / 2)) / (Rz + Tz) + ) / (rocket_dry_I_11 + motor_I_11_at_t + mu * b**2) + alpha3 = ( + M3 + - omega3 + * ( + motor_I_33_derivative_at_t + - mass_flow_rate_at_t * (nozzle_radius**2) / 2 + ) + ) / (rocket_dry_I_33 + motor_I_33_at_t) # Euler parameters derivative e0dot = 0.5 * (-omega1 * e1 - omega2 * e2 - omega3 * e3) e1dot = 0.5 * (omega1 * e0 + omega3 * e2 - omega2 * e3) @@ -1530,9 +1582,20 @@ def u_dot(self, t, u, post_processing=False): # Linear acceleration L = [ - (R1 - b * Mt * (omega2**2 + omega3**2) - 2 * c * Mtdot * omega2) / M, - (R2 + b * Mt * (alpha3 + omega1 * omega2) + 2 * c * Mtdot * omega1) / M, - (R3 - b * Mt * (alpha2 - omega1 * omega3) + thrust) / M, + ( + R1 + - b * propellant_mass_at_t * (omega2**2 + omega3**2) + - 2 * c * mass_flow_rate_at_t * omega2 + ) + / total_mass_at_t, + ( + R2 + + b * propellant_mass_at_t * (alpha3 + omega1 * omega2) + + 2 * c * mass_flow_rate_at_t * omega1 + ) + / total_mass_at_t, + (R3 - b * propellant_mass_at_t * (alpha2 - omega1 * omega3) + thrust) + / total_mass_at_t, ] ax, ay, az = np.dot(K, L) az -= self.env.gravity.get_value_opt(z) # Include gravity @@ -1561,7 +1624,9 @@ def u_dot(self, t, u, post_processing=False): return u_dot - def u_dot_generalized(self, t, u, post_processing=False): + def u_dot_generalized( + self, t, u, post_processing=False + ): # pylint: disable=too-many-locals,too-many-statements """Calculates derivative of u state vector with respect to time when the rocket is flying in 6 DOF motion in space and significant mass variation effects exist. Typical flight phases include powered ascent after launch @@ -1585,7 +1650,7 @@ def u_dot_generalized(self, t, u, post_processing=False): e0_dot, e1_dot, e2_dot, e3_dot, alpha1, alpha2, alpha3]. """ # Retrieve integration data - x, y, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u + _, _, z, vx, vy, vz, e0, e1, e2, e3, omega1, omega2, omega3 = u # Create necessary vectors # r = Vector([x, y, z]) # CDM position vector @@ -1609,13 +1674,13 @@ def u_dot_generalized(self, t, u, post_processing=False): ## Nozzle gyration tensor S_nozzle = self.rocket.nozzle_gyration_tensor ## Inertia tensor - I = self.rocket.get_inertia_tensor_at_time(t) + inertia_tensor = self.rocket.get_inertia_tensor_at_time(t) ## Inertia tensor time derivative in the body frame I_dot = self.rocket.get_inertia_tensor_derivative_at_time(t) # Calculate the Inertia tensor relative to CM H = (r_CM.cross_matrix @ -r_CM.cross_matrix) * total_mass - I_CM = I - H + I_CM = inertia_tensor - H # Prepare transformation matrices K = Matrix.transformation(e) @@ -1655,17 +1720,17 @@ def u_dot_generalized(self, t, u, post_processing=False): else: R3 += air_brakes_force # Get rocket velocity in body frame - vB = Kt @ v + velocity_in_body_frame = Kt @ v # Calculate lift and moment for each component of the rocket for aero_surface, position in self.rocket.aerodynamic_surfaces: comp_cpz = ( position - self.rocket.center_of_dry_mass_position ) * self.rocket._csys - aero_surface.cpz comp_cp = Vector([0, 0, comp_cpz]) - surface_radius = aero_surface.rocket_radius - reference_area = np.pi * surface_radius**2 + reference_area = aero_surface.reference_area + reference_length = aero_surface.reference_length # Component absolute velocity in body frame - comp_vb = vB + (w ^ comp_cp) + comp_vb = velocity_in_body_frame + (w ^ comp_cp) # Wind velocity at component altitude comp_z = z + (K @ comp_cp).z comp_wind_vx = self.env.wind_velocity_x.get_value_opt(comp_z) @@ -1704,23 +1769,22 @@ def u_dot_generalized(self, t, u, post_processing=False): # Calculates Roll Moment try: clf_delta, cld_omega, cant_angle_rad = aero_surface.roll_parameters - M3f = ( + M3_forcing = ( (1 / 2 * rho * comp_stream_speed**2) * reference_area - * 2 - * surface_radius + * reference_length * clf_delta.get_value_opt(comp_stream_mach) * cant_angle_rad ) - M3d = ( + M3_damping = ( (1 / 2 * rho * comp_stream_speed) * reference_area - * (2 * surface_radius) ** 2 + * (reference_length) ** 2 * cld_omega.get_value_opt(comp_stream_mach) * omega3 / 2 ) - M3 += M3f - M3d + M3 += M3_forcing - M3_damping except AttributeError: pass @@ -1736,7 +1800,10 @@ def u_dot_generalized(self, t, u, post_processing=False): ) M3 += self.rocket.cp_eccentricity_x * R2 - self.rocket.cp_eccentricity_y * R1 - weightB = Kt @ Vector([0, 0, -total_mass * self.env.gravity.get_value_opt(z)]) + weight_in_body_frame = Kt @ Vector( + [0, 0, -total_mass * self.env.gravity.get_value_opt(z)] + ) + T00 = total_mass * r_CM T03 = 2 * total_mass_dot * (r_NOZ - r_CM) - 2 * total_mass * r_CM_dot T04 = ( @@ -1747,9 +1814,20 @@ def u_dot_generalized(self, t, u, post_processing=False): ) T05 = total_mass_dot * S_nozzle - I_dot - T20 = ((w ^ T00) ^ w) + (w ^ T03) + T04 + weightB + Vector([R1, R2, R3]) + T20 = ( + ((w ^ T00) ^ w) + + (w ^ T03) + + T04 + + weight_in_body_frame + + Vector([R1, R2, R3]) + ) - T21 = ((I @ w) ^ w) + T05 @ w - (weightB ^ r_CM) + Vector([M1, M2, M3]) + T21 = ( + ((inertia_tensor @ w) ^ w) + + T05 @ w + - (weight_in_body_frame ^ r_CM) + + Vector([M1, M2, M3]) + ) # Angular velocity derivative w_dot = I_CM.inverse @ (T21 + (T20 ^ r_CM)) @@ -1835,11 +1913,11 @@ def u_dot_parachute(self, t, u, post_processing=False): free_stream_speed = (freestream_x**2 + freestream_y**2 + freestream_z**2) ** 0.5 # Determine drag force - pseudoD = -0.5 * rho * cd_s * free_stream_speed - # pseudoD = pseudoD - ka * rho * 4 * np.pi * (R**2) * Rdot - Dx = pseudoD * freestream_x - Dy = pseudoD * freestream_y - Dz = pseudoD * freestream_z + pseudo_drag = -0.5 * rho * cd_s * free_stream_speed + # pseudo_drag = pseudo_drag - ka * rho * 4 * np.pi * (R**2) * Rdot + Dx = pseudo_drag * freestream_x + Dy = pseudo_drag * freestream_y + Dz = pseudo_drag * freestream_z ax = Dx / (mp + ma) ay = Dy / (mp + ma) az = (Dz - 9.8 * mp) / (mp + ma) @@ -2460,12 +2538,13 @@ def potential_energy(self): """Potential energy as a Function of time in relation to sea level.""" # Constants - GM = 3.986004418e14 # TODO: this constant should come from Environment. + # TODO: this constant should come from Environment. + standard_gravitational_parameter = 3.986004418e14 # Redefine total_mass time grid to allow for efficient Function algebra total_mass = deepcopy(self.rocket.total_mass) total_mass.set_discrete_based_on_model(self.z) return ( - GM + standard_gravitational_parameter * total_mass * (1 / (self.z + self.env.earth_radius) - 1 / self.env.earth_radius) ) @@ -2861,6 +2940,7 @@ def post_process(self, interpolation="spline", extrapolation="natural"): ------- None """ + # pylint: disable=unused-argument # TODO: add a deprecation warning maybe? self.post_processed = True @@ -3020,7 +3100,7 @@ class attributes which are instances of the Function class. Usage # Loop through variables, get points and names (for the header) for variable in variables: - if variable in self.__dict__.keys(): + if variable in self.__dict__: variable_function = self.__dict__[variable] # Deal with decorated Flight methods else: @@ -3141,8 +3221,6 @@ def all_info(self): self.info() self.plots.all() - return None - def time_iterator(self, node_list): i = 0 while i < len(node_list) - 1: @@ -3212,14 +3290,16 @@ def add(self, flight_phase, index=None): # TODO: quite complex method return None warning_msg = ( ( - "Trying to add flight phase starting together with the one preceding it. ", - "This may be caused by multiple parachutes being triggered simultaneously.", + "Trying to add flight phase starting together with the " + "one preceding it. This may be caused by multiple " + "parachutes being triggered simultaneously." ) if flight_phase.t == previous_phase.t else ( - "Trying to add flight phase starting *before* the one *preceding* it. ", - "This may be caused by multiple parachutes being triggered simultaneously", - " or by having a negative parachute lag.", + "Trying to add flight phase starting *before* the one " + "*preceding* it. This may be caused by multiple " + "parachutes being triggered simultaneously " + "or by having a negative parachute lag.", ) ) self.display_warning(*warning_msg) @@ -3357,7 +3437,9 @@ class TimeNodes: TimeNodes object are instances of the TimeNode class. """ - def __init__(self, init_list=[]): + def __init__(self, init_list=None): + if not init_list: + init_list = [] self.list = init_list[:] def __getitem__(self, index): diff --git a/rocketpy/simulation/flight_data_importer.py b/rocketpy/simulation/flight_data_importer.py index 50be87388..b4498a1dc 100644 --- a/rocketpy/simulation/flight_data_importer.py +++ b/rocketpy/simulation/flight_data_importer.py @@ -2,7 +2,6 @@ and build a rocketpy.Flight object from it. """ -import warnings from os import listdir from os.path import isfile, join diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index 5bc8dcef9..cbe7b6734 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -2,14 +2,14 @@ Monte Carlo Simulation Module for RocketPy This module defines the `MonteCarlo` class, which is used to perform Monte Carlo -simulations of rocket flights. The Monte Carlo simulation is a powerful tool for -understanding the variability and uncertainty in the performance of rocket flights +simulations of rocket flights. The Monte Carlo simulation is a powerful tool for +understanding the variability and uncertainty in the performance of rocket flights by running multiple simulations with varied input parameters. Notes ----- -This module is still under active development, and some features or attributes may -change in future versions. Users are encouraged to check for updates and read the +This module is still under active development, and some features or attributes may +change in future versions. Users are encouraged to check for updates and read the latest documentation. """ @@ -79,7 +79,9 @@ class MonteCarlo: spent waiting for I/O operations or other processes to complete. """ - def __init__(self, filename, environment, rocket, flight, export_list=None): + def __init__( + self, filename, environment, rocket, flight, export_list=None + ): # pylint: disable=too-many-statements """ Initialize a MonteCarlo object. @@ -146,7 +148,10 @@ def __init__(self, filename, environment, rocket, flight, export_list=None): except FileNotFoundError: self._error_file = f"{filename}.errors.txt" - def simulate(self, number_of_simulations, append=False): + # pylint: disable=consider-using-with + def simulate( + self, number_of_simulations, append=False + ): # pylint: disable=too-many-statements """ Runs the Monte Carlo simulation and saves all data. @@ -392,10 +397,10 @@ def __check_export_list(self, export_list): "lateral_surface_wind", } ) - # NOTE: exportables needs to be updated with Flight numerical properties - # example: You added the property 'inclination' to Flight, so you may - # need to add it to exportables as well. But don't add other types. - exportables = set( + # NOTE: this list needs to be updated with Flight numerical properties + # example: You added the property 'inclination' to Flight. + # But don't add other types. + can_be_exported = set( { "inclination", "heading", @@ -451,7 +456,7 @@ def __check_export_list(self, export_list): raise TypeError("Variables in export_list must be strings.") # Checks if attribute is not valid - if attr not in exportables: + if attr not in can_be_exported: raise ValueError( f"Attribute '{attr}' can not be exported. Check export_list." ) @@ -760,12 +765,12 @@ def import_results(self, filename=None): # Export methods - def export_ellipses_to_kml( + def export_ellipses_to_kml( # pylint: disable=too-many-statements self, filename, origin_lat, origin_lon, - type="all", # TODO: Don't use "type" as a parameter name, it's a reserved word + type="all", # TODO: Don't use "type" as a parameter name, it's a reserved word # pylint: disable=redefined-builtin resolution=100, color="ff0000ff", ): @@ -814,12 +819,12 @@ def export_ellipses_to_kml( ) = generate_monte_carlo_ellipses(self.results) outputs = [] - if type == "all" or type == "impact": + if type in ["all", "impact"]: outputs = outputs + generate_monte_carlo_ellipses_coordinates( impact_ellipses, origin_lat, origin_lon, resolution=resolution ) - if type == "all" or type == "apogee": + if type in ["all", "apogee"]: outputs = outputs + generate_monte_carlo_ellipses_coordinates( apogee_ellipses, origin_lat, origin_lon, resolution=resolution ) diff --git a/rocketpy/stochastic/stochastic_environment.py b/rocketpy/stochastic/stochastic_environment.py index e36c19cf4..58afe0fed 100644 --- a/rocketpy/stochastic/stochastic_environment.py +++ b/rocketpy/stochastic/stochastic_environment.py @@ -178,11 +178,11 @@ def create_object(self): # special case for ensemble member # TODO: Generalize create_object() with a env.ensemble_member setter if key == "ensemble_member": - self.object.select_ensemble_member(value) + self.obj.select_ensemble_member(value) else: if "factor" in key: # get original attribute value and multiply by factor attribute_name = f"_{key.replace('_factor', '')}" value = getattr(self, attribute_name) * value - setattr(self.object, key, value) - return self.object + setattr(self.obj, key, value) + return self.obj diff --git a/rocketpy/stochastic/stochastic_flight.py b/rocketpy/stochastic/stochastic_flight.py index df1c31d45..38b7e761a 100644 --- a/rocketpy/stochastic/stochastic_flight.py +++ b/rocketpy/stochastic/stochastic_flight.py @@ -121,9 +121,9 @@ def create_object(self): generated_dict = next(self.dict_generator()) # TODO: maybe we should use generated_dict["rail_length"] instead return Flight( - environment=self.object.env, + environment=self.obj.env, rail_length=self._randomize_rail_length(), - rocket=self.object.rocket, + rocket=self.obj.rocket, inclination=generated_dict["inclination"], heading=generated_dict["heading"], initial_solution=self.initial_solution, diff --git a/rocketpy/stochastic/stochastic_generic_motor.py b/rocketpy/stochastic/stochastic_generic_motor.py index bf8b79aa1..558007e56 100644 --- a/rocketpy/stochastic/stochastic_generic_motor.py +++ b/rocketpy/stochastic/stochastic_generic_motor.py @@ -5,6 +5,7 @@ from .stochastic_motor_model import StochasticMotorModel +# pylint: disable=too-many-arguments class StochasticGenericMotor(StochasticMotorModel): """A Stochastic Generic Motor class that inherits from StochasticModel. diff --git a/rocketpy/stochastic/stochastic_model.py b/rocketpy/stochastic/stochastic_model.py index abb3472f2..02341a11d 100644 --- a/rocketpy/stochastic/stochastic_model.py +++ b/rocketpy/stochastic/stochastic_model.py @@ -40,13 +40,13 @@ class StochasticModel: "ensemble_member", ] - def __init__(self, object, **kwargs): + def __init__(self, obj, **kwargs): """ Initialize the StochasticModel class with validated input arguments. Parameters ---------- - object : object + obj : object The main object of the class. **kwargs : dict Dictionary of input arguments for the class. Valid argument types @@ -60,15 +60,14 @@ def __init__(self, object, **kwargs): AssertionError If the input arguments do not conform to the specified formats. """ - # TODO: don't use "object" as a variable name, it's a built-in function. - # We can simply change to "obj". Pylint W0622 - self.object = object + self.obj = obj self.last_rnd_dict = {} # TODO: This code block is too complex. Refactor it. for input_name, input_value in kwargs.items(): if input_name not in self.exception_list: + attr_value = None if input_value is not None: if "factor" in input_name: attr_value = self._validate_factors(input_name, input_value) @@ -84,13 +83,15 @@ def __init__(self, object, **kwargs): f"'{input_name}' must be a tuple, list, int, or float" ) else: - attr_value = [getattr(self.object, input_name)] + attr_value = [getattr(self.obj, input_name)] setattr(self, input_name, attr_value) def __repr__(self): return f"'{self.__class__.__name__}() object'" - def _validate_tuple(self, input_name, input_value, getattr=getattr): + def _validate_tuple( + self, input_name, input_value, getattr=getattr + ): # pylint: disable=redefined-builtin """ Validate tuple arguments. @@ -127,7 +128,9 @@ def _validate_tuple(self, input_name, input_value, getattr=getattr): if len(input_value) == 3: return self._validate_tuple_length_three(input_name, input_value, getattr) - def _validate_tuple_length_two(self, input_name, input_value, getattr=getattr): + def _validate_tuple_length_two( + self, input_name, input_value, getattr=getattr + ): # pylint: disable=redefined-builtin """ Validate tuples with length 2. @@ -161,7 +164,7 @@ def _validate_tuple_length_two(self, input_name, input_value, getattr=getattr): # function. In this case, the nominal value will be taken from the # object passed. dist_func = get_distribution(input_value[1]) - return (getattr(self.object, input_name), input_value[0], dist_func) + return (getattr(self.obj, input_name), input_value[0], dist_func) else: # if second item is an int or float, then it is assumed that the # first item is the nominal value and the second item is the @@ -169,7 +172,9 @@ def _validate_tuple_length_two(self, input_name, input_value, getattr=getattr): # "normal". return (input_value[0], input_value[1], get_distribution("normal")) - def _validate_tuple_length_three(self, input_name, input_value, getattr=getattr): + def _validate_tuple_length_three( + self, input_name, input_value, getattr=getattr + ): # pylint: disable=redefined-builtin,unused-argument """ Validate tuples with length 3. @@ -204,7 +209,9 @@ def _validate_tuple_length_three(self, input_name, input_value, getattr=getattr) dist_func = get_distribution(input_value[2]) return (input_value[0], input_value[1], dist_func) - def _validate_list(self, input_name, input_value, getattr=getattr): + def _validate_list( + self, input_name, input_value, getattr=getattr + ): # pylint: disable=redefined-builtin """ Validate list arguments. @@ -228,11 +235,13 @@ def _validate_list(self, input_name, input_value, getattr=getattr): If the input is not in a valid format. """ if not input_value: - return [getattr(self.object, input_name)] + return [getattr(self.obj, input_name)] else: return input_value - def _validate_scalar(self, input_name, input_value, getattr=getattr): + def _validate_scalar( + self, input_name, input_value, getattr=getattr + ): # pylint: disable=redefined-builtin """ Validate scalar arguments. If the input is a scalar, the nominal value will be taken from the object passed, and the standard deviation will be @@ -254,7 +263,7 @@ def _validate_scalar(self, input_name, input_value, getattr=getattr): distribution function). """ return ( - getattr(self.object, input_name), + getattr(self.obj, input_name), input_value, get_distribution("normal"), ) @@ -281,7 +290,7 @@ def _validate_factors(self, input_name, input_value): If the input is not in a valid format. """ attribute_name = input_name.replace("_factor", "") - setattr(self, f"_{attribute_name}", getattr(self.object, attribute_name)) + setattr(self, f"_{attribute_name}", getattr(self.obj, attribute_name)) if isinstance(input_value, tuple): return self._validate_tuple_factor(input_name, input_value) @@ -469,6 +478,7 @@ def dict_generator(self): self.last_rnd_dict = generated_dict yield generated_dict + # pylint: disable=too-many-statements def visualize_attributes(self): """ This method prints a report of the attributes stored in the Stochastic diff --git a/rocketpy/stochastic/stochastic_motor_model.py b/rocketpy/stochastic/stochastic_motor_model.py index a99368ab3..12ea5391c 100644 --- a/rocketpy/stochastic/stochastic_motor_model.py +++ b/rocketpy/stochastic/stochastic_motor_model.py @@ -12,8 +12,8 @@ class makes a common ground for other stochastic motor classes. :ref:`stochastic_model` """ - def __init__(self, object, **kwargs): + def __init__(self, obj, **kwargs): self._validate_1d_array_like("thrust_source", kwargs.get("thrust_source")) # TODO: never vary the grain_number self._validate_positive_int_list("grain_number", kwargs.get("grain_number")) - super().__init__(object, **kwargs) + super().__init__(obj, **kwargs) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 19975a6db..714637d1f 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -41,7 +41,7 @@ class StochasticRocket(StochasticModel): Attributes ---------- - object : Rocket + obj : Rocket The Rocket object to be used as a base for the Stochastic rocket. motors : Components A Components instance containing all the motors of the rocket. @@ -144,7 +144,7 @@ def __init__( self._validate_1d_array_like("power_off_drag", power_off_drag) self._validate_1d_array_like("power_on_drag", power_on_drag) super().__init__( - object=rocket, + obj=rocket, radius=radius, mass=mass, I_11_without_motor=inertia_11, @@ -195,7 +195,7 @@ def add_motor(self, motor, position=None): motor = StochasticGenericMotor(generic_motor=motor) self.motors.add(motor, self._validate_position(motor, position)) - def _add_surfaces(self, surfaces, positions, type, stochastic_type, error_message): + def _add_surfaces(self, surfaces, positions, type_, stochastic_type, error_message): """Adds a stochastic aerodynamic surface to the stochastic rocket. If an aerodynamic surface is already present, it will be replaced. @@ -205,7 +205,7 @@ def _add_surfaces(self, surfaces, positions, type, stochastic_type, error_messag The aerodynamic surface to be added to the stochastic rocket. positions : tuple, list, int, float, optional The position of the aerodynamic surface. - type : type + type_ : type The type of the aerodynamic surface to be added to the stochastic rocket. stochastic_type : type @@ -215,9 +215,9 @@ def _add_surfaces(self, surfaces, positions, type, stochastic_type, error_messag The error message to be raised if the input is not of the correct type. """ - if not isinstance(surfaces, (type, stochastic_type)): + if not isinstance(surfaces, (type_, stochastic_type)): raise AssertionError(error_message) - if isinstance(surfaces, type): + if isinstance(surfaces, type_): surfaces = stochastic_type(component=surfaces) self.aerodynamic_surfaces.add( surfaces, self._validate_position(surfaces, positions) @@ -236,7 +236,7 @@ def add_nose(self, nose, position=None): self._add_surfaces( surfaces=nose, positions=position, - type=NoseCone, + type_=NoseCone, stochastic_type=StochasticNoseCone, error_message="`nose` must be of NoseCone or StochasticNoseCone type", ) @@ -403,12 +403,12 @@ def _create_get_position(self, validated_object): # try to get position from object error_msg = ( "`position` standard deviation was provided but the rocket does " - f"not have the same {validated_object.object.__class__.__name__} " + f"not have the same {validated_object.obj.__class__.__name__} " "to get the nominal position value from." ) # special case for motor stochastic model if isinstance(validated_object, (StochasticMotorModel)): - if isinstance(self.object.motor, EmptyMotor): + if isinstance(self.obj.motor, EmptyMotor): raise AssertionError(error_msg) def get_motor_position(self_object, _): @@ -420,12 +420,12 @@ def get_motor_position(self_object, _): def get_surface_position(self_object, _): surfaces = self_object.rail_buttons.get_tuple_by_type( - validated_object.object.__class__ + validated_object.obj.__class__ ) if len(surfaces) == 0: raise AssertionError(error_msg) for surface in surfaces: - if surface.component == validated_object.object: + if surface.component == validated_object.obj: return surface.position else: raise AssertionError(error_msg) @@ -434,12 +434,12 @@ def get_surface_position(self_object, _): def get_surface_position(self_object, _): surfaces = self_object.aerodynamic_surfaces.get_tuple_by_type( - validated_object.object.__class__ + validated_object.obj.__class__ ) if len(surfaces) == 0: raise AssertionError(error_msg) for surface in surfaces: - if surface.component == validated_object.object: + if surface.component == validated_object.obj: return surface.position else: raise AssertionError(error_msg) @@ -453,6 +453,7 @@ def _randomize_position(self, position): elif isinstance(position, list): return choice(position) if position else position + # pylint: disable=stop-iteration-return def dict_generator(self): """Special generator for the rocket class that yields a dictionary with the randomly generated input arguments. The dictionary is saved as an diff --git a/rocketpy/stochastic/stochastic_solid_motor.py b/rocketpy/stochastic/stochastic_solid_motor.py index 8550c1e11..8b05f252f 100644 --- a/rocketpy/stochastic/stochastic_solid_motor.py +++ b/rocketpy/stochastic/stochastic_solid_motor.py @@ -64,6 +64,7 @@ class StochasticSolidMotor(StochasticMotorModel): Radius of the throat in the motor in meters. """ + # pylint: disable=too-many-arguments def __init__( self, solid_motor, diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 730067cfd..19445e0ff 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -369,6 +369,7 @@ def inverted_haversine(lat0, lon0, distance, bearing, earth_radius=6.3781e6): # Functions for monte carlo analysis +# pylint: disable=too-many-statements def generate_monte_carlo_ellipses(results): """A function to create apogee and impact ellipses from the monte carlo analysis results. @@ -475,16 +476,16 @@ def create_ellipse_objects(x, y, n, w, h, theta, rgb): return ell_list # Calculate error ellipses for impact and apogee - impactTheta, impactW, impactH = calculate_ellipses(impact_x, impact_y) - apogeeTheta, apogeeW, apogeeH = calculate_ellipses(apogee_x, apogee_y) + impact_theta, impact_w, impact_h = calculate_ellipses(impact_x, impact_y) + apogee_theta, apogee_w, apogee_h = calculate_ellipses(apogee_x, apogee_y) # Draw error ellipses for impact impact_ellipses = create_ellipse_objects( - impact_x, impact_y, 3, impactW, impactH, impactTheta, (0, 0, 1, 0.2) + impact_x, impact_y, 3, impact_w, impact_h, impact_theta, (0, 0, 1, 0.2) ) apogee_ellipses = create_ellipse_objects( - apogee_x, apogee_y, 3, apogeeW, apogeeH, apogeeTheta, (0, 1, 0, 0.2) + apogee_x, apogee_y, 3, apogee_w, apogee_h, apogee_theta, (0, 1, 0, 0.2) ) return impact_ellipses, apogee_ellipses, apogee_x, apogee_y, impact_x, impact_y @@ -622,6 +623,36 @@ def time_num_to_date_string(time_num, units, timezone, calendar="gregorian"): return date_string, hour_string, date_time +def geopotential_height_to_geometric_height(geopotential_height, radius=63781370.0): + """Converts geopotential height to geometric height. + + Parameters + ---------- + geopotential_height : float + Geopotential height in meters. This vertical coordinate, referenced to + Earth's mean sea level, accounts for variations in gravity with altitude + and latitude. + radius : float, optional + The Earth's radius in meters, defaulting to 6378137.0. + + Returns + ------- + geometric_height : float + Geometric height in meters. + + Examples + -------- + >>> from rocketpy.tools import geopotential_height_to_geometric_height + >>> geopotential_height_to_geometric_height(0) + 0.0 + >>> geopotential_height_to_geometric_height(10000) + 10001.568101798659 + >>> geopotential_height_to_geometric_height(20000) + 20006.2733909262 + """ + return radius * geopotential_height / (radius - geopotential_height) + + def geopotential_to_height_asl(geopotential, radius=63781370, g=9.80665): """Compute height above sea level from geopotential. @@ -653,7 +684,7 @@ def geopotential_to_height_asl(geopotential, radius=63781370, g=9.80665): 20400.84750449947 """ geopotential_height = geopotential / g - return radius * geopotential_height / (radius - geopotential_height) + return geopotential_height_to_geometric_height(geopotential_height, radius) def geopotential_to_height_agl(geopotential, elevation, radius=63781370, g=9.80665): @@ -826,7 +857,7 @@ def wrapper(*args, **kwargs): for i in range(max_attempts): try: return func(*args, **kwargs) - except Exception as e: + except Exception as e: # pylint: disable=broad-except if i == max_attempts - 1: raise e from None delay = min(delay * 2, max_delay) @@ -930,8 +961,8 @@ def quaternions_to_nutation(e1, e2): if __name__ == "__main__": import doctest - results = doctest.testmod() - if results.failed < 1: - print(f"All the {results.attempted} tests passed!") + res = doctest.testmod() + if res.failed < 1: + print(f"All the {res.attempted} tests passed!") else: - print(f"{results.failed} out of {results.attempted} tests failed.") + print(f"{res.failed} out of {res.attempted} tests failed.") diff --git a/rocketpy/utilities.py b/rocketpy/utilities.py index bcdf2f658..adb925eee 100644 --- a/rocketpy/utilities.py +++ b/rocketpy/utilities.py @@ -1,3 +1,4 @@ +import ast import inspect import traceback import warnings @@ -101,7 +102,7 @@ def calculate_equilibrium_altitude( """ final_sol = {} - if not v0 < 0: + if v0 >= 0: print("Please set a valid negative value for v0") return None @@ -125,9 +126,8 @@ def check_constant(f, eps): for i in range(len(f) - 2): if abs(f[i + 2] - f[i + 1]) < eps and abs(f[i + 1] - f[i]) < eps: return i - return None - if env == None: + if env is None: environment = Environment( latitude=0, longitude=0, @@ -200,6 +200,7 @@ def du(z, u): return altitude_function, velocity_function, final_sol +# pylint: disable=too-many-statements def fin_flutter_analysis( fin_thickness, shear_modulus, flight, see_prints=True, see_graphs=True ): @@ -230,15 +231,17 @@ def fin_flutter_analysis( None """ found_fin = False + surface_area = None + aspect_ratio = None + lambda_ = None # First, we need identify if there is at least one fin set in the rocket for aero_surface in flight.rocket.fins: if isinstance(aero_surface, TrapezoidalFins): - # s: surface area; ar: aspect ratio; la: lambda root_chord = aero_surface.root_chord - s = (aero_surface.tip_chord + root_chord) * aero_surface.span / 2 - ar = aero_surface.span * aero_surface.span / s - la = aero_surface.tip_chord / root_chord + surface_area = (aero_surface.tip_chord + root_chord) * aero_surface.span / 2 + aspect_ratio = aero_surface.span * aero_surface.span / surface_area + lambda_ = aero_surface.tip_chord / root_chord if not found_fin: found_fin = True else: @@ -250,14 +253,21 @@ def fin_flutter_analysis( # Calculate variables flutter_mach = _flutter_mach_number( - fin_thickness, shear_modulus, flight, root_chord, ar, la + fin_thickness, shear_modulus, flight, root_chord, aspect_ratio, lambda_ ) safety_factor = _flutter_safety_factor(flight, flutter_mach) # Prints and plots if see_prints: _flutter_prints( - fin_thickness, shear_modulus, s, ar, la, flutter_mach, safety_factor, flight + fin_thickness, + shear_modulus, + surface_area, + aspect_ratio, + lambda_, + flutter_mach, + safety_factor, + flight, ) if see_graphs: _flutter_plots(flight, flutter_mach, safety_factor) @@ -265,10 +275,12 @@ def fin_flutter_analysis( return flutter_mach, safety_factor -def _flutter_mach_number(fin_thickness, shear_modulus, flight, root_chord, ar, la): +def _flutter_mach_number( + fin_thickness, shear_modulus, flight, root_chord, aspect_ratio, lambda_ +): flutter_mach = ( - (shear_modulus * 2 * (ar + 2) * (fin_thickness / root_chord) ** 3) - / (1.337 * (ar**3) * (la + 1) * flight.pressure) + (shear_modulus * 2 * (aspect_ratio + 2) * (fin_thickness / root_chord) ** 3) + / (1.337 * (aspect_ratio**3) * (lambda_ + 1) * flight.pressure) ) ** 0.5 flutter_mach.set_title("Fin Flutter Mach Number") flutter_mach.set_outputs("Mach") @@ -351,9 +363,9 @@ def _flutter_plots(flight, flutter_mach, safety_factor): def _flutter_prints( fin_thickness, shear_modulus, - s, - ar, - la, + surface_area, + aspect_ratio, + lambda_, flutter_mach, safety_factor, flight, @@ -367,11 +379,11 @@ def _flutter_prints( The fin thickness, in meters shear_modulus : float Shear Modulus of fins' material, must be given in Pascal - s : float + surface_area : float Fin surface area, in squared meters - ar : float + aspect_ratio : float Fin aspect ratio - la : float + lambda_ : float Fin lambda, defined as the tip_chord / root_chord ratio flutter_mach : rocketpy.Function The Mach Number at which the fin flutter occurs, considering the @@ -399,9 +411,9 @@ def _flutter_prints( altitude_min_sf = flight.z(time_min_sf) - flight.env.elevation print("\nFin's parameters") - print(f"Surface area (S): {s:.4f} m2") - print(f"Aspect ratio (AR): {ar:.3f}") - print(f"tip_chord/root_chord ratio = \u03BB = {la:.3f}") + print(f"Surface area (S): {surface_area:.4f} m2") + print(f"Aspect ratio (AR): {aspect_ratio:.3f}") + print(f"tip_chord/root_chord ratio = \u03BB = {lambda_:.3f}") print(f"Fin Thickness: {fin_thickness:.5f} m") print(f"Shear Modulus (G): {shear_modulus:.3e} Pa") @@ -412,6 +424,7 @@ def _flutter_prints( print(f"Altitude of minimum Safety Factor: {altitude_min_sf:.3f} m (AGL)\n") +# TODO: deprecate and delete this function. Never used and now we have Monte Carlo. def create_dispersion_dictionary(filename): """Creates a dictionary with the rocket data provided by a .csv file. File should be organized in four columns: attribute_class, parameter_name, @@ -472,24 +485,24 @@ def create_dispersion_dictionary(filename): ) except ValueError: warnings.warn( - f"Error caught: the recommended delimiter is ';'. If using ',' " - + "instead, be aware that some resources might not work as " - + "expected if your data set contains lists where the items are " - + "separated by commas. Please consider changing the delimiter to " - + "';' if that is the case." + "Error caught: the recommended delimiter is ';'. If using ',' " + "instead, be aware that some resources might not work as " + "expected if your data set contains lists where the items are " + "separated by commas. Please consider changing the delimiter to " + "';' if that is the case." ) warnings.warn(traceback.format_exc()) file = np.genfromtxt( filename, usecols=(1, 2, 3), skip_header=1, delimiter=",", dtype=str ) - analysis_parameters = dict() + analysis_parameters = {} for row in file: if row[0] != "": if row[2] == "": try: analysis_parameters[row[0].strip()] = float(row[1]) except ValueError: - analysis_parameters[row[0].strip()] = eval(row[1]) + analysis_parameters[row[0].strip()] = ast.literal_eval(row[1]) else: try: analysis_parameters[row[0].strip()] = (float(row[1]), float(row[2])) @@ -652,7 +665,7 @@ def get_instance_attributes(instance): dictionary Dictionary with all attributes of the given instance. """ - attributes_dict = dict() + attributes_dict = {} members = inspect.getmembers(instance) for member in members: # Filter out methods and protected attributes diff --git a/tests/acceptance/test_bella_lui_rocket.py b/tests/acceptance/test_bella_lui_rocket.py index 0041074a8..42041bf42 100644 --- a/tests/acceptance/test_bella_lui_rocket.py +++ b/tests/acceptance/test_bella_lui_rocket.py @@ -4,6 +4,7 @@ # Importing libraries import matplotlib as mpl import numpy as np +from scipy.signal import savgol_filter from rocketpy import Environment, Flight, Function, Rocket, SolidMotor @@ -103,20 +104,20 @@ def test_bella_lui_rocket_data_asserts_acceptance(): ) BellaLui.set_rail_buttons(0.1, -0.5) BellaLui.add_motor(K828FJ, parameters.get("distance_rocket_nozzle")[0]) - NoseCone = BellaLui.add_nose( + BellaLui.add_nose( length=parameters.get("nose_length")[0], kind="tangent", position=parameters.get("nose_distance_to_cm")[0] + parameters.get("nose_length")[0], ) - fin_set = BellaLui.add_trapezoidal_fins( + BellaLui.add_trapezoidal_fins( 3, span=parameters.get("fin_span")[0], root_chord=parameters.get("fin_root_chord")[0], tip_chord=parameters.get("fin_tip_chord")[0], position=parameters.get("fin_distance_to_cm")[0], ) - tail = BellaLui.add_tail( + BellaLui.add_tail( top_radius=parameters.get("tail_top_radius")[0], bottom_radius=parameters.get("tail_bottom_radius")[0], length=parameters.get("tail_length")[0], @@ -130,7 +131,7 @@ def drogue_trigger(p, h, y): # activate drogue when vz < 0 m/s. return True if y[5] < 0 else False - Drogue = BellaLui.add_parachute( + BellaLui.add_parachute( "Drogue", cd_s=parameters.get("CdS_drogue")[0], trigger=drogue_trigger, @@ -213,7 +214,6 @@ def drogue_trigger(p, h, y): acceleration_rcp.append(test_flight.az(test_flight.t_final)) # Acceleration comparison (will not be used in our publication) - from scipy.signal import savgol_filter # Calculate the acceleration as a velocity derivative acceleration_kalt = [0] diff --git a/tests/acceptance/test_ndrt_2020_rocket.py b/tests/acceptance/test_ndrt_2020_rocket.py index a0b812f10..aa4e737d4 100644 --- a/tests/acceptance/test_ndrt_2020_rocket.py +++ b/tests/acceptance/test_ndrt_2020_rocket.py @@ -2,7 +2,7 @@ import pandas as pd from scipy.signal import savgol_filter -from rocketpy import Environment, Flight, Function, Rocket, SolidMotor +from rocketpy import Environment, Flight, Rocket, SolidMotor def test_ndrt_2020_rocket_data_asserts_acceptance(): @@ -17,11 +17,6 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(): # Drift: 2275 ft # Importing libraries - import numpy as np - import pandas as pd - from scipy.signal import savgol_filter - - from rocketpy import Environment, Flight, Function, Rocket, SolidMotor # Defining all parameters parameters = { @@ -69,19 +64,19 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(): } # Environment conditions - Env23 = Environment( + env = Environment( gravity=9.81, latitude=41.775447, longitude=-86.572467, date=(2020, 2, 23, 16), elevation=206, ) - Env23.set_atmospheric_model( + env.set_atmospheric_model( type="Reanalysis", file="tests/fixtures/acceptance/NDRT_2020/ndrt_2020_weather_data_ERA5.nc", dictionary="ECMWF", ) - Env23.max_expected_height = 2000 + env.max_expected_height = 2000 # motor information L1395 = SolidMotor( @@ -118,20 +113,20 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(): ) NDRT2020.set_rail_buttons(0.2, -0.5, 45) NDRT2020.add_motor(L1395, parameters.get("distance_rocket_nozzle")[0]) - nose_cone = NDRT2020.add_nose( + NDRT2020.add_nose( length=parameters.get("nose_length")[0], kind="tangent", position=parameters.get("nose_distance_to_cm")[0] + parameters.get("nose_length")[0], ) - fin_set = NDRT2020.add_trapezoidal_fins( + NDRT2020.add_trapezoidal_fins( 3, span=parameters.get("fin_span")[0], root_chord=parameters.get("fin_root_chord")[0], tip_chord=parameters.get("fin_tip_chord")[0], position=parameters.get("fin_distance_to_cm")[0], ) - transition = NDRT2020.add_tail( + NDRT2020.add_tail( top_radius=parameters.get("transition_top_radius")[0], bottom_radius=parameters.get("transition_bottom_radius")[0], length=parameters.get("transition_length")[0], @@ -139,19 +134,19 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(): ) # Parachute set-up - def drogue_trigger(p, h, y): + def drogue_trigger(p, h, y): # pylint: disable=unused-argument # p = pressure # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] # activate drogue when vz < 0 m/s. return True if y[5] < 0 else False - def main_trigger(p, h, y): + def main_trigger(p, h, y): # pylint: disable=unused-argument # p = pressure # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] # activate main when vz < 0 m/s and z < 167.64 m (AGL) or 550 ft (AGL) return True if y[5] < 0 and h < 167.64 else False - Drogue = NDRT2020.add_parachute( + NDRT2020.add_parachute( "Drogue", cd_s=parameters.get("cd_s_drogue")[0], trigger=drogue_trigger, @@ -159,7 +154,7 @@ def main_trigger(p, h, y): lag=parameters.get("lag_rec")[0], noise=(0, 8.3, 0.5), ) - Main = NDRT2020.add_parachute( + NDRT2020.add_parachute( "Main", cd_s=parameters.get("cd_s_main")[0], trigger=main_trigger, @@ -169,17 +164,19 @@ def main_trigger(p, h, y): ) # Flight - Flight23 = Flight( + rocketpy_flight = Flight( rocket=NDRT2020, - environment=Env23, + environment=env, rail_length=parameters.get("rail_length")[0], inclination=parameters.get("inclination")[0], heading=parameters.get("heading")[0], ) - df_ndrt_rocketpy = pd.DataFrame(Flight23.z[:, :], columns=["Time", "Altitude"]) - df_ndrt_rocketpy["Vertical Velocity"] = Flight23.vz[:, 1] - # df_ndrt_rocketpy["Vertical Acceleration"] = Flight23.az[:, 1] - df_ndrt_rocketpy["Altitude"] -= Env23.elevation + df_ndrt_rocketpy = pd.DataFrame( + rocketpy_flight.z[:, :], columns=["Time", "Altitude"] + ) + df_ndrt_rocketpy["Vertical Velocity"] = rocketpy_flight.vz[:, 1] + # df_ndrt_rocketpy["Vertical Acceleration"] = rocketpy_flight.az[:, 1] + df_ndrt_rocketpy["Altitude"] -= env.elevation # Reading data from the flightData (sensors: Raven) df_ndrt_raven = pd.read_csv( @@ -210,14 +207,14 @@ def main_trigger(p, h, y): apogee_time_measured = df_ndrt_raven.loc[ df_ndrt_raven[" Altitude (Ft-AGL)"].idxmax(), " Time (s)" ] - apogee_time_simulated = Flight23.apogee_time + apogee_time_simulated = rocketpy_flight.apogee_time assert ( abs(max(df_ndrt_raven[" Altitude (m-AGL)"]) - max(df_ndrt_rocketpy["Altitude"])) / max(df_ndrt_raven[" Altitude (m-AGL)"]) < 0.015 ) - assert (max(velocity_raven_filt) - Flight23.max_speed) / max( + assert (max(velocity_raven_filt) - rocketpy_flight.max_speed) / max( velocity_raven_filt ) < 0.06 assert ( diff --git a/tests/fixtures/environment/environment_fixtures.py b/tests/fixtures/environment/environment_fixtures.py index 312401d6c..a662d90e0 100644 --- a/tests/fixtures/environment/environment_fixtures.py +++ b/tests/fixtures/environment/environment_fixtures.py @@ -42,10 +42,27 @@ def example_spaceport_env(example_date_naive): datum="WGS84", ) spaceport_env.set_date(example_date_naive) - spaceport_env.height = 1425 return spaceport_env +@pytest.fixture +def example_euroc_env(example_date_naive): + """Environment class with location set to EuRoC launch site + + Returns + ------- + rocketpy.Environment + """ + euroc_env = Environment( + latitude=39.3897, + longitude=-8.28896388889, + elevation=100, + datum="WGS84", + ) + euroc_env.set_date(example_date_naive) + return euroc_env + + @pytest.fixture def env_analysis(): """Environment Analysis class with hardcoded parameters diff --git a/tests/fixtures/function/function_fixtures.py b/tests/fixtures/function/function_fixtures.py index 566e4d115..7cba3699e 100644 --- a/tests/fixtures/function/function_fixtures.py +++ b/tests/fixtures/function/function_fixtures.py @@ -98,7 +98,7 @@ def controller_function(): A controller function """ - def controller_function( + def controller_function( # pylint: disable=unused-argument time, sampling_rate, state, state_history, observed_variables, air_brakes ): z = state[2] @@ -134,7 +134,7 @@ def lambda_quad_func(): Function A lambda function based on a string. """ - func = lambda x: x**2 + func = lambda x: x**2 # pylint: disable=unnecessary-lambda-assignment return Function( source=func, ) diff --git a/tests/fixtures/motor/solid_motor_fixtures.py b/tests/fixtures/motor/solid_motor_fixtures.py index 587d5e970..eff7d65d5 100644 --- a/tests/fixtures/motor/solid_motor_fixtures.py +++ b/tests/fixtures/motor/solid_motor_fixtures.py @@ -117,3 +117,28 @@ def dimensionless_cesaroni_m1670(kg, m): # old name: dimensionless_motor coordinate_system_orientation="nozzle_to_combustion_chamber", ) return example_motor + + +@pytest.fixture +def dummy_empty_motor(): + # Create a motor with ZERO thrust and ZERO mass to keep the rocket's speed constant + # TODO: why don t we use these same values to create EmptyMotor class? + return SolidMotor( + thrust_source=1e-300, + burn_time=1e-10, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + center_of_dry_mass_position=0.317, + grains_center_of_mass_position=0.397, + grain_number=5, + grain_separation=5 / 1000, + grain_density=1e-300, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + nozzle_radius=33 / 1000, + throat_radius=11 / 1000, + nozzle_position=0, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) diff --git a/tests/fixtures/parachutes/parachute_fixtures.py b/tests/fixtures/parachutes/parachute_fixtures.py index 10dbd36d1..9723cda8e 100644 --- a/tests/fixtures/parachutes/parachute_fixtures.py +++ b/tests/fixtures/parachutes/parachute_fixtures.py @@ -13,9 +13,9 @@ def calisto_drogue_parachute_trigger(): The trigger for the drogue parachute of the Calisto rocket. """ - def drogue_trigger(p, h, y): + def drogue_trigger(p, h, y): # pylint: disable=unused-argument # activate drogue when vertical velocity is negative - return True if y[5] < 0 else False + return y[5] < 0 return drogue_trigger @@ -30,9 +30,9 @@ def calisto_main_parachute_trigger(): The trigger for the main parachute of the Calisto rocket. """ - def main_trigger(p, h, y): + def main_trigger(p, h, y): # pylint: disable=unused-argument # activate main when vertical velocity is <0 and altitude is below 800m - return True if y[5] < 0 and h < 800 else False + return y[5] < 0 and h < 800 return main_trigger diff --git a/tests/fixtures/rockets/rocket_fixtures.py b/tests/fixtures/rockets/rocket_fixtures.py index c89157b78..702506d06 100644 --- a/tests/fixtures/rockets/rocket_fixtures.py +++ b/tests/fixtures/rockets/rocket_fixtures.py @@ -136,7 +136,7 @@ def calisto_robust( calisto_nose_cone, calisto_tail, calisto_trapezoidal_fins, - calisto_rail_buttons, + calisto_rail_buttons, # pylint: disable=unused-argument calisto_main_chute, calisto_drogue_chute, ): @@ -335,8 +335,8 @@ def prometheus_cd_at_ma(mach): prometheus.set_rail_buttons(0.69, 0.21, 60) prometheus.add_motor(motor=generic_motor_cesaroni_M1520, position=0) - nose_cone = prometheus.add_nose(length=0.742, kind="Von Karman", position=2.229) - fin_set = prometheus.add_trapezoidal_fins( + prometheus.add_nose(length=0.742, kind="Von Karman", position=2.229) + prometheus.add_trapezoidal_fins( n=3, span=0.13, root_chord=0.268, @@ -344,12 +344,12 @@ def prometheus_cd_at_ma(mach): position=0.273, sweep_length=0.066, ) - drogue_chute = prometheus.add_parachute( + prometheus.add_parachute( "Drogue", cd_s=1.6 * np.pi * 0.3048**2, # Cd = 1.6, D_chute = 24 in trigger="apogee", ) - main_chute = prometheus.add_parachute( + prometheus.add_parachute( "Main", cd_s=2.2 * np.pi * 0.9144**2, # Cd = 2.2, D_chute = 72 in trigger=457.2, # 1500 ft diff --git a/tests/integration/test_environment.py b/tests/integration/test_environment.py index 9a6fce6dd..5495d2e03 100644 --- a/tests/integration/test_environment.py +++ b/tests/integration/test_environment.py @@ -1,18 +1,38 @@ import time -from datetime import datetime +from datetime import date, datetime, timezone from unittest.mock import patch -import numpy.ma as ma import pytest -from rocketpy import Environment + +@pytest.mark.parametrize( + "lat, lon, theoretical_elevation", + [ + (48.858844, 2.294351, 34), # The Eiffel Tower + (32.990254, -106.974998, 1401), # Spaceport America + ], +) +def test_set_elevation_open_elevation( + lat, lon, theoretical_elevation, example_plain_env +): + example_plain_env.set_location(lat, lon) + + # either successfully gets the elevation or raises RuntimeError + try: + example_plain_env.set_elevation(elevation="Open-Elevation") + assert example_plain_env.elevation == pytest.approx( + theoretical_elevation, abs=1 + ), "The Open-Elevation API returned an unexpected value for the elevation" + except RuntimeError: + pass # Ignore the error and pass the test -@pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_gfs_atmosphere(mock_show, example_spaceport_env): - """Tests the Forecast model with the GFS file. It does not test the values, - instead the test checks if the method runs without errors. +def test_era5_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + """Tests the Reanalysis model with the ERA5 file. It uses an example file + available in the data/weather folder of the RocketPy repository. Parameters ---------- @@ -21,62 +41,116 @@ def test_gfs_atmosphere(mock_show, example_spaceport_env): example_spaceport_env : rocketpy.Environment Example environment object to be tested. """ - example_spaceport_env.set_atmospheric_model(type="Forecast", file="GFS") - assert example_spaceport_env.all_info() == None + example_spaceport_env.set_date((2018, 10, 15, 12)) + example_spaceport_env.set_atmospheric_model( + type="Reanalysis", + file="data/weather/SpaceportAmerica_2018_ERA-5.nc", + dictionary="ECMWF", + ) + assert example_spaceport_env.all_info() is None -@pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_nam_atmosphere(mock_show, example_spaceport_env): - """Tests the Forecast model with the NAM file. +def test_custom_atmosphere( + mock_show, example_plain_env +): # pylint: disable=unused-argument + """Tests the custom atmosphere model in the environment object. Parameters ---------- mock_show : mock Mock object to replace matplotlib.pyplot.show() method. - example_spaceport_env : rocketpy.Environment + example_plain_env : rocketpy.Environment Example environment object to be tested. """ - example_spaceport_env.set_atmospheric_model(type="Forecast", file="NAM") - assert example_spaceport_env.all_info() == None - - -# Deactivated since it is hard to figure out and appropriate date to use RAP forecast -@pytest.mark.skip(reason="legacy tests") -@pytest.mark.slow -@patch("matplotlib.pyplot.show") -def test_rap_atmosphere(mock_show, example_spaceport_env): - today = datetime.date.today() - example_spaceport_env.set_date((today.year, today.month, today.day, 8)) - example_spaceport_env.set_atmospheric_model(type="Forecast", file="RAP") - assert example_spaceport_env.all_info() == None + example_plain_env.set_atmospheric_model( + type="custom_atmosphere", + pressure=None, + temperature=300, + wind_u=[(0, 5), (1000, 10)], + wind_v=[(0, -2), (500, 3), (1600, 2)], + ) + assert example_plain_env.all_info() is None + assert abs(example_plain_env.pressure(0) - 101325.0) < 1e-8 + assert abs(example_plain_env.barometric_height(101325.0)) < 1e-2 + assert abs(example_plain_env.wind_velocity_x(0) - 5) < 1e-8 + assert abs(example_plain_env.temperature(100) - 300) < 1e-8 @patch("matplotlib.pyplot.show") -def test_era5_atmosphere(mock_show, example_spaceport_env): - """Tests the Reanalysis model with the ERA5 file. It uses an example file - available in the data/weather folder of the RocketPy repository. +def test_standard_atmosphere( + mock_show, example_plain_env +): # pylint: disable=unused-argument + """Tests the standard atmosphere model in the environment object. Parameters ---------- mock_show : mock Mock object to replace matplotlib.pyplot.show() method. - example_spaceport_env : rocketpy.Environment + example_plain_env : rocketpy.Environment Example environment object to be tested. """ - example_spaceport_env.set_date((2018, 10, 15, 12)) - example_spaceport_env.set_atmospheric_model( - type="Reanalysis", - file="data/weather/SpaceportAmerica_2018_ERA-5.nc", - dictionary="ECMWF", + example_plain_env.set_atmospheric_model(type="standard_atmosphere") + assert example_plain_env.info() is None + assert example_plain_env.all_info() is None + assert abs(example_plain_env.pressure(0) - 101325.0) < 1e-8 + assert abs(example_plain_env.barometric_height(101325.0)) < 1e-2 + assert example_plain_env.prints.print_earth_details() is None + + +@patch("matplotlib.pyplot.show") +def test_noaaruc_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + url = ( + r"https://rucsoundings.noaa.gov/get_raobs.cgi?data_source=RAOB&latest=" + r"latest&start_year=2019&start_month_name=Feb&start_mday=5&start_hour=12" + r"&start_min=0&n_hrs=1.0&fcst_len=shortest&airport=83779&text=Ascii" + r"%20text%20%28GSD%20format%29&hydrometeors=false&start=latest" ) - assert example_spaceport_env.all_info() == None + example_spaceport_env.set_atmospheric_model(type="NOAARucSounding", file=url) + assert example_spaceport_env.all_info() is None + + +@pytest.mark.parametrize( + "model_name", + [ + "ECMWF", + "GFS", + "ICON", + "ICONEU", + ], +) +def test_windy_atmosphere(example_euroc_env, model_name): + """Tests the Windy model in the environment object. The test ensures the + pressure, temperature, and wind profiles are working and giving reasonable + values. The tolerances may be higher than usual due to the nature of the + atmospheric uncertainties, but it is ok since we are just testing if the + method is working. + + Parameters + ---------- + example_euroc_env : Environment + Example environment object to be tested. The EuRoC launch site is used + to test the ICONEU model, which only works in Europe. + model_name : str + The name of the model to be passed to the set_atmospheric_model() method + as the "file" parameter. + """ + example_euroc_env.set_atmospheric_model(type="Windy", file=model_name) + assert pytest.approx(100000.0, rel=0.1) == example_euroc_env.pressure(100) + assert 0 + 273 < example_euroc_env.temperature(100) < 40 + 273 + assert abs(example_euroc_env.wind_velocity_x(100)) < 20.0 + assert abs(example_euroc_env.wind_velocity_y(100)) < 20.0 @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_gefs_atmosphere(mock_show, example_spaceport_env): - """Tests the Ensemble model with the GEFS file. +def test_gfs_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + """Tests the Forecast model with the GFS file. It does not test the values, + instead the test checks if the method runs without errors. Parameters ---------- @@ -85,57 +159,63 @@ def test_gefs_atmosphere(mock_show, example_spaceport_env): example_spaceport_env : rocketpy.Environment Example environment object to be tested. """ - example_spaceport_env.set_atmospheric_model(type="Ensemble", file="GEFS") - assert example_spaceport_env.all_info() == None + example_spaceport_env.set_atmospheric_model(type="Forecast", file="GFS") + assert example_spaceport_env.all_info() is None -@pytest.mark.skip(reason="legacy tests") # deprecated method +@pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_custom_atmosphere(mock_show, example_plain_env): - """Tests the custom atmosphere model in the environment object. +def test_nam_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + """Tests the Forecast model with the NAM file. Parameters ---------- mock_show : mock Mock object to replace matplotlib.pyplot.show() method. - example_plain_env : rocketpy.Environment + example_spaceport_env : rocketpy.Environment Example environment object to be tested. """ - example_plain_env.set_atmospheric_model( - type="custom_atmosphere", - pressure=None, - temperature=300, - wind_u=[(0, 5), (1000, 10)], - wind_v=[(0, -2), (500, 3), (1600, 2)], - ) - assert example_plain_env.all_info() == None - assert abs(example_plain_env.pressure(0) - 101325.0) < 1e-8 - assert abs(example_plain_env.barometric_height(101325.0)) < 1e-2 - assert abs(example_plain_env.wind_velocity_x(0) - 5) < 1e-8 - assert abs(example_plain_env.temperature(100) - 300) < 1e-8 + example_spaceport_env.set_atmospheric_model(type="Forecast", file="NAM") + assert example_spaceport_env.all_info() is None +@pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_standard_atmosphere(mock_show, example_plain_env): - """Tests the standard atmosphere model in the environment object. +def test_rap_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + today = date.today() + now = datetime.now(timezone.utc) + example_spaceport_env.set_date((today.year, today.month, today.day, now.hour)) + example_spaceport_env.set_atmospheric_model(type="Forecast", file="RAP") + assert example_spaceport_env.all_info() is None + + +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_gefs_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument + """Tests the Ensemble model with the GEFS file. Parameters ---------- mock_show : mock Mock object to replace matplotlib.pyplot.show() method. - example_plain_env : rocketpy.Environment + example_spaceport_env : rocketpy.Environment Example environment object to be tested. """ - example_plain_env.set_atmospheric_model(type="standard_atmosphere") - assert example_plain_env.info() == None - assert example_plain_env.all_info() == None - assert abs(example_plain_env.pressure(0) - 101325.0) < 1e-8 - assert abs(example_plain_env.barometric_height(101325.0)) < 1e-2 - assert example_plain_env.prints.print_earth_details() == None + example_spaceport_env.set_atmospheric_model(type="Ensemble", file="GEFS") + assert example_spaceport_env.all_info() is None +@pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_wyoming_sounding_atmosphere(mock_show, example_plain_env): +def test_wyoming_sounding_atmosphere( + mock_show, example_plain_env +): # pylint: disable=unused-argument """Asserts whether the Wyoming sounding model in the environment object behaves as expected with respect to some attributes such as pressure, barometric_height, wind_velocity and temperature. @@ -150,16 +230,15 @@ def test_wyoming_sounding_atmosphere(mock_show, example_plain_env): # TODO:: this should be added to the set_atmospheric_model() method as a # "file" option, instead of receiving the URL as a string. - URL = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" + url = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" # give it at least 5 times to try to download the file for i in range(5): try: - example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=URL) + example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=url) break - except: - time.sleep(1) # wait 1 second before trying again - pass - assert example_plain_env.all_info() == None + except Exception: # pylint: disable=broad-except + time.sleep(2**i) + assert example_plain_env.all_info() is None assert abs(example_plain_env.pressure(0) - 93600.0) < 1e-8 assert ( abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0) @@ -171,7 +250,9 @@ def test_wyoming_sounding_atmosphere(mock_show, example_plain_env): @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_hiresw_ensemble_atmosphere(mock_show, example_spaceport_env): +def test_hiresw_ensemble_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument """Tests the Forecast model with the HIRESW file. Parameters @@ -181,36 +262,25 @@ def test_hiresw_ensemble_atmosphere(mock_show, example_spaceport_env): example_spaceport_env : rocketpy.Environment Example environment object to be tested. """ - # TODO: why isn't the HIRESW a built-in option in the set_atmospheric_model() method? - HIRESW_dictionary = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } - today = datetime.date.today() + today = date.today() date_info = (today.year, today.month, today.day, 12) # Hour given in UTC time - date_string = f"{date_info[0]}{date_info[1]:02}{date_info[2]:02}" example_spaceport_env.set_date(date_info) example_spaceport_env.set_atmospheric_model( type="Forecast", - file=f"https://nomads.ncep.noaa.gov/dods/hiresw/hiresw{date_string}/hiresw_conusarw_12z", - dictionary=HIRESW_dictionary, + file="HIRESW", + dictionary="HIRESW", ) - assert example_spaceport_env.all_info() == None + assert example_spaceport_env.all_info() is None -@pytest.mark.slow +@pytest.mark.skip(reason="CMC model is currently not working") @patch("matplotlib.pyplot.show") -def test_cmc_atmosphere(mock_show, example_spaceport_env): +def test_cmc_atmosphere( + mock_show, example_spaceport_env +): # pylint: disable=unused-argument """Tests the Ensemble model with the CMC file. Parameters @@ -221,4 +291,4 @@ def test_cmc_atmosphere(mock_show, example_spaceport_env): Example environment object to be tested. """ example_spaceport_env.set_atmospheric_model(type="Ensemble", file="CMC") - assert example_spaceport_env.all_info() == None + assert example_spaceport_env.all_info() is None diff --git a/tests/integration/test_environment_analysis.py b/tests/integration/test_environment_analysis.py index 4ef0146df..1be33fe96 100644 --- a/tests/integration/test_environment_analysis.py +++ b/tests/integration/test_environment_analysis.py @@ -5,14 +5,12 @@ import matplotlib as plt import pytest -from rocketpy.tools import import_optional_dependency - plt.rcParams.update({"figure.max_open_warning": 0}) @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_all_info(mock_show, env_analysis): +def test_all_info(mock_show, env_analysis): # pylint: disable=unused-argument """Test the EnvironmentAnalysis.all_info() method, which already invokes several other methods. It is a good way to test the whole class in a first view. However, if it fails, it is hard to know which method is failing. @@ -26,15 +24,15 @@ def test_all_info(mock_show, env_analysis): ------- None """ - assert env_analysis.info() == None - assert env_analysis.all_info() == None - assert env_analysis.plots.info() == None + assert env_analysis.info() is None + assert env_analysis.all_info() is None + assert env_analysis.plots.info() is None os.remove("wind_rose.gif") # remove the files created by the method @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_exports(mock_show, env_analysis): +def test_exports(mock_show, env_analysis): # pylint: disable=unused-argument """Check the export methods of the EnvironmentAnalysis class. It only checks if the method runs without errors. It does not check if the files are correct, as this would require a lot of work and would be @@ -46,12 +44,12 @@ def test_exports(mock_show, env_analysis): A simple object of the EnvironmentAnalysis class. """ - assert env_analysis.export_mean_profiles() == None - assert env_analysis.save("env_analysis_dict") == None + assert env_analysis.export_mean_profiles() is None + assert env_analysis.save("env_analysis_dict") is None env2 = copy.deepcopy(env_analysis) env2.load("env_analysis_dict") - assert env2.all_info() == None + assert env2.all_info() is None # Delete file created by save method os.remove("env_analysis_dict") diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index b00d082c4..422941c4d 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -5,13 +5,13 @@ import numpy as np import pytest -from rocketpy import Components, Environment, Flight, Function, Rocket, SolidMotor +from rocketpy import Environment, Flight plt.rcParams.update({"figure.max_open_warning": 0}) @patch("matplotlib.pyplot.show") -def test_all_info(mock_show, flight_calisto_robust): +def test_all_info(mock_show, flight_calisto_robust): # pylint: disable=unused-argument """Test that the flight class is working as intended. This basically calls the all_info() method and checks if it returns None. It is not testing if the values are correct, but whether the method is working without errors. @@ -24,69 +24,64 @@ def test_all_info(mock_show, flight_calisto_robust): Flight object to be tested. See the conftest.py file for more info regarding this pytest fixture. """ - assert flight_calisto_robust.all_info() == None - - -def test_export_data(flight_calisto): - """Tests wether the method Flight.export_data is working as intended - - Parameters: - ----------- - flight_calisto : rocketpy.Flight - Flight object to be tested. See the conftest.py file for more info - regarding this pytest fixture. - """ - test_flight = flight_calisto - - # Basic export - test_flight.export_data("test_export_data_1.csv") - - # Custom export - test_flight.export_data( - "test_export_data_2.csv", - "z", - "vz", - "e1", - "w3", - "angle_of_attack", - time_step=0.1, - ) - - # Load exported files and fixtures and compare them - test_1 = np.loadtxt("test_export_data_1.csv", delimiter=",") - test_2 = np.loadtxt("test_export_data_2.csv", delimiter=",") - - # Delete files - os.remove("test_export_data_1.csv") - os.remove("test_export_data_2.csv") - - # Check if basic exported content matches data - assert np.allclose(test_flight.x[:, 0], test_1[:, 0], atol=1e-5) == True - assert np.allclose(test_flight.x[:, 1], test_1[:, 1], atol=1e-5) == True - assert np.allclose(test_flight.y[:, 1], test_1[:, 2], atol=1e-5) == True - assert np.allclose(test_flight.z[:, 1], test_1[:, 3], atol=1e-5) == True - assert np.allclose(test_flight.vx[:, 1], test_1[:, 4], atol=1e-5) == True - assert np.allclose(test_flight.vy[:, 1], test_1[:, 5], atol=1e-5) == True - assert np.allclose(test_flight.vz[:, 1], test_1[:, 6], atol=1e-5) == True - assert np.allclose(test_flight.e0[:, 1], test_1[:, 7], atol=1e-5) == True - assert np.allclose(test_flight.e1[:, 1], test_1[:, 8], atol=1e-5) == True - assert np.allclose(test_flight.e2[:, 1], test_1[:, 9], atol=1e-5) == True - assert np.allclose(test_flight.e3[:, 1], test_1[:, 10], atol=1e-5) == True - assert np.allclose(test_flight.w1[:, 1], test_1[:, 11], atol=1e-5) == True - assert np.allclose(test_flight.w2[:, 1], test_1[:, 12], atol=1e-5) == True - assert np.allclose(test_flight.w3[:, 1], test_1[:, 13], atol=1e-5) == True - - # Check if custom exported content matches data - timePoints = np.arange(test_flight.t_initial, test_flight.t_final, 0.1) - assert np.allclose(timePoints, test_2[:, 0], atol=1e-5) == True - assert np.allclose(test_flight.z(timePoints), test_2[:, 1], atol=1e-5) == True - assert np.allclose(test_flight.vz(timePoints), test_2[:, 2], atol=1e-5) == True - assert np.allclose(test_flight.e1(timePoints), test_2[:, 3], atol=1e-5) == True - assert np.allclose(test_flight.w3(timePoints), test_2[:, 4], atol=1e-5) == True - assert ( - np.allclose(test_flight.angle_of_attack(timePoints), test_2[:, 5], atol=1e-5) - == True - ) + assert flight_calisto_robust.all_info() is None + + +class TestExportData: + """Tests the export_data method of the Flight class.""" + + def test_basic_export(self, flight_calisto): + """Tests basic export functionality""" + file_name = "test_export_data_1.csv" + flight_calisto.export_data(file_name) + self.validate_basic_export(flight_calisto, file_name) + os.remove(file_name) + + def test_custom_export(self, flight_calisto): + """Tests custom export functionality""" + file_name = "test_export_data_2.csv" + flight_calisto.export_data( + file_name, + "z", + "vz", + "e1", + "w3", + "angle_of_attack", + time_step=0.1, + ) + self.validate_custom_export(flight_calisto, file_name) + os.remove(file_name) + + def validate_basic_export(self, flight_calisto, file_name): + """Validates the basic export file content""" + test_data = np.loadtxt(file_name, delimiter=",") + assert np.allclose(flight_calisto.x[:, 0], test_data[:, 0], atol=1e-5) + assert np.allclose(flight_calisto.x[:, 1], test_data[:, 1], atol=1e-5) + assert np.allclose(flight_calisto.y[:, 1], test_data[:, 2], atol=1e-5) + assert np.allclose(flight_calisto.z[:, 1], test_data[:, 3], atol=1e-5) + assert np.allclose(flight_calisto.vx[:, 1], test_data[:, 4], atol=1e-5) + assert np.allclose(flight_calisto.vy[:, 1], test_data[:, 5], atol=1e-5) + assert np.allclose(flight_calisto.vz[:, 1], test_data[:, 6], atol=1e-5) + assert np.allclose(flight_calisto.e0[:, 1], test_data[:, 7], atol=1e-5) + assert np.allclose(flight_calisto.e1[:, 1], test_data[:, 8], atol=1e-5) + assert np.allclose(flight_calisto.e2[:, 1], test_data[:, 9], atol=1e-5) + assert np.allclose(flight_calisto.e3[:, 1], test_data[:, 10], atol=1e-5) + assert np.allclose(flight_calisto.w1[:, 1], test_data[:, 11], atol=1e-5) + assert np.allclose(flight_calisto.w2[:, 1], test_data[:, 12], atol=1e-5) + assert np.allclose(flight_calisto.w3[:, 1], test_data[:, 13], atol=1e-5) + + def validate_custom_export(self, flight_calisto, file_name): + """Validates the custom export file content""" + test_data = np.loadtxt(file_name, delimiter=",") + time_points = np.arange(flight_calisto.t_initial, flight_calisto.t_final, 0.1) + assert np.allclose(time_points, test_data[:, 0], atol=1e-5) + assert np.allclose(flight_calisto.z(time_points), test_data[:, 1], atol=1e-5) + assert np.allclose(flight_calisto.vz(time_points), test_data[:, 2], atol=1e-5) + assert np.allclose(flight_calisto.e1(time_points), test_data[:, 3], atol=1e-5) + assert np.allclose(flight_calisto.w3(time_points), test_data[:, 4], atol=1e-5) + assert np.allclose( + flight_calisto.angle_of_attack(time_points), test_data[:, 5], atol=1e-5 + ) def test_export_kml(flight_calisto_robust): @@ -107,14 +102,13 @@ def test_export_kml(flight_calisto_robust): ) # Load exported files and fixtures and compare them - test_1 = open("test_export_data_1.kml", "r") - - for row in test_1: - if row[:29] == " ": - r = row[29:-15] - r = r.split(",") - for i, j in enumerate(r): - r[i] = j.split(" ") + with open("test_export_data_1.kml", "r") as test_1: + for row in test_1: + if row[:29] == " ": + r = row[29:-15] + r = r.split(",") + for i, j in enumerate(r): + r[i] = j.split(" ") lon, lat, z, coords = [], [], [], [] for i in r: for j in i: @@ -123,14 +117,11 @@ def test_export_kml(flight_calisto_robust): lon.append(float(coords[i])) lat.append(float(coords[i + 1])) z.append(float(coords[i + 2])) - - # Delete temporary test file - test_1.close() os.remove("test_export_data_1.kml") - assert np.allclose(test_flight.latitude[:, 1], lat, atol=1e-3) == True - assert np.allclose(test_flight.longitude[:, 1], lon, atol=1e-3) == True - assert np.allclose(test_flight.z[:, 1], z, atol=1e-3) == True + assert np.allclose(test_flight.latitude[:, 1], lat, atol=1e-3) + assert np.allclose(test_flight.longitude[:, 1], lon, atol=1e-3) + assert np.allclose(test_flight.z[:, 1], z, atol=1e-3) def test_export_pressures(flight_calisto_robust): @@ -162,7 +153,9 @@ def test_export_pressures(flight_calisto_robust): @patch("matplotlib.pyplot.show") -def test_hybrid_motor_flight(mock_show, calisto_hybrid_modded): +def test_hybrid_motor_flight( + mock_show, calisto_hybrid_modded +): # pylint: disable=unused-argument """Test the flight of a rocket with a hybrid motor. This test only validates that a flight simulation can be performed with a hybrid motor; it does not validate the results. @@ -183,11 +176,13 @@ def test_hybrid_motor_flight(mock_show, calisto_hybrid_modded): max_time_step=0.25, ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_liquid_motor_flight(mock_show, calisto_liquid_modded): +def test_liquid_motor_flight( + mock_show, calisto_liquid_modded +): # pylint: disable=unused-argument """Test the flight of a rocket with a liquid motor. This test only validates that a flight simulation can be performed with a liquid motor; it does not validate the results. @@ -208,12 +203,14 @@ def test_liquid_motor_flight(mock_show, calisto_liquid_modded): max_time_step=0.25, ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_time_overshoot(mock_show, calisto_robust, example_spaceport_env): +def test_time_overshoot( + mock_show, calisto_robust, example_spaceport_env +): # pylint: disable=unused-argument """Test the time_overshoot parameter of the Flight class. This basically calls the all_info() method for a simulation without time_overshoot and checks if it returns None. It is not testing if the values are correct, @@ -238,11 +235,13 @@ def test_time_overshoot(mock_show, calisto_robust, example_spaceport_env): time_overshoot=False, ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_simpler_parachute_triggers(mock_show, example_plain_env, calisto_robust): +def test_simpler_parachute_triggers( + mock_show, example_plain_env, calisto_robust +): # pylint: disable=unused-argument """Tests different types of parachute triggers. This is important to ensure the code is working as intended, since the parachute triggers can have very different format definitions. It will add 3 parachutes using different @@ -309,12 +308,12 @@ def test_simpler_parachute_triggers(mock_show, example_plain_env, calisto_robust ) <= 1 ) - assert calisto_robust.all_info() == None - assert test_flight.all_info() == None + assert calisto_robust.all_info() is None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_rolling_flight( +def test_rolling_flight( # pylint: disable=unused-argument mock_show, example_plain_env, cesaroni_m1670, @@ -328,7 +327,7 @@ def test_rolling_flight( test_rocket.set_rail_buttons(0.082, -0.618) test_rocket.add_motor(cesaroni_m1670, position=-1.373) - fin_set = test_rocket.add_trapezoidal_fins( + test_rocket.add_trapezoidal_fins( 4, span=0.100, root_chord=0.120, @@ -349,11 +348,11 @@ def test_rolling_flight( heading=0, ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_eccentricity_on_flight( +def test_eccentricity_on_flight( # pylint: disable=unused-argument mock_show, example_plain_env, cesaroni_m1670, @@ -380,11 +379,13 @@ def test_eccentricity_on_flight( terminate_on_apogee=True, ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_air_brakes_flight(mock_show, flight_calisto_air_brakes): +def test_air_brakes_flight( + mock_show, flight_calisto_air_brakes +): # pylint: disable=unused-argument """Test the flight of a rocket with air brakes. This test only validates that a flight simulation can be performed with air brakes; it does not validate the results. @@ -404,7 +405,9 @@ def test_air_brakes_flight(mock_show, flight_calisto_air_brakes): @patch("matplotlib.pyplot.show") -def test_initial_solution(mock_show, example_plain_env, calisto_robust): +def test_initial_solution( + mock_show, example_plain_env, calisto_robust +): # pylint: disable=unused-argument """Tests the initial_solution option of the Flight class. This test simply simulates the flight using the initial_solution option and checks if the all_info method returns None. @@ -445,11 +448,13 @@ def test_initial_solution(mock_show, example_plain_env, calisto_robust): ], ) - assert test_flight.all_info() == None + assert test_flight.all_info() is None @patch("matplotlib.pyplot.show") -def test_empty_motor_flight(mock_show, example_plain_env, calisto_motorless): +def test_empty_motor_flight( + mock_show, example_plain_env, calisto_motorless +): # pylint: disable=unused-argument flight = Flight( rocket=calisto_motorless, environment=example_plain_env, @@ -471,4 +476,38 @@ def test_empty_motor_flight(mock_show, example_plain_env, calisto_motorless): 2.0747266017020563, ], ) - assert flight.all_info() == None + assert flight.all_info() is None + + +def test_freestream_speed_at_apogee(example_plain_env, calisto): + """ + Asserts that a rocket at apogee has a free stream speed of 0.0 m/s in all + directions given that the environment doesn't have any wind. + """ + # NOTE: this rocket doesn't move in x or z direction. There's no wind. + hard_atol = 1e-12 + soft_atol = 1e-6 + test_flight = Flight( + environment=example_plain_env, + rocket=calisto, + rail_length=5.2, + inclination=90, + heading=0, + terminate_on_apogee=False, + atol=13 * [hard_atol], + ) + + assert np.isclose( + test_flight.stream_velocity_x(test_flight.apogee_time), 0.0, atol=hard_atol + ) + assert np.isclose( + test_flight.stream_velocity_y(test_flight.apogee_time), 0.0, atol=hard_atol + ) + # NOTE: stream_velocity_z has a higher error due to apogee detection estimation + assert np.isclose( + test_flight.stream_velocity_z(test_flight.apogee_time), 0.0, atol=soft_atol + ) + assert np.isclose( + test_flight.free_stream_speed(test_flight.apogee_time), 0.0, atol=soft_atol + ) + assert np.isclose(test_flight.apogee_freestream_speed, 0.0, atol=soft_atol) diff --git a/tests/integration/test_function.py b/tests/integration/test_function.py index 15fae4e7e..a7e3144e5 100644 --- a/tests/integration/test_function.py +++ b/tests/integration/test_function.py @@ -87,15 +87,15 @@ def test_function_from_csv(func_from_csv, func_2d_from_csv): assert np.isclose(func_from_csv(0), 0.0, atol=1e-6) assert np.isclose(func_2d_from_csv(0, 0), 0.0, atol=1e-6) # Check the __str__ method - assert func_from_csv.__str__() == "Function from R1 to R1 : (Scalar) → (Scalar)" + assert str(func_from_csv) == "Function from R1 to R1 : (Scalar) → (Scalar)" assert ( - func_2d_from_csv.__str__() + str(func_2d_from_csv) == "Function from R2 to R1 : (Input 1, Input 2) → (Scalar)" ) # Check the __repr__ method - assert func_from_csv.__repr__() == "'Function from R1 to R1 : (Scalar) → (Scalar)'" + assert repr(func_from_csv) == "'Function from R1 to R1 : (Scalar) → (Scalar)'" assert ( - func_2d_from_csv.__repr__() + repr(func_2d_from_csv) == "'Function from R2 to R1 : (Input 1, Input 2) → (Scalar)'" ) @@ -112,13 +112,15 @@ def test_func_from_csv_with_header(csv_file): line. It tests cases where the fields are separated by quotes and without quotes.""" f = Function(csv_file) - assert f.__repr__() == "'Function from R1 to R1 : (time) → (value)'" + assert repr(f) == "'Function from R1 to R1 : (time) → (value)'" assert np.isclose(f(0), 100) assert np.isclose(f(0) + f(1), 300), "Error summing the values of the function" @patch("matplotlib.pyplot.show") -def test_plots(mock_show, func_from_csv, func_2d_from_csv): +def test_plots( # pylint: disable=unused-argument + mock_show, func_from_csv, func_2d_from_csv +): """Test different plot methods of the Function class. Parameters @@ -129,11 +131,11 @@ def test_plots(mock_show, func_from_csv, func_2d_from_csv): A Function object created from a .csv file. """ # Test plot methods - assert func_from_csv.plot() == None - assert func_2d_from_csv.plot() == None + assert func_from_csv.plot() is None + assert func_2d_from_csv.plot() is None # Test plot methods with limits - assert func_from_csv.plot(-1, 1) == None - assert func_2d_from_csv.plot(-1, 1) == None + assert func_from_csv.plot(-1, 1) is None + assert func_2d_from_csv.plot(-1, 1) is None # Test compare_plots func2 = Function( source="tests/fixtures/airfoils/e473-10e6-degrees.csv", @@ -143,12 +145,12 @@ def test_plots(mock_show, func_from_csv, func_2d_from_csv): extrapolation="natural", ) assert ( - func_from_csv.compare_plots([func_from_csv, func2], return_object=False) == None + func_from_csv.compare_plots([func_from_csv, func2], return_object=False) is None ) @patch("matplotlib.pyplot.show") -def test_multivariable_dataset_plot(mock_show): +def test_multivariable_dataset_plot(mock_show): # pylint: disable=unused-argument """Test the plot method of the Function class with a multivariable dataset.""" # Test plane f(x,y) = x - y source = [ @@ -165,15 +167,19 @@ def test_multivariable_dataset_plot(mock_show): func = Function(source=source, inputs=["x", "y"], outputs=["z"]) # Assert plot - assert func.plot() == None + assert func.plot() is None @patch("matplotlib.pyplot.show") -def test_multivariable_function_plot(mock_show): +def test_multivariable_function_plot(mock_show): # pylint: disable=unused-argument """Test the plot method of the Function class with a multivariable function.""" - # Test plane f(x,y) = sin(x + y) - source = lambda x, y: np.sin(x * y) + + def source(x, y): + + # Test plane f(x,y) = sin(x + y) + return np.sin(x * y) + func = Function(source=source, inputs=["x", "y"], outputs=["z"]) # Assert plot - assert func.plot() == None + assert func.plot() is None diff --git a/tests/integration/test_genericmotor.py b/tests/integration/test_genericmotor.py index e7591eca1..6373fc055 100644 --- a/tests/integration/test_genericmotor.py +++ b/tests/integration/test_genericmotor.py @@ -1,20 +1,6 @@ +# pylint: disable=unused-argument from unittest.mock import patch -import numpy as np -import pytest -import scipy.integrate - -burn_time = (2, 7) -thrust_source = lambda t: 2000 - 100 * (t - 2) -chamber_height = 0.5 -chamber_radius = 0.075 -chamber_position = -0.25 -propellant_initial_mass = 5.0 -nozzle_position = -0.5 -nozzle_radius = 0.075 -dry_mass = 8.0 -dry_inertia = (0.2, 0.2, 0.08) - @patch("matplotlib.pyplot.show") def test_generic_motor_info(mock_show, generic_motor): @@ -27,5 +13,5 @@ def test_generic_motor_info(mock_show, generic_motor): generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. """ - assert generic_motor.info() == None - assert generic_motor.all_info() == None + assert generic_motor.info() is None + assert generic_motor.all_info() is None diff --git a/tests/integration/test_hybridmotor.py b/tests/integration/test_hybridmotor.py index a595f3c8a..1c7ed5cc8 100644 --- a/tests/integration/test_hybridmotor.py +++ b/tests/integration/test_hybridmotor.py @@ -1,23 +1,6 @@ +# pylint: disable=unused-argument from unittest.mock import patch -import numpy as np - -thrust_function = lambda t: 2000 - 100 * t -burn_time = 10 -center_of_dry_mass = 0 -dry_inertia = (4, 4, 0.1) -dry_mass = 8 -grain_density = 1700 -grain_number = 4 -grain_initial_height = 0.1 -grain_separation = 0 -grain_initial_inner_radius = 0.04 -grain_outer_radius = 0.1 -nozzle_position = -0.4 -nozzle_radius = 0.07 -grains_center_of_mass_position = -0.1 -oxidizer_tank_position = 0.3 - @patch("matplotlib.pyplot.show") def test_hybrid_motor_info(mock_show, hybrid_motor): @@ -30,5 +13,5 @@ def test_hybrid_motor_info(mock_show, hybrid_motor): hybrid_motor : rocketpy.HybridMotor The HybridMotor object to be used in the tests. """ - assert hybrid_motor.info() == None - assert hybrid_motor.all_info() == None + assert hybrid_motor.info() is None + assert hybrid_motor.all_info() is None diff --git a/tests/integration/test_liquidmotor.py b/tests/integration/test_liquidmotor.py index 1bc679721..94c550160 100644 --- a/tests/integration/test_liquidmotor.py +++ b/tests/integration/test_liquidmotor.py @@ -1,24 +1,8 @@ from unittest.mock import patch -import numpy as np -import pytest -import scipy.integrate - -from rocketpy import Function - -burn_time = (8, 20) -dry_mass = 10 -dry_inertia = (5, 5, 0.2) -center_of_dry_mass = 0 -nozzle_position = -1.364 -nozzle_radius = 0.069 / 2 -pressurant_tank_position = 2.007 -fuel_tank_position = -1.048 -oxidizer_tank_position = 0.711 - @patch("matplotlib.pyplot.show") -def test_liquid_motor_info(mock_show, liquid_motor): +def test_liquid_motor_info(mock_show, liquid_motor): # pylint: disable=unused-argument """Tests the LiquidMotor.all_info() method. Parameters @@ -28,5 +12,5 @@ def test_liquid_motor_info(mock_show, liquid_motor): liquid_motor : rocketpy.LiquidMotor The LiquidMotor object to be used in the tests. """ - assert liquid_motor.info() == None - assert liquid_motor.all_info() == None + assert liquid_motor.info() is None + assert liquid_motor.all_info() is None diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 91838c828..5f11a9b25 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-argument import os from unittest.mock import patch @@ -85,7 +86,7 @@ def test_monte_carlo_prints(monte_carlo_calisto): monte_carlo_calisto.info() -@patch("matplotlib.pyplot.show") +@patch("matplotlib.pyplot.show") # pylint: disable=unused-argument def test_monte_carlo_plots(mock_show, monte_carlo_calisto_pre_loaded): """Tests the plots methods of the MonteCarlo class.""" assert monte_carlo_calisto_pre_loaded.all_info() is None diff --git a/tests/integration/test_plots.py b/tests/integration/test_plots.py index bef7e6915..232ef71c6 100644 --- a/tests/integration/test_plots.py +++ b/tests/integration/test_plots.py @@ -1,10 +1,9 @@ +# pylint: disable=unused-argument import os from unittest.mock import patch -import matplotlib.pyplot as plt - from rocketpy import Flight -from rocketpy.plots.compare import Compare, CompareFlights +from rocketpy.plots.compare import CompareFlights @patch("matplotlib.pyplot.show") @@ -53,14 +52,14 @@ def test_compare_flights(mock_show, calisto, example_plain_env): comparison = CompareFlights(flights) - assert comparison.all() == None - assert comparison.trajectories_2d(plane="xz", legend=False) == None - assert comparison.trajectories_2d(plane="yz", legend=True) == None + assert comparison.all() is None + assert comparison.trajectories_2d(plane="xz", legend=False) is None + assert comparison.trajectories_2d(plane="yz", legend=True) is None # Test save fig and then remove file - assert comparison.positions(filename="test.png") == None + assert comparison.positions(filename="test.png") is None os.remove("test.png") # Test xlim and ylim arguments - assert comparison.positions(x_lim=[0, 100], y_lim=[0, 1000]) == None - assert comparison.positions(x_lim=[0, "apogee"]) == None + assert comparison.positions(x_lim=[0, 100], y_lim=[0, 1000]) is None + assert comparison.positions(x_lim=[0, "apogee"]) is None diff --git a/tests/integration/test_rocket.py b/tests/integration/test_rocket.py index 69efd7ca5..4d5daf7a6 100644 --- a/tests/integration/test_rocket.py +++ b/tests/integration/test_rocket.py @@ -1,15 +1,11 @@ from unittest.mock import patch import numpy as np -import pytest - -from rocketpy import Rocket, SolidMotor -from rocketpy.rocket import NoseCone @patch("matplotlib.pyplot.show") def test_airfoil( - mock_show, + mock_show, # pylint: disable=unused-argument calisto, calisto_main_chute, calisto_drogue_chute, @@ -21,7 +17,7 @@ def test_airfoil( calisto.aerodynamic_surfaces.add(calisto_nose_cone, 1.160) calisto.aerodynamic_surfaces.add(calisto_tail, -1.313) - fin_set_NACA = test_rocket.add_trapezoidal_fins( + test_rocket.add_trapezoidal_fins( 2, span=0.100, root_chord=0.120, @@ -30,7 +26,7 @@ def test_airfoil( airfoil=("tests/fixtures/airfoils/NACA0012-radians.txt", "radians"), name="NACA0012", ) - fin_set_E473 = test_rocket.add_trapezoidal_fins( + test_rocket.add_trapezoidal_fins( 2, span=0.100, root_chord=0.120, @@ -44,11 +40,13 @@ def test_airfoil( static_margin = test_rocket.static_margin(0) - assert test_rocket.all_info() == None or not abs(static_margin - 2.03) < 0.01 + assert test_rocket.all_info() is None or not abs(static_margin - 2.03) < 0.01 @patch("matplotlib.pyplot.show") -def test_air_brakes_clamp_on(mock_show, calisto_air_brakes_clamp_on): +def test_air_brakes_clamp_on( + mock_show, calisto_air_brakes_clamp_on +): # pylint: disable=unused-argument """Test the air brakes class with clamp on configuration. This test checks the basic attributes and the deployment_level setter. It also checks the all_info method. @@ -78,11 +76,13 @@ def test_air_brakes_clamp_on(mock_show, calisto_air_brakes_clamp_on): air_brakes_clamp_on.deployment_level = 0 assert air_brakes_clamp_on.deployment_level == 0 - assert air_brakes_clamp_on.all_info() == None + assert air_brakes_clamp_on.all_info() is None @patch("matplotlib.pyplot.show") -def test_air_brakes_clamp_off(mock_show, calisto_air_brakes_clamp_off): +def test_air_brakes_clamp_off( # pylint: disable=unused-argument + mock_show, calisto_air_brakes_clamp_off +): """Test the air brakes class with clamp off configuration. This test checks the basic attributes and the deployment_level setter. It also checks the all_info method. @@ -113,22 +113,22 @@ def test_air_brakes_clamp_off(mock_show, calisto_air_brakes_clamp_off): air_brakes_clamp_off.deployment_level = 0 assert air_brakes_clamp_off.deployment_level == 0 - assert air_brakes_clamp_off.all_info() == None + assert air_brakes_clamp_off.all_info() is None @patch("matplotlib.pyplot.show") -def test_rocket(mock_show, calisto_robust): +def test_rocket(mock_show, calisto_robust): # pylint: disable=unused-argument test_rocket = calisto_robust static_margin = test_rocket.static_margin(0) # Check if all_info and static_method methods are working properly - assert test_rocket.all_info() == None or not abs(static_margin - 2.05) < 0.01 + assert test_rocket.all_info() is None or not abs(static_margin - 2.05) < 0.01 @patch("matplotlib.pyplot.show") -def test_aero_surfaces_infos( +def test_aero_surfaces_infos( # pylint: disable=unused-argument mock_show, calisto_nose_cone, calisto_tail, calisto_trapezoidal_fins ): - assert calisto_nose_cone.all_info() == None - assert calisto_trapezoidal_fins.all_info() == None - assert calisto_tail.all_info() == None - assert calisto_trapezoidal_fins.draw() == None + assert calisto_nose_cone.all_info() is None + assert calisto_trapezoidal_fins.all_info() is None + assert calisto_tail.all_info() is None + assert calisto_trapezoidal_fins.draw() is None diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py index 58c0203cd..7769f7b85 100644 --- a/tests/unit/test_environment.py +++ b/tests/unit/test_environment.py @@ -1,9 +1,7 @@ import json import os -from unittest.mock import patch import numpy as np -import numpy.ma as ma import pytest import pytz @@ -29,7 +27,7 @@ def test_location_set_location_saves_location(latitude, longitude, example_plain assert example_plain_env.longitude == longitude -@pytest.mark.parametrize("elevation", [(-200), (0), (200)]) +@pytest.mark.parametrize("elevation", [(0), (100), (1000), (100000)]) def test_elevation_set_elevation_saves_elevation(elevation, example_plain_env): """Tests the wether the 'set_elevation' method within the Environment class sets the elevation correctly. @@ -73,18 +71,20 @@ def test_location_set_topographic_profile_computes_elevation( def test_geodesic_coordinate_geodesic_to_utm_converts_coordinate(): """Tests the conversion from geodesic to UTM coordinates.""" - x, y, utm_zone, utm_letter, hemis, EW = Environment.geodesic_to_utm( - lat=32.990254, - lon=-106.974998, - semi_major_axis=6378137.0, # WGS84 - flattening=1 / 298.257223563, # WGS84 + x, y, utm_zone, utm_letter, north_south_hemis, east_west_hemis = ( + Environment.geodesic_to_utm( + lat=32.990254, + lon=-106.974998, + semi_major_axis=6378137.0, # WGS84 + flattening=1 / 298.257223563, # WGS84 + ) ) - assert np.isclose(x, 315468.64, atol=1e-5) == True - assert np.isclose(y, 3651938.65, atol=1e-5) == True + assert np.isclose(x, 315468.64, atol=1e-5) + assert np.isclose(y, 3651938.65, atol=1e-5) assert utm_zone == 13 assert utm_letter == "S" - assert hemis == "N" - assert EW == "W" + assert north_south_hemis == "N" + assert east_west_hemis == "W" def test_utm_to_geodesic_converts_coordinates(): @@ -101,8 +101,8 @@ class and checks the conversion results from UTM to geodesic semi_major_axis=6378137.0, # WGS84 flattening=1 / 298.257223563, # WGS84 ) - assert np.isclose(lat, 32.99025, atol=1e-5) == True - assert np.isclose(lon, -106.9750, atol=1e-5) == True + assert np.isclose(lat, 32.99025, atol=1e-5) + assert np.isclose(lon, -106.9750, atol=1e-5) @pytest.mark.parametrize( @@ -158,51 +158,6 @@ def test_decimal_degrees_to_arc_seconds_computes_correct_values( assert pytest.approx(computed_data[2], abs=1e-8) == theoretical_arc_seconds -@patch("matplotlib.pyplot.show") -def test_info_returns(mock_show, example_plain_env): - """Tests the all_info_returned() all_plot_info_returned() and methods of the - Environment class. - - Parameters - ---------- - mock_show : mock - Mock object to replace matplotlib.pyplot.show() method. - example_plain_env : rocketpy.Environment - Example environment object to be tested. - """ - returned_plots = example_plain_env.all_plot_info_returned() - returned_infos = example_plain_env.all_info_returned() - expected_info = { - "grav": example_plain_env.gravity, - "elevation": 0, - "model_type": "standard_atmosphere", - "model_type_max_expected_height": 80000, - "wind_speed": 0, - "wind_direction": 0, - "wind_heading": 0, - "surface_pressure": 1013.25, - "surface_temperature": 288.15, - "surface_air_density": 1.225000018124288, - "surface_speed_of_sound": 340.293988026089, - "lat": 0, - "lon": 0, - } - expected_plots_keys = [ - "grid", - "wind_speed", - "wind_direction", - "speed_of_sound", - "density", - "wind_vel_x", - "wind_vel_y", - "pressure", - "temperature", - ] - assert list(returned_infos.keys()) == list(expected_info.keys()) - assert list(returned_infos.values()) == list(expected_info.values()) - assert list(returned_plots.keys()) == expected_plots_keys - - def test_date_naive_set_date_saves_utc_timezone_by_default( example_plain_env, example_date_naive ): @@ -232,70 +187,53 @@ def test_date_aware_set_date_saves_custom_timezone( assert example_plain_env.datetime_date == example_date_aware +@pytest.mark.parametrize("env_name", ["example_spaceport_env", "example_euroc_env"]) def test_environment_export_environment_exports_valid_environment_json( - example_spaceport_env, + request, env_name ): """Tests the export_environment() method of the Environment class. Parameters ---------- - example_spaceport_env : rocketpy.Environment + env_name : str + The name of the environment fixture to be tested. """ + # get the fixture with the name in the string + env = request.getfixturevalue(env_name) # Check file creation - assert example_spaceport_env.export_environment(filename="environment") is None + assert env.export_environment(filename="environment") is None with open("environment.json", "r") as json_file: exported_env = json.load(json_file) assert os.path.isfile("environment.json") # Check file content - assert exported_env["gravity"] == example_spaceport_env.gravity( - example_spaceport_env.elevation - ) + assert exported_env["gravity"] == env.gravity(env.elevation) assert exported_env["date"] == [ - example_spaceport_env.datetime_date.year, - example_spaceport_env.datetime_date.month, - example_spaceport_env.datetime_date.day, - example_spaceport_env.datetime_date.hour, + env.datetime_date.year, + env.datetime_date.month, + env.datetime_date.day, + env.datetime_date.hour, ] - assert exported_env["latitude"] == example_spaceport_env.latitude - assert exported_env["longitude"] == example_spaceport_env.longitude - assert exported_env["elevation"] == example_spaceport_env.elevation - assert exported_env["datum"] == example_spaceport_env.datum - assert exported_env["timezone"] == example_spaceport_env.timezone - assert exported_env["max_expected_height"] == float( - example_spaceport_env.max_expected_height - ) - assert ( - exported_env["atmospheric_model_type"] - == example_spaceport_env.atmospheric_model_type - ) - assert exported_env["atmospheric_model_file"] == "" - assert exported_env["atmospheric_model_dict"] == "" - assert ( - exported_env["atmospheric_model_pressure_profile"] - == ma.getdata( - example_spaceport_env.pressure.get_source()(example_spaceport_env.height) - ).tolist() + assert exported_env["latitude"] == env.latitude + assert exported_env["longitude"] == env.longitude + assert exported_env["elevation"] == env.elevation + assert exported_env["datum"] == env.datum + assert exported_env["timezone"] == env.timezone + assert exported_env["max_expected_height"] == float(env.max_expected_height) + assert exported_env["atmospheric_model_type"] == env.atmospheric_model_type + assert exported_env["atmospheric_model_file"] is None + assert exported_env["atmospheric_model_dict"] is None + assert exported_env["atmospheric_model_pressure_profile"] == str( + env.pressure.get_source() ) - assert ( - exported_env["atmospheric_model_temperature_profile"] - == ma.getdata(example_spaceport_env.temperature.get_source()).tolist() + assert exported_env["atmospheric_model_temperature_profile"] == str( + env.temperature.get_source() ) - assert ( - exported_env["atmospheric_model_wind_velocity_x_profile"] - == ma.getdata( - example_spaceport_env.wind_velocity_x.get_source()( - example_spaceport_env.height - ) - ).tolist() + assert exported_env["atmospheric_model_wind_velocity_x_profile"] == str( + env.wind_velocity_x.get_source() ) - assert ( - exported_env["atmospheric_model_wind_velocity_y_profile"] - == ma.getdata( - example_spaceport_env.wind_velocity_y.get_source()( - example_spaceport_env.height - ) - ).tolist() + assert exported_env["atmospheric_model_wind_velocity_y_profile"] == str( + env.wind_velocity_y.get_source() ) os.remove("environment.json") diff --git a/tests/unit/test_environment_analysis.py b/tests/unit/test_environment_analysis.py index 439fa0f04..fb86e9c04 100644 --- a/tests/unit/test_environment_analysis.py +++ b/tests/unit/test_environment_analysis.py @@ -1,4 +1,3 @@ -import copy import os from unittest.mock import patch @@ -12,7 +11,7 @@ @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_distribution_plots(mock_show, env_analysis): +def test_distribution_plots(mock_show, env_analysis): # pylint: disable=unused-argument """Tests the distribution plots method of the EnvironmentAnalysis class. It only checks if the method runs without errors. It does not check if the plots are correct, as this would require a lot of work and would be @@ -29,21 +28,21 @@ def test_distribution_plots(mock_show, env_analysis): """ # Check distribution plots - assert env_analysis.plots.wind_gust_distribution() == None + assert env_analysis.plots.wind_gust_distribution() is None assert ( env_analysis.plots.surface10m_wind_speed_distribution(wind_speed_limit=True) - == None + is None ) - assert env_analysis.plots.wind_gust_distribution_grid() == None + assert env_analysis.plots.wind_gust_distribution_grid() is None assert ( env_analysis.plots.surface_wind_speed_distribution_grid(wind_speed_limit=True) - == None + is None ) @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_average_plots(mock_show, env_analysis): +def test_average_plots(mock_show, env_analysis): # pylint: disable=unused-argument """Tests the average plots method of the EnvironmentAnalysis class. It only checks if the method runs without errors. It does not check if the plots are correct, as this would require a lot of work and would be @@ -59,17 +58,17 @@ def test_average_plots(mock_show, env_analysis): None """ # Check "average" plots - assert env_analysis.plots.average_surface_temperature_evolution() == None - assert env_analysis.plots.average_surface10m_wind_speed_evolution(False) == None - assert env_analysis.plots.average_surface10m_wind_speed_evolution(True) == None - assert env_analysis.plots.average_surface100m_wind_speed_evolution() == None - assert env_analysis.plots.average_wind_rose_grid() == None - assert env_analysis.plots.average_wind_rose_specific_hour(12) == None + assert env_analysis.plots.average_surface_temperature_evolution() is None + assert env_analysis.plots.average_surface10m_wind_speed_evolution(False) is None + assert env_analysis.plots.average_surface10m_wind_speed_evolution(True) is None + assert env_analysis.plots.average_surface100m_wind_speed_evolution() is None + assert env_analysis.plots.average_wind_rose_grid() is None + assert env_analysis.plots.average_wind_rose_specific_hour(12) is None @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_profile_plots(mock_show, env_analysis): +def test_profile_plots(mock_show, env_analysis): # pylint: disable=unused-argument """Check the profile plots method of the EnvironmentAnalysis class. It only checks if the method runs without errors. It does not check if the plots are correct, as this would require a lot of work and would be @@ -83,29 +82,29 @@ def test_profile_plots(mock_show, env_analysis): A simple object of the EnvironmentAnalysis class. """ # Check profile plots - assert env_analysis.plots.wind_heading_profile_grid(clear_range_limits=True) == None + assert env_analysis.plots.wind_heading_profile_grid(clear_range_limits=True) is None assert ( env_analysis.plots.average_wind_heading_profile(clear_range_limits=False) - == None + is None ) assert ( - env_analysis.plots.average_wind_heading_profile(clear_range_limits=True) == None + env_analysis.plots.average_wind_heading_profile(clear_range_limits=True) is None ) assert ( - env_analysis.plots.average_wind_speed_profile(clear_range_limits=False) == None + env_analysis.plots.average_wind_speed_profile(clear_range_limits=False) is None ) assert ( - env_analysis.plots.average_wind_speed_profile(clear_range_limits=True) == None + env_analysis.plots.average_wind_speed_profile(clear_range_limits=True) is None ) - assert env_analysis.plots.average_pressure_profile(clear_range_limits=False) == None - assert env_analysis.plots.average_pressure_profile(clear_range_limits=True) == None - assert env_analysis.plots.wind_speed_profile_grid(clear_range_limits=True) == None + assert env_analysis.plots.average_pressure_profile(clear_range_limits=False) is None + assert env_analysis.plots.average_pressure_profile(clear_range_limits=True) is None + assert env_analysis.plots.wind_speed_profile_grid(clear_range_limits=True) is None assert ( env_analysis.plots.average_wind_velocity_xy_profile(clear_range_limits=True) - == None + is None ) assert ( - env_analysis.plots.average_temperature_profile(clear_range_limits=True) == None + env_analysis.plots.average_temperature_profile(clear_range_limits=True) is None ) @@ -119,12 +118,8 @@ def test_values(env_analysis): ---------- env_analysis : EnvironmentAnalysis A simple object of the EnvironmentAnalysis class. - - Returns - ------- - None """ - assert pytest.approx(env_analysis.record_min_surface_wind_speed, 1e-6) == 5.190407 + assert pytest.approx(0.07569172, 1e-2) == env_analysis.record_min_surface_wind_speed assert ( pytest.approx(env_analysis.max_average_temperature_at_altitude, 1e-6) == 24.52549 @@ -139,7 +134,7 @@ def test_values(env_analysis): @pytest.mark.slow @patch("matplotlib.pyplot.show") -def test_animation_plots(mock_show, env_analysis): +def test_animation_plots(mock_show, env_analysis): # pylint: disable=unused-argument """Check the animation plots method of the EnvironmentAnalysis class. It only checks if the method runs without errors. It does not check if the plots are correct, as this would require a lot of work and would be diff --git a/tests/unit/test_flight.py b/tests/unit/test_flight.py index fd19b3558..078c682c9 100644 --- a/tests/unit/test_flight.py +++ b/tests/unit/test_flight.py @@ -5,7 +5,7 @@ import pytest from scipy import optimize -from rocketpy import Components, Environment, Flight, Function, Rocket, SolidMotor +from rocketpy import Components, Flight, Function, Rocket plt.rcParams.update({"figure.max_open_warning": 0}) @@ -190,13 +190,13 @@ def test_aerodynamic_moments(flight_calisto_custom_wind, flight_time, expected_v The expected values of the aerodynamic moments vector at the point to be tested. """ - expected_attr, expected_M = flight_time, expected_values + expected_attr, expected_moment = flight_time, expected_values test = flight_calisto_custom_wind t = getattr(test, expected_attr) atol = 5e-3 - assert pytest.approx(expected_M, abs=atol) == ( + assert pytest.approx(expected_moment, abs=atol) == ( test.M1(t), test.M2(t), test.M3(t), @@ -229,13 +229,13 @@ def test_aerodynamic_forces(flight_calisto_custom_wind, flight_time, expected_va The expected values of the aerodynamic forces vector at the point to be tested. """ - expected_attr, expected_R = flight_time, expected_values + expected_attr, expected_forces = flight_time, expected_values test = flight_calisto_custom_wind t = getattr(test, expected_attr) atol = 5e-3 - assert pytest.approx(expected_R, abs=atol) == ( + assert pytest.approx(expected_forces, abs=atol) == ( test.R1(t), test.R2(t), test.R3(t), @@ -466,7 +466,9 @@ def test_rail_length(calisto_robust, example_plain_env, rail_length, out_of_rail @patch("matplotlib.pyplot.show") -def test_lat_lon_conversion_robust(mock_show, example_spaceport_env, calisto_robust): +def test_lat_lon_conversion_robust( + mock_show, example_spaceport_env, calisto_robust +): # pylint: disable=unused-argument test_flight = Flight( rocket=calisto_robust, environment=example_spaceport_env, @@ -483,7 +485,9 @@ def test_lat_lon_conversion_robust(mock_show, example_spaceport_env, calisto_rob @patch("matplotlib.pyplot.show") -def test_lat_lon_conversion_from_origin(mock_show, example_plain_env, calisto_robust): +def test_lat_lon_conversion_from_origin( + mock_show, example_plain_env, calisto_robust +): # pylint: disable=unused-argument "additional tests to capture incorrect behaviors during lat/lon conversions" test_flight = Flight( @@ -503,7 +507,9 @@ def test_lat_lon_conversion_from_origin(mock_show, example_plain_env, calisto_ro "static_margin, max_time", [(-0.1, 2), (-0.01, 5), (0, 5), (0.01, 20), (0.1, 20), (1.0, 20)], ) -def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): +def test_stability_static_margins( + wind_u, wind_v, static_margin, max_time, example_plain_env, dummy_empty_motor +): """Test stability margins for a constant velocity flight, 100 m/s, wind a lateral wind speed of 10 m/s. Rocket has infinite mass to prevent side motion. Check if a restoring moment exists depending on static margins. @@ -518,11 +524,14 @@ def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): Static margin to be tested max_time : float Maximum time to be simulated + example_plain_env : rocketpy.Environment + This is a fixture. + dummy_empty_motor : rocketpy.SolidMotor + This is a fixture. """ # Create an environment with ZERO gravity to keep the rocket's speed constant - env = Environment(gravity=0, latitude=0, longitude=0, elevation=0) - env.set_atmospheric_model( + example_plain_env.set_atmospheric_model( type="custom_atmosphere", wind_u=wind_u, wind_v=wind_v, @@ -531,29 +540,7 @@ def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): ) # Make sure that the free_stream_mach will always be 0, so that the rocket # behaves as the STATIC (free_stream_mach=0) margin predicts - env.speed_of_sound = Function(1e16) - - # Create a motor with ZERO thrust and ZERO mass to keep the rocket's speed constant - # TODO: why don t we use these same values to create EmptyMotor class? - dummy_motor = SolidMotor( - thrust_source=1e-300, - burn_time=1e-10, - dry_mass=1.815, - dry_inertia=(0.125, 0.125, 0.002), - center_of_dry_mass_position=0.317, - grains_center_of_mass_position=0.397, - grain_number=5, - grain_separation=5 / 1000, - grain_density=1e-300, - grain_outer_radius=33 / 1000, - grain_initial_inner_radius=15 / 1000, - grain_initial_height=120 / 1000, - nozzle_radius=33 / 1000, - throat_radius=11 / 1000, - nozzle_position=0, - interpolation_method="linear", - coordinate_system_orientation="nozzle_to_combustion_chamber", - ) + example_plain_env.speed_of_sound = Function(1e16) # create a rocket with zero drag and huge mass to keep the rocket's speed constant dummy_rocket = Rocket( @@ -565,7 +552,7 @@ def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): center_of_mass_without_motor=0, ) dummy_rocket.set_rail_buttons(0.082, -0.618) - dummy_rocket.add_motor(dummy_motor, position=-1.373) + dummy_rocket.add_motor(dummy_empty_motor, position=-1.373) setup_rocket_with_given_static_margin(dummy_rocket, static_margin) @@ -578,13 +565,12 @@ def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): test_flight = Flight( rocket=dummy_rocket, rail_length=1, - environment=env, + environment=example_plain_env, initial_solution=initial_solution, max_time=max_time, max_time_step=1e-2, verbose=False, ) - test_flight.post_process(interpolation="linear") # Check stability according to static margin if wind_u == 0: @@ -594,8 +580,9 @@ def test_stability_static_margins(wind_u, wind_v, static_margin, max_time): moments = test_flight.M2.get_source()[:, 1] wind_sign = -np.sign(wind_u) - assert ( - (static_margin > 0 and np.max(moments) * np.min(moments) < 0) - or (static_margin < 0 and np.all(moments / wind_sign <= 0)) - or (static_margin == 0 and np.all(np.abs(moments) <= 1e-10)) - ) + if static_margin > 0: + assert np.max(moments) * np.min(moments) < 0 + elif static_margin < 0: + assert np.all(moments / wind_sign <= 0) + else: # static_margin == 0 + assert np.all(np.abs(moments) <= 1e-10) diff --git a/tests/unit/test_flight_time_nodes.py b/tests/unit/test_flight_time_nodes.py index 10f6b6c30..1e2661210 100644 --- a/tests/unit/test_flight_time_nodes.py +++ b/tests/unit/test_flight_time_nodes.py @@ -2,9 +2,7 @@ TimeNode. """ -import pytest - -from rocketpy.rocket import Parachute, _Controller +# from rocketpy.rocket import Parachute, _Controller def test_time_nodes_init(flight_calisto): diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index c68fe6587..9efb64c0c 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -1,9 +1,7 @@ -"""Unit tests for the Function class. Each method in tis module tests an +"""Unit tests for the Function class. Each method in tis module tests an individual method of the Function class. The tests are made on both the expected behaviour and the return instances.""" -from unittest.mock import patch - import matplotlib as plt import numpy as np import pytest @@ -307,7 +305,7 @@ def test_get_domain_dim(linear_func): def test_bool(linear_func): """Test the __bool__ method of the Function class.""" - assert bool(linear_func) == True + assert bool(linear_func) def test_getters(func_from_csv, func_2d_from_csv): @@ -361,89 +359,78 @@ def test_setters(func_from_csv, func_2d_from_csv): assert func_2d_from_csv.get_extrapolation_method() == "natural" -def test_interpolation_methods(linear_func): - """Tests some of the interpolation methods of the Function class. Methods - not tested here are already being called in other tests. - - Parameters - ---------- - linear_func : rocketpy.Function - A Function object created from a list of values. - """ - # Test Akima - assert isinstance(linear_func.set_interpolation("akima"), Function) - linear_func.set_interpolation("akima") - assert isinstance(linear_func.get_interpolation_method(), str) - assert linear_func.get_interpolation_method() == "akima" - assert np.isclose(linear_func.get_value(0), 0.0, atol=1e-6) - - # Test polynomial - - assert isinstance(linear_func.set_interpolation("polynomial"), Function) - linear_func.set_interpolation("polynomial") - assert isinstance(linear_func.get_interpolation_method(), str) - assert linear_func.get_interpolation_method() == "polynomial" - assert np.isclose(linear_func.get_value(0), 0.0, atol=1e-6) - - -def test_extrapolation_methods(linear_func): - """Test some of the extrapolation methods of the Function class. Methods - not tested here are already being called in other tests. - - Parameters - ---------- - linear_func : rocketpy.Function - A Function object created from a list of values. - """ - # Test zero - linear_func.set_extrapolation("zero") - assert linear_func.get_extrapolation_method() == "zero" - assert np.isclose(linear_func.get_value(-1), 0, atol=1e-6) - - # Test constant - assert isinstance(linear_func.set_extrapolation("constant"), Function) - linear_func.set_extrapolation("constant") - assert isinstance(linear_func.get_extrapolation_method(), str) - assert linear_func.get_extrapolation_method() == "constant" - assert np.isclose(linear_func.get_value(-1), 0, atol=1e-6) - - # Test natural for linear interpolation - linear_func.set_interpolation("linear") - assert isinstance(linear_func.set_extrapolation("natural"), Function) - linear_func.set_extrapolation("natural") - assert isinstance(linear_func.get_extrapolation_method(), str) - assert linear_func.get_extrapolation_method() == "natural" - assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) - - # Test natural for spline interpolation - linear_func.set_interpolation("spline") - assert isinstance(linear_func.set_extrapolation("natural"), Function) - linear_func.set_extrapolation("natural") - assert isinstance(linear_func.get_extrapolation_method(), str) - assert linear_func.get_extrapolation_method() == "natural" - assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) - - # Test natural for akima interpolation - linear_func.set_interpolation("akima") - assert isinstance(linear_func.set_extrapolation("natural"), Function) - linear_func.set_extrapolation("natural") - assert isinstance(linear_func.get_extrapolation_method(), str) - assert linear_func.get_extrapolation_method() == "natural" - assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) - - # Test natural for polynomial interpolation - linear_func.set_interpolation("polynomial") - assert isinstance(linear_func.set_extrapolation("natural"), Function) - linear_func.set_extrapolation("natural") - assert isinstance(linear_func.get_extrapolation_method(), str) - assert linear_func.get_extrapolation_method() == "natural" - assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) +class TestInterpolationMethods: + """Tests some of the interpolation methods of the Function class.""" + + def test_akima_interpolation(self, linear_func): + """Tests Akima interpolation method""" + assert isinstance(linear_func.set_interpolation("akima"), Function) + linear_func.set_interpolation("akima") + assert isinstance(linear_func.get_interpolation_method(), str) + assert linear_func.get_interpolation_method() == "akima" + assert np.isclose(linear_func.get_value(0), 0.0, atol=1e-6) + + def test_polynomial_interpolation(self, linear_func): + """Tests polynomial interpolation method""" + assert isinstance(linear_func.set_interpolation("polynomial"), Function) + linear_func.set_interpolation("polynomial") + assert isinstance(linear_func.get_interpolation_method(), str) + assert linear_func.get_interpolation_method() == "polynomial" + assert np.isclose(linear_func.get_value(0), 0.0, atol=1e-6) + + +class TestExtrapolationMethods: + """Test some of the extrapolation methods of the Function class.""" + + def test_zero_extrapolation(self, linear_func): + linear_func.set_extrapolation("zero") + assert linear_func.get_extrapolation_method() == "zero" + assert np.isclose(linear_func.get_value(-1), 0, atol=1e-6) + + def test_constant_extrapolation(self, linear_func): + assert isinstance(linear_func.set_extrapolation("constant"), Function) + linear_func.set_extrapolation("constant") + assert isinstance(linear_func.get_extrapolation_method(), str) + assert linear_func.get_extrapolation_method() == "constant" + assert np.isclose(linear_func.get_value(-1), 0, atol=1e-6) + + def test_natural_extrapolation_linear(self, linear_func): + linear_func.set_interpolation("linear") + assert isinstance(linear_func.set_extrapolation("natural"), Function) + linear_func.set_extrapolation("natural") + assert isinstance(linear_func.get_extrapolation_method(), str) + assert linear_func.get_extrapolation_method() == "natural" + assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) + + def test_natural_extrapolation_spline(self, linear_func): + linear_func.set_interpolation("spline") + assert isinstance(linear_func.set_extrapolation("natural"), Function) + linear_func.set_extrapolation("natural") + assert isinstance(linear_func.get_extrapolation_method(), str) + assert linear_func.get_extrapolation_method() == "natural" + assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) + + def test_natural_extrapolation_akima(self, linear_func): + linear_func.set_interpolation("akima") + assert isinstance(linear_func.set_extrapolation("natural"), Function) + linear_func.set_extrapolation("natural") + assert isinstance(linear_func.get_extrapolation_method(), str) + assert linear_func.get_extrapolation_method() == "natural" + assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) + + def test_natural_extrapolation_polynomial(self, linear_func): + linear_func.set_interpolation("polynomial") + assert isinstance(linear_func.set_extrapolation("natural"), Function) + linear_func.set_extrapolation("natural") + assert isinstance(linear_func.get_extrapolation_method(), str) + assert linear_func.get_extrapolation_method() == "natural" + assert np.isclose(linear_func.get_value(-1), -1, atol=1e-6) @pytest.mark.parametrize("a", [-1, 0, 1]) @pytest.mark.parametrize("b", [-1, 0, 1]) -def test_multivariable_dataset(a, b): - """Test the Function class with a multivariable dataset.""" +def test_multivariate_dataset(a, b): + """Test the Function class with a multivariate dataset.""" # Test plane f(x,y) = x + y source = [ (-1, -1, -2), @@ -517,10 +504,12 @@ def test_3d_shepard_interpolation(x, y, z, w_expected): @pytest.mark.parametrize("a", [-1, -0.5, 0, 0.5, 1]) @pytest.mark.parametrize("b", [-1, -0.5, 0, 0.5, 1]) -def test_multivariable_function(a, b): - """Test the Function class with a multivariable function.""" - # Test plane f(x,y) = sin(x + y) - source = lambda x, y: np.sin(x + y) +def test_multivariate_function(a, b): + """Test the Function class with a multivariate function.""" + + def source(x, y): + return np.sin(x + y) + func = Function(source=source, inputs=["x", "y"], outputs=["z"]) # Assert values diff --git a/tests/unit/test_genericmotor.py b/tests/unit/test_genericmotor.py index b1bd5fd8e..c6321ae4d 100644 --- a/tests/unit/test_genericmotor.py +++ b/tests/unit/test_genericmotor.py @@ -1,19 +1,22 @@ -from unittest.mock import patch - import numpy as np import pytest import scipy.integrate -burn_time = (2, 7) -thrust_source = lambda t: 2000 - 100 * (t - 2) -chamber_height = 0.5 -chamber_radius = 0.075 -chamber_position = -0.25 -propellant_initial_mass = 5.0 -nozzle_position = -0.5 -nozzle_radius = 0.075 -dry_mass = 8.0 -dry_inertia = (0.2, 0.2, 0.08) +BURN_TIME = (2, 7) + + +def thrust_source(t): + return 2000 - 100 * (t - 2) + + +CHAMBER_HEIGHT = 0.5 +CHAMBER_RADIUS = 0.075 +CHAMBER_POSITION = -0.25 +PROPELLANT_INITIAL_MASS = 5.0 +NOZZLE_POSITION = -0.5 +NOZZLE_RADIUS = 0.075 +DRY_MASS = 8.0 +DRY_INERTIA = (0.2, 0.2, 0.08) def test_generic_motor_basic_parameters(generic_motor): @@ -24,19 +27,19 @@ def test_generic_motor_basic_parameters(generic_motor): generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. """ - assert generic_motor.burn_time == burn_time - assert generic_motor.dry_mass == dry_mass + assert generic_motor.burn_time == BURN_TIME + assert generic_motor.dry_mass == DRY_MASS assert ( generic_motor.dry_I_11, generic_motor.dry_I_22, generic_motor.dry_I_33, - ) == dry_inertia - assert generic_motor.nozzle_position == nozzle_position - assert generic_motor.nozzle_radius == nozzle_radius - assert generic_motor.chamber_position == chamber_position - assert generic_motor.chamber_radius == chamber_radius - assert generic_motor.chamber_height == chamber_height - assert generic_motor.propellant_initial_mass == propellant_initial_mass + ) == DRY_INERTIA + assert generic_motor.nozzle_position == NOZZLE_POSITION + assert generic_motor.nozzle_radius == NOZZLE_RADIUS + assert generic_motor.chamber_position == CHAMBER_POSITION + assert generic_motor.chamber_radius == CHAMBER_RADIUS + assert generic_motor.chamber_height == CHAMBER_HEIGHT + assert generic_motor.propellant_initial_mass == PROPELLANT_INITIAL_MASS def test_generic_motor_thrust_parameters(generic_motor): @@ -48,20 +51,20 @@ def test_generic_motor_thrust_parameters(generic_motor): The GenericMotor object to be used in the tests. """ expected_thrust = np.array( - [(t, thrust_source(t)) for t in np.linspace(*burn_time, 50)] + [(t, thrust_source(t)) for t in np.linspace(*BURN_TIME, 50)] ) expected_total_impulse = scipy.integrate.trapezoid( expected_thrust[:, 1], expected_thrust[:, 0] ) - expected_exhaust_velocity = expected_total_impulse / propellant_initial_mass + expected_exhaust_velocity = expected_total_impulse / PROPELLANT_INITIAL_MASS expected_mass_flow_rate = -1 * expected_thrust[:, 1] / expected_exhaust_velocity # Discretize mass flow rate for testing purposes - mass_flow_rate = generic_motor.total_mass_flow_rate.set_discrete(*burn_time, 50) + mass_flow_rate = generic_motor.total_mass_flow_rate.set_discrete(*BURN_TIME, 50) assert generic_motor.thrust.y_array == pytest.approx(expected_thrust[:, 1]) assert generic_motor.total_impulse == pytest.approx(expected_total_impulse) - assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( + assert generic_motor.exhaust_velocity.average(*BURN_TIME) == pytest.approx( expected_exhaust_velocity ) assert mass_flow_rate.y_array == pytest.approx(expected_mass_flow_rate) @@ -80,8 +83,8 @@ def test_generic_motor_center_of_mass(generic_motor): center_of_mass = -0.25 # Discretize center of mass for testing purposes - generic_motor.center_of_propellant_mass.set_discrete(*burn_time, 50) - generic_motor.center_of_mass.set_discrete(*burn_time, 50) + generic_motor.center_of_propellant_mass.set_discrete(*BURN_TIME, 50) + generic_motor.center_of_mass.set_discrete(*BURN_TIME, 50) assert generic_motor.center_of_propellant_mass.y_array == pytest.approx( center_of_propellant_mass @@ -101,24 +104,24 @@ def test_generic_motor_inertia(generic_motor): The GenericMotor object to be used in the tests. """ # Tests the inertia formulation from the propellant mass - propellant_mass = generic_motor.propellant_mass.set_discrete(*burn_time, 50).y_array + propellant_mass = generic_motor.propellant_mass.set_discrete(*BURN_TIME, 50).y_array - propellant_I_11 = propellant_mass * (chamber_radius**2 / 4 + chamber_height**2 / 12) + propellant_I_11 = propellant_mass * (CHAMBER_RADIUS**2 / 4 + CHAMBER_HEIGHT**2 / 12) propellant_I_22 = propellant_I_11 - propellant_I_33 = propellant_mass * (chamber_radius**2 / 2) + propellant_I_33 = propellant_mass * (CHAMBER_RADIUS**2 / 2) # Centers of mass coincide, so no translation is needed - I_11 = propellant_I_11 + dry_inertia[0] - I_22 = propellant_I_22 + dry_inertia[1] - I_33 = propellant_I_33 + dry_inertia[2] + I_11 = propellant_I_11 + DRY_INERTIA[0] + I_22 = propellant_I_22 + DRY_INERTIA[1] + I_33 = propellant_I_33 + DRY_INERTIA[2] # Discretize inertia for testing purposes - generic_motor.propellant_I_11.set_discrete(*burn_time, 50) - generic_motor.propellant_I_22.set_discrete(*burn_time, 50) - generic_motor.propellant_I_33.set_discrete(*burn_time, 50) - generic_motor.I_11.set_discrete(*burn_time, 50) - generic_motor.I_22.set_discrete(*burn_time, 50) - generic_motor.I_33.set_discrete(*burn_time, 50) + generic_motor.propellant_I_11.set_discrete(*BURN_TIME, 50) + generic_motor.propellant_I_22.set_discrete(*BURN_TIME, 50) + generic_motor.propellant_I_33.set_discrete(*BURN_TIME, 50) + generic_motor.I_11.set_discrete(*BURN_TIME, 50) + generic_motor.I_22.set_discrete(*BURN_TIME, 50) + generic_motor.I_33.set_discrete(*BURN_TIME, 50) assert generic_motor.propellant_I_11.y_array == pytest.approx(propellant_I_11) assert generic_motor.propellant_I_22.y_array == pytest.approx(propellant_I_22) diff --git a/tests/unit/test_hybridmotor.py b/tests/unit/test_hybridmotor.py index acf4b3e54..ef03a1998 100644 --- a/tests/unit/test_hybridmotor.py +++ b/tests/unit/test_hybridmotor.py @@ -1,26 +1,28 @@ -from unittest.mock import patch - import numpy as np import pytest import scipy.integrate from rocketpy import Function -thrust_function = lambda t: 2000 - 100 * t -burn_time = 10 -center_of_dry_mass = 0 -dry_inertia = (4, 4, 0.1) -dry_mass = 8 -grain_density = 1700 -grain_number = 4 -grain_initial_height = 0.1 -grain_separation = 0 -grain_initial_inner_radius = 0.04 -grain_outer_radius = 0.1 -nozzle_position = -0.4 -nozzle_radius = 0.07 -grains_center_of_mass_position = -0.1 -oxidizer_tank_position = 0.3 + +def thrust_function(t): + return 2000 - 100 * t + + +BURN_TIME = 10 +CENTER_OF_DRY_MASS = 0 +DRY_INERTIA = (4, 4, 0.1) +DRY_MASS = 8 +GRAIN_DENSITY = 1700 +GRAIN_NUMBER = 4 +GRAIN_INITIAL_HEIGHT = 0.1 +GRAIN_SEPARATION = 0 +GRAIN_INITIAL_INNER_RADIUS = 0.04 +GRAIN_OUTER_RADIUS = 0.1 +NOZZLE_POSITION = -0.4 +NOZZLE_RADIUS = 0.07 +GRAINS_CENTER_OF_MASS_POSITION = -0.1 +OXIDIZER_TANK_POSITION = 0.3 def test_hybrid_motor_basic_parameters(hybrid_motor): @@ -31,25 +33,25 @@ def test_hybrid_motor_basic_parameters(hybrid_motor): hybrid_motor : rocketpy.HybridMotor The HybridMotor object to be used in the tests. """ - assert hybrid_motor.burn_time == (0, burn_time) - assert hybrid_motor.dry_mass == dry_mass + assert hybrid_motor.burn_time == (0, BURN_TIME) + assert hybrid_motor.dry_mass == DRY_MASS assert ( hybrid_motor.dry_I_11, hybrid_motor.dry_I_22, hybrid_motor.dry_I_33, - ) == dry_inertia - assert hybrid_motor.center_of_dry_mass_position == center_of_dry_mass - assert hybrid_motor.nozzle_position == nozzle_position - assert hybrid_motor.nozzle_radius == nozzle_radius - assert hybrid_motor.solid.grain_number == grain_number - assert hybrid_motor.solid.grain_density == grain_density - assert hybrid_motor.solid.grain_initial_height == grain_initial_height - assert hybrid_motor.solid.grain_separation == grain_separation - assert hybrid_motor.solid.grain_initial_inner_radius == grain_initial_inner_radius - assert hybrid_motor.solid.grain_outer_radius == grain_outer_radius + ) == DRY_INERTIA + assert hybrid_motor.center_of_dry_mass_position == CENTER_OF_DRY_MASS + assert hybrid_motor.nozzle_position == NOZZLE_POSITION + assert hybrid_motor.nozzle_radius == NOZZLE_RADIUS + assert hybrid_motor.solid.grain_number == GRAIN_NUMBER + assert hybrid_motor.solid.grain_density == GRAIN_DENSITY + assert hybrid_motor.solid.grain_initial_height == GRAIN_INITIAL_HEIGHT + assert hybrid_motor.solid.grain_separation == GRAIN_SEPARATION + assert hybrid_motor.solid.grain_initial_inner_radius == GRAIN_INITIAL_INNER_RADIUS + assert hybrid_motor.solid.grain_outer_radius == GRAIN_OUTER_RADIUS assert ( hybrid_motor.solid.grains_center_of_mass_position - == grains_center_of_mass_position + == GRAINS_CENTER_OF_MASS_POSITION ) assert hybrid_motor.liquid.positioned_tanks[0]["position"] == 0.3 @@ -69,11 +71,11 @@ def test_hybrid_motor_thrust_parameters(hybrid_motor, spherical_oxidizer_tank): expected_total_impulse = scipy.integrate.quad(expected_thrust, 0, 10)[0] initial_grain_mass = ( - grain_density + GRAIN_DENSITY * np.pi - * (grain_outer_radius**2 - grain_initial_inner_radius**2) - * grain_initial_height - * grain_number + * (GRAIN_OUTER_RADIUS**2 - GRAIN_INITIAL_INNER_RADIUS**2) + * GRAIN_INITIAL_HEIGHT + * GRAIN_NUMBER ) initial_oxidizer_mass = spherical_oxidizer_tank.fluid_mass(0) initial_mass = initial_grain_mass + initial_oxidizer_mass @@ -111,13 +113,13 @@ def test_hybrid_motor_center_of_mass(hybrid_motor, spherical_oxidizer_tank): oxidizer_mass = spherical_oxidizer_tank.fluid_mass grain_mass = hybrid_motor.solid.propellant_mass - propellant_balance = grain_mass * grains_center_of_mass_position + oxidizer_mass * ( - oxidizer_tank_position + spherical_oxidizer_tank.center_of_mass + propellant_balance = grain_mass * GRAINS_CENTER_OF_MASS_POSITION + oxidizer_mass * ( + OXIDIZER_TANK_POSITION + spherical_oxidizer_tank.center_of_mass ) - balance = propellant_balance + dry_mass * center_of_dry_mass + balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS propellant_center_of_mass = propellant_balance / (grain_mass + oxidizer_mass) - center_of_mass = balance / (grain_mass + oxidizer_mass + dry_mass) + center_of_mass = balance / (grain_mass + oxidizer_mass + DRY_MASS) for t in np.linspace(0, 100, 100): assert pytest.approx( @@ -145,12 +147,12 @@ def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank): # Validate parallel axis theorem translation grain_inertia += ( grain_mass - * (grains_center_of_mass_position - hybrid_motor.center_of_propellant_mass) ** 2 + * (GRAINS_CENTER_OF_MASS_POSITION - hybrid_motor.center_of_propellant_mass) ** 2 ) oxidizer_inertia += ( oxidizer_mass * ( - oxidizer_tank_position + OXIDIZER_TANK_POSITION + spherical_oxidizer_tank.center_of_mass - hybrid_motor.center_of_propellant_mass ) @@ -164,8 +166,8 @@ def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank): propellant_inertia + propellant_mass * (hybrid_motor.center_of_propellant_mass - hybrid_motor.center_of_mass) ** 2 - + dry_inertia[0] - + dry_mass * (-hybrid_motor.center_of_mass + center_of_dry_mass) ** 2 + + DRY_INERTIA[0] + + DRY_MASS * (-hybrid_motor.center_of_mass + CENTER_OF_DRY_MASS) ** 2 ) for t in np.linspace(0, 100, 100): diff --git a/tests/unit/test_liquidmotor.py b/tests/unit/test_liquidmotor.py index ed4fe0ab3..6208a7dc0 100644 --- a/tests/unit/test_liquidmotor.py +++ b/tests/unit/test_liquidmotor.py @@ -1,20 +1,18 @@ -from unittest.mock import patch - import numpy as np import pytest import scipy.integrate from rocketpy import Function -burn_time = (8, 20) -dry_mass = 10 -dry_inertia = (5, 5, 0.2) -center_of_dry_mass = 0 -nozzle_position = -1.364 -nozzle_radius = 0.069 / 2 -pressurant_tank_position = 2.007 -fuel_tank_position = -1.048 -oxidizer_tank_position = 0.711 +BURN_TIME = (8, 20) +DRY_MASS = 10 +DRY_INERTIA = (5, 5, 0.2) +CENTER_OF_DRY_MASS = 0 +NOZZLE_POSITION = -1.364 +NOZZLE_RADIUS = 0.069 / 2 +PRESSURANT_TANK_POSITION = 2.007 +FUEL_TANK_POSITION = -1.048 +OXIDIZER_TANK_POSITION = 0.711 def test_liquid_motor_basic_parameters(liquid_motor): @@ -25,19 +23,19 @@ def test_liquid_motor_basic_parameters(liquid_motor): liquid_motor : rocketpy.LiquidMotor The LiquidMotor object to be used in the tests. """ - assert liquid_motor.burn_time == burn_time - assert liquid_motor.dry_mass == dry_mass + assert liquid_motor.burn_time == BURN_TIME + assert liquid_motor.dry_mass == DRY_MASS assert ( liquid_motor.dry_I_11, liquid_motor.dry_I_22, liquid_motor.dry_I_33, - ) == dry_inertia - assert liquid_motor.center_of_dry_mass_position == center_of_dry_mass - assert liquid_motor.nozzle_position == nozzle_position - assert liquid_motor.nozzle_radius == nozzle_radius - assert liquid_motor.positioned_tanks[0]["position"] == pressurant_tank_position - assert liquid_motor.positioned_tanks[1]["position"] == fuel_tank_position - assert liquid_motor.positioned_tanks[2]["position"] == oxidizer_tank_position + ) == DRY_INERTIA + assert liquid_motor.center_of_dry_mass_position == CENTER_OF_DRY_MASS + assert liquid_motor.nozzle_position == NOZZLE_POSITION + assert liquid_motor.nozzle_radius == NOZZLE_RADIUS + assert liquid_motor.positioned_tanks[0]["position"] == PRESSURANT_TANK_POSITION + assert liquid_motor.positioned_tanks[1]["position"] == FUEL_TANK_POSITION + assert liquid_motor.positioned_tanks[2]["position"] == OXIDIZER_TANK_POSITION def test_liquid_motor_thrust_parameters( @@ -125,12 +123,12 @@ def test_liquid_motor_mass_volume( ) # Perform default discretization - expected_pressurant_mass.set_discrete(*burn_time, 100) - expected_fuel_mass.set_discrete(*burn_time, 100) - expected_oxidizer_mass.set_discrete(*burn_time, 100) - expected_pressurant_volume.set_discrete(*burn_time, 100) - expected_fuel_volume.set_discrete(*burn_time, 100) - expected_oxidizer_volume.set_discrete(*burn_time, 100) + expected_pressurant_mass.set_discrete(*BURN_TIME, 100) + expected_fuel_mass.set_discrete(*BURN_TIME, 100) + expected_oxidizer_mass.set_discrete(*BURN_TIME, 100) + expected_pressurant_volume.set_discrete(*BURN_TIME, 100) + expected_fuel_volume.set_discrete(*BURN_TIME, 100) + expected_oxidizer_volume.set_discrete(*BURN_TIME, 100) assert ( pytest.approx(expected_pressurant_mass.y_array, 0.01) @@ -180,14 +178,14 @@ def test_liquid_motor_center_of_mass( propellant_mass = pressurant_mass + fuel_mass + oxidizer_mass propellant_balance = ( - pressurant_mass * (pressurant_tank.center_of_mass + pressurant_tank_position) - + fuel_mass * (fuel_tank.center_of_mass + fuel_tank_position) - + oxidizer_mass * (oxidizer_tank.center_of_mass + oxidizer_tank_position) + pressurant_mass * (pressurant_tank.center_of_mass + PRESSURANT_TANK_POSITION) + + fuel_mass * (fuel_tank.center_of_mass + FUEL_TANK_POSITION) + + oxidizer_mass * (oxidizer_tank.center_of_mass + OXIDIZER_TANK_POSITION) ) - balance = propellant_balance + dry_mass * center_of_dry_mass + balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS propellant_center_of_mass = propellant_balance / propellant_mass - center_of_mass = balance / (propellant_mass + dry_mass) + center_of_mass = balance / (propellant_mass + DRY_MASS) assert ( pytest.approx(liquid_motor.center_of_propellant_mass.y_array) @@ -223,7 +221,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer * ( pressurant_tank.center_of_mass - liquid_motor.center_of_propellant_mass - + pressurant_tank_position + + PRESSURANT_TANK_POSITION ) ** 2 ) @@ -232,7 +230,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer * ( fuel_tank.center_of_mass - liquid_motor.center_of_propellant_mass - + fuel_tank_position + + FUEL_TANK_POSITION ) ** 2 ) @@ -241,7 +239,7 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer * ( oxidizer_tank.center_of_mass - liquid_motor.center_of_propellant_mass - + oxidizer_tank_position + + OXIDIZER_TANK_POSITION ) ** 2 ) @@ -253,8 +251,8 @@ def test_liquid_motor_inertia(liquid_motor, pressurant_tank, fuel_tank, oxidizer propellant_inertia + propellant_mass * (liquid_motor.center_of_propellant_mass - liquid_motor.center_of_mass) ** 2 - + dry_inertia[0] - + dry_mass * (-liquid_motor.center_of_mass + center_of_dry_mass) ** 2 + + DRY_INERTIA[0] + + DRY_MASS * (-liquid_motor.center_of_mass + CENTER_OF_DRY_MASS) ** 2 ) assert ( diff --git a/tests/unit/test_monte_carlo.py b/tests/unit/test_monte_carlo.py index 7af6a5db5..f168b8bfe 100644 --- a/tests/unit/test_monte_carlo.py +++ b/tests/unit/test_monte_carlo.py @@ -1,8 +1,5 @@ -from unittest.mock import patch - import matplotlib as plt import numpy as np -import pytest plt.rcParams.update({"figure.max_open_warning": 0}) @@ -37,13 +34,12 @@ def test_stochastic_solid_motor_create_object_with_impulse(stochastic_solid_moto stochastic_solid_motor : StochasticSolidMotor The stochastic solid motor object, this is a pytest fixture. """ - total_impulse = [] - for _ in range(20): - random_motor = stochastic_solid_motor.create_object() - total_impulse.append(random_motor.total_impulse) + total_impulse = [ + stochastic_solid_motor.create_object().total_impulse for _ in range(200) + ] assert np.isclose(np.mean(total_impulse), 6500, rtol=0.3) - assert np.isclose(np.std(total_impulse), 1000, rtol=0.3) + assert np.isclose(np.std(total_impulse), 1000, rtol=0.4) def test_stochastic_calisto_create_object_with_static_margin(stochastic_calisto): diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index cafd7cf8b..cd35f8d11 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -1,14 +1,12 @@ -import os from unittest.mock import patch import matplotlib.pyplot as plt -from rocketpy import Flight -from rocketpy.plots.compare import Compare, CompareFlights +from rocketpy.plots.compare import Compare @patch("matplotlib.pyplot.show") -def test_compare(mock_show, flight_calisto): +def test_compare(mock_show, flight_calisto): # pylint: disable=unused-argument """Here we want to test the 'x_attributes' argument, which is the only one that is not tested in the other tests. @@ -39,4 +37,4 @@ def test_compare(mock_show, flight_calisto): x_attributes=["time"], ) - assert isinstance(fig, plt.Figure) == True + assert isinstance(fig, plt.Figure) diff --git a/tests/unit/test_rocket.py b/tests/unit/test_rocket.py index ea9b5972f..a984466ee 100644 --- a/tests/unit/test_rocket.py +++ b/tests/unit/test_rocket.py @@ -8,14 +8,14 @@ @patch("matplotlib.pyplot.show") -def test_elliptical_fins(mock_show, calisto_robust, calisto_trapezoidal_fins): +def test_elliptical_fins( + mock_show, calisto_robust, calisto_trapezoidal_fins +): # pylint: disable=unused-argument test_rocket = calisto_robust calisto_robust.aerodynamic_surfaces.remove(calisto_trapezoidal_fins) - fin_set = test_rocket.add_elliptical_fins( - 4, span=0.100, root_chord=0.120, position=-1.168 - ) + test_rocket.add_elliptical_fins(4, span=0.100, root_chord=0.120, position=-1.168) static_margin = test_rocket.static_margin(0) - assert test_rocket.all_info() == None or not abs(static_margin - 2.30) < 0.01 + assert test_rocket.all_info() is None or not abs(static_margin - 2.30) < 0.01 def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto): @@ -36,11 +36,11 @@ def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto): @pytest.mark.parametrize( - "k, type", + "k, type_", ([2 / 3, "conical"], [0.46469957130675876, "ogive"], [0.563, "lvhaack"]), ) -def test_add_nose_assert_cp_cm_plus_nose(k, type, calisto, dimensionless_calisto, m): - calisto.add_nose(length=0.55829, kind=type, position=1.160) +def test_add_nose_assert_cp_cm_plus_nose(k, type_, calisto, dimensionless_calisto, m): + calisto.add_nose(length=0.55829, kind=type_, position=1.160) cpz = (1.160) - k * 0.55829 # Relative to the center of dry mass clalpha = 2 @@ -53,7 +53,7 @@ def test_add_nose_assert_cp_cm_plus_nose(k, type, calisto, dimensionless_calisto assert clalpha == pytest.approx(calisto.total_lift_coeff_der(0), 1e-8) assert calisto.cp_position(0) == pytest.approx(cpz, 1e-8) - dimensionless_calisto.add_nose(length=0.55829 * m, kind=type, position=(1.160) * m) + 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( calisto.static_margin(0), 1e-8 ) @@ -245,7 +245,7 @@ def test_add_fins_assert_cp_cm_plus_fins(calisto, dimensionless_calisto, m): @pytest.mark.parametrize( - """cdm_position, grain_cm_position, nozzle_position, coord_direction, + """cdm_position, grain_cm_position, nozzle_position, coord_direction, motor_position, expected_motor_cdm, expected_motor_cpp""", [ (0.317, 0.397, 0, "nozzle_to_combustion_chamber", -1.373, -1.056, -0.976), @@ -449,9 +449,9 @@ def test_evaluate_com_to_cdm_function(calisto): def test_get_inertia_tensor_at_time(calisto): # Expected values (for t = 0) # TODO: compute these values by hand or using CAD. - Ixx = 10.31379 - Iyy = 10.31379 - Izz = 0.039942 + I_11 = 10.31379 + I_22 = 10.31379 + I_33 = 0.039942 # Set tolerance threshold atol = 1e-5 @@ -460,9 +460,9 @@ def test_get_inertia_tensor_at_time(calisto): inertia_tensor = calisto.get_inertia_tensor_at_time(0) # Check if the values are close to the expected ones - assert pytest.approx(Ixx, atol) == inertia_tensor.x[0] - assert pytest.approx(Iyy, atol) == inertia_tensor.y[1] - assert pytest.approx(Izz, atol) == inertia_tensor.z[2] + assert pytest.approx(I_11, atol) == inertia_tensor.x[0] + assert pytest.approx(I_22, atol) == inertia_tensor.y[1] + assert pytest.approx(I_33, atol) == inertia_tensor.z[2] # Check if products of inertia are zero assert pytest.approx(0, atol) == inertia_tensor.x[1] assert pytest.approx(0, atol) == inertia_tensor.x[2] @@ -475,9 +475,9 @@ def test_get_inertia_tensor_at_time(calisto): def test_get_inertia_tensor_derivative_at_time(calisto): # Expected values (for t = 2s) # TODO: compute these values by hand or using CAD. - Ixx_dot = -0.634805230901143 - Iyy_dot = -0.634805230901143 - Izz_dot = -0.000671493662305 + I_11_dot = -0.634805230901143 + I_22_dot = -0.634805230901143 + I_33_dot = -0.000671493662305 # Set tolerance threshold atol = 1e-3 @@ -486,9 +486,9 @@ def test_get_inertia_tensor_derivative_at_time(calisto): inertia_tensor = calisto.get_inertia_tensor_derivative_at_time(2) # Check if the values are close to the expected ones - assert pytest.approx(Ixx_dot, atol) == inertia_tensor.x[0] - assert pytest.approx(Iyy_dot, atol) == inertia_tensor.y[1] - assert pytest.approx(Izz_dot, atol) == inertia_tensor.z[2] + assert pytest.approx(I_11_dot, atol) == inertia_tensor.x[0] + assert pytest.approx(I_22_dot, atol) == inertia_tensor.y[1] + assert pytest.approx(I_33_dot, atol) == inertia_tensor.z[2] # Check if products of inertia are zero assert pytest.approx(0, atol) == inertia_tensor.x[1] assert pytest.approx(0, atol) == inertia_tensor.x[2] @@ -514,77 +514,72 @@ def test_add_cm_eccentricity(calisto): assert calisto.thrust_eccentricity_y == 0.1 -def test_add_surfaces_different_noses(calisto): +class TestAddSurfaces: """Test the add_surfaces method with different nose cone configurations. More specifically, this will check the static margin of the rocket with - different nose cone configurations. - - Parameters - ---------- - calisto : Rocket - Pytest fixture for the calisto rocket. - """ - length = 0.55829 - kind = "vonkarman" - position = 1.16 - bluffness = 0 - base_radius = 0.0635 - rocket_radius = 0.0635 - - # Case 1: base_radius == rocket_radius - nose1 = NoseCone( - length, - kind, - base_radius=base_radius, - bluffness=bluffness, - rocket_radius=rocket_radius, - name="Nose Cone 1", - ) - calisto.add_surfaces(nose1, position) - assert nose1.radius_ratio == pytest.approx(1, 1e-8) - assert calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) - - # Case 2: base_radius == rocket_radius / 2 - calisto.aerodynamic_surfaces.remove(nose1) - nose2 = NoseCone( - length, - kind, - base_radius=base_radius / 2, - bluffness=bluffness, - rocket_radius=rocket_radius, - name="Nose Cone 2", - ) - calisto.add_surfaces(nose2, position) - assert nose2.radius_ratio == pytest.approx(0.5, 1e-8) - assert calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) - - # Case 3: base_radius == None - calisto.aerodynamic_surfaces.remove(nose2) - nose3 = NoseCone( - length, - kind, - base_radius=None, - bluffness=bluffness, - rocket_radius=rocket_radius * 2, - name="Nose Cone 3", - ) - calisto.add_surfaces(nose3, position) - assert nose3.radius_ratio == pytest.approx(1, 1e-8) - assert calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) - - # Case 4: rocket_radius == None - calisto.aerodynamic_surfaces.remove(nose3) - nose4 = NoseCone( - length, - kind, - base_radius=base_radius, - bluffness=bluffness, - rocket_radius=None, - name="Nose Cone 4", - ) - calisto.add_surfaces(nose4, position) - assert nose4.radius_ratio == pytest.approx(1, 1e-8) - assert calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) + different nose cone configurations.""" + + @pytest.fixture(autouse=True) + def setup(self, calisto): + self.calisto = calisto + self.length = 0.55829 + self.kind = "vonkarman" + self.position = 1.16 + self.bluffness = 0 + self.base_radius = 0.0635 + self.rocket_radius = 0.0635 + + def test_add_surfaces_base_equals_rocket_radius(self): + nose = NoseCone( + self.length, + self.kind, + base_radius=self.base_radius, + bluffness=self.bluffness, + rocket_radius=self.rocket_radius, + name="Nose Cone 1", + ) + self.calisto.add_surfaces(nose, self.position) + assert nose.radius_ratio == pytest.approx(1, 1e-8) + assert self.calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) + + def test_add_surfaces_base_half_rocket_radius(self): + nose = NoseCone( + self.length, + self.kind, + base_radius=self.base_radius / 2, + bluffness=self.bluffness, + rocket_radius=self.rocket_radius, + name="Nose Cone 2", + ) + self.calisto.add_surfaces(nose, self.position) + assert nose.radius_ratio == pytest.approx(0.5, 1e-8) + assert self.calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) + + def test_add_surfaces_base_radius_none(self): + nose = NoseCone( + self.length, + self.kind, + base_radius=None, + bluffness=self.bluffness, + rocket_radius=self.rocket_radius * 2, + name="Nose Cone 3", + ) + self.calisto.add_surfaces(nose, self.position) + assert nose.radius_ratio == pytest.approx(1, 1e-8) + assert self.calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) + + def test_add_surfaces_rocket_radius_none(self): + nose = NoseCone( + self.length, + self.kind, + base_radius=self.base_radius, + bluffness=self.bluffness, + rocket_radius=None, + name="Nose Cone 4", + ) + self.calisto.add_surfaces(nose, self.position) + assert nose.radius_ratio == pytest.approx(1, 1e-8) + assert self.calisto.static_margin(0) == pytest.approx(-8.9053, 0.01) def test_coordinate_system_orientation( diff --git a/tests/unit/test_solidmotor.py b/tests/unit/test_solidmotor.py index dd3e54faa..064c8210e 100644 --- a/tests/unit/test_solidmotor.py +++ b/tests/unit/test_solidmotor.py @@ -18,21 +18,9 @@ GRAIN_VOL = 0.12 * (np.pi * (0.033**2 - 0.015**2)) GRAIN_MASS = GRAIN_VOL * 1815 * 5 -burn_time = 3.9 -grain_number = 5 -grain_separation = 5 / 1000 -grain_density = 1815 -grain_outer_radius = 33 / 1000 -grain_initial_inner_radius = 15 / 1000 -grain_initial_height = 120 / 1000 -nozzle_radius = 33 / 1000 -throat_radius = 11 / 1000 -grain_vol = 0.12 * (np.pi * (0.033**2 - 0.015**2)) -grain_mass = grain_vol * 1815 * 5 - @patch("matplotlib.pyplot.show") -def test_motor(mock_show, cesaroni_m1670): +def test_motor(mock_show, cesaroni_m1670): # pylint: disable=unused-argument """Tests the SolidMotor.all_info() method. Parameters @@ -42,7 +30,7 @@ def test_motor(mock_show, cesaroni_m1670): cesaroni_m1670 : rocketpy.SolidMotor The SolidMotor object to be used in the tests. """ - assert cesaroni_m1670.all_info() == None + assert cesaroni_m1670.all_info() is None def test_evaluate_inertia_11_asserts_extreme_values(cesaroni_m1670): @@ -152,11 +140,11 @@ def tests_export_eng_asserts_exported_values_correct(cesaroni_m1670): assert comments == [] assert description == [ "test_motor", - "{:3.1f}".format(2000 * GRAIN_OUTER_RADIUS), - "{:3.1f}".format(1000 * 5 * (0.12 + 0.005)), + f"{2000 * GRAIN_OUTER_RADIUS:3.1f}", + f"{1000 * 5 * (0.12 + 0.005):3.1f}", "0", - "{:2.3}".format(GRAIN_MASS), - "{:2.3}".format(GRAIN_MASS), + f"{GRAIN_MASS:2.3}", + f"{GRAIN_MASS:2.3}", "RocketPy", ] @@ -181,31 +169,31 @@ def tests_export_eng_asserts_exported_values_correct(cesaroni_m1670): def test_initialize_motor_asserts_dynamic_values(cesaroni_m1670): - grain_vol = grain_initial_height * ( - np.pi * (grain_outer_radius**2 - grain_initial_inner_radius**2) + grain_vol = GRAIN_INITIAL_HEIGHT * ( + np.pi * (GRAIN_OUTER_RADIUS**2 - GRAIN_INITIAL_INNER_RADIUS**2) ) - grain_mass = grain_vol * grain_density + grain_mass = grain_vol * GRAIN_DENSITY assert abs(cesaroni_m1670.max_thrust - 2200.0) < 1e-9 assert abs(cesaroni_m1670.max_thrust_time - 0.15) < 1e-9 - assert abs(cesaroni_m1670.burn_time[1] - burn_time) < 1e-9 + assert abs(cesaroni_m1670.burn_time[1] - BURN_TIME) < 1e-9 assert ( - abs(cesaroni_m1670.total_impulse - cesaroni_m1670.thrust.integral(0, burn_time)) + abs(cesaroni_m1670.total_impulse - cesaroni_m1670.thrust.integral(0, BURN_TIME)) < 1e-9 ) assert ( cesaroni_m1670.average_thrust - - cesaroni_m1670.thrust.integral(0, burn_time) / burn_time + - cesaroni_m1670.thrust.integral(0, BURN_TIME) / BURN_TIME ) < 1e-9 assert abs(cesaroni_m1670.grain_initial_volume - grain_vol) < 1e-9 assert abs(cesaroni_m1670.grain_initial_mass - grain_mass) < 1e-9 assert ( - abs(cesaroni_m1670.propellant_initial_mass - grain_number * grain_mass) < 1e-9 + abs(cesaroni_m1670.propellant_initial_mass - GRAIN_NUMBER * grain_mass) < 1e-9 ) assert ( abs( cesaroni_m1670.exhaust_velocity(0) - - cesaroni_m1670.thrust.integral(0, burn_time) / (grain_number * grain_mass) + - cesaroni_m1670.thrust.integral(0, BURN_TIME) / (GRAIN_NUMBER * grain_mass) ) < 1e-9 ) @@ -227,14 +215,14 @@ def test_grain_geometry_progression_asserts_extreme_values(cesaroni_m1670): def test_mass_curve_asserts_extreme_values(cesaroni_m1670): - grain_vol = grain_initial_height * ( - np.pi * (grain_outer_radius**2 - grain_initial_inner_radius**2) + grain_vol = GRAIN_INITIAL_HEIGHT * ( + np.pi * (GRAIN_OUTER_RADIUS**2 - GRAIN_INITIAL_INNER_RADIUS**2) ) - grain_mass = grain_vol * grain_density + grain_mass = grain_vol * GRAIN_DENSITY assert np.allclose(cesaroni_m1670.propellant_mass.get_source()[-1][-1], 0) assert np.allclose( - cesaroni_m1670.propellant_mass.get_source()[0][-1], grain_number * grain_mass + cesaroni_m1670.propellant_mass.get_source()[0][-1], GRAIN_NUMBER * grain_mass ) @@ -243,11 +231,11 @@ def test_burn_area_asserts_extreme_values(cesaroni_m1670): 2 * np.pi * ( - grain_outer_radius**2 - - grain_initial_inner_radius**2 - + grain_initial_inner_radius * grain_initial_height + GRAIN_OUTER_RADIUS**2 + - GRAIN_INITIAL_INNER_RADIUS**2 + + GRAIN_INITIAL_INNER_RADIUS * GRAIN_INITIAL_HEIGHT ) - * grain_number + * GRAIN_NUMBER ) final_burn_area = ( 2 @@ -256,7 +244,7 @@ def test_burn_area_asserts_extreme_values(cesaroni_m1670): cesaroni_m1670.grain_inner_radius.get_source()[-1][-1] * cesaroni_m1670.grain_height.get_source()[-1][-1] ) - * grain_number + * GRAIN_NUMBER ) assert np.allclose(cesaroni_m1670.burn_area.get_source()[0][-1], initial_burn_area) diff --git a/tests/unit/test_tank.py b/tests/unit/test_tank.py index 14bc733c4..3a77a8bca 100644 --- a/tests/unit/test_tank.py +++ b/tests/unit/test_tank.py @@ -1,3 +1,5 @@ +# TODO: This file must be refactored to improve readability and maintainability. +# pylint: disable=too-many-statements import os from math import isclose @@ -43,7 +45,7 @@ def test_tank_bounds(params, request): @parametrize_fixtures def test_tank_coordinates(params, request): """Test basic coordinate values of the tanks.""" - tank, (radius, height) = params + tank, (_, height) = params tank = request.getfixturevalue(tank) expected_bottom = -height / 2 @@ -131,21 +133,32 @@ def test_mass_based_tank(): tank and a simplified tank. """ lox = Fluid(name="LOx", density=1141.7) - propane = Fluid( - name="Propane", - density=493, - ) n2 = Fluid( name="Nitrogen Gas", density=51.75, ) # density value may be estimate - top_endcap = lambda y: np.sqrt( - 0.0775**2 - (y - 0.7924) ** 2 - ) # Hemisphere equation creating top endcap - bottom_endcap = lambda y: np.sqrt( - 0.0775**2 - (0.0775 - y) ** 2 - ) # Hemisphere equation creating bottom endcap + def top_endcap(y): + """Calculate the top endcap based on hemisphere equation. + + Parameters: + y (float): The y-coordinate. + + Returns: + float: The result of the hemisphere equation for the top endcap. + """ + return np.sqrt(0.0775**2 - (y - 0.7924) ** 2) + + def bottom_endcap(y): + """Calculate the bottom endcap based on hemisphere equation. + + Parameters: + y (float): The y-coordinate. + + Returns: + float: The result of the hemisphere equation for the bottom endcap. + """ + return np.sqrt(0.0775**2 - (0.0775 - y) ** 2) # Generate tank geometry {radius: height, ...} real_geometry = TankGeometry( @@ -191,6 +204,7 @@ def test_mass_based_tank(): ) # Assert volume bounds + # pylint: disable=comparison-with-callable assert (real_tank_lox.gas_height <= real_tank_lox.geometry.top).all assert (real_tank_lox.fluid_volume <= real_tank_lox.geometry.total_volume).all assert (example_tank_lox.gas_height <= example_tank_lox.geometry.top).all @@ -220,17 +234,22 @@ def test(calculated, expected, t, real=False): def test_mass(): """Test mass function of MassBasedTank subclass of Tank""" - example_expected = ( - lambda t: initial_liquid_mass - + t * (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) - + initial_gas_mass - + t * (gas_mass_flow_rate_in - gas_mass_flow_rate_out) - ) + + def example_expected(t): + return ( + initial_liquid_mass + + t * (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) + + initial_gas_mass + + t * (gas_mass_flow_rate_in - gas_mass_flow_rate_out) + ) + example_calculated = example_tank_lox.fluid_mass lox_vals = Function(lox_masses).y_array - real_expected = lambda t: lox_vals[t] + def real_expected(t): + return lox_vals[t] + real_calculated = real_tank_lox.fluid_mass test(example_calculated, example_expected, 5) @@ -238,19 +257,24 @@ def test_mass(): def test_net_mfr(): """Test net_mass_flow_rate function of MassBasedTank subclass of Tank""" - example_expected = ( - lambda t: liquid_mass_flow_rate_in - - liquid_mass_flow_rate_out - + gas_mass_flow_rate_in - - gas_mass_flow_rate_out - ) + + def example_expected(_): + return ( + liquid_mass_flow_rate_in + - liquid_mass_flow_rate_out + + gas_mass_flow_rate_in + - gas_mass_flow_rate_out + ) + example_calculated = example_tank_lox.net_mass_flow_rate liquid_mfrs = Function(example_liquid_masses).y_array gas_mfrs = Function(example_gas_masses).y_array - real_expected = lambda t: (liquid_mfrs[t] + gas_mfrs[t]) / t + def real_expected(t): + return (liquid_mfrs[t] + gas_mfrs[t]) / t + real_calculated = real_tank_lox.net_mass_flow_rate test(example_calculated, example_expected, 10) @@ -269,8 +293,12 @@ def test_level_based_tank(): test_dir = "./data/berkeley/" - top_endcap = lambda y: np.sqrt(0.0775**2 - (y - 0.692300000000001) ** 2) - bottom_endcap = lambda y: np.sqrt(0.0775**2 - (0.0775 - y) ** 2) + def top_endcap(y): + return np.sqrt(0.0775**2 - (y - 0.692300000000001) ** 2) + + def bottom_endcap(y): + return np.sqrt(0.0775**2 - (0.0775 - y) ** 2) + tank_geometry = TankGeometry( { (0, 0.0559): bottom_endcap, @@ -280,7 +308,7 @@ def test_level_based_tank(): ) ullage_data = Function(os.path.abspath(test_dir + "loxUllage.csv")).get_source() - levelTank = LevelBasedTank( + level_tank = LevelBasedTank( name="LevelTank", geometry=tank_geometry, flux_time=(0, 10), @@ -307,18 +335,18 @@ def align_time_series(small_source, large_source): for val in small_source: time = val[0] delta_time_vector = abs(time - large_source[:, 0]) - largeIndex = np.argmin(delta_time_vector) - delta_time = abs(time - large_source[largeIndex][0]) + large_index = np.argmin(delta_time_vector) + delta_time = abs(time - large_source[large_index][0]) if delta_time < tolerance: - result_larger_source[curr_ind] = large_source[largeIndex] + result_larger_source[curr_ind] = large_source[large_index] result_smaller_source[curr_ind] = val curr_ind += 1 return result_larger_source, result_smaller_source - assert np.allclose(levelTank.liquid_height, ullage_data) + assert np.allclose(level_tank.liquid_height, ullage_data) - calculated_mass = levelTank.liquid_mass.set_discrete( + calculated_mass = level_tank.liquid_mass.set_discrete( mass_data[0][0], mass_data[0][-1], len(mass_data[0]) ) calculated_mass, mass_data = align_time_series( @@ -326,12 +354,12 @@ def align_time_series(small_source, large_source): ) assert np.allclose(calculated_mass, mass_data, rtol=1, atol=2) - calculated_mfr = levelTank.net_mass_flow_rate.set_discrete( + calculated_mfr = level_tank.net_mass_flow_rate.set_discrete( mass_flow_rate_data[0][0], mass_flow_rate_data[0][-1], len(mass_flow_rate_data[0]), ) - calculated_mfr, test_mfr = align_time_series( + calculated_mfr, _ = align_time_series( calculated_mfr.get_source(), mass_flow_rate_data ) @@ -347,91 +375,133 @@ def test(t, a, tol=1e-4): assert isclose(t.get_value(i), a(i), abs_tol=tol) def test_nmfr(): - nmfr = ( - lambda x: liquid_mass_flow_rate_in - + gas_mass_flow_rate_in - - liquid_mass_flow_rate_out - - gas_mass_flow_rate_out - ) + def nmfr(_): + return ( + liquid_mass_flow_rate_in + + gas_mass_flow_rate_in + - liquid_mass_flow_rate_out + - gas_mass_flow_rate_out + ) + test(t.net_mass_flow_rate, nmfr) def test_mass(): - m = lambda x: ( - initial_liquid_mass - + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x - ) + (initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x) + def m(x): + return ( + initial_liquid_mass + + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x + ) + ( + initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x + ) + lm = t.fluid_mass test(lm, m) def test_liquid_height(): - alv = ( - lambda x: ( + def alv(x): + return ( initial_liquid_mass + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x - ) - / lox.density - ) - alh = lambda x: alv(x) / (np.pi) + ) / lox.density + + def alh(x): + return alv(x) / (np.pi) + tlh = t.liquid_height test(tlh, alh) def test_com(): - liquid_mass = lambda x: ( - initial_liquid_mass - + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x - ) # liquid mass - liquid_volume = lambda x: liquid_mass(x) / lox.density # liquid volume - liquid_height = lambda x: liquid_volume(x) / (np.pi) # liquid height - gas_mass = lambda x: ( - initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x - ) # gas mass - gas_volume = lambda x: gas_mass(x) / n2.density - gas_height = lambda x: gas_volume(x) / np.pi + liquid_height(x) - - liquid_com = lambda x: liquid_height(x) / 2 # liquid com - gas_com = lambda x: (gas_height(x) - liquid_height(x)) / 2 + liquid_height( - x - ) # gas com - acom = lambda x: (liquid_mass(x) * liquid_com(x) + gas_mass(x) * gas_com(x)) / ( - liquid_mass(x) + gas_mass(x) - ) + def liquid_mass(x): + return ( + initial_liquid_mass + + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x + ) + + def liquid_volume(x): + return liquid_mass(x) / lox.density + + def liquid_height(x): + return liquid_volume(x) / (np.pi) + + def gas_mass(x): + return ( + initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x + ) + + def gas_volume(x): + return gas_mass(x) / n2.density + + def gas_height(x): + return gas_volume(x) / np.pi + liquid_height(x) + + def liquid_com(x): + return liquid_height(x) / 2 + + def gas_com(x): + return (gas_height(x) - liquid_height(x)) / 2 + liquid_height(x) + + def acom(x): + return (liquid_mass(x) * liquid_com(x) + gas_mass(x) * gas_com(x)) / ( + liquid_mass(x) + gas_mass(x) + ) tcom = t.center_of_mass test(tcom, acom) def test_inertia(): - liquid_mass = lambda x: ( - initial_liquid_mass - + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x - ) # liquid mass - liquid_volume = lambda x: liquid_mass(x) / lox.density # liquid volume - liquid_height = lambda x: liquid_volume(x) / (np.pi) # liquid height - gas_mass = lambda x: ( - initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x - ) # gas mass - gas_volume = lambda x: gas_mass(x) / n2.density - gas_height = lambda x: gas_volume(x) / np.pi + liquid_height(x) - - liquid_com = lambda x: liquid_height(x) / 2 # liquid com - gas_com = lambda x: (gas_height(x) - liquid_height(x)) / 2 + liquid_height( - x - ) # gas com - acom = lambda x: (liquid_mass(x) * liquid_com(x) + gas_mass(x) * gas_com(x)) / ( - liquid_mass(x) + gas_mass(x) - ) + def liquid_mass(x): + return ( + initial_liquid_mass + + (liquid_mass_flow_rate_in - liquid_mass_flow_rate_out) * x + ) + + def liquid_volume(x): + return liquid_mass(x) / lox.density + + def liquid_height(x): + return liquid_volume(x) / (np.pi) + + def gas_mass(x): + return ( + initial_gas_mass + (gas_mass_flow_rate_in - gas_mass_flow_rate_out) * x + ) + + def gas_volume(x): + return gas_mass(x) / n2.density + + def gas_height(x): + return gas_volume(x) / np.pi + liquid_height(x) + + def liquid_com(x): + return liquid_height(x) / 2 + + def gas_com(x): + return (gas_height(x) - liquid_height(x)) / 2 + liquid_height(x) + + def acom(x): + return (liquid_mass(x) * liquid_com(x) + gas_mass(x) * gas_com(x)) / ( + liquid_mass(x) + gas_mass(x) + ) r = 1 - ixy_gas = ( - lambda x: 1 / 4 * gas_mass(x) * r**2 - + 1 / 12 * gas_mass(x) * (gas_height(x) - liquid_height(x)) ** 2 - + gas_mass(x) * (gas_com(x) - acom(x)) ** 2 - ) - ixy_liq = ( - lambda x: 1 / 4 * liquid_mass(x) * r**2 - + 1 / 12 * liquid_mass(x) * (liquid_height(x) - t.geometry.bottom) ** 2 - + liquid_mass(x) * (liquid_com(x) - acom(x)) ** 2 - ) - ixy = lambda x: ixy_gas(x) + ixy_liq(x) + + def ixy_gas(x): + return ( + 1 / 4 * gas_mass(x) * r**2 + + 1 / 12 * gas_mass(x) * (gas_height(x) - liquid_height(x)) ** 2 + + gas_mass(x) * (gas_com(x) - acom(x)) ** 2 + ) + + def ixy_liq(x): + return ( + 1 / 4 * liquid_mass(x) * r**2 + + 1 / 12 * liquid_mass(x) * (liquid_height(x) - t.geometry.bottom) ** 2 + + liquid_mass(x) * (liquid_com(x) - acom(x)) ** 2 + ) + + def ixy(x): + return ixy_gas(x) + ixy_liq(x) + test(t.gas_inertia, ixy_gas, tol=1e-3) test(t.liquid_inertia, ixy_liq, tol=1e-3) test(t.inertia, ixy, tol=1e-3) @@ -474,7 +544,7 @@ def test_inertia(): test_inertia() -"""Auxiliary testing functions""" +# Auxiliary testing functions def cylinder_volume(radius, height): diff --git a/tests/unit/test_tools_matrix.py b/tests/unit/test_tools_matrix.py index 959e56f19..89e75de0f 100644 --- a/tests/unit/test_tools_matrix.py +++ b/tests/unit/test_tools_matrix.py @@ -97,7 +97,7 @@ def test_matrix_inverse(components): matrix = Matrix(components) if matrix.det == 0: with pytest.raises(ZeroDivisionError): - matrix.inverse + assert matrix.inverse else: assert matrix.inverse == np.linalg.inv(matrix) @@ -113,68 +113,68 @@ def test_matrix_neg(components): assert -Matrix(components) + Matrix(components) == Matrix.zeros() -@pytest.mark.parametrize("A_c", test_matrices) -@pytest.mark.parametrize("B_c", test_matrices) -def test_matrix_add(A_c, B_c): - expected_result = np.array(A_c) + np.array(B_c) - assert Matrix(A_c) + Matrix(B_c) == expected_result +@pytest.mark.parametrize("A", test_matrices) +@pytest.mark.parametrize("B", test_matrices) +def test_matrix_add(A, B): + expected_result = np.array(A) + np.array(B) + assert Matrix(A) + Matrix(B) == expected_result -@pytest.mark.parametrize("A_c", test_matrices) -@pytest.mark.parametrize("B_c", test_matrices) -def test_matrix_sub(A_c, B_c): - expected_result = np.array(A_c) - np.array(B_c) - assert Matrix(A_c) - Matrix(B_c) == expected_result +@pytest.mark.parametrize("A", test_matrices) +@pytest.mark.parametrize("B", test_matrices) +def test_matrix_sub(A, B): + expected_result = np.array(A) - np.array(B) + assert Matrix(A) - Matrix(B) == expected_result @pytest.mark.parametrize("k", [-1, 0, 1, np.pi]) -@pytest.mark.parametrize("A_c", test_matrices) -def test_matrix_mul(A_c, k): - A = Matrix(A_c) - assert A * k == k * np.array(A_c) +@pytest.mark.parametrize("A", test_matrices) +def test_matrix_mul(A, k): + A = Matrix(A) + assert A * k == k * np.array(A) @pytest.mark.parametrize("k", [-1, 0, 1, np.pi]) -@pytest.mark.parametrize("A_c", test_matrices) -def test_matrix_rmul(A_c, k): - A = Matrix(A_c) - assert k * A == k * np.array(A_c) +@pytest.mark.parametrize("A", test_matrices) +def test_matrix_rmul(A, k): + np_array = np.array(A) + A = Matrix(A) + assert k * A == k * np_array -@pytest.mark.parametrize("A_c", test_matrices) +@pytest.mark.parametrize("A", test_matrices) @pytest.mark.parametrize("k", [-1, 1, np.pi, np.e]) -def test_matrix_truediv(A_c, k): - A = Matrix(A_c) +def test_matrix_truediv(A, k): + A = Matrix(A) assert A / k == np.array(A) / k -@pytest.mark.parametrize("A_c", test_matrices) -@pytest.mark.parametrize("B_c", test_matrices) -def test_matrix_matmul_matrices(A_c, B_c): - expected_result = np.dot(A_c, B_c) - assert Matrix(A_c) @ Matrix(B_c) == expected_result +@pytest.mark.parametrize("A", test_matrices) +@pytest.mark.parametrize("B", test_matrices) +def test_matrix_matmul_matrices(A, B): + expected_result = np.dot(A, B) + assert Matrix(A) @ Matrix(B) == expected_result -@pytest.mark.parametrize("A_c", test_matrices) -@pytest.mark.parametrize("B_c", [[1, 2, 3], [-np.pi, 1, np.e], [3 * 1j, -2j, 0j]]) -def test_matrix_matmul_vectors(A_c, B_c): - expected_result = np.dot(A_c, B_c) - assert Matrix(A_c) @ Vector(B_c) == expected_result +@pytest.mark.parametrize("A", test_matrices) +@pytest.mark.parametrize("B", [[1, 2, 3], [-np.pi, 1, np.e], [3 * 1j, -2j, 0j]]) +def test_matrix_matmul_vectors(A, B): + expected_result = np.dot(A, B) + assert Matrix(A) @ Vector(B) == expected_result @pytest.mark.parametrize("k", [0, 1, 2, 3, 4, 5]) -@pytest.mark.parametrize("A_c", test_matrices) -def test_matrix_pow(A_c, k): - A = Matrix(A_c) +@pytest.mark.parametrize("A", test_matrices) +def test_matrix_pow(A, k): + A = Matrix(A) assert A**k == np.linalg.matrix_power(A, k) @pytest.mark.parametrize("matrix_components", test_matrices) def test_matrix_eq(matrix_components): matrix = Matrix(matrix_components) - assert matrix == matrix assert matrix == matrix_components - assert (matrix == 2 * matrix) == False + assert (matrix == 2 * matrix) is False @pytest.mark.parametrize("operation", [lambda i: i**2, lambda i: 1 / (i + 1.1)]) @@ -191,10 +191,10 @@ def test_matrix_element_wise(matrix_components, operation): ) -@pytest.mark.parametrize("A_c", test_matrices) -@pytest.mark.parametrize("B_c", test_matrices) -def test_matrix_dot(A_c, B_c): - A, B = Matrix(A_c), Matrix(B_c) +@pytest.mark.parametrize("A", test_matrices) +@pytest.mark.parametrize("B", test_matrices) +def test_matrix_dot(A, B): + A, B = Matrix(A), Matrix(B) assert A.dot(B) == np.dot(A, B) diff --git a/tests/unit/test_tools_vector.py b/tests/unit/test_tools_vector.py index 476bb01c0..f9ded7161 100644 --- a/tests/unit/test_tools_vector.py +++ b/tests/unit/test_tools_vector.py @@ -69,7 +69,7 @@ def test_vector_cross_matrix(vector_components): def test_vector_abs(vector_components): vector = Vector(vector_components) vector_magnitude = abs(vector) - assert vector_magnitude == sum([i**2 for i in vector_components]) ** 0.5 + assert vector_magnitude == sum(i**2 for i in vector_components) ** 0.5 @pytest.mark.parametrize("vector_components", test_vectors) @@ -140,7 +140,7 @@ def test_vector_eq(vector_components): u, v = Vector(vector_components), Vector(vector_components) assert u == vector_components assert u == v - assert (u == 2 * v) == False + assert (u == 2 * v) is False @pytest.mark.parametrize("vector_components", test_vectors) @@ -148,8 +148,8 @@ def test_vector_is_parallel_to(vector_components): u = Vector(vector_components) v = 2 * Vector(vector_components) w = u - Vector.i() - assert u.is_parallel_to(v) == True - assert u.is_parallel_to(w) == False + assert u.is_parallel_to(v) is True + assert u.is_parallel_to(w) is False @pytest.mark.parametrize("vector_components", test_vectors) @@ -159,8 +159,8 @@ def test_vector_is_orthogonal_to(vector_components): projection = u.proj(v) projection_vector = projection * v.unit_vector w = u - projection_vector - assert u.is_orthogonal_to(2 * u) == False - assert w.is_orthogonal_to(v) == True + assert u.is_orthogonal_to(2 * u) is False + assert w.is_orthogonal_to(v) is True @pytest.mark.parametrize("operation", [lambda i: i**2, lambda i: 1 / i]) @@ -199,12 +199,14 @@ def test_vector_proj(u_c, v_c): @pytest.mark.parametrize("vector_components", test_vectors) def test_vector_str(vector_components): vector = Vector(vector_components) + # pylint: disable=eval-used assert eval("Vector(" + str(vector) + ")") == vector @pytest.mark.parametrize("vector_components", test_vectors) def test_vector_repr(vector_components): vector = Vector(vector_components) + # pylint: disable=eval-used assert eval(repr(vector).replace("(", "((").replace(")", "))")) == vector diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py index 43df536dd..a6d1972a7 100644 --- a/tests/unit/test_utilities.py +++ b/tests/unit/test_utilities.py @@ -20,7 +20,7 @@ (40, 21, 1.04, 0.2475236), ], ) -def test_compute_CdS_from_drop_test( +def test_compute_cd_s_from_drop_test( terminal_velocity, rocket_mass, air_density, result ): """Test if the function `compute_cd_s_from_drop_test` returns the correct @@ -44,55 +44,15 @@ def test_compute_CdS_from_drop_test( assert abs(cds - result) < 1e-6 -def test_create_dispersion_dictionary(): - """Test if the function returns a dictionary with the correct keys. - It reads the keys from the dictionary generated by the utilities function - and compares them to the expected. - Be careful if you change the "fixtures/monte_carlo/Valetudo_inputs.csv" file. - - Parameters - ---------- - None - - Returns - ------- - None - """ - - returned_dict = utilities.create_dispersion_dictionary( - "tests/fixtures/monte_carlo/Valetudo_inputs.csv" - ) - - test_array = np.genfromtxt( - "tests/fixtures/monte_carlo/Valetudo_inputs.csv", - usecols=(1, 2, 3), - skip_header=1, - delimiter=";", - dtype=str, - ) - test_dict = dict() - for row in test_array: - if row[0] != "": - if row[2] == "": - try: - test_dict[row[0].strip()] = float(row[1]) - except: - test_dict[row[0].strip()] = eval(row[1]) - else: - try: - test_dict[row[0].strip()] = (float(row[1]), float(row[2])) - except: - test_dict[row[0].strip()] = "" - assert returned_dict == test_dict - - # Tests not passing in the CI, but passing locally due to # different values in the ubuntu and windows machines -@pytest.mark.skip(reason="legacy tests") +@pytest.mark.skip( + reason="legacy tests" +) # it is not working on CI and I don't have time @patch("matplotlib.pyplot.show") -def test_apogee_by_mass(mock_show, flight): +def test_apogee_by_mass(mock_show, flight): # pylint: disable=unused-argument """Tests the apogee_by_mass function. Parameters @@ -107,12 +67,12 @@ def test_apogee_by_mass(mock_show, flight): assert abs(f(10) - 3697.1896424) < 1e-6 assert abs(f(15) - 3331.6521059) < 1e-6 assert abs(f(20) - 2538.4542953) < 1e-6 - assert f.plot() == None + assert f.plot() is None @pytest.mark.skip(reason="legacy tests") @patch("matplotlib.pyplot.show") -def test_liftoff_by_mass(mock_show, flight): +def test_liftoff_by_mass(mock_show, flight): # pylint: disable=unused-argument """Tests the liftoff_by_mass function. Parameters @@ -129,7 +89,7 @@ def test_liftoff_by_mass(mock_show, flight): assert abs(f(10) - 31.07885818306235) < 1e-6 assert abs(f(15) - 26.054819726081266) < 1e-6 assert abs(f(20) - 22.703279913437058) < 1e-6 - assert f.plot() == None + assert f.plot() is None def test_fin_flutter_analysis(flight_calisto_custom_wind): @@ -171,9 +131,9 @@ def test_flutter_prints(flight_calisto_custom_wind): utilities._flutter_prints( # pylint: disable=protected-access fin_thickness=2 / 1000, shear_modulus=10e9, - s=0.009899999999999999, - ar=1.2222222222222223, - la=0.5, + surface_area=0.009899999999999999, + aspect_ratio=1.2222222222222223, + lambda_=0.5, flutter_mach=flutter_mach, safety_factor=safety_factor, flight=flight_calisto_custom_wind, @@ -183,7 +143,9 @@ def test_flutter_prints(flight_calisto_custom_wind): @patch("matplotlib.pyplot.show") -def test_flutter_plots(mock_show, flight_calisto_custom_wind): +def test_flutter_plots( + mock_show, flight_calisto_custom_wind +): # pylint: disable=unused-argument """Tests the _flutter_plots function. Parameters