diff --git a/.github/workflows/openconcept.yaml b/.github/workflows/openconcept.yaml index 63d1a334..3c6527a4 100644 --- a/.github/workflows/openconcept.yaml +++ b/.github/workflows/openconcept.yaml @@ -9,11 +9,16 @@ on: - v*.*.* jobs: + black: + uses: mdolab/.github/.github/workflows/black.yaml@main + flake8: + uses: mdolab/.github/.github/workflows/flake8.yaml@main build: runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "windows-latest", "macos-latest"] + python-version: ["3.8", "3.9", "3.10"] fail-fast: false env: OMP_NUM_THREADS: 1 @@ -26,7 +31,7 @@ jobs: uses: conda-incubator/setup-miniconda@v2 with: auto-update-conda: true - python-version: 3.8 + python-version: ${{ matrix.python-version }} - name: Setup conda run: | conda config --set always_yes yes @@ -35,15 +40,19 @@ jobs: run: | pip install .[testing] pip install .[docs] + - name: List Python and package info + run: | + python --version + pip list - name: Download engine deck surrogate model run: | curl -L -o engine_kriging_surrogate_model.tar.gz http://umich.edu/~mdolaboratory/repo_files/openconcept/engine_kriging_surrogate_model.tar.gz - name: Move engine deck files to appropriate location run: | tar -xvf engine_kriging_surrogate_model.tar.gz - mv cfm56* ./openconcept/components/empirical_data/cfm56 - mv n3_hybrid* ./openconcept/components/empirical_data/n+3_hybrid - mv n3* ./openconcept/components/empirical_data/n+3 + mv cfm56* ./openconcept/propulsion/empirical_data/cfm56 + mv n3_hybrid* ./openconcept/propulsion/empirical_data/n+3_hybrid + mv n3* ./openconcept/propulsion/empirical_data/n+3 - name: Build and Test run: | python -m pytest --cov-config=.coveragerc --cov=openconcept --cov-report=xml diff --git a/docs/_static/images/full_parallel_system_chiller.png b/docs/_static/images/full_parallel_system_chiller.png new file mode 100644 index 00000000..3fa674ed Binary files /dev/null and b/docs/_static/images/full_parallel_system_chiller.png differ diff --git a/docs/_static/images/readme_charts.png b/docs/_static/images/readme_charts.png index 80ffb7f9..25bfbe07 100644 Binary files a/docs/_static/images/readme_charts.png and b/docs/_static/images/readme_charts.png differ diff --git a/docs/conf.py b/docs/conf.py index e810beed..388c7e02 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ import os import sys import openconcept +import subprocess from sphinx_mdolab_theme.config import * @@ -157,6 +158,43 @@ def generate_src_docs(dir, top, packages): index.close() +def run_file_move_result(file_name, output_files, destination_files, optional_cl_args=[]): + """ + Run a file (as a subprocess) that produces output file(s) of interest. + This function then moves the file(s) to a specified location. + + For example, a file may produce a figure that is used in the docs. + This function can be used to automatically generate the figure in the RTD build + and move it to a specific location in the RTD build. + + Note that the file is run from the openconcept/docs directory and all relative paths + are relative to this directory. If the output file name is defined in the script + using a relative path remember to take it into account. + + Parameters + ---------- + file_name : str + Python file to be run + output_files : list of str + Output files produced by running file_name + destination_files : list of str + Destination paths/file names to move output_file to (must be same length as output_files) + optional_cl_args : list of str + Optional command line arguments to add when file_name is run by Python + """ + # Error check + if len(output_files) != len(destination_files): + raise ValueError("The number of output files must be the same as destination file paths") + + # Run the file + subprocess.run(["python", file_name] + optional_cl_args) + + # Move the files + for output_file, destination_file in zip(output_files, destination_files): + os.makedirs(os.path.dirname(destination_file), exist_ok=True) + os.replace(output_file, destination_file) + + # Patch the Napoleon parser to find Inputs, Outputs, and Options headings in docstrings from sphinx.ext.napoleon.docstring import NumpyDocstring @@ -210,7 +248,11 @@ def patched_parse(self): 'sphinx.ext.napoleon', 'sphinx.ext.todo', 'sphinx.ext.coverage', - "sphinx_copybutton", + 'sphinxcontrib.bibtex', + 'sphinx_copybutton', + 'sphinx_mdolab_theme.ext.embed_code', + 'sphinx_mdolab_theme.ext.embed_compare', + 'sphinx_mdolab_theme.ext.embed_n2', ] autodoc_inherit_docstrings = False autodoc_member_order = 'bysource' @@ -240,6 +282,8 @@ def patched_parse(self): # Usually you set "language" from the command line for these cases. language = 'en' +# This sets the bibtex bibliography file(s) to reference in the documentation +bibtex_bibfiles = ['ref.bib'] # -- Options for HTML output ------------------------------------------------- @@ -308,6 +352,16 @@ def patched_parse(self): # -- Extension configuration ------------------------------------------------- +# -- Run examples to get figures for docs ------------------------------------ +run_file_move_result("../openconcept/examples/minimal.py", ["minimal_example_results.svg"], ["tutorials/assets/minimal_example_results.svg"], optional_cl_args=["--hide_visuals"]) +run_file_move_result("../openconcept/examples/minimal_integrator.py", ["minimal_integrator_results.svg"], ["tutorials/assets/minimal_integrator_results.svg"], optional_cl_args=["--hide_visuals"]) +run_file_move_result("../openconcept/examples/TBM850.py", ["turboprop_takeoff_results.svg", "turboprop_mission_results.svg"], ["tutorials/assets/turboprop_takeoff_results.svg", "tutorials/assets/turboprop_mission_results.svg"], optional_cl_args=["--hide_visuals"]) + +# Remove the N2 diagrams it also created +files_remove = ["minimal_example_n2.html", "minimal_integrator_n2.html", "turboprop_n2.html"] +for file in files_remove: + os.remove(file) + # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. @@ -324,11 +378,16 @@ def patched_parse(self): # subprocess.call(['sphinx-apidoc','-o','_srcdocs_native','../openconcept']) # os.rename('_srcdocs_native/modules.rst','_srcdocs_native/index.rst') # openmdao way - packages = ['analysis', - 'analysis.openaerostruct', - 'analysis.atmospherics', - 'analysis.performance', - 'components', - 'utilities', - 'utilities.math'] + packages = [ + 'aerodynamics', + 'aerodynamics.openaerostruct', + 'atmospherics', + 'energy_storage', + 'mission', + 'propulsion', + 'propulsion.systems', + 'thermal', + 'utilities', + 'utilities.math' + ] generate_src_docs(".", "../openconcept", packages) diff --git a/docs/developer/roadmap.rst b/docs/developer/roadmap.rst index b2caa5e1..d35b60a2 100644 --- a/docs/developer/roadmap.rst +++ b/docs/developer/roadmap.rst @@ -4,13 +4,11 @@ Development Roadmap ******************* -OpenConcept is still very much alpha and is basically the product of one conference paper and a few months of work by one person. - Known issues to be addressed include: - No distinction right now between calibrated, equivalent, indicated airspeeds (compressibility effects) in the standard atmosphere - - Spotty automated testing coverage - - Spotty documentation coverage - Limited validation of the takeoff performance code (it is hard to find actual CLmax and drag polar data!) Future ideas include: - - Unifying the ODE integration math with NASA's Dymos toolkit \ No newline at end of file + - Unifying the ODE integration math with NASA's Dymos toolkit + - Adding locations to weights to be able to include stability constraints and trim OpenAeroStruct aerodynamic models + - Incorporate OpenVSP for visualizations of the configuration diff --git a/docs/features/aerodynamics.rst b/docs/features/aerodynamics.rst new file mode 100644 index 00000000..528a4226 --- /dev/null +++ b/docs/features/aerodynamics.rst @@ -0,0 +1,144 @@ +.. _Aerodynamics: + +************ +Aerodynamics +************ + +OpenConcept's aerodynamics components provide an estimate of drag as a function of lift coefficient, flight conditions, and other parameters. + +Simple drag polar: ``PolarDrag`` +================================ + +``PolarDrag`` is the most basic aerodynamic model and uses a parabolic drag polar. +This component computes the drag force given the flight condition (dynamic pressure and lift coefficient) at each node along the mission profile. +In run script, users should set the values for the following aircraft design parameters, or declare them as design variables. + +.. list-table:: Aircraft design variables for drag polar model + :header-rows: 1 + + * - Variable name + - Property + * - ac|geom|wing|S_ref + - Reference wing area + * - ac|geom|wing|AR + - Wing aspect ratio + * - e + - Wing Oswald efficiency + * - CD0 + - Zero-lift drag coefficient + + +Using OpenAeroStruct +==================== +Instead of the simple drag polar model, you can use `OpenAeroStruct `_ to compute the drag. +This allows you to parametrize the aircraft wing design in more details and explore the effects of wing geometry, including taper, sweep, and twist. +OpenAeroStruct implements the vortex-lattice method (VLM) for aerodynamics and beam-based finite element method (FEM) for structures (in the case of the aerostructural drag polar). +For more detail, please check the `documentation `_. + +The wing is currently limited to simple planform geometries. +Additionally, the wing does not include a tail to trim it because there is no OpenConcept weight position model with which to trim. + +OpenConcept uses a surrogate model trained by OpenAeroStruct analyses to reduce the computational cost. +The data generation and surrogate training is automated; specifying the training grid manually may improve accuracy or decrease computational cost. + +VLM-based aerodynamic model: ``VLMDragPolar`` +------------------------------------------------ +This model uses the vortex-lattice method (VLM) to compute the drag. +The inputs to this model are the flight conditions (Mach number, altitude, dynamic pressure, lift coefficient) and aircraft design parameters. + +Users should set the following design parameters and options in the run script. + +.. list-table:: Aircraft design variables for VLM-based model + :header-rows: 1 + + * - Variable name + - Property + - Type + * - ac|geom|wing|S_ref + - Reference wing area + - float + * - ac|geom|wing|AR + - Wing aspect ratio + - float + * - ac|geom|wing|taper + - Taper ratio + - float + * - ac|geom|wing|c4sweep + - Sweep angle at quarter chord + - float + * - ac|geom|wing|twist + - Spanwise distribution of twist, from wint tip to root. + - 1D ndarray, lendth ``num_twist`` + * - ac|aero|CD_nonwing + - Drag coefficient of components other than the wing; e.g. fuselage, + tail, interference drag, etc. + - float + * - fltcond|TempIncrement + - Temperature increment for non-standard day + - float + +.. list-table:: Options for VLM-based model + :widths: 30 50 20 + :header-rows: 1 + + * - Variable name + - Property + - Type + * - ``num_x`` + - VLM mesh size (number of vertices) in chordwise direction. + - int + * - ``num_y`` + - VLM mesh size (number of vertices) in spanwise direction. + - int + * - ``num_twist`` + - Number of spanwise control points for twist distribution. + - int + +There are other advanced options, e.g., the surrogate training points in Mach-alpha-altitude space. +The default settings should work fine for these advanced options, but if you want to make changes, please refer to the source docs. + +Aerostructural model: ``AeroStructDragPolar`` +----------------------------------------------------- +This model is similar to the VLM-based aerodynamic model, but it performs aerostructural analysis (that couples VLM and structural FEM) instead of aerodynamic analysis (just FEM). +This means that we now consider the wing deformation due to aerodynamic loads, which is important for high aspect ratio wings. +The structural model does not include point loads (e.g., for the engine) or distributed fuel loads. + +The additional input variables users need to set in the run script are listed below. +Like the ``num_twist`` option, you may need to set ``num_toverc``, ``num_skin``, and ``num_spar``. + +.. list-table:: Additional design variables for aerostructural model + :widths: 30 50 20 + :header-rows: 1 + + * - Variable name + - Property + - Type + * - ac|geom|wing|toverc + - Spanwise distribution of thickness to chord ratio. + - 1D ndarray, lendth ``num_toverc`` + * - ac|geom|wing|skin_thickness + - Spanwise distribution of skin thickness. + - 1D ndarray, lendth ``num_skin`` + * - ac|geom|wing|spar_thickness + - Spanwise distribution of spar thickness. + - 1D ndarray, lendth ``num_spar`` + +In addition to the drag, the aerostructural model also outputs the structural failure indicator (``failure``) and wing weight (``ac|weights|W_wing``). +The `failure` variable must be negative (``failure <= 0``) to constrain wingbox stresses to be less than the yield stress. + +Understanding the surrogate modeling +------------------------------------ + +OpenConcept uses surrogate models based on OpenAeroStruct analyses to reduce the computational cost for mission analysis. +The surrogate models are trained in the 3D input space of Mach number, angle of attack, and altitude. +The outputs of the surrogate models are CL and CD (and failure for ``AeroStructDragPolar``). + +For more details about the surrogate models, see our `paper `_. + +Other models +============ + +The aerodynamics module also includes a couple components that may be useful to be aware of: + + - ``StallSpeed``, which uses :math:`C_{L, \text{max}}`, aircraft weight, and wing area to compute the stall speed + - ``Lift``, which computes lift force using lift coefficient, wing area, and dynamic pressure diff --git a/docs/features/atmospherics.rst b/docs/features/atmospherics.rst new file mode 100644 index 00000000..4cf737ce --- /dev/null +++ b/docs/features/atmospherics.rst @@ -0,0 +1,32 @@ +.. _Atmospherics: + +************ +Atmospherics +************ + +Most of OpenConcept's atmospheric models use the 1976 Standard Atmosphere. +For more details, see the source documentation for the ``ComputeAtmosphericProperties`` component. + +Models for specific atmospheric properties are also available on their own: + + - ``TrueAirspeedComp`` + - ``EquivalentAirpseedComp`` + - ``TemperatureComp`` + - ``SpeedOfSoundComp`` + - ``PressureComp`` + - ``MachNumberComp`` + - ``DynamicPressureComp`` + - ``DensityComp`` + +The code is adapted from this paper, with permission: + +.. code:: bibtex + + @conference{Jasa2018b, + Address = {Orlando, FL}, + Author = {John P. Jasa and John T. Hwang and Joaquim R. R. A. Martins}, + Booktitle = {2018 AIAA/ASCE/AHS/ASC Structures, Structural Dynamics, and Materials Conference; AIAA SciTech Forum}, + Month = {January}, + Title = {Design and Trajectory Optimization of a Morphing Wing Aircraft}, + Year = {2018} + } diff --git a/docs/features/costs.rst b/docs/features/costs.rst new file mode 100644 index 00000000..5dc435fb --- /dev/null +++ b/docs/features/costs.rst @@ -0,0 +1,10 @@ +.. _Costs: + +***** +Costs +***** + +OpenConcept's existing cost model is rather rudimentary and computes operating costs for a turboprop. +The component is called ``TurbopropOperatingCost``. +Unfortunately, there are not example aircraft that use this model, but the source code is relatively readable to figure out what inputs and outputs are needed. +Other cost models may be added in the future. diff --git a/docs/features/energy_storage.rst b/docs/features/energy_storage.rst new file mode 100644 index 00000000..49487e72 --- /dev/null +++ b/docs/features/energy_storage.rst @@ -0,0 +1,28 @@ +.. _Energy-storage: + +************** +Energy Storage +************** + +This module contains components that can store energy. +For now this consists only of battery models, but hydrogen tanks would go here too, for example. + +Battery models +============== + +``SimpleBattery`` +----------------- + +This component simple uses a simple equation to relate the electrical power draw to the heat generated: :math:`\text{heat} = \text{electricity load} (1 - \eta)`. +Cost is assumed to be a linear function of weight. +Component sizing margin is computed which describes the electrical load to the max power of the battery (defined by battery weight and specific power). +This is not automatically forced to be less than one, so the user is responsible for checking/enforcing this in an analysis or optimization. + +.. warning:: + This component does not track its state of charge, so without an additional integrator there is no way to know when the battery has been depleted. For this reason, it is recommended to use the ``SOCBattery``. + +``SOCBattery`` +-------------- + +This component uses the same model as the ``SimpleBattery``, but adds an integrator to compute the state of charge (from 0.0 to 1.0). +By default, it starts at a state of charge of 1.0 (100% charge). diff --git a/docs/features/index.rst b/docs/features/index.rst deleted file mode 100644 index f4bdf39f..00000000 --- a/docs/features/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -.. _Features: - -******** -Features -******** - -OpenConcept features will be documented here. - -.. toctree:: - :maxdepth: 2 - - odeint/ode_integration.rst - propulsion/prop_modeling.rst - openmdao/openmdao_basics.rst diff --git a/docs/features/mission.rst b/docs/features/mission.rst new file mode 100644 index 00000000..dd940387 --- /dev/null +++ b/docs/features/mission.rst @@ -0,0 +1,115 @@ +.. _MissionAnalysis: + +******* +Mission +******* + +The mission analysis computes the fuel and/or battery energy consumption for a specified flight mission. +You can also keep track of the temperature of components if you have thermal models. + +In OpenConcept, a **mission** consists of multiple **phases**: +*phases* are the building blocks of the mission. +For example, a basic three-phase mission is composed of a climb phase, cruise phase, and descent phase. + +Mission profiles +================ + +OpenConcept implements several mission profiles that users can use for an analysis. +The missions are implemented in ``openconcept/mission/profiles.py``. +You can also make your own mission profile following the format from these examples. + +Basic three-phase mission: ``BasicMission`` +------------------------------------------- +This is a basic climb-cruise-descent mission for a fixed-wing aircraft. + +For this mission, users should specify the following variables in the run script: + +- takeoff altitude ``takeoff|h0``, default is 0 ft. +- cruise altitude ``cruise|h0``. +- mission range ``mission_range``. +- payload weight ``payload``. +- vertical speed ``.fltcond|vs`` for each phase. +- airspeed ``.fltcond|Ueas`` for each phase. +- (optional) ``takeoff|v2`` if you include a ground roll phase before climb. The ground roll phase is not included by default. + +The duration of each phase is automatically set given the cruise altitude and mission range. + +Full mission including takeoff: ``FullMissionAnalysis`` +------------------------------------------------------- +This adds a balanced-field takeoff analysis to the three-phase mission. +The additional takeoff phases are: + +- ``v0v1``: from a standstill to decision speed (v1). An instance of ``GroundRollPhase``. +- ``v1vr``: from v1 to rotation. An instance of ``GroundRollPhase``. +- ``rotate``: rotation in the air before steady climb. An instance of ``RotationPhase`` or ``RobustRotationPhase``. +- ``v1vr``: energency stopping from v1 to a stop. An instance of ``GroundRollPhase``. + +We use ``BLIImplicitSolve`` to solve for the decision speed ``v1`` where the one-engine-out takeoff distance is equal to the braking distance for rejected takeoff. + +The optional variables you may set in the run scripts are + +- throttle for takeoff phases ``.throttle``, default is 1.0. +- ground rolling friction coefficient ``.braking``, default is 0.03 for accelerating phases and 0.4 for braking. +- altitude ``.fltcond|h``. +- obstacle clearance height ``rotate.h_obs``, default is 35 ft. +- CL/CLmax ration in rotation ``rotate.CL_rotate_mult``, default is 0.83. + +It may be necessary to set initial values for the takeoff airspeeds (``.fltcond|Utrue``) before the solver is called to improve convergence. + +Mission with reserve: ``MissionWithReserve`` +-------------------------------------------- +This adds a reserve mission and loiter phase to the three-phase mission. +Additional variables you need to set in the run script are + +- vertical speed and airspeed for additional phases: ``.`` +- reserve range ``reserve_range`` and altitude ``reserve|h0``. +- loiter duration ``loiter_duration`` and loiter altitude ``loiter|h0``. + +Phase types +=========== +A phase is a building block of a mission profile. +The phases and relevant classes are implemented in ``openconcept/mission/phases.py``. +Users usually don't need to modify these code when creating their own mission profile. + +Steady flight: ``SteadyFlightPhase`` +------------------------------------ +The ``SteadyFlightPhase`` class can be instantiated for steady climb, cruise, descent, and loiter phases. +For this phase, you need to specify the airspeed (``.fltcond|Ueas``) and vertical speed (``.fltcond|Ueas``) in your run script. +You may optionally set the duration of the phase (``.duration``), or alternatively, the duration can be set automatically in the mission profile group. + +To ensure steady flight, both vertical and horizontal accelerations will be set to 0. +It first computes the lift coefficient required for zero vertical accelration; CL is then passed to the aircraft model, which returns the lift and drag. +Then, it solves for the throttle values such that horizontal acceleration is zero. +This is done by solving a system of nonlinear equations (``horizontal acceleration = 0``) w.r.t. throttle using a `BalanceComp `_ for each phase. + +Balanced-field takeoff +---------------------- +Balanced-field takeoff analysis is implemented in the following classes: ``BFLImplicitSolve``, ``GroundRollPhase``, ``RotationPhase``, ``RobustRotationPhase``, ``ClimbAnglePhase``. +Unlike the steady flight phases, the takeoff phases are not steady and acceleration is non-zero. +Therefore, the engine throttle needs to be specified to compute the acceleration (100% by defalut for accelerating phases and 0 for braking). +Users can also set the throttle manually in the run script. +The acceleration is then integrated to compute the velocity. + +.. VTOL transition +.. --------------- +.. This is only relevant to VTOL configurations. Maybe move to a different page (like eVTOL mission and phases) to avoid confusion? + +Mission groups +============== +OpenConcept provides some groups that make mission analysis and phase definition easier. + +``PhaseGroup`` +-------------- +This is the base class for an OpenConcept mission phase. +It automatically identifies ``Integrator`` instances within the model and links the time duration variable to them. +It also collects the names of all the integrand states so that the ``TrajectoryGroup`` can find them to link across phases. + +``IntegratorGroup`` +------------------- +The ``IntegratorGroup`` is an alternative way of setting up and integrator (the ``Integrator`` component is used more frequently). +This group adds an ODE integration component (called ``"ode_integ"``), locates output variables tagged with the "integrate" tag, and automatically connects the tagged rate source to the integrator. + +``TrajectoryGroup`` +------------------- +This is the base class for a mission profile. +It provides the ``link_phases`` method which is used to connect integration variables across mission phases. diff --git a/docs/features/odeint/ode_integration.rst b/docs/features/odeint/ode_integration.rst deleted file mode 100644 index 5df184d1..00000000 --- a/docs/features/odeint/ode_integration.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. _ODEIntegration: - -*************** -ODE Integration -*************** - -OpenConcept includes built-in utilities for integrating ODEs, a frequent task in airplane performance analysis. - -Component, Group, and Connections ---------------------------------- -If users are running examples or instantiating OpenConcept native components (e.g. the battery), there's no need to know what's going on under the hood in the ODE integration. -However, for writing custom analysis routines, developing new components, or troubleshooting new airplane models, some background can be helpful. -The ``openconcept.Trajectory`` class acts just like an OpenMDAO ``Group`` except that it adds the ability to automatically link integrated states from one phase of a trajectory to the next using the ``link_phases`` method. -The ``openconcept.Phase`` class acts just like an OpenMDAO ``Group`` except it finds all the integrated states and automatically links the time duration variable to them. -It also collects the names of all the integrated states so that the ``Trajectory`` can find them and link them. -The ``openconcept.IntegratorGroup`` class again acts just like OpenMDAO's ``Group`` except it adds an ODE Integration component (called ode_integ), locates output variables tagged with the "integrate" tag, and automatically connects the tagged rate source to the integrator. -Any state you wish to be automatically integrated needs to be held in an ``IntegratorGroup``. -However, ``IntegratorGroup`` instances can be buried deep in a model made up of mainly plain ``Group``. - -The following example illustrates the usage of this feature. -The tags following the "integrate" tag define the name, units, and default values of the integrated output. - -.. literalinclude:: /../openconcept/analysis/tests/test_trajectories.py - :pyobject: TestForDocs.trajectory_example - :language: python - :dedent: 4 diff --git a/docs/features/openmdao/openmdao_basics.rst b/docs/features/openmdao/openmdao_basics.rst deleted file mode 100644 index a281872b..00000000 --- a/docs/features/openmdao/openmdao_basics.rst +++ /dev/null @@ -1,108 +0,0 @@ -.. _OpenMDAOBasics: - -*************** -OpenMDAO Basics -*************** - -NASA Glenn's `OpenMDAO `_ is the framework on which OpenConcept is built. -It is a Python-based environment for modeling, simulation, and optimization which handles derivatives efficiently. -This greatly reduces the computational cost of solving nonlinear equations and performing gradient-based optimization. - -Users unfamiliar with OpenMDAO are encouraged to visit the `getting started `_ page on their doc site. -I will cover several portions of the OpenMDAO package that are used extensively in OpenConcept. - -Component, Group, and Connections ---------------------------------- -OpenMDAO models are generally made up of numerous ``Component`` instances. -Components can be combined into a ``Group`` of components. -Each component (with some exceptions) has *inputs* and *outputs*. -When outputs of one component are connected to inputs of another, components can be chained together and more complex models are formed. - -The following simple model calculates the drag on an airplane using a simple drag polar formulation. - -Pay attention to these four class methods which are very common in OpenMDAO models: - - The ``initialize`` method allows the user to define options which are instantiated with the component in a larger model. - - The ``setup`` method declares inputs, outputs, and the nature of the derivatives of outputs with respect to inputs (this is important for reasons that will be addressed later). - - The ``compute`` method establishes how the outputs should be computed. - - The ``compute_partials`` method tells the component how to compute partial derivatives. - -.. literalinclude:: /../openconcept/analysis/aerodynamics.py - :pyobject: PolarDrag - :language: python - -Connections in OpenMDAO can be `defined explicitly `_, or `semi-implicitly through variable promotion `_. -Refer to the OpenMDAO docs for more details. - -In general, I use variable promotion if I have some high-level design parameters which need to be propagated down to many different components (e.g. wing area). -I use structured, unambiguous variable names so I can find and replace them if I need to refactor the code. -Explicit connections are useful down at lower levels in the model where only one or two connections need to be made and it's unlikely that end users will edit the model. - -The Problem Class ------------------ -To perform analysis and/or optimization, OpenMDAO requires a ``Problem`` object to be instantiated. -Examples of problems representative of aircraft design can be found in the OpenConcept ``examples/`` folder. - -The ``Problem`` object needs some attributes to be set in order to work properly. - - ``problem.model`` is an OpenMDAO ``Group`` containing all the necessary ``Component`` or ``Group`` objects to model the problem. It is the problem physics. - - ``problem.driver`` is the optimization algorithm (required in order to do MDO). A common choice of driver is the ``ScipyOptimizeDriver``. - - ``problem.nonlinear_solver`` is required for problems which have implicit state variables. OpenMDAO's ``NewtonSolver`` is amazingly efficient at solving problems with accurate derivatives. - - ``problem.linear_solver`` is required when Newton methods are used for nonlinear systems, since a linear solve is a necssary component in computing the Newton step. - -If optimization is being done, the following methods must be employed: - - ``problem.model.add_design_var()`` method tells the optimizer which variables can be altered during the optimization. - - ``problem.model.add_objective()`` method sets the objective function for the optimization (the variable to be minimized/maximized). - - ``problem.model.add_constraint()`` method adds constraints (since most MDO problems are constrained in some way) - -Finally, the ``problem.setup()`` method is run once the model, settings, and optimization are all defined. This is required for OpenMDAO to work properly. - -Setting and Accessing Values ----------------------------- -If variable values need to be set (for example, initial design variable values), they can be accessed (read and written) like: - -.. code-block:: python - - problem['mycomponent.myvarname'] = 30 - -Running the Model/Optimization ------------------------------- -To run an analysis-only problem, the ``problem.run_model()`` method will execute all the components. -If optimization is being conducted, the ``problem.run_driver()`` method must be used instead. - -Partial Derivatives -------------------- -OpenMDAO uses the *Modular Analysis and Unified Derivatives (MAUD)* formulation to compute efficient derivatives. -Both gradient-based optimization and Newton nonlinear solutions require *total derivatives* of the first input variables with respect to the last output variables. -In complex models, there may be a series of complicated mathematical expressions in between (intermediate states). -OpenMDAO uses the *partial* derivatives of each component and assembles them together efficiently to obtain total derivatives (see OpenMDAO `doc page `_). - -If you use the pre-built OpenConcept components, you don't need to worry about this at all. Enjoy the efficient and accurate solutions. - -If you build your own, custom components, you need to make sure that you're supplying accurate partial derivatives. -This usually means either ensuring your model can handle complex input and output variables (to use the complex step method), or supplying analytic derivatives to each component using the ``compute_partials`` method. -If you do this, you should make sure to use OpenMDAO's ``check_partials`` method regularly. - -**Errors in partial derivatives can cause MAJOR Newton and optimizer convergence issues which can be difficult to debug.** - -Recording and Retrieving Results --------------------------------- -OpenMDAO includes a SQLlite database interface for recording the results of model/optimization runs. -First, instantiate an ``openmdao.api.SqliteRecorder`` object. Then attach the object to the problem.model object, like this: - -.. code-block:: python - - recorder = SqliteRecorder(filename_to_save) - problem.model.add_recorder(recorder) - -Command Line Tools ------------------- -OpenMDAO provides `command line utilities `_ to make sure your models are configured correctly. -While the utility has many uses (see the OpenMDAO docs), every user should make a habit of running `openmdao check myscript.py` and ensuring that no inputs are left unconnected. - -Putting it all Together ------------------------ -The ``examples/TBM850.py`` `script `_ models a single-engine turboprop aircraft and uses all of the elements mentioned on this page in an OpenConcept context. - - - - - diff --git a/docs/features/propulsion.rst b/docs/features/propulsion.rst new file mode 100644 index 00000000..c4302a81 --- /dev/null +++ b/docs/features/propulsion.rst @@ -0,0 +1,91 @@ +.. _Propulsion: + +********** +Propulsion +********** + +Propulsion systems +================== +User can build their own propulsion systems following the format of the example systems listed here. +For details of each propulsion systems, see the :ref:`source docs `. + +All-electric propulsion +----------------------- +This is an electric propulsion system consisting of a constant-speed propeller, motor, and battery. +In addition, this models a thermal management system using a compressible (``AllElectricSinglePropulsionSystemWithThermal_Compressible``) or incompressible (``AllElectricSinglePropulsionSystemWithThermal_Incompressible``) 1D duct with heat exchanger. + +This model takes the motor throttle as a control input and computes the thrust and battery state of charge (SOC). + +Turboprop +--------- +This is a simple turboprop system consisting of constant-speed propeller(s) and turboshaft(s). +``TurbopropPropulsionSystem`` implements a system of one propeller and one turboshaft. +Also, ``TwinTurbopropPropulsionSystem`` implements a twin system that has two propellers and turboshafts. +Users can create their own turboprop model by changing the parameters, e.g. engine rating and propeller diameter. + +This model takes the engine throttle as a control input and computes the thrust and fuel flow. + +Series-hybrid electric propulsion +--------------------------------- +In series-hybrid propulsion systems, motor(s) draws electrical load from both battery and turboshaft generator. +The control inputs are the motor throttle, turboshaft throttle, and the power split fraction between the battery and generator; +The turboshaft throttle must be driven by an implicit solver or optimizer to equate the generator's electric power output and the engine power required by the splitter. +Given these inputs, the model the computes the thrust, fuel flow, and electric load. + +OpenConcept implements both single and twin series-hybrid electric propulsion systems in ``simple_series_hybrid.py``. +``TwinSeriesHybridElectricPropulsionSystem`` is the recommended one to use. +Others require the user to explicitly set up additional components to generate feasible analysis results (see the comments in the code). + +The systems with thermal management components are also implemented in ``thermal_series_hybrid.py``. + +Turbofan +-------- +OpenConcept implements two turbofan engine models, CFM56 and a geared turbofan (GTF) for NASA's N+3 aircraft. +Both are surrogate models derived from `pyCycle `__, a thermodynamic cycle modeling tool built on the OpenMDAO framework. +The inputs to the turbofan models are the engine throttle and flight conditions (Mach number and altitude), and outputs are the thrust and fuel flow. +In addition, the CFM56 model outputs the turbine inlet temperature, and the N+3 model outputs the surge margin. + +We also implement a N+3 engine with hybridization, which requires hybrid shaft power as an additional input. + +Models +====== + +The propulsion systems are made up of individual propulsion components. +Available individual models are listed here. + +Electric motor: ``SimpleMotor`` +------------------------------- + +An electric motor model that computes shaft power by multiplying throttle by the motor's electrical power rating and efficiency. +The electrical power that does not go toward shaft power is modeled as heat (see the ``LiquidCooledMotor`` to add thermal management to this motor model). +Weight and cost are linear functions of the electric power rating. + +Turboshaft: ``SimpleTurboshaft`` +-------------------------------- + +Computes shaft power by multiplying throttle by the engine's rated shaft power. +The fuel flow is computed by multiplying the generated shaft power by a provided power-specific fuel consumption. +As with the electric motor, cost and weight are modeled as linear functions of the power rating. + +Propeller: ``SimplePropeller`` +------------------------------ + +This model uses an empirical propeller efficiency map for a constant speed turboprop propeller under the hood. +For low speed, it uses a static thrust coefficient map from :footcite:t:`raymer2006aircraft`. +Propeller maps for three and four bladed propellers are included. + +Generator: ``SimpleGenerator`` +------------------------------ + +This model uses essentially the same model as ``SimpleMotor`` but in reverse. +It takes in a shaft power and computes electrical power and heat generated. + +Power splitter: ``PowerSplit`` +------------------------------ + +This component enables electrical or mechanical shaft power to be split to two components. +It uses either a fractional or fixed split method where fractional splits the input power by a fraction (set by an input) and fixed sends a specified amount of power (set by an input) to one of the outputs. +The efficiency can be changed from the default of 100%, which results in some heat being generated. +Cost and weight are modeled as linear functions of the power rating. + +.. footbibliography:: diff --git a/docs/features/propulsion/motor.rst b/docs/features/propulsion/motor.rst deleted file mode 100644 index a4c367b5..00000000 --- a/docs/features/propulsion/motor.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _motor: - -*************** -Motor Component -*************** - -The motor component generates mechanical power and consumes an electrical load. - diff --git a/docs/features/propulsion/prop_modeling.rst b/docs/features/propulsion/prop_modeling.rst deleted file mode 100644 index 06c486b5..00000000 --- a/docs/features/propulsion/prop_modeling.rst +++ /dev/null @@ -1,39 +0,0 @@ -.. propmodeling: - -******************** -Propulsion Modeling -******************** - -OpenConcept is designed to facilitate bottoms-up modeling of aircraft propulsion architectures with conceptual-level fidelity. -Electric and fuel-burning components are supported. - -Single Turboprop Example ------------------------- - -This example illustrates the simples possible case (turboprop engine connected to a propeller). -The propulsion system is instantiated as an OpenMDAO ``Group``. - -Source: ``examples/propulsion_layouts/simple_turboprop.py`` - -.. literalinclude:: /../examples/propulsion_layouts/simple_turboprop.py - :pyobject: TurbopropPropulsionSystem - :language: python - -Series Hybrid Example ---------------------- - -This example illustrates the complexities which arise when electrical components are included. - -Source: ``examples/propulsion_layouts/simple_series_hybrid.py`` - -.. literalinclude:: /../examples/propulsion_layouts/simple_series_hybrid.py - :pyobject: SingleSeriesHybridElectricPropulsionSystem - :language: python - -Components ----------- - -.. toctree:: - :maxdepth: 2 - - motor.rst \ No newline at end of file diff --git a/docs/features/thermal.rst b/docs/features/thermal.rst new file mode 100644 index 00000000..a04d9c6f --- /dev/null +++ b/docs/features/thermal.rst @@ -0,0 +1,170 @@ +.. _Thermal: + +******* +Thermal +******* + +OpenConcept includes a range of thermal components to model thermal management systems, primarily for electrified propulsion architectures. +Most of these components are used in example aircraft. +The ``N3_HybridSingleAisle_Refrig.py`` example uses most of these components, so it's a good place to look for example usage. +The propulsion system in ``HybridTwin_thermal.py`` uses other ones. + +Liquid-cooled components +======================== + +These group together basic thermal models to create components that model the effect of heat being added to some thermal mass (or it's massless) and then dumped into a liquid coolant loop. + +Generic: ``LiquidCooledComp`` +----------------------------- + +This is a generic liquid cooled component. +Given some heat generated, it models the accumulation of the heat within some thermal mass (in mass mode), which is then dumped into a coolant stream using a ``ConstantSurfaceTemperatureColdPlate_NTU``. +In massless mode, the temperature of the component is set such that the amount of heat it generates is the same as the amount of heat entering the coolant. + +Battery: ``LiquidCooledBattery`` +-------------------------------- + +This component performs a similar function to the ``LiquidCooledComp`` but it introduces a model of heat accumulation and heat transfer to coolant that is specific to a bandolier-style battery cooling system. +It also enables tracking of battery core and surface temperatures. +The thermal model is from the ``BandolierCoolingSystem``. + +Motor: ``LiquidCooledMotor`` +---------------------------- + +This component performs a similar function to the ``LiquidCooledComp`` but it introduces a model of heat accumulation and heat transfer to coolant that is specific to an electric motor with a liquid cooling jacket around it. +The thermal model is from the ``MotorCoolingJacket``. + +Refrigerator: ``HeatPumpWithIntegratedCoolantLoop`` +=================================================== + +This models a refrigerator (a.k.a. chiller) that is connected to two coolant loops: one on the hot side and one on the cold side. +Coolant in the loop on the cold side is chilled. +Heat extracted from cold side coolant (and additional heat due to inefficiency) is added to coolant on the hot side loop. +The model also enables the use of a bypass, which connects the cold and hot side loops around the refrigerator. + +Ducts +===== + +The heat generated onboard needs to be dumped to the atmosphere somehow. +In many cases, this is done using a heat exchanger in a duct. +Ambient air flows through the duct and extracts heat from the heat exchanger. +These ducts are designed to model the effect on drag of adding a ducted heat exchanger to an aircraft. + +``ImplicitCompressibleDuct`` +---------------------------- + +This component models a ducted heat exchanger with compressible flow assumptions, which means the effects of heat addition are captured. +The ``ImplicitCompressibleDuct`` includes a heat exchanger (``HXGroup``) within the model. +If you'd like to define your own heat exchanger outside the duct, use the (``ImplicitCompressibleDuct_ExternalHX``). + +``ImplicitCompressibleDuct_ExternalHX`` +--------------------------------------- + +The same as the ``ImplicitCompressibleDuct`` but without a heat exchanger within the model. +Details on how to incorporate the heat exchanger can be found in comments in the code. + +``ExplicitIncompressibleDuct`` +------------------------------ + +This duct models a ducted heat exchanger at incompressible flow conditions. +Because it cannot model flow with heat addition, it is generally a conservative estimate of the cooling drag. +It assumes that the static pressure at the duct's exist is the ambient pressure, which may or may not be a reasonable assumption. + +.. note:: + Given the limitations of the duct, it is recommended to use one of the compressible duct models instead. + +Heat exchanger: ``HXGroup`` +=========================== + +A detailed liquid-air compact heat exchanger model. +For more details on the model, see the source code in ``openconcept/thermal/heat_exchanger.py``. + +Heat pipe: ``HeatPipe`` +======================= + +Models an ammonia heat pipe. +A heat pipe is a device that takes advantage of evaporation and condensation of a working fluid to move heat passively and rapidly. +The model may be inaccurate for temperatures outside the -75 to 100 deg C range because the ammonia properties interpolate data. +To change the working fluid, a new surrogate model of fluid properties would be required. + +Coolant pump: ``SimplePump`` +============================ + +Coolant pump model that computes the required electrical load given flow properties and a required pressure rise. +The pressure drop required is accumulated from hoses and the heat exchanger. +Weight is a linear function of the rated power. +The component sizing margin is computed as the electrical load required divided by the power rating. + +Coolant hose: ``SimpleHose`` +============================ + +A hose to transport coolant. +The purpose of modeling the hoses on the aircraft level is that it computes a weight added to the aircraft and the pressure drop to size the pumps. + +Manifolds +========= + +These components are used to split and combine coolant flows. + +``FlowSplit`` +------------- + +This component splits coolant mass flow based on a fractional parameter. + +``FlowCombine`` +--------------- + +This component combines to coolant flow streams. +It includes equations to compute the output coolant temperature of the combined input flows. + +Heat transfer to coolant +======================== + +At some point in the liquid-cooled thermal management system, heat must be transferred to the coolant. +These components provide general ways of doing this. +Other models provide-component specific methods for this, such as the ``LiquidCooledBattery`` and ``LiquidCooledMotor``. + +``PerfectHeatTransferComp`` +--------------------------- + +This component assumes that all heat enters the coolant with no thermal resistance. + +``ConstantSurfaceTemperatureColdPlate_NTU`` +------------------------------------------- + +This component models a microchannel cold plate with a uniform temperature. +Unlike the ``PerfectHeatTransferComp``, it computes heat entering the coolant using some thermal resistance computed based on the plate geometry. + +Coolant reservoir: ``CoolantReservoir`` +======================================= + +This component models a reservoir of coolant that can be used to buffer transient temperature changes by adding thermal mass to the system. + +Thermal component residuals +=========================== + +For modelling temperatures of components, OpenConcept methods can be separated in two categories. +The first assumes that the component has mass, which means it can accumulate heat so the heat added to it may not be the same as the heat removed from it by the cooling system. +The second assumes the component is massless, which means that the heat added to it equals the heat removed. + +The first category can more accurately model the temperature, particularly when the component has significant mass and the conditions/heat flows change substantially in time. +Deciding when it is important to model the mass requires engineering judgement, but if you can afford the added complexity it is usually a good choice to model mass. + +At the most basic level, we need some sort of base equation to solve for each of these two cases. +This is the function these components provide. + +Both of these are used in ``LiquidCooledComp``, so look there for example usage. + +``ThermalComponentWithMass`` +---------------------------- + +When thermal mass is considered, the base equation we need is one that defines the rate of change of temperature of the component for a given amount of heat added and removed. +An integrator should then be attached to integrate the temperature rate of change, computing component temperature. + +``ThermalComponentMassless`` +---------------------------- + +When mass is ignored, we use a different structure. +We figure out the temperature of the component that results in the heat added being equal to the heat removed. +This becomes an implicit relationship, so ``ThermalComponentMassless`` computes net heat flow that will be driven to zero by the solver. +The temperature it outputs should be used in the heat addition/removal models (e.g., the heat flow to the coolant depends on the temperature of the component and the coolant). diff --git a/docs/features/utilities.rst b/docs/features/utilities.rst new file mode 100644 index 00000000..0db76c10 --- /dev/null +++ b/docs/features/utilities.rst @@ -0,0 +1,104 @@ +.. _Utilities: + +********* +Utilities +********* + +OpenConcept provides utilities for math and other generally-useful operations. + +Math +==== + +Integration: ``Integrator`` +--------------------------- + +This integrator is perhaps the most important part of the OpenConcept interface because it is critical for mission analysis, energy usage modeling, and unsteady thermal models. +It can use the BDF3 integration scheme or Simpson's rule to perform integration. +By default it uses BDF3, but Simpson's rule is the most common one used by OpenConcept. + +For a description of how to use it, see the :ref:`integrator tutorial `. + +Addition and subtraction: ``AddSubtractComp`` +--------------------------------------------- + +This component can add/subtract a combination of vectors and scalars (any vector in the equation must be the same length). +Scaling factors on each input can be defined to switch between addition and subtraction (and other scaling factors if desired). + +Multiplication and division: ``ElementMultiplyDivideComp`` +---------------------------------------------------------- + +Similar to the ``AddSubtractComp``, but instead of scaling factors you specify whether each input is divided (by default multiplied). + +Vector manipulation +------------------- + +``VectorConcatenateComp`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Concatenates one or more sets of more than one vector into one or more output vectors. + +``VectorSplitComp`` +~~~~~~~~~~~~~~~~~~~ + +Splits one or more vectors into one or more sets of 2+ vectors. + +Differentiation: ``FirstDerivative`` +------------------------------------- + +Differentiates a vector using a second or fourth order finite difference approximation. + +Maximum: ``MaxComp`` +-------------------- + +Returns the maximum value of a vector input. + +Minimum: ``MinComp`` +-------------------- + +Returns the minimum value of a vector input. + +General +======= + +Outputs from dictionary: ``DictIndepVarComp`` +--------------------------------------------- + +Creates a component with outputs defined by keys in a nested dictionary. +The values of each output are taken from the dictionary. +Each variable from the dictionary must be added by the user with ``add_output_from_dict``. +This component is based on OpenMDAO's ``IndepVarComp``. + +Linear interpolation: ``LinearInterpolator`` +-------------------------------------------- + +Creates a vector that linearly interpolates between an initial and final value. + +Rename variables: ``DVLabel`` +----------------------------- + +Helper component that is needed when variables must be passed directly from input to output of an element with no other component in between. + +This component is adapted from Justin Gray's pyCycle software. + +Select elements from vector: ``SelectorComp`` +--------------------------------------------- + +Given a set of vector inputs, this component allows the user to specify which input each spot in the vector output pulls from. + +Dymos parameters from dictionary: ``DymosDesignParamsFromDict`` +--------------------------------------------------------------- + +Creates Dymos parameters from an external file with a Python dictionary. + +Visulization +============ + +``plot_trajectory`` +------------------- + +Plot data from a mission. + +``plot_trajectory_grid`` +------------------------ + +Plot data from multiple missions against each other. diff --git a/docs/features/weights.rst b/docs/features/weights.rst new file mode 100644 index 00000000..e08cdb22 --- /dev/null +++ b/docs/features/weights.rst @@ -0,0 +1,36 @@ +.. _Weights: + +******* +Weights +******* + +This module provides empty weight approximations using mostly empirical textbook methods. +For now there are only models for small turboprop-sized aircraft, but more may be added in the future. +Component positions within the aircraft are not considered; all masses are accumulated into a single number. + +Single-engine turboprop OEW: ``SingleTurboPropEmptyWeight`` +=========================================================== + +This model combines estimates from :footcite:t:`raymer2006aircraft` and :footcite:t:`roskam2019airplane` to compute the total operating empty weight of a small single-engine turboprop aircraft. +The engine and propeller weight are not computed since OpenConcept's turboshaft and propeller models compute those separately. +Thus, those weights must be provided to this component by the user. + +This model uses the following components from the `openconcept.weights` module to estimate the total empty weight: + +- ``WingWeight_SmallTurboprop`` +- ``EmpennageWeight_SmallTurboprop`` +- ``FuselageWeight_SmallTurboprop`` +- ``NacelleWeight_SmallSingleTurboprop`` +- ``LandingGearWeight_SmallTurboprop`` +- ``FuelSystemWeight_SmallTurboprop`` +- ``EquipmentWeight_SmallTurboprop`` + +For turboprops with multiple engines, ``NacelleWeight_MultiTurboprop`` may be used instead of ``NacelleWeight_SmallSingleTurboprop``. + +Twin-engine series hybrid OEW: ``TwinSeriesHybridEmptyWeight`` +============================================================== + +This model uses all the same components as ``SingleTurboPropEmptyWeight``, except it adds weight inputs required by the user to account for the hybrid propulsion system. +The additional weights, which are computed by other OpenConcept components, are electric motor weight and generator weight. + +.. footbibliography:: diff --git a/docs/index.rst b/docs/index.rst index ab414056..989454f7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,11 +11,23 @@ It is open source (GitHub: https://github.com/mdolab/openconcept) and MIT licens OpenConcept was developed in order to model and optimize aircraft with electric propulsion at low computational cost. The tools are built on top of NASA Glenn's `OpenMDAO `__ framework, which in turn is written in Python. +OpenConcept is capable of modeling a wide range of propulsion systems, including detailed thermal management systems. +The following figure (from `this paper `__) shows one such system that is modeled in the ``N3_HybridSingleAisle_Refrig.py`` example. + +.. image:: _static/images/full_parallel_system_chiller.png + :width: 500px + :align: center + +| + The following charts show more than 250 individually optimized hybrid-electric light twin aircraft (similar to a King Air C90GT). Optimizing hundreds of configurations can be done in a couple of hours on a standard laptop computer. .. image:: _static/images/readme_charts.png - :width: 800px + :width: 600px + :align: center + +| The reason for OpenConcept's efficiency is the analytic derivatives built into each analysis routine and component. Accurate, efficient derivatives enable the use of Newton nonlinear equation solutions and gradient-based optimization at low computational cost. @@ -31,19 +43,34 @@ To run the examples or edit the source code: #. Navigate to the root ``openconcept`` folder #. Run ``pip install -e .`` to install the package (the ``-e`` can be omitted if not editing the source) -Get started by running the ``TBM850`` example: - -#. Navigate to the ``examples`` folder -#. Run ``python TBM850.py`` to test OpenConcept on a single-engine turboprop aircraft (the TBM 850) -#. Look at the ``examples/aircraft data/TBM850.py`` file to play with the assumptions / config / geometry and see the effects on the output result - -``examples/HybridTwin.py`` is set up to do MDO in a grid of specific energies and design ranges and save the results to disk. Visualization utilities will be added soon (to produce contour plots as shown in this Readme). +Get started by following the tutorials to learn the most important parts of OpenConcept. +The features section of the documentation describes most of the components and system models available in OpenConcept. ------------ Dependencies ------------ -This toolkit requires the use of `OpenMDAO `__ 3.10.0 or later due to backward-incompatible changes. OpenMDAO requires a recent NumPy and SciPy. -Python 3.8 is recommended since it is the version with which the code is tested, but newer Python versions will likely work as well. + +.. Remember to change in the readme too! + +This toolkit requires the use of `OpenMDAO `__ 3.10.0 or later due to backward-incompatible changes. +OpenMDAO requires a recent NumPy and SciPy. +Python 3.8, 3.9, or 3.10 are recommended since they are the versions with which the code is tested, but newer Python versions will likely work as well. + +.. list-table:: Latest tested dependencies + :header-rows: 1 + + * - Package + - Version + * - Python + - 3.10.4 + * - OpenMDAO + - 3.16.0 + * - NumPy + - 1.22.4 + * - SciPy + - 1.7.3 + * - OpenAeroStruct + - 2.5.1 --------------- Please Cite Us! @@ -83,26 +110,38 @@ Eytan J. Adler and Joaquim R.R.A. Martins, "Aerostructural wing design optimizat year = {2022} } ------------- -Contributing ------------- -A contributor's guide is coming third (after completing documentation and automatic testing coverage). -I'm open to pull requests and issues in the meantime. Stay tuned. The development roadmap is :ref:`here `. - .. currentmodule:: openconcept .. toctree:: - :maxdepth: 1 - :caption: Documentation: + :maxdepth: 2 + :caption: Tutorials + :hidden: - features/index.rst - _srcdocs/index.rst - developer/roadmap.rst + tutorials/minimal_example.rst + tutorials/integrator.rst + tutorials/turboprop.rst + tutorials/more_examples.rst +.. toctree:: + :maxdepth: 2 + :caption: Features + :hidden: + + features/aerodynamics.rst + features/atmospherics.rst + features/costs.rst + features/energy_storage.rst + features/mission.rst + features/propulsion.rst + features/thermal.rst + features/weights.rst + features/utilities.rst -Indices and tables -================== +.. toctree:: + :maxdepth: 2 + :caption: Other Useful Docs + :hidden: -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + _srcdocs/index.rst + developer/roadmap.rst + publications.rst diff --git a/docs/publications.rst b/docs/publications.rst new file mode 100644 index 00000000..00023146 --- /dev/null +++ b/docs/publications.rst @@ -0,0 +1,92 @@ +.. _Publications: + +************ +Publications +************ + +2022 +==== + +Eytan J. Adler, Benjamin J. Brelje, and Joaquim R.R.A. Martins, +*Thermal Management System Optimization for a Parallel Hybrid Aircraft Considering Mission Fuel Burn*, +Aerospace, 9(5), 2022 +DOI: `10.3390/aerospace9050243 `_. +PDF is available `here `__. + +.. code-block:: bibtex + + @article{Adler2022b, + article-number = {243}, + author = {Adler, Eytan J. and Brelje, Benjamin J. and Martins, Joaquim R. R. A.}, + doi = {10.3390/aerospace9050243}, + issn = {2226-4310}, + journal = {Aerospace}, + keywords = {OpenMDAO, OpenConcept}, + month = apr, + number = {5}, + title = {Thermal Management System Optimization for a Parallel Hybrid Aircraft Considering Mission Fuel Burn}, + volume = {9}, + year = {2022} + } + +Eytan J. Adler and Joaquim R.R.A. Martins, +*Aerostructural wing design optimization considering full mission analysis*, +2022 AIAA SciTech Forum, San Diego, CA, January 2022, +DOI: `10.2514/6.2022-0382 `_. +PDF is available `here `__. + +.. code-block:: bibtex + + @inproceedings{Adler2022a, + author = {Eytan J. Adler and Joaquim R. R. A. Martins}, + title = {Aerostructural wing design optimization considering full mission analysis}, + booktitle = {AIAA SciTech Forum}, + doi = {10.2514/6.2022-0382}, + month = {January}, + year = {2022} + } + +2019 +==== + +Benjamin J. Brelje, John P. Jasa, Joaquim R.R.A. Martins, and Justin S. Gray, +*Development of a Conceptual-Level Thermal Management System Design Capability in OpenConcept*, +NATO Research Symposium on Hybrid/Electric Aero-Propulsion Systems for Military Applications (AVT-RSY-323), 2019, +DOI: 10.14339/STO-MP-AVT-323. +PDF is available `here `__. + +.. code-block:: bibtex + + @inproceedings{Brelje2019d, + address = {Trondheim, NO}, + author = {Brelje, Benjamin J. and Jasa, John P. and Martins, Joaquim R. R. A. and Gray, Justin S.}, + booktitle = {NATO Research Symposium on Hybrid/Electric Aero-Propulsion Systems for Military Applications (AVT-RSY-323)}, + doi = {10.14339/STO-MP-AVT-323}, + institution = {NATO Research and Technology Organization}, + keywords = {OpenMDAO, OpenConcept, ccavd}, + month = oct, + title = {Development of a Conceptual-Level Thermal Management System Design Capability in {OpenConcept}}, + year = {2019} + } + +2018 +==== + +Benjamin J. Brelje and Joaquim R.R.A. Martins, +"Development of a Conceptual Design Model for Aircraft Electric Propulsion with Efficient Gradients", +2018 AIAA/IEEE Electric Aircraft Technologies Symposium, +AIAA Propulsion and Energy Forum, (AIAA 2018-4979), +DOI: `10.2514/6.2018-4979 `_. +PDF is available `here `__. + +.. code-block:: bibtex + + @inproceedings{Brelje2018, + address = {{C}incinnati,~{OH}}, + author = {Benjamin J. Brelje and Joaquim R. R. A. Martins}, + booktitle = {2018 AIAA/IEEE Electric Aircraft Technologies Symposium}, + month = {July}, + title = {Development of a Conceptual Design Model for Aircraft Electric Propulsion with Efficient Gradients}, + year = {2018}, + doi = {10.2514/6.2018-4979} + } diff --git a/docs/ref.bib b/docs/ref.bib new file mode 100644 index 00000000..50ab37c5 --- /dev/null +++ b/docs/ref.bib @@ -0,0 +1,17 @@ +@book{raymer2006aircraft, + title={Aircraft design: a conceptual approach fourth edition}, + author={Raymer, Daniel P.}, + publisher={American Institute of Aeronautics and Astronautics}, + address={Reston, VA}, + year={2006}, + isbn={1563478293}, +} + +@book{roskam2019airplane, + title={Airplane Design---Part V: Component Weight Estimation}, + author={Roskam, Jan}, + publisher={{DARcorporation}}, + address={Lawrence, Kansas}, + year={2019}, + isbn={978-1-994995-50-1}, +} \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..ee42abc0 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +sphinx-mdolab-theme +openmdao +matplotlib diff --git a/docs/tutorials/integrator.rst b/docs/tutorials/integrator.rst new file mode 100644 index 00000000..4c2db232 --- /dev/null +++ b/docs/tutorials/integrator.rst @@ -0,0 +1,194 @@ +.. _Integrator-tutorial: + +******************** +Using the Integrator +******************** + +In this tutorial, we will build off the previous minimal example tutorial by adding a numerical integrator to compute fuel burn throughout the mission. +This will require the following additions to the previous aircraft model: + +- A component to compute fuel flow rate using thrust and thrust-specific fuel consumption (TSFC) +- An integrator to integrate the fuel flow rate w.r.t. time to compute the weight of fuel burned at each numerical integration point +- A component to compute the weight at each integration point by subtracting the fuel burned from the takeoff weight + +Other than these changes to the aircraft model the code will look very similar to the minimal example, so we will gloss over some details that are described more in the :ref:`minimal example `. +If you have not done so already, it is recommended to go through that tutorial first. + +.. note:: + The script described in this tutorial is called minimal_integrator.py and can be found in the examples folder. + +Imports +======= + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Imports (beg) + :end-before: # rst Imports (end) + +The notable addition to the imports is OpenConcept's `Integrator` class. +We also import the `Aircraft` class and `setup_problem` function from the previous tutorial. + +Aircraft model +============== + +The aircraft model is no longer a set of explicit equations; it now requires a combination of OpenMDAO components to compute the weight. +For this reason, the aircraft model is now an OpenMDAO ``Group`` instead of an ``ExplicitComponent``. + +Let's first take a look at the code for the entire aircraft model and then we will break down what is happening in each section. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Aircraft (beg) + :end-at: # rst Weight (end) + +Options +------- + +The options are the same as the minimal example tutorial. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Options + :end-before: # rst Setup + :dedent: 4 + +Setup +----- + +.. note:: + The order you add components to OpenMDAO groups (using ``add_subsystem``) matters! + Generally, it is best to try to add components in the order that achieves as much feed-forward variable passing as possible. + For example, we have a component that computes thrust and another that takes thrust as an input. + To make this feed-forward, we add the component that takes thrust as an input *after* the component that computes it. + +Thrust and drag from minimal aircraft +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The first step in setting up the new aircraft model is to add the simplified aircraft to the group. +We still use this model to compute thrust and drag, but the weight calculation will be modified. +For this reason, we promote only the thrust and drag outputs to the new aircraft model group level. +All the inputs are still required, so we promote them all to the group level (this way OpenConcept will automatically connect them as we discussed last time). +If you are confused about the promotion, check out the OpenMDAO documentation. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Simple aircraft (beg) + :end-before: # rst Simple aircraft (end) + :dedent: 8 + +Fuel flow rate +~~~~~~~~~~~~~~ + +Next, we need to compute the fuel flow rate to pass to the integrator. +Since this is a simple function of thrust and TSFC, we use an OpenMDAO ``ExecComp`` (the OpenMDAO docs are very thorough if you are confused about the syntax). +We give it the fuel flow equation we want it to evaluate and define the units and shape of each parameter. +Notice that fuel flow and thrust are both vectors because they are evaluated at each numerical integration point and will change throughout each flight phase. +The TSFC is a scalar because it is a single constant parameter defined for the aircraft. +Finally, we promote the inputs. +Thrust is automatically connected to the thrust output from the minimal aircraft model. +TSFC is promoted to a name beginning with ``"ac|"`` so that the mission analysis promotes the variable to the top level so we can set it the same way as the other aircraft parameters. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Fuel flow (beg) + :end-before: # rst Fuel flow (end) + :dedent: 8 + +Integrator +~~~~~~~~~~ + +Now we are ready to add the integration. +This is done by adding an OpenConcept ``Integrator`` component to the model. +After adding the integrator, we add an integrated variable and associated variable to integrate using the integrator's ``add_integrand`` method. +Let's step through all the details of these calls---there's a lot to unpack. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Integrator (beg) + :end-before: # rst Integrator (end) + :dedent: 8 + +When ``Integrator`` is initialized, there are a few important options that must be set. +As we've seen before, we set ``num_nodes`` to tell it how many integration points to use. + +``diff_units`` are the units of the differential. +For example, in our equation we are computing + +.. math:: + \text{fuel burn} = \int_{t_\text{initial}}^{t_\text{final}} \dot{m}_\text{fuel} \: dt + +The differential is :math:`dt` and has units of time (we'll use seconds here). + +The ``time_setup`` option sets what information the integrator uses to figure out the time at each integration point. +The options are ``"dt"``, ``"duration"``, or ``"bounds"``. + +- ``"dt"`` creates an input called ``"dt"`` that specifies the time spacing between each numerical integration point +- ``"duration"`` creates an input called ``"duration"`` that specifies the total time of the phase. The time between each integration point is computed by dividing the duration by the number of time steps (number of nodes minus one). This is the most common choice for the time setup and has the advantage that **OpenConcept automatically connects the** ``"duration"`` **input to the mission-level duration, so there is no manual time connection needed**. +- ``"bounds"`` creates inputs called ``"t_initial"`` and ``"t_final"`` that specify the initial and final time of the phase. This internally computes duration and then time is computed the same was as for the duration approach. + +The final option is the integration scheme. +The two options are ``"bdf3"`` and ``"simpson"``. +``"bdf3"`` uses the third-order-accurate BDF3 integration scheme. +``"simpson"`` uses Simpson's rule. +Simpson's rule is the most common choice for use in OpenConcept. + +In the next line we add information about the quantity we want to integrate. +We first define the name of the integrated quantity: ``"fuel_burned"``. +This will become a vector output of the integrator (accessed in this case as ``"fuel_integrator.fuel_burned"``). +We then define the rate we want integrated: ``"fuel_flow"``. +This will create a vector input to the integrator called ``"fuel_flow"``. +It also automatically adds an input called ``"fuel_flow_initial"`` and an output called ``"fuel_flow_final"``. +Instead of appending ``"_initial"`` or ``"_final"``, these names can be set manually using the ``start_name`` and ``end_name`` optional arguments. +The final value variable of each phase is automatically linked to the initial value variable of the following one. +The initial value in the first mission phase is zero by default but can be changed either using the ``start_val`` optional argument or by setting the variable in a usual OpenMDAO way with an ``IndepVarComp`` or ``set_input_defaults``. +We set the units of the fuel burn (the integrated quantity) to kilograms. +Other available options can be found in the ``Integrator`` source docs. + +The final step is to connect the fuel flow output from the fuel flow computation component to the integrator's fuel flow input. + +Mission +======= + +The rest of the code will look very similar to the :ref:`minimal example `. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Mission (beg) + :end-before: # rst Mission (end) + +The mission is identical except for two changes. +Firstly, we set the TSFC variable called ``"ac|propulsion|TSFC"``. +Secondly, the ``aircraft_model`` passed to the mission analysis component is now ``AircraftWithFuelBurn``. + +Run script +========== + +We reuse the ``setup_problem`` function from the :ref:`minimal example `. +The remaining code is the same, except for adding a couple more variables of interest to the output plot. + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py + :start-after: # rst Run (beg) + :end-before: # rst Run (end) + +The model should converge in a few iterations. +The plot it generates should look like this: + +.. image:: assets/minimal_integrator_results.svg + +The N2 diagram for the model is the following: + +.. embed-n2:: + ../openconcept/examples/minimal_integrator.py + +You can see that the weight is no longer constant. +This results in a varying throttle in the cruise phase, unlike the constant throttle from the :ref:`minimal example `. +Also notice that the fuel flow and throttle have the exact same shape, which makes sense because they are directly related by a factor of TSFC. + +Summary +======= + +In this tutorial, we extended the previous minimal aircraft example to use an integrator to compute fuel burn. +Our aircraft model is now an OpenMDAO group with a few more components in it. +We compute fuel flow using thrust output by the aircraft model and TSFC. +The integrator integrates fuel flow to compute fuel burn. +Finally, we compute the aircraft weight by subtracting the fuel burned from the takeoff weight. + +The time input for the integrator is connected automatically and the final integrated value from one phase is connected to the initial value for the following one with some Ben Brelje magic. +You're encouraged to figure out how this works for yourself by looking at the source code for the ``PhaseGroup`` and ``TrajectoryGroup`` (these are used by the flight phase and mission analysis groups). + +The final script looks like this: + +.. literalinclude:: ../../openconcept/examples/minimal_integrator.py diff --git a/docs/tutorials/minimal_example.rst b/docs/tutorials/minimal_example.rst new file mode 100644 index 00000000..5c755d23 --- /dev/null +++ b/docs/tutorials/minimal_example.rst @@ -0,0 +1,256 @@ +.. _Minimal-example-tutorial: + +*************** +Minimal Example +*************** + +This example shows how to set up an OpenConcept aircraft and mission analysis model. +The goal here is to use only what is absolutely necessary with the idea of introducing the starting point for building more complicated and detailed models. +It uses a simplified aircraft model and basic mission profile with climb, cruise, and descent phases. + +OpenConcept leans heavily on OpenMDAO for the numerical framework, in particular ``ExplicitComponent``, ``ImplicitComponent``, ``Group``, ``Problem``, the OpenMDAO solvers, and the optimizer interfaces. +If you are not already familiar with OpenMDAO, we strongly recommend going through their `basic user guide `_. + +.. note:: + The script described in this tutorial is called minimal.py and can be found in the examples folder. + +Imports +======= + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Imports (beg) + :end-before: # rst Imports (end) + +We start by importing the necessary modules. +In this example, we need OpenMDAO to use its classes and solvers. +From OpenConcept, we use the BasicMission mission analysis component. +Lastly, we use NumPy to initialize vectors and matplotlib to make figures at the end. + +Aircraft model +============== + +At it's most basic, an OpenConcept aircraft model takes in a lift coefficient and throttle position (from 0 to 1) and returns thrust, weight, and drag. +In the code, these variables are named ``"fltcond|CL"``, ``"throttle"``, ``"thrust"``, ``"weight"``, and ``"drag"``, respectively. +You can add as much or as little detail in this computation of the outputs as you'd like, but all the model cares is that the outputs can be controlled by the inputs. +OpenConcept provides component models to build these aircraft systems. + +The complexity can grow rapidly, so for now we will not use any of these OpenConcept models; instead we develop a minimal aircraft model. +We assume constant weight across the whole mission. +Thrust is modeled as maximum thrust times the throttle. +Drag is computed as lift divided by lift-to-drag ratio. + +Let's first take a look at the code for the whole aircraft model, and then we'll explain each part. +The whole model looks like this: + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Aircraft (beg) + :end-before: # rst Aircraft (end) + +Options +------- + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Options + :end-before: # rst Setup + :dedent: 4 + +We start by defining the options for the model. +These two options are **required** for all OpenConcept aircraft models: + +- ``"num_nodes"``: OpenConcept numerically integrates states w.r.t. time in each mission phase. This option will tell the aircraft model how many numerical integration points are used in the phase. All the required inputs and outputs listed above are vectors of length ``"num_nodes"``. +- ``"flight_phase"``: The mission analysis group sets this option to a string that is the name of the current flight phase. For example in the basic three-phase mission, this will be set either to ``"climb"``, ``"cruise"``, or ``"descent"``. This option can be used by the aircraft model to set phase-specific values. For example, in takeoff segments the user may want to set different aerodynamic parameters that correspond to a flaps-extended configuration. + +Setup +----- + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Setup + :end-before: # rst Compute + :dedent: 4 + +Next, we add the inputs and outputs to the aircraft model. +We start with the lift coefficient and throttle---the inputs required by OpenConcept. +Note that the shape of these inputs is defined as the number of nodes. +In other words, these inputs are vectors of length ``"num_nodes"``. + +There are other parameters that the mission analysis will automatically connect to the aircraft model if they're defined as inputs. +A set of flight condition variables and other aircraft parameters defined by the user at the top level analysis group. + +The available flight condition variables are the following: + +.. list-table:: Flight condition variables for steady flight phases + :header-rows: 1 + + * - Variable name + - Property + - Vector length + * - fltcond|CL + - Lift coefficient + - ``"num_nodes"`` + * - fltcond|q + - Dynamic pressure + - ``"num_nodes"`` + * - fltcond|rho + - Density + - ``"num_nodes"`` + * - fltcond|p + - Pressure + - ``"num_nodes"`` + * - fltcond|T + - Temperature (includes increment) + - ``"num_nodes"`` + * - fltcond|a + - Speed of sound + - ``"num_nodes"`` + * - fltcond|TempIncrement + - Increment on the 1976 Standard Atmosphere temperature + - ``"num_nodes"`` + * - fltcond|M + - Mach number + - ``"num_nodes"`` + * - fltcond|Utrue + - True airspeed + - ``"num_nodes"`` + * - fltcond|Ueas + - Equivalent airspeed + - ``"num_nodes"`` + * - fltcond|groundspeed + - Ground speed + - ``"num_nodes"`` + * - fltcond|vs + - Vertical speed + - ``"num_nodes"`` + * - fltcond|h + - Altitude + - ``"num_nodes"`` + * - fltcond|h_initial + - Initial altitude in phase + - 1 + * - fltcond|h_final + - Final altitude in phase + - 1 + * - fltcond|cosgamma + - Cosine of the flight path angle + - ``"num_nodes"`` + * - fltcond|singamma + - Sine of the flight path angle + - ``"num_nodes"`` + +The aircraft parameters that the mission analysis passes through to the aircraft model are those set by the user in the top-level group (called ``"MissionAnalysis"`` in our case). +It will pass any variable whose name starts with ``"ac|"``. +Here, we define four aircraft parameters that are used by the aircraft model: ``"ac|geom|wing|S_ref"`` (wing area), ``"ac||weights|TOW"`` (aircraft weight), ``"ac|propulsion|max_thrust"``, and ``"ac|aero|L_over_D"`` (lift-to-drag ratio). +We will see in the mission tutorial section how they are defined at the top level. + +Next, we add the outputs needed by OpenConcept to converge the mission: ``"weight"``, ``"drag"``, and ``"thrust"``. + +Finally, we declare the derivatives of the outputs w.r.t. the inputs. +In this case, we have OpenMDAO compute all the partial derivatives using complex step. +In practice, analytically defining the partial derivatives offers more accurate and faster derivative computations. + +Compute +------- + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Compute + :end-before: # rst Aircraft (end) + :dedent: 4 + +In this section, we actually compute the output values using the inputs. +The weight is simply equal to the defined weight. +The thrust is equal to the throttle input times the max thrust input. +The drag is equal to the lift divided by L/D, where lift is computed using the dynamic pressure, lift coefficient, and wing area. + +Mission +======= + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Mission (beg) + :end-before: # rst Mission (end) + +Now that we have an aircraft model, we need to define the mission it will fly. +In OpenConcept, we do this by defining a top-level OpenMDAO group. +This group usually contains two components: + +- An ``IndepVarComp`` or ``DictIndepVarComp`` with the aircraft parameters (the values are automatically passed to the aircraft model) +- The OpenConcept mission analysis block + +In this case we have only four aircraft parameters, so we define them in the script using an OpenMDAO ``IndepVarComp``. +Following tutorials will use a slightly different method to keep the parameters more organized. +These outputs are promoted from the ``IndepVarComp`` to the level of the ``MissionAnalysis`` group (``promote_outputs=["*"]`` promotes all outputs). + +Next we add the mission analysis. +In this tutorial, we use OpenConcept's ``BasicMission``, which consists of a climb, a cruise, and a descent flight phase. +The aircraft model class is passed into the ``BasicMission``, along with the number of nodes per phase. +``BasicMission`` will instantiate copies of the aircraft model class in each flight phase and promote all inputs from the aircraft model that begin with ``"ac|"`` to the ``BasicMission`` level. +We promote all inputs that begin with ``"ac|*"`` from the ``BasicMission`` to the ``MissionAnalysis`` level. +This way, OpenMDAO will automatically connect the outputs from the ``IndepVarComp`` to the aircraft parameter inputs of the aircraft models in each phase. + +In this group, we declare an option to set the number of nodes per phase so that the user can initialize the value when the problem is set up. + +.. note:: + ``"ac|geom|wing|S_ref"`` is a **required** top-level aircraft parameter that must be defined. + OpenConcept uses it to compute lift coefficient from lift force. + Which other aircraft parameters are required is dependent on which OpenConcept models the user chooses to include in the aircraft model. + +Run script +========== + +Setup problem +------------- + +Now that we have the necessary models. +The last step before running the model is setting up the OpenMDAO problem and providing the necessary values to define the mission profile. + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Setup problem (beg) + :end-before: # rst Setup problem (end) + +We initialize an OpenMDAO Problem and add the ``MissionAnalysis`` class we defined as the problem's model. +Here is where we specify the number of nodes per flight phase, using 11. +Next, we add a solver that is used to determine the throttle and lift coefficient values that satisfy the steady flight force balance across the mission. +We use OpenMDAO's Newton solver and assign a direct linear solver to solve each subiteration of the nonlinear solver. + +Once the problem is setup, we set the necessary values to specify the mission profile. +``BasicMission`` has climb, cruise, and descent phases, but we still need to tell it the speed each is flown at, the cruise altitude, etc. +This mission requires a vertical speed and airspeed in each phase. +It also requires an initial cruise altitude and total mission length. +For more details, see the :ref:`mission analysis documentation `. + +Run it! +------- + +Finally, we actually run the analysis. + +.. literalinclude:: ../../openconcept/examples/minimal.py + :start-after: # rst Run (beg) + :end-before: # rst Run (end) + +After running the model, we do a bit of postprocessing to visualize the results. +The first thing we do is create an N2 diagram. +This allows you to explore the structure of the model and the values of each variable. +Lastly, we get some values from the model and create plot of some values, using matplotlib. + +The model should converge in a few iterations. +The plot it generates should look like this: + +.. image:: assets/minimal_example_results.svg + +The N2 diagram for the model is the following: + +.. embed-n2:: + ../openconcept/examples/minimal.py + +Summary +======= + +In this tutorial, we developed a simple run script to explain how to set up an OpenConcept mission analysis. +The aircraft model is very simplified and does not use any of OpenConcept's models. +Nonetheless, it obeys all the input/output requirements of an OpenConcept aircraft and thus can be used in the mission analysis. + +You may notice that the results of this analysis are not particularly useful. +It does not offer any information about fuel burn, energy usage, component temperatures, or sizing requirements. +In the next tutorial, we'll develop a more comprehensive aircraft model that is more useful for conceptual design. + +The final script looks like this: + +.. literalinclude:: ../../openconcept/examples/minimal.py diff --git a/docs/tutorials/more_examples.rst b/docs/tutorials/more_examples.rst new file mode 100644 index 00000000..f7cbc60f --- /dev/null +++ b/docs/tutorials/more_examples.rst @@ -0,0 +1,47 @@ +.. _More-examples: + +************* +More Examples +************* + +While we work on more extensive tutorials, you can learn more about OpenConcept by diving into the source code and playing around with OpenConcept's models. +This page suggests logical next places to look to gain an understanding of what is possible. + +Propulsion modeling +=================== + +The :ref:`turboprop example ` uses OpenConcept's ``TurbopropPropulsionSystem``. +If you have not already looked at the underlying code for that model, that's a good place to start. +The component's source code is in ``openconcept/propulsion/systems/simple_turboprop.py`` and can be found on GitHub `here `__. + +After that, we recommend looking at the ``TwinTurbopropPropulsionSystem`` in the same file to understand how to build models with more than one propulsor that properly handle the failed engine case on takeoff. +This propulsion system is used in the ``KingAirC90GT.py`` example. + +For an introduction to hybrid electric propulsion systems, have a look at the ``TwinSeriesHybridElectricPropulsionSystem``. +This model is in ``openconcept/propulsion/systems/simple_series_hybrid.py`` or on GitHub `here `__. +The ``HybridTwin.py`` example aircraft shows how to use this propulsion system and sets up an optimization problem for it. + +Finally, the ``B738.py`` example shows the use of the CFM56 surrogate model from pyCycle. +There are similar propulsion models for the N+3 geared turbofan and a parallel hybrid version of the same N+3 engine. + + +Thermal management modeling +=========================== + +One of OpenConcept's key contributions is the ability to model an aircraft thermal management system and the associated unsteady heat flows. +``AllElectricSinglePropulsionSystemWithThermal_Incompressible`` models an all-electric propulsion architecture with a thermal management system. +The code is located in ``openconcept/propulsion/systems/simple_all_electric.py`` and on GitHub `here `__. +The ``ElectricSinglewithThermal.py`` example models the TBM aircraft from the turboprop example, but retrofit with this all-electric propulsion system. + +The ``TwinSeriesHybridElectricThermalPropulsionSystem`` model adds a thermal management system to the twin-engine series hybrid propulsion system. +That model can be found in ``openconcept/propulsion/systems/thermal_series_hybrid.py`` or on GitHub `here `__. +The ``HybridTwin_thermal.py`` example shows how to use this propulsion system. + +The most detailed thermal management system model is in the ``N3_HybridSingleAisle_Refrig.py`` example aircraft. +It models a parallel hybrid single aisle commercial transport aircraft. +The model includes an electric motor with a cooling jacket, a battery with a bandolier cooling system, a refrigerator, compressible ducts, pumps, and even coolant hose models. + +Other useful places to look +=========================== + +The ``B738_VLM_drag.py`` and ``B738_aerostructural.py`` examples show how to use the OpenAeroStruct VLM and aerostructural models. diff --git a/docs/tutorials/readme.md b/docs/tutorials/readme.md new file mode 100644 index 00000000..ceb993c4 --- /dev/null +++ b/docs/tutorials/readme.md @@ -0,0 +1 @@ +When Read the Docs builds the documentation, the assets directory will be created and figures from the examples will be moved there. See the calls to `run_file_move_result` in `conf.py`. \ No newline at end of file diff --git a/docs/tutorials/turboprop.rst b/docs/tutorials/turboprop.rst new file mode 100644 index 00000000..82dd05a1 --- /dev/null +++ b/docs/tutorials/turboprop.rst @@ -0,0 +1,199 @@ +.. _Turboprop-tutorial: + +************************************ +Turboprop Model and Mission Analysis +************************************ + +This tutorial builds on the :ref:`previous tutorial ` by vastly improving the aircraft model. +We'll use components from OpenConcept to model the turboshaft engine, constant speed propeller, aerodynamics, and weight. +We'll also use a new mission profile that models takeoff by performing a balanced field length computation. +The model here could be considered the first "useful" aircraft model since it more accurately models the relationship between throttle, thrust, and fuel flow and also the aerodynamics. +This aircraft model is based on the Socata TBM 850 aircraft. + +.. note:: + The script described in this tutorial is called TBM850.py and can be found in the examples folder. + +Imports +======= + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Imports (beg) + :end-before: # rst Imports (end) + +Compared to the previous openconcept/examples, this adds a handful of imports from OpenConcept. +We import the propulsion system, aerodynamic model, weight estimate, and a few math utilities. +We also import a new type of mission analysis we haven't seen in previous tutorials: ``FullMissionAnalysis``. +This includes a balanced field length takeoff calculation. +Finally, we import ``acdata`` from the TBM's data file. +``acdata`` is a dictionary that organizes aircraft parameters (this is an alternative to what we've done so far of defining these values in the mission group). + +Aircraft model +============== + +This aircraft model builds on the aircraft in the :ref:`integrator tutorial ` by replacing the simple thrust and drag model we developed with much more detailed OpenConcept models. +The propulsion system uses OpenConcept's ``TurbopropPropulsionSystem``, which couples a turboshaft engine to a constant speed propeller. +We also use OpenConcept's ``PolarDrag`` component to compute drag using a simple drag polar. +The final addition is an operating empty weight (OEW) computation. +The OEW output is not used in the weight calculation, but it is a useful output to know (perhaps for optimization) and shows off another OpenConcept component. + +Let's take a look at the aircraft model as a whole and then we'll dive into each part. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Aircraft (beg) + :end-at: # rst Weight (end) + +Options +------- + +The options are the same as the previous tutorials. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Options + :end-before: # rst Setup + :dedent: 4 + +Setup +----- + +Now we'll break down the components of the setup method for the aircraft model. + +Propulsion +~~~~~~~~~~ + +We use OpenConcept's ``TurbopropPropulsionSystem`` to estimate the thrust as a function of throttle. +It uses a turboshaft, which assumes constant TSFC, connected to a constant speed propeller, which uses a propeller map. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Propulsion (beg) + :end-before: # rst Propulsion (end) + :dedent: 8 + +The propulsion system requires some flight conditions, an engine rating, a propeller diameter, and a throttle. +We set the propeller speed to 2000 rpm. +The propulsion system computes thrust, which is promoted, and fuel flow, which will be connected to the fuel burn integrator. + +Aerodynamics +~~~~~~~~~~~~ + +For the aerodynamics, we use a simple drag polar that computes drag using the equation + +.. math:: + \text{drag} = q S (C_{D0} + \frac{C_L^2}{\pi e AR}) + +where :math:`q` is dynamic pressure, :math:`S` is wing reference area, :math:`C_{D0}` is the zero-lift drag coefficient, :math:`C_L` is the lift coefficient, :math:`e` is the span efficiency, and :math:`AR` is the aspect ratio. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Aero (beg) + :end-before: # rst Aero (end) + :dedent: 8 + +We use a different zero-lift drag coefficient for the takeoff phases than for the climb, cruise, and descent phases because we assume the aircraft is not in its clean configuration on takeoff (e.g., flaps extended). +This uses the name of the flight phase option to figure out which phase the model is in. + +We then add the ``PolarDrag`` OpenConcept component and promote the necessary variables. +Some of the inputs are renamed using OpenMDAO's parenthesis format, which allows the selection on the fly of which :math:`C_{D0}` value to connect. + +Weights +~~~~~~~ + +Finally, we add the weight models. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Weight (beg) + :end-before: # rst Weight (end) + :dedent: 8 + +We begin by adding an operating empty weight estimate, using OpenConcept's ``SingleTurboPropEmptyWeight`` component. +Including this component is not necessary because the computed OEW value is not used +in the thrust, weight, or drag estimate returned to the OpenConcept mission. +However, OEW is a useful value to know, so we compute it anyway. + +It can be a little trick to figure out what is actually getting passed to the OEW component's inputs because there are so many and each one is not promoted individually (instead it uses the ``"*"`` glob pattern that promotes all of them). +You're encouraged to look at the N2 diagram to see what is being connected here. +The OEW component does not compute its own turboshaft and propeller weight because those are computed in the ``TurbopropPropulsionSystem``. + +The next two components added should look pretty familiar. +We add an integrator to integrate the fuel flow computed in the ``TurbopropPropulsionSystem`` and then subtract it from the takeoff weight to compute the current weight. +One change from the integrator tutorial is instead of using an OpenMDAO ``ExecComp`` to do the final arithmetic, we use OpenConcept's ``AddSubtractComp``. +This provides an easy way to combine variables by adding and subtracting and includes analytical derivatives. + +Mission +======= + +This mission group has two changes from previous openconcept/examples. +The aircraft parameters are added from the data file using a ``DictIndepVarComp`` instead of manually defining them as before with an ``IndepVarComp``. +This allows the parameters to be defined in one location (that is not the run script). +The second change is the switch to using a ``FullMissionAnalysis`` mission, which adds a balanced field length calculation to the ``BasicMission`` we used previously. + +The ``DictIndepVarComp`` takes in a nested Python dictionary, which in this case is imported from the TBM's aircraft data file. +Values from the dictionary can be added as outputs by calling ``add_output_from_dict`` and passing in a string with the keys for the nested dictionary for the variable you want separated by a pipe. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Mission (beg) + :end-before: # rst Mission (end) + +Run script +========== + +Setup problem +------------- + +We start by writing a function to set up the problem, assign solvers, and define the mission profile, just as we did in the :ref:`minimal example `. +The new addition here is the setup of the takeoff segment. +We set initial guesses for the takeoff speeds to initialize the solver with reasonable guesses. +This improves the convergence behavior. +We also set the structural fudge value, a multiplier on the structural weights, to account for additional weights not modeled by the empty weight component. +Finally, we decrease the throttle values for the takeoff segments from the default of 1 to 0.826. + + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Setup problem (beg) + :end-before: # rst Setup problem (end) + +Run it! +------- + +Now we get to actually run the problem. +After running it, we print some values from the solved problem. +The plotting section from previous tutorials is used twice to add a plot showing the takeoff portion of the mission. + +.. literalinclude:: ../../openconcept/examples/TBM850.py + :start-after: # rst Run (beg) + :end-before: # rst Run (end) + +The plot from the takeoff phases looks like this: + +.. image:: assets/turboprop_takeoff_results.svg + +The balanced field length consists of four phases. +The first models accelerating from a standstill to the decision speed, V1. +If an engine fails (in an airplane with at least two engines) before V1, the pilot brakes to a stop. +The second phase in the legend models this braking. +If it fails after V1, the takeoff continues to the rotation speed and takes off with the remaining engine(s). +This is modeled by the third and fourth phases in the legend. + +The mission looks like this: + +.. image:: assets/turboprop_mission_results.svg + +Compared to the previous tutorial, this model more accurately models the fuel flow and thrust force. +It also incorporates a better drag model. + +The N2 diagram for the model is the following: + +.. embed-n2:: + ../openconcept/examples/TBM850.py + +Summary +======= + +In this tutorial, we created a more detailed aircraft using OpenConcept's models for propulsion, aerodynamics, and weights. +We also incorporated a mission profile that includes a balanced field length takeoff. + +Hopefully, the tutorials this far have given you a baseline knowledge that is sufficient to have a general idea of what is going on in each part of OpenConcept. +From here we recommend diving into different parts of the OpenConcept source code to gain an idea of how to build more complex models. +:ref:`This page ` recommends some models to dig through next to learn more about OpenConcept. + +The final script looks like this: + +.. literalinclude:: ../../openconcept/examples/TBM850.py diff --git a/examples/B738_Dymos.py b/examples/B738_Dymos.py deleted file mode 100644 index 3f4d582d..00000000 --- a/examples/B738_Dymos.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import division -import sys -import os -import numpy as np - -sys.path.insert(0, os.getcwd()) -import openmdao.api as om -import openconcept.api as oc -# imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from examples.aircraft_data.B738 import data as acdata -from openconcept.analysis.performance.mission_profiles import MissionWithReserve -from openconcept.components.cfm56 import CFM56 - -class B738AirplaneModel(oc.IntegratorGroup): - """ - A custom model specific to the Boeing 737-800 airplane. - This class will be passed in to the mission analysis code. - - """ - def initialize(self): - self.options.declare('num_nodes', default=1) - self.options.declare('flight_phase', default=None) - - def setup(self): - nn = self.options['num_nodes'] - flight_phase = self.options['flight_phase'] - - - # a propulsion system needs to be defined in order to provide thrust - # information for the mission analysis code - propulsion_promotes_inputs = ["fltcond|*", "throttle"] - - self.add_subsystem('propmodel', CFM56(num_nodes=nn, plot=False), - promotes_inputs=propulsion_promotes_inputs) - - doubler = om.ExecComp(['thrust=2*thrust_in', 'fuel_flow=2*fuel_flow_in'], - thrust_in={'val': 1.0*np.ones((nn,)), - 'units': 'kN'}, - thrust={'val': 1.0*np.ones((nn,)), - 'units': 'kN'}, - fuel_flow={'val': 1.0*np.ones((nn,)), - 'units': 'kg/s', - 'tags': ['integrate', 'state_name:fuel_used', 'state_units:kg', 'state_val:1.0', 'state_promotes:True']}, - fuel_flow_in={'val': 1.0*np.ones((nn,)), - 'units': 'kg/s'}) - - self.add_subsystem('doubler', doubler, promotes_outputs=['*']) - self.connect('propmodel.thrust', 'doubler.thrust_in') - self.connect('propmodel.fuel_flow', 'doubler.fuel_flow_in') - - # use a different drag coefficient for takeoff versus cruise - if flight_phase not in ['v0v1', 'v1v0', 'v1vr', 'rotate']: - cd0_source = 'ac|aero|polar|CD0_cruise' - else: - cd0_source = 'ac|aero|polar|CD0_TO' - self.add_subsystem('drag', PolarDrag(num_nodes=nn), - promotes_inputs=['fltcond|CL', 'ac|geom|*', ('CD0', cd0_source), - 'fltcond|q', ('e', 'ac|aero|polar|e')], - promotes_outputs=['drag']) - - # generally the weights module will be custom to each airplane - passthru = om.ExecComp('OEW=x', - x={'val': 1.0, - 'units': 'kg'}, - OEW={'val': 1.0, - 'units': 'kg'}) - self.add_subsystem('OEW', passthru, - promotes_inputs=[('x', 'ac|weights|OEW')], - promotes_outputs=['OEW']) - - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', - input_names=['ac|weights|MTOW', 'fuel_used'], - units='kg', vec_size=[1, nn], - scaling_factors=[1, -1]), - promotes_inputs=['*'], - promotes_outputs=['weight']) - -class B738AnalysisGroup(om.Group): - def setup(self): - # Define number of analysis points to run pers mission segment - nn = 11 - - # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), - promotes_outputs=["*"]) - dv_comp.add_output_from_dict('ac|aero|CLmax_TO') - dv_comp.add_output_from_dict('ac|aero|polar|e') - dv_comp.add_output_from_dict('ac|aero|polar|CD0_TO') - dv_comp.add_output_from_dict('ac|aero|polar|CD0_cruise') - - dv_comp.add_output_from_dict('ac|geom|wing|S_ref') - dv_comp.add_output_from_dict('ac|geom|wing|AR') - dv_comp.add_output_from_dict('ac|geom|wing|c4sweep') - dv_comp.add_output_from_dict('ac|geom|wing|taper') - dv_comp.add_output_from_dict('ac|geom|wing|toverc') - dv_comp.add_output_from_dict('ac|geom|hstab|S_ref') - dv_comp.add_output_from_dict('ac|geom|hstab|c4_to_wing_c4') - dv_comp.add_output_from_dict('ac|geom|vstab|S_ref') - - dv_comp.add_output_from_dict('ac|geom|nosegear|length') - dv_comp.add_output_from_dict('ac|geom|maingear|length') - - dv_comp.add_output_from_dict('ac|weights|MTOW') - dv_comp.add_output_from_dict('ac|weights|W_fuel_max') - dv_comp.add_output_from_dict('ac|weights|MLW') - dv_comp.add_output_from_dict('ac|weights|OEW') - - dv_comp.add_output_from_dict('ac|propulsion|engine|rating') - - dv_comp.add_output_from_dict('ac|num_passengers_max') - dv_comp.add_output_from_dict('ac|q_cruise') - - # Run a full mission analysis including takeoff, reserve_, cruise,reserve_ and descereserve_nt - analysis = self.add_subsystem('analysis', - MissionWithReserve(num_nodes=nn, - aircraft_model=B738AirplaneModel), - promotes_inputs=['*'], promotes_outputs=['*']) - -def configure_problem(): - prob = om.Problem() - prob.model = B738AnalysisGroup() - prob.model.nonlinear_solver = om.NewtonSolver(iprint=2,solve_subsystems=True) - prob.model.linear_solver = om.DirectSolver() - prob.model.nonlinear_solver.options['maxiter'] = 20 - prob.model.nonlinear_solver.options['atol'] = 1e-6 - prob.model.nonlinear_solver.options['rtol'] = 1e-6 - prob.model.nonlinear_solver.linesearch = om.BoundsEnforceLS(bound_enforcement='scalar', print_bound_enforce=False) - return prob - -def set_values(prob, num_nodes): - # set some (required) mission parameters. Each pahse needs a vertical and air-speed - # the entire mission needs a cruise altitude and range - prob.set_val('climb.fltcond|vs', np.linspace(2300., 600.,num_nodes), units='ft/min') - prob.set_val('climb.fltcond|Ueas', np.linspace(230, 220,num_nodes), units='kn') - prob.set_val('cruise.fltcond|vs', np.ones((num_nodes,)) * 4., units='ft/min') - prob.set_val('cruise.fltcond|Ueas', np.linspace(265, 258, num_nodes), units='kn') - prob.set_val('descent.fltcond|vs', np.linspace(-1000, -150, num_nodes), units='ft/min') - prob.set_val('descent.fltcond|Ueas', np.ones((num_nodes,)) * 250, units='kn') - prob.set_val('reserve_climb.fltcond|vs', np.linspace(3000., 2300.,num_nodes), units='ft/min') - prob.set_val('reserve_climb.fltcond|Ueas', np.linspace(230, 230,num_nodes), units='kn') - prob.set_val('reserve_cruise.fltcond|vs', np.ones((num_nodes,)) * 4., units='ft/min') - prob.set_val('reserve_cruise.fltcond|Ueas', np.linspace(250, 250, num_nodes), units='kn') - prob.set_val('reserve_descent.fltcond|vs', np.linspace(-800, -800, num_nodes), units='ft/min') - prob.set_val('reserve_descent.fltcond|Ueas', np.ones((num_nodes,)) * 250, units='kn') - prob.set_val('loiter.fltcond|vs', np.linspace(0.0, 0.0, num_nodes), units='ft/min') - prob.set_val('loiter.fltcond|Ueas', np.ones((num_nodes,)) * 200, units='kn') - prob.set_val('cruise|h0',33000.,units='ft') - prob.set_val('reserve|h0',15000.,units='ft') - prob.set_val('mission_range',2050,units='NM') - -def show_outputs(prob): - # print some outputs - vars_list = ['descent.fuel_used_final','loiter.fuel_used_final'] - units = ['lb','lb'] - nice_print_names = ['Block fuel', 'Total fuel'] - print("=======================================================================") - for i, thing in enumerate(vars_list): - print(nice_print_names[i]+': '+str(prob.get_val(thing,units=units[i])[0])+' '+units[i]) - - # plot some stuff - plots = True - if plots: - x_var = 'range' - x_unit = 'NM' - y_vars = ['fltcond|h','fltcond|Ueas','fuel_used','throttle','fltcond|vs','fltcond|M','fltcond|CL'] - y_units = ['ft','kn','lbm',None,'ft/min', None, None] - x_label = 'Range (nmi)' - y_labels = ['Altitude (ft)', 'Veas airspeed (knots)', 'Fuel used (lb)', 'Throttle setting', 'Vertical speed (ft/min)', 'Mach number', 'CL'] - phases = ['climb', 'cruise', 'descent','reserve_climb','reserve_cruise','reserve_descent','loiter'] - oc.plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, - x_label=x_label, y_labels=y_labels, marker='-', - plot_title='737-800 Mission Profile') - -def run_738_analysis(plots=False): - num_nodes = 11 - prob = configure_problem() - prob.setup(check=True, mode='fwd') - set_values(prob, num_nodes) - prob.run_model() - prob.model.list_outputs() - if plots: - show_outputs(prob) - return prob - - -if __name__ == "__main__": - run_738_analysis(plots=True) diff --git a/examples/TBM850.py b/examples/TBM850.py deleted file mode 100644 index 7a4a7aa3..00000000 --- a/examples/TBM850.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import division -import sys -import os -import numpy as np - -sys.path.insert(0, os.getcwd()) -from openmdao.api import Problem, Group, ScipyOptimizeDriver -from openmdao.api import DirectSolver, SqliteRecorder, IndepVarComp -from openmdao.api import NewtonSolver, BoundsEnforceLS - -# imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from examples.methods.weights_turboprop import SingleTurboPropEmptyWeight -from examples.propulsion_layouts.simple_turboprop import TurbopropPropulsionSystem -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.TBM850 import data as acdata -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis - -class TBM850AirplaneModel(Group): - """ - A custom model specific to the TBM 850 airplane - This class will be passed in to the mission analysis code. - - """ - def initialize(self): - self.options.declare('num_nodes', default=1) - self.options.declare('flight_phase', default=None) - - def setup(self): - nn = self.options['num_nodes'] - flight_phase = self.options['flight_phase'] - - # any control variables other than throttle and braking need to be defined here - controls = self.add_subsystem('controls', IndepVarComp(), promotes_outputs=['*']) - controls.add_output('prop1rpm', val=np.ones((nn,)) * 2000, units='rpm') - - # a propulsion system needs to be defined in order to provide thrust - # information for the mission analysis code - propulsion_promotes_outputs = ['fuel_flow', 'thrust'] - propulsion_promotes_inputs = ["fltcond|*", "ac|propulsion|*", "throttle"] - - self.add_subsystem('propmodel', TurbopropPropulsionSystem(num_nodes=nn), - promotes_inputs=propulsion_promotes_inputs, - promotes_outputs=propulsion_promotes_outputs) - self.connect('prop1rpm', 'propmodel.prop1.rpm') - - # use a different drag coefficient for takeoff versus cruise - if flight_phase not in ['v0v1', 'v1v0', 'v1vr', 'rotate']: - cd0_source = 'ac|aero|polar|CD0_cruise' - else: - cd0_source = 'ac|aero|polar|CD0_TO' - self.add_subsystem('drag', PolarDrag(num_nodes=nn), - promotes_inputs=['fltcond|CL', 'ac|geom|*', ('CD0', cd0_source), - 'fltcond|q', ('e', 'ac|aero|polar|e')], - promotes_outputs=['drag']) - - # generally the weights module will be custom to each airplane - self.add_subsystem('OEW', SingleTurboPropEmptyWeight(), - promotes_inputs=['*', ('P_TO', 'ac|propulsion|engine|rating')], - promotes_outputs=['OEW']) - self.connect('propmodel.prop1.component_weight', 'W_propeller') - self.connect('propmodel.eng1.component_weight', 'W_engine') - - # airplanes which consume fuel will need to integrate - # fuel usage across the mission and subtract it from TOW - intfuel = self.add_subsystem('intfuel', Integrator(num_nodes=nn, method='simpson', diff_units='s', - time_setup='duration'), promotes_inputs=['*'], promotes_outputs=['*']) - intfuel.add_integrand('fuel_used', rate_name='fuel_flow', val=1.0, units='kg') - - self.add_subsystem('weight', AddSubtractComp(output_name='weight', - input_names=['ac|weights|MTOW', 'fuel_used'], - units='kg', vec_size=[1, nn], - scaling_factors=[1, -1]), - promotes_inputs=['*'], - promotes_outputs=['weight']) - - -class TBMAnalysisGroup(Group): - """This is an example of a balanced field takeoff and three-phase mission analysis. - """ - def setup(self): - # Define number of analysis points to run pers mission segment - nn = 11 - - # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), - promotes_outputs=["*"]) - dv_comp.add_output_from_dict('ac|aero|CLmax_TO') - dv_comp.add_output_from_dict('ac|aero|polar|e') - dv_comp.add_output_from_dict('ac|aero|polar|CD0_TO') - dv_comp.add_output_from_dict('ac|aero|polar|CD0_cruise') - - dv_comp.add_output_from_dict('ac|geom|wing|S_ref') - dv_comp.add_output_from_dict('ac|geom|wing|AR') - dv_comp.add_output_from_dict('ac|geom|wing|c4sweep') - dv_comp.add_output_from_dict('ac|geom|wing|taper') - dv_comp.add_output_from_dict('ac|geom|wing|toverc') - dv_comp.add_output_from_dict('ac|geom|hstab|S_ref') - dv_comp.add_output_from_dict('ac|geom|hstab|c4_to_wing_c4') - dv_comp.add_output_from_dict('ac|geom|vstab|S_ref') - dv_comp.add_output_from_dict('ac|geom|fuselage|S_wet') - dv_comp.add_output_from_dict('ac|geom|fuselage|width') - dv_comp.add_output_from_dict('ac|geom|fuselage|length') - dv_comp.add_output_from_dict('ac|geom|fuselage|height') - dv_comp.add_output_from_dict('ac|geom|nosegear|length') - dv_comp.add_output_from_dict('ac|geom|maingear|length') - - dv_comp.add_output_from_dict('ac|weights|MTOW') - dv_comp.add_output_from_dict('ac|weights|W_fuel_max') - dv_comp.add_output_from_dict('ac|weights|MLW') - - dv_comp.add_output_from_dict('ac|propulsion|engine|rating') - dv_comp.add_output_from_dict('ac|propulsion|propeller|diameter') - - dv_comp.add_output_from_dict('ac|num_passengers_max') - dv_comp.add_output_from_dict('ac|q_cruise') - - # Run a full mission analysis including takeoff, climb, cruise, and descent - analysis = self.add_subsystem('analysis', - FullMissionAnalysis(num_nodes=nn, - aircraft_model=TBM850AirplaneModel), - promotes_inputs=['*'], promotes_outputs=['*']) - - - -def run_tbm_analysis(): - # Set up OpenMDAO to analyze the airplane - num_nodes = 11 - prob = Problem() - prob.model = TBMAnalysisGroup() - prob.model.nonlinear_solver = NewtonSolver(iprint=2) - prob.model.options['assembled_jac_type'] = 'csc' - prob.model.linear_solver = DirectSolver(assemble_jac=True) - prob.model.nonlinear_solver.options['solve_subsystems'] = True - prob.model.nonlinear_solver.options['maxiter'] = 10 - prob.model.nonlinear_solver.options['atol'] = 1e-6 - prob.model.nonlinear_solver.options['rtol'] = 1e-6 - prob.model.nonlinear_solver.linesearch = BoundsEnforceLS(bound_enforcement='scalar', print_bound_enforce=False) - prob.setup(check=True, mode='fwd') - - # set some (required) mission parameters. Each pahse needs a vertical and air-speed - # the entire mission needs a cruise altitude and range - prob.set_val('climb.fltcond|vs', np.ones((num_nodes,))*1500, units='ft/min') - prob.set_val('climb.fltcond|Ueas', np.ones((num_nodes,))*124, units='kn') - prob.set_val('cruise.fltcond|vs', np.ones((num_nodes,))*0.01, units='ft/min') - prob.set_val('cruise.fltcond|Ueas', np.ones((num_nodes,))*201, units='kn') - prob.set_val('descent.fltcond|vs', np.ones((num_nodes,))*(-600), units='ft/min') - prob.set_val('descent.fltcond|Ueas', np.ones((num_nodes,))*140, units='kn') - - prob.set_val('cruise|h0',28000.,units='ft') - prob.set_val('mission_range',1250,units='NM') - - # (optional) guesses for takeoff speeds may help with convergence - prob.set_val('v0v1.fltcond|Utrue',np.ones((num_nodes))*50,units='kn') - prob.set_val('v1vr.fltcond|Utrue',np.ones((num_nodes))*85,units='kn') - prob.set_val('v1v0.fltcond|Utrue',np.ones((num_nodes))*85,units='kn') - - # set some airplane-specific values. The throttle edits are to derate the takeoff power of the PT6A - prob['climb.OEW.structural_fudge'] = 1.67 - prob['v0v1.throttle'] = np.ones((num_nodes)) / 1.21 - prob['v1vr.throttle'] = np.ones((num_nodes)) / 1.21 - prob['rotate.throttle'] = np.ones((num_nodes)) / 1.21 - - prob.run_model() - return prob - -if __name__ == "__main__": - from openconcept.utilities.visualization import plot_trajectory - # run the analysis - prob = run_tbm_analysis() - - # print some outputs - vars_list = ['ac|weights|MTOW','climb.OEW','rotate.fuel_used_final','climb.fuel_used_final','cruise.fuel_used_final','descent.fuel_used_final','rotate.range_final','engineoutclimb.gamma'] - units = ['lb','lb','lb','lb','lb','lb','ft','deg'] - nice_print_names = ['MTOW', 'OEW', 'Rotate fuel', 'Climb fuel', 'Cruise fuel','Fuel used', 'TOFL (over 35ft obstacle)','Climb angle at V2'] - print("=======================================================================") - for i, thing in enumerate(vars_list): - print(nice_print_names[i]+': '+str(prob.get_val(thing,units=units[i])[0])+' '+units[i]) - - # plot some stuff - plots = True - if plots: - x_var = 'range' - x_unit = 'ft' - y_vars = ['fltcond|Ueas', 'fltcond|h', 'fuel_used'] - y_units = ['kn', 'ft', 'lb'] - x_label = 'Distance (ft)' - y_labels = ['Veas airspeed (knots)', 'Altitude (ft)', 'fuel used'] - phases = ['v0v1', 'v1vr', 'rotate', 'v1v0'] - plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, - x_label=x_label, y_labels=y_labels, - plot_title='TBM850 Takeoff') - - x_var = 'range' - x_unit = 'NM' - y_vars = ['fltcond|h','fltcond|Ueas','fuel_used','throttle','fltcond|vs'] - y_units = ['ft','kn','lbm',None,'ft/min'] - x_label = 'Range (nmi)' - y_labels = ['Altitude (ft)', 'Veas airspeed (knots)', 'Fuel used (lb)', 'Throttle setting', 'Vertical speed (ft/min)'] - phases = ['climb', 'cruise', 'descent'] - plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, - x_label=x_label, y_labels=y_labels, marker='-', - plot_title='TBM850 Mission Profile') - diff --git a/examples/methods/weights_twin_hybrid.py b/examples/methods/weights_twin_hybrid.py deleted file mode 100644 index 468e03c8..00000000 --- a/examples/methods/weights_twin_hybrid.py +++ /dev/null @@ -1,429 +0,0 @@ -from __future__ import division -import numpy as np -from openmdao.api import ExplicitComponent, IndepVarComp -from openmdao.api import Group -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp -import math - -##TODO: add fuel system weight back in (depends on Wf, which depends on MTOW and We, and We depends on fuel system weight) - -class WingWeight_SmallTurboprop(ExplicitComponent): - """Inputs: MTOW, ac|geom|wing|S_ref, ac|geom|wing|AR, ac|geom|wing|c4sweep, ac|geom|wing|taper, ac|geom|wing|toverc, V_H (max SL speed) - Outputs: W_wing - Metadata: n_ult (ult load factor) - - """ - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - #nn = self.options['num_nodes'] - self.add_input('ac|weights|MTOW', units='lb', desc='Maximum rated takeoff weight') - self.add_input('ac|weights|W_fuel_max', units='lb', desc='Fuel weight') - self.add_input('ac|geom|wing|S_ref', units='ft**2', desc='Reference wing area in sq ft') - self.add_input('ac|geom|wing|AR', desc='Wing aspect ratio') - self.add_input('ac|geom|wing|c4sweep', units='rad', desc='Quarter-chord sweep angle') - self.add_input('ac|geom|wing|taper', desc='Wing taper ratio') - self.add_input('ac|geom|wing|toverc', desc='Wing max thickness to chord ratio') - #self.add_input('V_H', units='kn', desc='Max sea-level speed') - self.add_input('ac|q_cruise', units='lb*ft**-2') - - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_wing', units='lb', desc='Wing weight') - - self.declare_partials(['W_wing'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #USAF method, Roskam PVC5pg68eq5.4 - #W_wing_USAF = 96.948*((inputs['ac|weights|MTOW']*n_ult/1e5)**0.65 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep']))**0.57 * (inputs['ac|geom|wing|S_ref']/100)**0.61 * ((1+inputs['ac|geom|wing|taper'])/2/inputs['ac|geom|wing|toverc'])**0.36 * (1+inputs['V_H']/500)**0.5)**0.993 - #Torenbeek, Roskam PVC5p68eq5.5 - #b = math.sqrt(inputs['ac|geom|wing|S_ref']*inputs['ac|geom|wing|AR']) - #root_chord = 2*inputs['ac|geom|wing|S_ref']/b/(1+inputs['ac|geom|wing|taper']) - #tr = root_chord * inputs['ac|geom|wing|toverc'] - #c2sweep_wing = inputs['ac|geom|wing|c4sweep'] # a hack for now - #W_wing_Torenbeek = 0.00125*inputs['ac|weights|MTOW'] * (b/math.cos(c2sweep_wing))**0.75 * (1+ (6.3*math.cos(c2sweep_wing)/b)**0.5) * n_ult**0.55 * (b*inputs['ac|geom|wing|S_ref']/tr/inputs['ac|weights|MTOW']/math.cos(c2sweep_wing))**0.30 - - W_wing_Raymer = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - - outputs['W_wing'] = W_wing_Raymer - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - J['W_wing','ac|weights|MTOW'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**(0.49-1)*n_ult*0.49 - J['W_wing','ac|weights|W_fuel_max'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * 0.0035 * inputs['ac|weights|W_fuel_max']**(0.0035-1) * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - J['W_wing','ac|geom|wing|S_ref'] = 0.036 * inputs['ac|geom|wing|S_ref']**(0.758-1)*0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - J['W_wing','ac|geom|wing|AR'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * 0.6 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**(0.6-1) / math.cos(inputs['ac|geom|wing|c4sweep'])**2 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - c4const = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - c4multa = (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 - c4multb = (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 - dc4multa = 0.6 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**(0.6-1) * (-2* inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**3) * (-math.sin(inputs['ac|geom|wing|c4sweep'])) - dc4multb = -0.3 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**(-0.3-1) * -100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep'])**2 * (-math.sin(inputs['ac|geom|wing|c4sweep'])) - J['W_wing','ac|geom|wing|c4sweep'] = c4const*(c4multa*dc4multb + c4multb*dc4multa) - J['W_wing','ac|geom|wing|taper'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * 0.04 * inputs['ac|geom|wing|taper']**(0.04-1) * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - J['W_wing','ac|geom|wing|toverc'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * inputs['ac|q_cruise']**0.006 * inputs['ac|geom|wing|taper']**0.04 * -0.3 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**(-0.3-1) * (100/math.cos(inputs['ac|geom|wing|c4sweep'])) * (n_ult * inputs['ac|weights|MTOW'])**0.49 - J['W_wing','ac|q_cruise'] = 0.036 * inputs['ac|geom|wing|S_ref']**0.758 * inputs['ac|weights|W_fuel_max']**0.0035 * (inputs['ac|geom|wing|AR']/math.cos(inputs['ac|geom|wing|c4sweep'])**2)**0.6 * 0.006 * inputs['ac|q_cruise']**(0.006-1) * inputs['ac|geom|wing|taper']**0.04 * (100*inputs['ac|geom|wing|toverc']/math.cos(inputs['ac|geom|wing|c4sweep']))**-0.3 * (n_ult * inputs['ac|weights|MTOW'])**0.49 - - -class EmpennageWeight_SmallTurboprop(ExplicitComponent): - """Inputs: MTOW, ac|geom|wing|S_ref, ac|geom|wing|AR, ac|geom|wing|c4sweep, ac|geom|wing|taper, ac|geom|wing|toverc, V_H (max SL speed) - Outputs: W_wing - Metadata: n_ult (ult load factor) - - """ - def initialize(self): - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - self.add_input('ac|geom|hstab|S_ref', units='ft**2', desc='Projected horiz stab area in sq ft') - self.add_input('ac|geom|vstab|S_ref', units='ft**2', desc='Projected vert stab area in sq ft') - #self.add_input('ac|geom|hstab|c4_to_wing_c4', units='ft', desc='Distance from wing c/4 to horiz stab c/4 (tail arm distance)') - # self.add_input('ac|weights|MTOW', units='lb', desc='Maximum rated takeoff weight') - # self.add_input('AR_h', desc='Horiz stab aspect ratio') - # self.add_input('AR_v', units='rad', desc='Vert stab aspect ratio') - # self.add_input('troot_h', units='ft', desc='Horiz stab root thickness (ft)') - # self.add_input('troot_v', units='ft', desc='Vert stab root thickness (ft)') - #self.add_input('ac|q_cruise', units='lb*ft**-2', desc='Cruise dynamic pressure') - - self.add_output('W_empennage', units='lb', desc='Empennage weight') - self.declare_partials(['W_empennage'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #USAF method, Roskam PVC5pg72eq5.14/15 - # bh = math.sqrt(inputs['ac|geom|hstab|S_ref']*inputs['AR_h']) - # bv = math.sqrt(inputs['ac|geom|vstab|S_ref']*inputs['AR_v']) - # # Wh = 127 * ((inputs['ac|weights|MTOW']*n_ult/1e5)**0.87 * (inputs['ac|geom|hstab|S_ref']/100)**1.2 * 0.289*(inputs['ac|geom|hstab|c4_to_wing_c4']/10)**0.483 * (bh/inputs['troot_h'])**0.5)**0.458 - # # #Wh_raymer = 0.016 * (n_ult*inputs['ac|weights|MTOW'])**0.414 * inputs['ac|q_cruise']**0.168 * inputs['ac|geom|hstab|S_ref']**0.896 * (100 * 0.18)**-0.12 * (inputs['AR_h'])**0.043 * 0.7**-0.02 - # # Wv = 98.5 * ((inputs['ac|weights|MTOW']*n_ult/1e5)**0.87 * (inputs['ac|geom|vstab|S_ref']/100)**1.2 * 0.289 * (bv/inputs['troot_v'])**0.5)**0.458 - - # # Wemp_USAF = Wh + Wv - - #Torenbeek, Roskam PVC5p73eq5.16 - Wemp_Torenbeek = 0.04 * (n_ult * (inputs['ac|geom|vstab|S_ref'] + inputs['ac|geom|hstab|S_ref'])**2)**0.75 - outputs['W_empennage'] = Wemp_Torenbeek - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - J['W_empennage','ac|geom|vstab|S_ref'] = 0.75* 0.04 * (n_ult * (inputs['ac|geom|vstab|S_ref'] + inputs['ac|geom|hstab|S_ref'])**2)**(0.75-1)*(n_ult * 2* (inputs['ac|geom|vstab|S_ref'] + inputs['ac|geom|hstab|S_ref'])) - J['W_empennage','ac|geom|hstab|S_ref'] = 0.75* 0.04 * (n_ult * (inputs['ac|geom|vstab|S_ref'] + inputs['ac|geom|hstab|S_ref'])**2)**(0.75-1)*(n_ult * 2* (inputs['ac|geom|vstab|S_ref'] + inputs['ac|geom|hstab|S_ref'])) - - -class FuselageWeight_SmallTurboprop(ExplicitComponent): - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - #nn = self.options['num_nodes'] - self.add_input('ac|weights|MTOW', units='lb', desc='Maximum rated takeoff weight') - self.add_input('ac|geom|fuselage|length', units='ft', desc='Fuselage length (not counting nacelle') - self.add_input('ac|geom|fuselage|height', units='ft', desc='Fuselage height') - self.add_input('ac|geom|fuselage|width', units='ft', desc='Fuselage weidth') - #self.add_input('V_C', units='kn', desc='Indicated cruise airspeed (KEAS)') - #self.add_input('V_MO', units='kn', desc='Max operating speed (indicated)') - self.add_input('ac|geom|fuselage|S_wet', units='ft**2', desc='Fuselage shell area') - self.add_input('ac|geom|hstab|c4_to_wing_c4', units='ft', desc='Horiz tail arm') - self.add_input('ac|q_cruise', units='lb*ft**-2', desc='Dynamic pressure at cruise') - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_fuselage', units='lb', desc='Fuselage weight') - self.declare_partials(['W_fuselage'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #USAF method, Roskam PVC5pg76eq5.25 - # W_fuselage_USAF = 200*((inputs['ac|weights|MTOW']*n_ult/1e5)**0.286 * (inputs['ac|geom|fuselage|length']/10)**0.857 * (inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/10 * (inputs['V_C']/100)**0.338)**1.1 - # print(W_fuselage_USAF) - - #W_fuselage_Torenbeek = 0.021 * 1.08 * ((inputs['V_MO']*inputs['ac|geom|hstab|c4_to_wing_c4']/(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height']))**0.5 * inputs['ac|geom|fuselage|S_wet']**1.2) - W_press = 11.9*(math.pi*(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/2*inputs['ac|geom|fuselage|length']*0.8 * 8)**0.271 - W_fuselage_Raymer = 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * (n_ult * inputs['ac|weights|MTOW'])**0.177 * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**-0.072 * inputs['ac|q_cruise']**0.241 + W_press - outputs['W_fuselage'] = W_fuselage_Raymer - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - J['W_fuselage','ac|weights|MTOW'] = 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * 0.177 * n_ult * (n_ult * inputs['ac|weights|MTOW'])**(0.177-1) * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**-0.072 * inputs['ac|q_cruise']**0.241 - J['W_fuselage','ac|geom|fuselage|width'] = 0.271 * 11.9*(math.pi*(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/2*inputs['ac|geom|fuselage|length']*0.8 * 8)**(0.271-1) * (math.pi/2*inputs['ac|geom|fuselage|length']*0.8 * 8) - J['W_fuselage','ac|geom|fuselage|height'] = 0.271 * 11.9*(math.pi*(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/2*inputs['ac|geom|fuselage|length']*0.8 * 8)**(0.271-1) * (math.pi/2*inputs['ac|geom|fuselage|length']*0.8 * 8) + 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * (n_ult * inputs['ac|weights|MTOW'])**0.177 * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * -0.072 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**(-0.072-1) * (-inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height']**2) * inputs['ac|q_cruise']**0.241 - J['W_fuselage','ac|geom|fuselage|length'] = 0.271 * 11.9*(math.pi*(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/2*inputs['ac|geom|fuselage|length']*0.8 * 8)**(0.271-1) * (math.pi*(inputs['ac|geom|fuselage|width']+inputs['ac|geom|fuselage|height'])/2*0.8 * 8) + 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * (n_ult * inputs['ac|weights|MTOW'])**0.177 * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * -0.072 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**(-0.072-1) * (1/inputs['ac|geom|fuselage|height']) * inputs['ac|q_cruise']**0.241 - J['W_fuselage','ac|geom|fuselage|S_wet'] = 0.052 * 1.086 * inputs['ac|geom|fuselage|S_wet']**(1.086-1) * (n_ult * inputs['ac|weights|MTOW'])**0.177 * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**-0.072 * inputs['ac|q_cruise']**0.241 - J['W_fuselage','ac|geom|hstab|c4_to_wing_c4'] = 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * (n_ult * inputs['ac|weights|MTOW'])**0.177 * -0.051 * inputs['ac|geom|hstab|c4_to_wing_c4']**(-0.051-1) * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**-0.072 * inputs['ac|q_cruise']**0.241 - J['W_fuselage','ac|q_cruise'] = 0.052 * inputs['ac|geom|fuselage|S_wet']**1.086 * (n_ult * inputs['ac|weights|MTOW'])**0.177 * inputs['ac|geom|hstab|c4_to_wing_c4']**-0.051 * (inputs['ac|geom|fuselage|length']/inputs['ac|geom|fuselage|height'])**-0.072 * 0.241 * inputs['ac|q_cruise']**(0.241-1) - -class NacelleWeight_SmallSingleTurboprop(ExplicitComponent): - """Inputs: MTOW, ac|geom|wing|S_ref, ac|geom|wing|AR, ac|geom|wing|c4sweep, ac|geom|wing|taper, ac|geom|wing|toverc, V_H (max SL speed) - Outputs: W_wing - Metadata: n_ult (ult load factor) - - """ - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - #nn = self.options['num_nodes'] - self.add_input('P_TO', units='hp', desc='Takeoff power') - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_nacelle', units='lb', desc='Nacelle weight') - self.declare_partials(['W_nacelle'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #Torenbeek method, Roskam PVC5pg78eq5.30 - W_nacelle = 2.5*inputs['P_TO']**0.5 - outputs['W_nacelle'] = W_nacelle - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - #Torenbeek method, Roskam PVC5pg78eq5.30 - J['W_nacelle','P_TO'] = 0.5 * 2.5*inputs['P_TO']**(0.5-1) - -class NacelleWeight_MultiTurboprop(ExplicitComponent): - """Inputs: MTOW, ac|geom|wing|S_ref, ac|geom|wing|AR, ac|geom|wing|c4sweep, ac|geom|wing|taper, ac|geom|wing|toverc, V_H (max SL speed) - Outputs: W_wing - Metadata: n_ult (ult load factor) - - """ - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - #nn = self.options['num_nodes'] - self.add_input('P_TO', units='hp', desc='Takeoff power') - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_nacelle', units='lb', desc='Nacelle weight') - self.declare_partials(['W_nacelle'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #Torenbeek method, Roskam PVC5pg78eq5.33 - W_nacelle = 0.14*inputs['P_TO'] - outputs['W_nacelle'] = W_nacelle - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - #Torenbeek method, Roskam PVC5pg78eq5.30 - J['W_nacelle','P_TO'] = 0.14 - -class LandingGearWeight_SmallTurboprop(ExplicitComponent): - """Inputs: MTOW, ac|geom|wing|S_ref, ac|geom|wing|AR, ac|geom|wing|c4sweep, ac|geom|wing|taper, ac|geom|wing|toverc, V_H (max SL speed) - Outputs: W_wing - Metadata: n_ult (ult load factor) - - """ - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('n_ult', default=3.8*1.5, desc='Ultimate load factor (dimensionless)') - - - def setup(self): - #nn = self.options['num_nodes'] - # self.add_input('ac|weights|MTOW', units='lb',desc='Max takeoff weight') - self.add_input('ac|weights|MLW', units='lb', desc='Max landing weight') - self.add_input('ac|geom|maingear|length', units='ft', desc='Main landing gear extended length') - self.add_input('ac|geom|nosegear|length', units='ft', desc='Nose gear extended length') - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_gear', units='lb', desc='Gear weight (nose and main)') - self.declare_partials(['W_gear'], ['*']) - - def compute(self, inputs, outputs): - n_ult = self.options['n_ult'] - #Torenbeek method, Roskam PVC5pg82eq5.42 - # W_gear_Torenbeek_main = 33.0+0.04*inputs['ac|weights|MTOW']**0.75 + 0.021*inputs['ac|weights|MTOW'] - # W_gear_Torenbeek_nose = 12.0+0.06*inputs['ac|weights|MTOW']**0.75 - - W_gear_Raymer_main = 0.095*(n_ult*inputs['ac|weights|MLW'])**0.768 * (inputs['ac|geom|maingear|length']/12)**0.409 - W_gear_Raymer_nose = 0.125*(n_ult*inputs['ac|weights|MLW'])**0.566 * (inputs['ac|geom|nosegear|length']/12)**0.845 - - - W_gear = W_gear_Raymer_main + W_gear_Raymer_nose - outputs['W_gear'] = W_gear - - def compute_partials(self, inputs, J): - n_ult = self.options['n_ult'] - J['W_gear','ac|weights|MLW'] = 0.095*(n_ult*inputs['ac|weights|MLW'])**(0.768-1) * 0.768 * n_ult * (inputs['ac|geom|maingear|length']/12)**0.409 + 0.125*(n_ult*inputs['ac|weights|MLW'])**(0.566-1) * 0.566 * n_ult * (inputs['ac|geom|nosegear|length']/12)**0.845 - J['W_gear','ac|geom|maingear|length'] = 0.095*(n_ult*inputs['ac|weights|MLW'])**0.768 * (inputs['ac|geom|maingear|length']/12)**(0.409-1)* ( 1/12 ) * 0.409 - J['W_gear','ac|geom|nosegear|length'] = 0.125*(n_ult*inputs['ac|weights|MLW'])**0.566 * (inputs['ac|geom|nosegear|length']/12)**(0.845-1)*(1/12) *0.845 - -class FuelSystemWeight_SmallTurboprop(ExplicitComponent): - def initialize(self): - #self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - #define configuration parameters - self.options.declare('Kfsp', default=6.55, desc='Fuel density (lbs/gal)') - # self.options.declare('num_tanks', default=2, desc='Number of fuel tanks') - # self.options.declare('num_engines', default=1, desc='Number of engines') - - def setup(self): - #nn = self.options['num_nodes'] - self.add_input('ac|weights|W_fuel_max', units='lb',desc='Full fuel weight') - - #self.add_output('heat_out', units='W', desc='Waste heat out',shape=(nn,)) - self.add_output('W_fuelsystem', units='lb', desc='Fuel system weight') - self.declare_partials('W_fuelsystem','ac|weights|W_fuel_max') - - def compute(self, inputs, outputs): - # n_t = self.options['num_tanks'] - # n_e = self.options['num_engines'] - Kfsp = self.options['Kfsp'] - #Torenbeek method, Roskam PVC6pg92eq6.24 - #W_fs_Cessna = 0.4 * inputs['ac|weights|W_fuel_max'] / Kfsp - # W_fs_Torenbeek = 80*(n_e+n_t-1) + 15*n_t**0.5 * (inputs['ac|weights|W_fuel_max']/Kfsp)**0.333 - # print(W_fs_Torenbeek) - # W_fs_USAF = 2.49* ((inputs['ac|weights|W_fuel_max']/Kfsp)**0.6 * n_t**0.20 * n_e**0.13)**1.21 - # print(W_fs_USAF) - W_fs_Raymer = 2.49 * (inputs['ac|weights|W_fuel_max']*1.0/Kfsp)**0.726*(0.5)**0.363 - outputs['W_fuelsystem'] = W_fs_Raymer - - def compute_partials(self, inputs, J): - Kfsp = self.options['Kfsp'] - J['W_fuelsystem','ac|weights|W_fuel_max'] = 2.49 * 0.726 *(inputs['ac|weights|W_fuel_max']*1.0/Kfsp)**(0.726-1) * (1.0/Kfsp) * (0.5)**0.363 - -class EquipmentWeight_SmallTurboprop(ExplicitComponent): - def setup(self): - self.add_input('ac|weights|MTOW', units='lb',desc='Max takeoff weight') - self.add_input('ac|num_passengers_max',desc='Number of passengers') - self.add_input('ac|geom|fuselage|length', units='ft', desc='fuselage width') - self.add_input('ac|geom|wing|AR', desc='Wing aspect ratio') - self.add_input('ac|geom|wing|S_ref', units='ft**2', desc='Wing reference area') - self.add_input('W_fuelsystem', units='lb', desc='Fuel system weight') - self.add_output('W_equipment', units='lb',desc='Equipment weight') - self.declare_partials(['W_equipment'], ['*']) - def compute(self, inputs, outputs): - b = math.sqrt(inputs['ac|geom|wing|S_ref']*inputs['ac|geom|wing|AR']) - - #Flight control system (unpowered) - #Roskam PVC7p98eq7.2 - #Wfc_USAF = 1.066*inputs['ac|weights|MTOW']**0.626 - Wfc_Torenbeek = 0.23*inputs['ac|weights|MTOW']**0.666 - #Hydraulic system weight included in flight controls and LG weight - Whydraulics = 0.2673*1*(inputs['ac|geom|fuselage|length']*b)**0.937 - - #Guesstimate of avionics weight - #This is a guess for a single turboprop class airplane (such as TBM, Pilatus, etc) - Wavionics = 2.117*(np.array([110]))**0.933 - #Electrical system weight (NOT including elec propulsion) - Welec = 12.57*(inputs['W_fuelsystem']+Wavionics)**0.51 - - #pressurization and air conditioning from Roskam - Wapi = 0.265*inputs['ac|weights|MTOW']**0.52 * inputs['ac|num_passengers_max']**0.68 * Wavionics**0.17 * 0.95 - Woxygen = 30 + 1.2*inputs['ac|num_passengers_max'] - #furnishings (Cessna method) - Wfur = 0.412*inputs['ac|num_passengers_max']**1.145 * inputs['ac|weights|MTOW'] ** 0.489 - Wpaint = 0.003 * inputs['ac|weights|MTOW'] - - outputs['W_equipment'] = Wfc_Torenbeek + Welec + Wavionics + Wapi + Woxygen + Wfur + Wpaint + Whydraulics - - def compute_partials(self, inputs, J): - b = math.sqrt(inputs['ac|geom|wing|S_ref']*inputs['ac|geom|wing|AR']) - Wavionics = 2.117*(np.array([110]))**0.933 - J['W_equipment','ac|weights|MTOW'] = 0.23*inputs['ac|weights|MTOW']**(0.666-1)*0.666 + 0.52 * 0.265*inputs['ac|weights|MTOW']**(0.52-1) * inputs['ac|num_passengers_max']**0.68 * Wavionics**0.17 * 0.95 + 0.412*inputs['ac|num_passengers_max']**1.145 * inputs['ac|weights|MTOW'] ** (0.489-1) * 0.489 + 0.003 - J['W_equipment','ac|num_passengers_max'] = 0.265*inputs['ac|weights|MTOW']**0.52 * 0.68 * inputs['ac|num_passengers_max']**(0.68-1) * Wavionics**0.17 * 0.95 + 1.2 + 0.412*1.145 * inputs['ac|num_passengers_max']**(1.145-1) * inputs['ac|weights|MTOW'] ** 0.489 - J['W_equipment','ac|geom|fuselage|length'] = 0.2673*1*0.937 * (inputs['ac|geom|fuselage|length']*b)**(0.937 - 1) * b - J['W_equipment','W_fuelsystem'] = 12.57*(inputs['W_fuelsystem']+Wavionics)**(0.51-1) * 0.51 - J['W_equipment','ac|geom|wing|S_ref'] = 0.2673*1*(inputs['ac|geom|fuselage|length']*b)**(0.937-1)*0.937*inputs['ac|geom|fuselage|length']*(1/2)*1/b*inputs['ac|geom|wing|AR'] - J['W_equipment','ac|geom|wing|AR'] = 0.2673*1*(inputs['ac|geom|fuselage|length']*b)**(0.937-1)*0.937*inputs['ac|geom|fuselage|length']*(1/2)/b*inputs['ac|geom|wing|S_ref'] - -class TwinSeriesHybridEmptyWeight(Group): - - def setup(self): - const = self.add_subsystem('const',IndepVarComp(),promotes_outputs=["*"]) - const.add_output('W_fluids', val=20, units='kg') - const.add_output('structural_fudge', val=1.6, units='m/m') - self.add_subsystem('wing',WingWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('empennage',EmpennageWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('fuselage',FuselageWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('nacelle',NacelleWeight_SmallSingleTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('gear',LandingGearWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('fuelsystem', FuelSystemWeight_SmallTurboprop(), promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('equipment',EquipmentWeight_SmallTurboprop(), promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('structural',AddSubtractComp(output_name='W_structure',input_names=['W_wing','W_fuselage','W_nacelle','W_empennage','W_gear'], units='lb'),promotes_outputs=['*'],promotes_inputs=["*"]) - self.add_subsystem('structural_fudge',ElementMultiplyDivideComp(output_name='W_structure_adjusted',input_names=['W_structure','structural_fudge'],input_units=['lb','m/m']),promotes_inputs=["*"],promotes_outputs=["*"]) - self.add_subsystem('totalempty',AddSubtractComp(output_name='OEW',input_names=['W_structure_adjusted','W_fuelsystem','W_equipment','W_engine','W_motors','W_generator','W_propeller','W_fluids'], units='lb'),promotes_outputs=['*'],promotes_inputs=["*"]) - -if __name__ == "__main__": - from openmdao.api import IndepVarComp, Problem - prob = Problem() - prob.model = Group() - dvs = prob.model.add_subsystem('dvs',IndepVarComp(),promotes_outputs=["*"]) - AR = 41.5**2/193.75 - dvs.add_output('ac|weights|MTOW',7394.0, units='lb') - dvs.add_output('ac|geom|wing|S_ref',193.75, units='ft**2') - dvs.add_output('ac|geom|wing|AR',AR) - dvs.add_output('ac|geom|wing|c4sweep',1.0, units='deg') - dvs.add_output('ac|geom|wing|taper',0.622) - dvs.add_output('ac|geom|wing|toverc',0.16) - #dvs.add_output('V_H',255, units='kn') - - dvs.add_output('ac|geom|hstab|S_ref',47.5, units='ft**2') - #dvs.add_output('AR_h',4.13) - dvs.add_output('ac|geom|vstab|S_ref',31.36, units='ft**2') - #dvs.add_output('AR_v',1.2) - #dvs.add_output('troot_h',0.8, units='ft') - #dvs.add_output('troot_v',0.8, units='ft') - dvs.add_output('ac|geom|hstab|c4_to_wing_c4',17.9, units='ft') - - dvs.add_output('ac|geom|fuselage|length',27.39, units='ft') - dvs.add_output('ac|geom|fuselage|height',5.555, units='ft') - dvs.add_output('ac|geom|fuselage|width',4.58, units='ft') - dvs.add_output('ac|geom|fuselage|S_wet',392, units='ft**2') - #dvs.add_output('V_C',201, units='kn') #IAS (converted from 315kt true at 28,000 ) - #dvs.add_output('V_MO',266, units='kn') - dvs.add_output('P_TO',850, units='hp') - dvs.add_output('ac|weights|W_fuel_max',2000, units='lb') - dvs.add_output('ac|num_passengers_max', 6) - dvs.add_output('ac|q_cruise', 135.4, units='lb*ft**-2') - dvs.add_output('ac|weights|MLW', 7000, units='lb') - dvs.add_output('ac|geom|nosegear|length', 3, units='ft') - dvs.add_output('ac|geom|maingear|length', 4, units='ft') - dvs.add_output('W_engine',475, units='lb') - dvs.add_output('W_propeller',150, units='lb') - - prob.model.add_subsystem('OEW',SingleTurboPropEmptyWeight(),promotes_inputs=["*"]) - - - # prob.model.add_subsystem('wing',WingWeight_SmallTurboprop(),promotes_inputs=["*"]) - # prob.model.add_subsystem('empennage',EmpennageWeight_SmallTurboprop(),promotes_inputs=["*"]) - # prob.model.add_subsystem('fuselage',FuselageWeight_SmallTurboprop(),promotes_inputs=["*"]) - # prob.model.add_subsystem('nacelle',NacelleWeight_SmallSingleTurboprop(),promotes_inputs=["*"]) - # prob.model.add_subsystem('gear',LandingGearWeight_SmallTurboprop(),promotes_inputs=["*"]) - # prob.model.add_subsystem('fuelsystem', FuelSystemWeight_SmallTurboprop(), promotes_inputs=["*"]) - # prob.model.add_subsystem('equipment',EquipmentWeight_SmallTurboprop(), promotes_inputs=["*"]) - - - prob.setup() - prob.run_model() - print('Wing weight:') - print(prob['OEW.W_wing']) - print('Fuselage weight:') - print(prob['OEW.W_fuselage']) - print('Empennage weight:') - print(prob['OEW.W_empennage']) - print('Nacelle weight:') - print(prob['OEW.W_nacelle']) - print('Fuel system weight') - print(prob['OEW.W_fuelsystem']) - print('Gear weight') - print(prob['OEW.W_gear']) - print('Equipment weight') - print(prob['OEW.W_equipment']) - print('Operating empty weight:') - print(prob['OEW.OEW']) - data = prob.check_partials(compact_print=True) - diff --git a/examples/propulsion_layouts/simple_turboprop.py b/examples/propulsion_layouts/simple_turboprop.py deleted file mode 100644 index 3e386944..00000000 --- a/examples/propulsion_layouts/simple_turboprop.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import division -from openconcept.components.turboshaft import SimpleTurboshaft -from openconcept.components.propeller import SimplePropeller -from openconcept.utilities.dvlabel import DVLabel -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp -from openmdao.api import Group, IndepVarComp, ExplicitComponent - -class TurbopropPropulsionSystem(Group): - """This is an example model of the simplest possible propulsion system - consisting of a constant-speed prop and a turboshaft. - - This is the Pratt and Whitney Canada PT6A-66D with 4-bladed - propeller used by the SOCATA-DAHER TBM-850. - - Inputs - ------ - ac|propulsion|engine|rating : float - The maximum rated shaft power of the engine - ac|propulsion|propeller|diameter : float - Diameter of the propeller - - Options - ------- - num_nodes : float - Number of analysis points to run (default 1) - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc="Number of mission analysis points to run") - - def setup(self): - nn = self.options['num_nodes'] - - # rename incoming design variables - dvlist = [['ac|propulsion|engine|rating', 'eng1_rating', 850, 'hp'], - ['ac|propulsion|propeller|diameter', 'prop1_diameter', 2.3, 'm']] - self.add_subsystem('dvs', DVLabel(dvlist), - promotes_inputs=["*"], promotes_outputs=["*"]) - - # introduce model components - self.add_subsystem('eng1', - SimpleTurboshaft(num_nodes=nn, weight_inc=0.14 / 1000, weight_base=104), - promotes_inputs=["throttle"], promotes_outputs=["fuel_flow"]) - self.add_subsystem('prop1', - SimplePropeller(num_nodes=nn, num_blades=4, - design_J=2.2, design_cp=0.55), - promotes_inputs=["fltcond|*"], promotes_outputs=["thrust"]) - - # connect design variables to model component inputs - self.connect('eng1_rating', 'eng1.shaft_power_rating') - self.connect('eng1_rating', 'prop1.power_rating') - self.connect('prop1_diameter', 'prop1.diameter') - - # connect components to each other - self.connect('eng1.shaft_power_out', 'prop1.shaft_power_in') - - -class TwinTurbopropPropulsionSystem(Group): - """This is an example model multiple constant-speed props and turboshafts. - These are two P&W Canada PT6A-135A with 4-bladed Hartzell propellers used by the Beechcraft King Air C90GT - https://www.easa.europa.eu/sites/default/files/dfu/TCDS_EASA-IM-A-503_C90-Series%20issue%206.pdf - INPUTS: ac|propulsion|engine|rating - the maximum rated shaft power of the engine (each engine) - dv_prop1_diameter - propeller diameter - """ - def initialize(self): - self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") - - def setup(self): - #define design variables that are independent of flight condition or control states - dvlist = [['ac|propulsion|engine|rating','eng_rating',750,'hp'], - ['ac|propulsion|propeller|diameter','prop_diameter',2.28,'m'], - ] - self.add_subsystem('dvs',DVLabel(dvlist),promotes_inputs=["*"],promotes_outputs=["*"]) - nn = self.options['num_nodes'] - #introduce model components - self.add_subsystem('eng1',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104),promotes_inputs=['throttle']) - self.add_subsystem('prop1',SimplePropeller(num_nodes=nn,num_blades=4,design_J=2.2,design_cp=0.55),promotes_inputs=["fltcond|*"]) - self.add_subsystem('eng2',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104)) - self.add_subsystem('prop2',SimplePropeller(num_nodes=nn,num_blades=4,design_J=2.2,design_cp=0.55),promotes_inputs=["fltcond|*"]) - - #connect design variables to model component inputs - self.connect('eng_rating','eng1.shaft_power_rating') - self.connect('eng_rating','eng2.shaft_power_rating') - self.connect('eng_rating','prop1.power_rating') - self.connect('eng_rating','prop2.power_rating') - self.connect('prop_diameter','prop1.diameter') - self.connect('prop_diameter','prop2.diameter') - - #propulsion models expect a high-level 'throttle' parameter and a 'propulsor_active' flag to set individual throttles - failedengine = ElementMultiplyDivideComp() - failedengine.add_equation('eng2throttle',input_names=['throttle','propulsor_active'],vec_size=nn) - self.add_subsystem('failedengine', failedengine, - promotes_inputs=['throttle', 'propulsor_active']) - self.connect('failedengine.eng2throttle','eng2.throttle') - - - #connect components to each other - self.connect('eng1.shaft_power_out','prop1.shaft_power_in') - self.connect('eng2.shaft_power_out','prop2.shaft_power_in') - - #add up the weights, thrusts and fuel flows - add1 = AddSubtractComp(output_name='fuel_flow',input_names=['eng1_fuel_flow','eng2_fuel_flow'],vec_size=nn, units='kg/s') - add1.add_equation(output_name='thrust',input_names=['prop1_thrust','prop2_thrust'],vec_size=nn, units='N') - add1.add_equation(output_name='engines_weight',input_names=['eng1_weight','eng2_weight'], units='kg') - add1.add_equation(output_name='propellers_weight',input_names=['prop1_weight','prop2_weight'], units='kg') - self.add_subsystem('adder',subsys=add1,promotes_inputs=["*"],promotes_outputs=["*"]) - self.connect('prop1.thrust','prop1_thrust') - self.connect('prop2.thrust','prop2_thrust') - self.connect('eng1.fuel_flow','eng1_fuel_flow') - self.connect('eng2.fuel_flow','eng2_fuel_flow') - self.connect('prop1.component_weight','prop1_weight') - self.connect('prop2.component_weight','prop2_weight') - self.connect('eng1.component_weight','eng1_weight') - self.connect('eng2.component_weight','eng2_weight') \ No newline at end of file diff --git a/examples/tests/__init__.py b/examples/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/aerodynamics/__init__.py b/openconcept/aerodynamics/__init__.py new file mode 100644 index 00000000..113ac1f5 --- /dev/null +++ b/openconcept/aerodynamics/__init__.py @@ -0,0 +1,2 @@ +from .aerodynamics import PolarDrag, StallSpeed, Lift +from .openaerostruct import VLMDragPolar, AerostructDragPolar diff --git a/openconcept/analysis/aerodynamics.py b/openconcept/aerodynamics/aerodynamics.py similarity index 92% rename from openconcept/analysis/aerodynamics.py rename to openconcept/aerodynamics/aerodynamics.py index 53cdaa36..0b62b9c2 100644 --- a/openconcept/analysis/aerodynamics.py +++ b/openconcept/aerodynamics/aerodynamics.py @@ -1,7 +1,7 @@ """Aerodynamic analysis routines usable for multiple purposes / flight phases""" -from __future__ import division from openmdao.api import ExplicitComponent import numpy as np +from openconcept.utilities.constants import GRAV_CONST class PolarDrag(ExplicitComponent): @@ -131,9 +131,9 @@ class StallSpeed(ExplicitComponent): Inputs ------ CLmax : float - Maximum lfit coefficient (scalar, dimensionless) + Maximum lift coefficient (scalar, dimensionless) weight : float - Dynamic pressure (scalar, kg) + Aircraft weight (scalar, kg) ac|geom|wing|S_ref : float Reference wing area (scalar, m**2) @@ -151,25 +151,23 @@ def setup(self): self.declare_partials(['Vstall_eas'], ['weight', 'ac|geom|wing|S_ref', 'CLmax']) def compute(self, inputs, outputs): - g = 9.80665 # m/s^2 rho = 1.225 # kg/m3 - outputs['Vstall_eas'] = np.sqrt(2 * inputs['weight'] * g / rho / + outputs['Vstall_eas'] = np.sqrt(2 * inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) def compute_partials(self, inputs, J): - g = 9.80665 # m/s^2 rho = 1.225 # kg/m3 - J['Vstall_eas', 'weight'] = (1 / np.sqrt(2 * inputs['weight'] * g / rho / + J['Vstall_eas', 'weight'] = (1 / np.sqrt(2 * inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) * - g / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) - J['Vstall_eas', 'ac|geom|wing|S_ref'] = - (1 / np.sqrt(2 * inputs['weight'] * g / rho / + GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) + J['Vstall_eas', 'ac|geom|wing|S_ref'] = - (1 / np.sqrt(2 * inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) * - inputs['weight'] * g / rho / + inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] ** 2 / inputs['CLmax']) - J['Vstall_eas', 'CLmax'] = - (1 / np.sqrt(2 * inputs['weight'] * g / rho / + J['Vstall_eas', 'CLmax'] = - (1 / np.sqrt(2 * inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax']) * - inputs['weight'] * g / rho / + inputs['weight'] * GRAV_CONST / rho / inputs['ac|geom|wing|S_ref'] / inputs['CLmax'] ** 2) diff --git a/openconcept/aerodynamics/openaerostruct/__init__.py b/openconcept/aerodynamics/openaerostruct/__init__.py new file mode 100644 index 00000000..b6e3e0fa --- /dev/null +++ b/openconcept/aerodynamics/openaerostruct/__init__.py @@ -0,0 +1,2 @@ +from .drag_polar import VLMDragPolar, VLMDataGen, VLM, PlanformMesh +from .aerostructural import AerostructDragPolar, OASDataGen, Aerostruct, AerostructDragPolarExact diff --git a/openconcept/analysis/openaerostruct/aerostructural.py b/openconcept/aerodynamics/openaerostruct/aerostructural.py similarity index 98% rename from openconcept/analysis/openaerostruct/aerostructural.py rename to openconcept/aerodynamics/openaerostruct/aerostructural.py index b4056155..9c6f705a 100644 --- a/openconcept/analysis/openaerostruct/aerostructural.py +++ b/openconcept/aerodynamics/openaerostruct/aerostructural.py @@ -1,5 +1,3 @@ -from __future__ import division - import numpy as np import openmdao.api as om from time import time @@ -21,18 +19,15 @@ from openaerostruct.integration.aerostruct_groups import AerostructPoint from openaerostruct.structures.spatial_beam_setup import SpatialBeamSetup from openaerostruct.structures.wingbox_group import WingboxGroup - from openconcept.analysis.openaerostruct.drag_polar import PlanformMesh + from openconcept.aerodynamics.openaerostruct.drag_polar import PlanformMesh except ImportError: - raise ImportError("OpenAeroStruct must be installed to use the OASAerostructDragPolar component") + raise ImportError("OpenAeroStruct must be installed to use the AerostructDragPolar component") # Atmospheric calculations -from openconcept.analysis.atmospherics.temperature_comp import TemperatureComp -from openconcept.analysis.atmospherics.pressure_comp import PressureComp -from openconcept.analysis.atmospherics.density_comp import DensityComp -from openconcept.analysis.atmospherics.speedofsound_comp import SpeedOfSoundComp +from openconcept.atmospherics import TemperatureComp, PressureComp, DensityComp, SpeedOfSoundComp # Utitilty for vector manipulation -from openconcept.utilities.math.combine_split_comp import VectorConcatenateComp +from openconcept.utilities import VectorConcatenateComp CITATION = """ @InProceedings{Adler2022a, @@ -46,7 +41,7 @@ """ -class OASAerostructDragPolar(om.Group): +class AerostructDragPolar(om.Group): """ Drag polar and wing weight estimate generated using OpenAeroStruct's aerostructural analysis capabilities and a surrogate @@ -416,7 +411,7 @@ def setup(self): error = error or OASDataGen.surf_options[key] != self.options["surf_options"][key] if error: raise ValueError( - "The OASDataGen and OASAerostructDragPolar components do not support\n" + "The OASDataGen and AerostructDragPolar components do not support\n" "differently-valued surf_options within an OpenMDAO model. Trying to replace:\n" f"{OASDataGen.surf_options}\n" f"with new options:\n{self.options['surf_options']}" @@ -523,13 +518,12 @@ def compute_partials(self, inputs, partials): partials[key][:] = value partials["CD_train", "ac|aero|CD_nonwing"] = np.ones(OASDataGen.CD.shape) - """ Generates training data and its total derivatives by calling OpenAeroStruct at each training point. -Inputs ------- +Parameters +---------- inputs : dict A dictionary containing the following entries: Mach_number_grid : mdarray @@ -573,7 +567,7 @@ def compute_partials(self, inputs, partials): as modifying the twist_cp option in the surface dictionary. The mesh geometry modification is limited to adjusting the input parameters to this component. -Outputs +Returns ------- data : dict A dictionary containing the following entries: @@ -589,8 +583,6 @@ def compute_partials(self, inputs, partials): Partial derivatives of the training data flattened in the proper OpenMDAO-style format for use as partial derivatives in the OASDataGen component """ - - def compute_training_data(inputs, surf_dict=None): t_start = time() print(f"Generating OpenAeroStruct aerostructural training data...") @@ -1308,16 +1300,16 @@ def setup(self): self.connect("aerostruct_point.fuelburn", "aerostruct_point.total_perf.L_equals_W.fuelburn") -class OASAerostructDragPolarExact(om.Group): +class AerostructDragPolarExact(om.Group): """ .. warning:: This component is far more computationally expensive than the - OASAerostructDragPolar component, which uses a surrogate. For missions + AerostructDragPolar component, which uses a surrogate. For missions with many flight segments, many num_nodes, or wing models with high num_x and num_y values this component will result in a system that returns a memory error when solved with a DirectSolver linear solver because the Jacobian is too large to be factorized. Unless you know what you're doing, this component should not be used (use - OASAerostructDragPolar instead). + AerostructDragPolar instead). Drag polar and wing weight estimate generated using OpenAeroStruct's aerostructural analysis capabilities directly, without a surrogate in the loop. @@ -1521,7 +1513,7 @@ def example_usage(): p = om.Problem() p.model.add_subsystem( "aerostruct", - OASAerostructDragPolar( + AerostructDragPolar( num_nodes=nn, num_x=num_x, num_y=num_y, diff --git a/openconcept/analysis/openaerostruct/drag_polar.py b/openconcept/aerodynamics/openaerostruct/drag_polar.py similarity index 98% rename from openconcept/analysis/openaerostruct/drag_polar.py rename to openconcept/aerodynamics/openaerostruct/drag_polar.py index a5c37731..7f00eabe 100644 --- a/openconcept/analysis/openaerostruct/drag_polar.py +++ b/openconcept/aerodynamics/openaerostruct/drag_polar.py @@ -1,5 +1,3 @@ -from __future__ import division - import numpy as np import openmdao.api as om from time import time @@ -19,13 +17,10 @@ from openaerostruct.geometry.geometry_mesh_transformations import Rotate from openaerostruct.aerodynamics.aero_groups import AeroPoint except ImportError: - raise ImportError("OpenAeroStruct must be installed to use the OASDragPolar component") + raise ImportError("OpenAeroStruct must be installed to use the VLMDragPolar component") # Atmospheric calculations -from openconcept.analysis.atmospherics.temperature_comp import TemperatureComp -from openconcept.analysis.atmospherics.pressure_comp import PressureComp -from openconcept.analysis.atmospherics.density_comp import DensityComp -from openconcept.analysis.atmospherics.speedofsound_comp import SpeedOfSoundComp +from openconcept.atmospherics import TemperatureComp, PressureComp, DensityComp, SpeedOfSoundComp CITATION = """ @InProceedings{Adler2022a, @@ -39,7 +34,7 @@ """ -class OASDragPolar(om.Group): +class VLMDragPolar(om.Group): """ Drag polar generated using OpenAeroStruct's vortex lattice method and a surrogate model to decrease the computational cost. @@ -321,7 +316,7 @@ def setup(self): error = error or VLMDataGen.surf_options[key] != self.options["surf_options"][key] if error: raise ValueError( - "The VLMDataGen and OASDragPolar components do not support\n" + "The VLMDataGen and VLMDragPolar components do not support\n" "differently-valued surf_options within an OpenMDAO model. Trying to replace:\n" f"{VLMDataGen.surf_options}\n" f"with new options:\n{self.options['surf_options']}" @@ -408,8 +403,8 @@ def compute_partials(self, inputs, partials): Generates training data and its total derivatives by calling OpenAeroStruct at each training point. -Inputs ------- +Parameters +---------- inputs : dict A dictionary containing the following entries: Mach_number_grid : mdarray @@ -444,7 +439,7 @@ def compute_partials(self, inputs, partials): as modifying the twist_cp option in the surface dictionary. The mesh geometry modification is limited to adjusting the input parameters to this component. -Outputs +Returns ------- data : dict A dictionary containing the following entries: @@ -456,8 +451,6 @@ def compute_partials(self, inputs, partials): Partial derivatives of the training data flattened in the proper OpenMDAO-style format for use as partial derivatives in the VLMDataGen component """ - - def compute_training_data(inputs, surf_dict=None): t_start = time() print(f"Generating OpenAeroStruct aerodynamic training data...") @@ -950,7 +943,7 @@ def example_usage(): p = om.Problem() p.model.add_subsystem( "drag_polar", - OASDragPolar( + VLMDragPolar( num_nodes=nn, num_x=num_x, num_y=num_y, diff --git a/openconcept/analysis/openaerostruct/tests/test_aerostructural.py b/openconcept/aerodynamics/openaerostruct/tests/test_aerostructural.py similarity index 98% rename from openconcept/analysis/openaerostruct/tests/test_aerostructural.py rename to openconcept/aerodynamics/openaerostruct/tests/test_aerostructural.py index f5080d62..9d49e32d 100644 --- a/openconcept/analysis/openaerostruct/tests/test_aerostructural.py +++ b/openconcept/aerodynamics/openaerostruct/tests/test_aerostructural.py @@ -7,7 +7,7 @@ try: from openaerostruct.geometry.geometry_group import Geometry from openaerostruct.aerodynamics.aero_groups import AeroPoint - from openconcept.analysis.openaerostruct.aerostructural import * + from openconcept.aerodynamics.openaerostruct.aerostructural import * OAS_installed = True except: @@ -15,7 +15,7 @@ @unittest.skipIf(not OAS_installed, "OpenAeroStruct is not installed") -class OASAerostructDragPolarTestCase(unittest.TestCase): +class AerostructDragPolarTestCase(unittest.TestCase): def tearDown(self): # Get rid of any specified surface options in the OASDataGen # class after every test. This is necessary because the class @@ -69,7 +69,7 @@ def test(self): mesh.run_model() p = om.Problem( - OASAerostructDragPolar( + AerostructDragPolar( num_nodes=1, num_x=3, num_y=7, @@ -138,7 +138,7 @@ def test_surf_options(self): t_skin = np.array([5, 13, 15]) # mm t_spar = np.array([5, 13, 15]) # mm p = om.Problem( - OASAerostructDragPolar( + AerostructDragPolar( num_nodes=nn, num_x=3, num_y=7, @@ -181,7 +181,7 @@ def test_vectorized(self): t_skin = np.array([5, 13, 15]) # mm t_spar = np.array([5, 13, 15]) # mm p = om.Problem( - OASAerostructDragPolar( + AerostructDragPolar( num_nodes=nn, num_x=3, num_y=7, @@ -360,14 +360,14 @@ def test_viscous_drag(self): @unittest.skipIf(not OAS_installed, "OpenAeroStruct is not installed") -class OASAerostructDragPolarExactTestCase(unittest.TestCase): +class AerostructDragPolarExactTestCase(unittest.TestCase): def test_defaults(self): S = 427.8 CD0 = 0.01 q = 0.5 * 0.55427276 * 264.20682682 ** 2 nn = 3 p = om.Problem( - OASAerostructDragPolarExact( + AerostructDragPolarExact( num_nodes=nn, num_x=3, num_y=5, num_twist=2, num_toverc=2, num_skin=2, num_spar=2 ) ) diff --git a/openconcept/analysis/openaerostruct/tests/test_drag_polar.py b/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py similarity index 93% rename from openconcept/analysis/openaerostruct/tests/test_drag_polar.py rename to openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py index d779d7aa..14c2555c 100644 --- a/openconcept/analysis/openaerostruct/tests/test_drag_polar.py +++ b/openconcept/aerodynamics/openaerostruct/tests/test_drag_polar.py @@ -7,7 +7,7 @@ try: from openaerostruct.geometry.geometry_group import Geometry from openaerostruct.aerodynamics.aero_groups import AeroPoint - from openconcept.analysis.openaerostruct.drag_polar import * + from openconcept.aerodynamics.openaerostruct.drag_polar import * OAS_installed = True except: @@ -15,7 +15,7 @@ @unittest.skipIf(not OAS_installed, "OpenAeroStruct is not installed") -class OASDragPolarTestCase(unittest.TestCase): +class VLMDragPolarTestCase(unittest.TestCase): def tearDown(self): # Get rid of any specified surface options in the VLMDataGen # class after every test. This is necessary because the class @@ -42,7 +42,7 @@ def test(self): mesh.run_model() p = om.Problem( - OASDragPolar( + VLMDragPolar( num_nodes=1, num_x=3, num_y=5, @@ -98,7 +98,7 @@ def test_surf_options(self): nn = 1 twist = np.array([-1, 0, 1]) p = om.Problem( - OASDragPolar( + VLMDragPolar( num_nodes=nn, num_x=3, num_y=5, @@ -132,7 +132,7 @@ def test_vectorized(self): nn = 7 twist = np.array([-1, 0, 1]) p = om.Problem( - OASDragPolar( + VLMDragPolar( num_nodes=nn, num_x=3, num_y=5, @@ -508,46 +508,44 @@ def test_777ish_regression(self): assert_check_partials(partials, atol=2e-5) -""" -Runs OpenAeroStruct with flight condition and mesh inputs. - -Inputs ------- -inputs : dict - Input dictionary containing - mesh : ndarray - Flat wing mesh (m) - twist : ndarray - Twist control points (deg) - v : float - Flight speed (m/s) - alpha : float - Angle of attack (deg) - Mach_number : float - Mach number - re : float - Dimensional Reynolds number (1/m) - rho : float - Flow density (kg/m^3) -with_viscous : bool (optional) - Include viscous drag -with_wave : bool (optional) - Include wave drag -t_over_c : float (optional) - Thickness to chord ratio of the airfoil - -Outputs -------- -outputs : dict - Output dictionary containing - CL : float - Lift coefficient - CD : float - Drag coefficient -""" - - def run_OAS(inputs, with_viscous=True, with_wave=True, t_over_c=np.array([0.12])): + """ + Runs OpenAeroStruct with flight condition and mesh inputs. + + Inputs + ------ + inputs : dict + Input dictionary containing + mesh : ndarray + Flat wing mesh (m) + twist : ndarray + Twist control points (deg) + v : float + Flight speed (m/s) + alpha : float + Angle of attack (deg) + Mach_number : float + Mach number + re : float + Dimensional Reynolds number (1/m) + rho : float + Flow density (kg/m^3) + with_viscous : bool (optional) + Include viscous drag + with_wave : bool (optional) + Include wave drag + t_over_c : float (optional) + Thickness to chord ratio of the airfoil + + Outputs + ------- + outputs : dict + Output dictionary containing + CL : float + Lift coefficient + CD : float + Drag coefficient + """ # Create a dictionary with info and options about the aerodynamic # lifting surface surface = { @@ -629,6 +627,14 @@ def test(self): # Test that it runs with no errors example_usage() + # Get rid of any specified surface options in the VLMDataGen + # class after every test. This is necessary because the class + # stores the surface options as a "static" variable and + # prevents multiple VLMDataGen instances with different + # surface options. Doing this prevents that error when doing + # multiple tests with different surface options. + del VLMDataGen.surf_options + if __name__ == "__main__": unittest.main() diff --git a/openconcept/analysis/tests/test_aerodynamics.py b/openconcept/aerodynamics/tests/test_aerodynamics.py similarity index 98% rename from openconcept/analysis/tests/test_aerodynamics.py rename to openconcept/aerodynamics/tests/test_aerodynamics.py index c5e55d59..e12b7724 100644 --- a/openconcept/analysis/tests/test_aerodynamics.py +++ b/openconcept/aerodynamics/tests/test_aerodynamics.py @@ -2,7 +2,7 @@ import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.analysis.aerodynamics import PolarDrag, Lift, StallSpeed +from openconcept.aerodynamics import PolarDrag, Lift, StallSpeed class PolarDragTestGroup(Group): """ diff --git a/openconcept/analysis/__init__.py b/openconcept/analysis/__init__.py deleted file mode 100644 index 6918e794..00000000 --- a/openconcept/analysis/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Analysis routines for conceptual aircraft design""" \ No newline at end of file diff --git a/openconcept/analysis/atmospherics/__init__.py b/openconcept/analysis/atmospherics/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/analysis/openaerostruct/__init__.py b/openconcept/analysis/openaerostruct/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/analysis/performance/__init__.py b/openconcept/analysis/performance/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/analysis/performance/dymos_phases.py b/openconcept/analysis/performance/dymos_phases.py deleted file mode 100644 index c2664c7e..00000000 --- a/openconcept/analysis/performance/dymos_phases.py +++ /dev/null @@ -1,366 +0,0 @@ -from __future__ import division -from openmdao.api import Group, ExplicitComponent, IndepVarComp, BalanceComp, ImplicitComponent -import openmdao.api as om -import openconcept.api as oc -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties -from openconcept.analysis.aerodynamics import Lift, StallSpeed -from openconcept.utilities.math import ElementMultiplyDivideComp, AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.math.integrals import Integrator -import numpy as np -import copy - -class Groundspeeds(ExplicitComponent): - """ - Computes groundspeed for vectorial true airspeed and true vertical speed. - - This is a helper function for the main mission analysis routines - and shouldn't be instantiated directly. - - Inputs - ------ - fltcond|vs : float - Vertical speed for all mission phases (vector, m/s) - fltcond|Utrue : float - True airspeed for all mission phases (vector, m/s) - - Outputs - ------- - fltcond|groundspeed : float - True groundspeed for all mission phases (vector, m/s) - fltcond|cosgamma : float - Cosine of the flght path angle for all mission phases (vector, dimensionless) - fltcond|singamma : float - Sine of the flight path angle for all mission phases (vector, dimensionless) - - Options - ------- - num_nodes : int - Number of points to run - """ - def initialize(self): - - self.options.declare('num_nodes',default=1,desc="Number of Simpson intervals to use per seg (eg. climb, cruise, descend). Number of analysis points is 2N+1") - - def setup(self): - nn = self.options['num_nodes'] - self.add_input('fltcond|vs', units='m/s',shape=(nn,)) - self.add_input('fltcond|Utrue', units='m/s',shape=(nn,)) - self.add_output('fltcond|groundspeed', units='m/s',shape=(nn,)) - self.add_output('fltcond|cosgamma', shape=(nn,), desc='Cosine of the flight path angle') - self.add_output('fltcond|singamma', shape=(nn,), desc='sin of the flight path angle' ) - self.declare_partials(['fltcond|groundspeed','fltcond|cosgamma','fltcond|singamma'], ['fltcond|vs','fltcond|Utrue'], rows=range(nn), cols=range(nn)) - - def compute(self, inputs, outputs): - - nn = self.options['num_nodes'] - #compute the groundspeed on climb and desc - inside = inputs['fltcond|Utrue']**2-inputs['fltcond|vs']**2 - groundspeed = np.sqrt(inside) - groundspeed_fixed = np.sqrt(np.where(np.less(inside, 0.0), 0.01, inside)) - #groundspeed = np.sqrt(inputs['fltcond|Utrue']**2-inputs['fltcond|vs']**2) - #groundspeed_fixed= np.where(np.isnan(groundspeed),0,groundspeed) - outputs['fltcond|groundspeed'] = groundspeed_fixed - outputs['fltcond|singamma'] = np.where(np.isnan(groundspeed),1,inputs['fltcond|vs'] / inputs['fltcond|Utrue']) - outputs['fltcond|cosgamma'] = groundspeed_fixed / inputs['fltcond|Utrue'] - - def compute_partials(self, inputs, J): - inside = inputs['fltcond|Utrue']**2-inputs['fltcond|vs']**2 - groundspeed = np.sqrt(inside) - groundspeed_fixed = np.sqrt(np.where(np.less(inside, 0.0), 0.01, inside)) - J['fltcond|groundspeed','fltcond|vs'] = np.where(np.isnan(groundspeed),0,(1/2) / groundspeed_fixed * (-2) * inputs['fltcond|vs']) - J['fltcond|groundspeed','fltcond|Utrue'] = np.where(np.isnan(groundspeed),0, (1/2) / groundspeed_fixed * 2 * inputs['fltcond|Utrue']) - J['fltcond|singamma','fltcond|vs'] = np.where(np.isnan(groundspeed), 0, 1 / inputs['fltcond|Utrue']) - J['fltcond|singamma','fltcond|Utrue'] = np.where(np.isnan(groundspeed), 0, - inputs['fltcond|vs'] / inputs['fltcond|Utrue'] ** 2) - J['fltcond|cosgamma','fltcond|vs'] = J['fltcond|groundspeed','fltcond|vs'] / inputs['fltcond|Utrue'] - J['fltcond|cosgamma','fltcond|Utrue'] = (J['fltcond|groundspeed','fltcond|Utrue'] * inputs['fltcond|Utrue'] - groundspeed_fixed) / inputs['fltcond|Utrue']**2 - -class HorizontalAcceleration(ExplicitComponent): - """ - Computes acceleration during takeoff run and effectively forms the T-D residual. - - Inputs - ------ - weight : float - Aircraft weight (scalar, kg) - drag : float - Aircraft drag at each analysis point (vector, N) - lift : float - Aircraft lift at each analysis point (vector, N) - thrust : float - Thrust at each TO analysis point (vector, N) - fltcond|singamma : float - The sine of the flight path angle gamma (vector, dimensionless) - braking : float - Effective rolling friction multiplier at each point (vector, dimensionless) - - Outputs - ------- - accel_horiz : float - Aircraft horizontal acceleration (vector, m/s**2) - - Options - ------- - num_nodes : int - Number of analysis points to run - """ - def initialize(self): - self.options.declare('num_nodes',default=1) - - def setup(self): - nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 - self.add_input('weight', units='kg', shape=(nn,)) - self.add_input('drag', units='N',shape=(nn,)) - self.add_input('lift', units='N',shape=(nn,)) - self.add_input('thrust', units='N',shape=(nn,)) - self.add_input('fltcond|singamma',shape=(nn,)) - self.add_input('braking',shape=(nn,)) - - self.add_output('accel_horiz', units='m/s**2', shape=(nn,)) - arange=np.arange(nn) - self.declare_partials(['accel_horiz'], ['weight','drag','lift','thrust','braking'], rows=arange, cols=arange) - self.declare_partials(['accel_horiz'], ['fltcond|singamma'], rows=arange, cols=arange, val=-g*np.ones((nn,))) - - - def compute(self, inputs, outputs): - nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 - m = inputs['weight'] - floor_vec = np.where(np.less((g-inputs['lift']/m),0.0),0.0,1.0) - accel = inputs['thrust']/m - inputs['drag']/m - floor_vec*inputs['braking']*(g-inputs['lift']/m) - g*inputs['fltcond|singamma'] - outputs['accel_horiz'] = accel - - def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 - m = inputs['weight'] - floor_vec = np.where(np.less((g-inputs['lift']/m),0.0),0.0,1.0) - J['accel_horiz','thrust'] = 1/m - J['accel_horiz','drag'] = -1/m - J['accel_horiz','braking'] = -floor_vec*(g-inputs['lift']/m) - J['accel_horiz','lift'] = floor_vec*inputs['braking']/m - J['accel_horiz','weight'] = (inputs['drag']-inputs['thrust']-floor_vec*inputs['braking']*inputs['lift'])/m**2 - - """ - Computes acceleration during takeoff run in the vertical plane. - Only used during full unsteady takeoff performance analysis due to stability issues - - Inputs - ------ - weight : float - Aircraft weight (scalar, kg) - drag : float - Aircraft drag at each analysis point (vector, N) - lift : float - Aircraft lift at each analysis point (vector, N) - thrust : float - Thrust at each TO analysis point (vector, N) - fltcond|singamma : float - The sine of the flight path angle gamma (vector, dimensionless) - fltcond|cosgamma : float - The sine of the flight path angle gamma (vector, dimensionless) - - Outputs - ------- - accel_vert : float - Aircraft horizontal acceleration (vector, m/s**2) - - Options - ------- - num_nodes : int - Number of analysis points to run - """ - def initialize(self): - self.options.declare('num_nodes',default=1) - - def setup(self): - nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 - self.add_input('weight', units='kg', shape=(nn,)) - self.add_input('drag', units='N',shape=(nn,)) - self.add_input('lift', units='N',shape=(nn,)) - self.add_input('thrust', units='N',shape=(nn,)) - self.add_input('fltcond|singamma',shape=(nn,)) - self.add_input('fltcond|cosgamma',shape=(nn,)) - - self.add_output('accel_vert', units='m/s**2', shape=(nn,),upper=2.5*g,lower=-1*g) - arange=np.arange(nn) - self.declare_partials(['accel_vert'], ['weight','drag','lift','thrust','fltcond|singamma','fltcond|cosgamma'], rows=arange, cols=arange) - - - def compute(self, inputs, outputs): - nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 - cosg = inputs['fltcond|cosgamma'] - sing = inputs['fltcond|singamma'] - accel = (inputs['lift']*cosg + (inputs['thrust']-inputs['drag'])*sing - g*inputs['weight'])/inputs['weight'] - accel = np.clip(accel, -g, 2.5*g) - outputs['accel_vert'] = accel - - def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 - m = inputs['weight'] - cosg = inputs['fltcond|cosgamma'] - sing = inputs['fltcond|singamma'] - - J['accel_vert','thrust'] = sing / m - J['accel_vert','drag'] = -sing / m - J['accel_vert','lift'] = cosg / m - J['accel_vert','fltcond|singamma'] = (inputs['thrust']-inputs['drag']) / m - J['accel_vert','fltcond|cosgamma'] = inputs['lift'] / m - J['accel_vert','weight'] = -(inputs['lift']*cosg + (inputs['thrust']-inputs['drag'])*sing)/m**2 - -class SteadyFlightCL(ExplicitComponent): - """ - Computes lift coefficient at each analysis point - - This is a helper function for the main mission analysis routine - and shouldn't be instantiated directly. - - Inputs - ------ - weight : float - Aircraft weight at each analysis point (vector, kg) - fltcond|q : float - Dynamic pressure at each analysis point (vector, Pascal) - ac|geom|wing|S_ref : float - Reference wing area (scalar, m**2) - fltcond|cosgamma : float - Cosine of the flght path angle for all mission phases (vector, dimensionless) - - Outputs - ------- - fltcond|CL : float - Lift coefficient (vector, dimensionless) - - Options - ------- - num_nodes : int - Number of analysis nodes to run - mission_segments : list - The list of mission segments to track - """ - def initialize(self): - - self.options.declare('num_nodes',default=5,desc="Number of Simpson intervals to use per seg (eg. climb, cruise, descend). Number of analysis points is 2N+1") - self.options.declare('mission_segments',default=['climb','cruise','descent']) - def setup(self): - nn = self.options['num_nodes'] - arange = np.arange(nn) - self.add_input('weight', units='kg', shape=(nn,)) - self.add_input('fltcond|q', units='N * m**-2', shape=(nn,)) - self.add_input('ac|geom|wing|S_ref', units='m **2') - self.add_input('fltcond|cosgamma', val=1.0, shape=(nn,)) - self.add_output('fltcond|CL',shape=(nn,)) - self.declare_partials(['fltcond|CL'], ['weight','fltcond|q',"fltcond|cosgamma"], rows=arange, cols=arange) - self.declare_partials(['fltcond|CL'], ['ac|geom|wing|S_ref'], rows=arange, cols=np.zeros(nn)) - - def compute(self, inputs, outputs): - g = 9.80665 #m/s^2 - outputs['fltcond|CL'] = inputs['fltcond|cosgamma']*g*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] - - def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 - J['fltcond|CL','weight'] = inputs['fltcond|cosgamma']*g/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] - J['fltcond|CL','fltcond|q'] = - inputs['fltcond|cosgamma']*g*inputs['weight'] / inputs['fltcond|q']**2 / inputs['ac|geom|wing|S_ref'] - J['fltcond|CL','ac|geom|wing|S_ref'] = - inputs['fltcond|cosgamma']*g*inputs['weight'] / inputs['fltcond|q'] / inputs['ac|geom|wing|S_ref']**2 - J['fltcond|CL','fltcond|cosgamma'] = g*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] - -class DymosSteadyFlightODE(om.Group): - """ - This component group models steady flight conditions. - Settable mission parameters include: - Airspeed (fltcond|Ueas) - Vertical speed (fltcond|vs) - Duration of the segment (duration) - - Throttle is set automatically to ensure steady flight - - The BaseAircraftGroup object is passed in. - The BaseAircraftGroup should be built to accept the following inputs - and return the following outputs. - The outputs should be promoted to the top level in the component. - - Inputs - ------ - range : float - Total distance travelled (vector, m) - fltcond|h : float - Altitude (vector, m) - fltcond|vs : float - Vertical speed (vector, m/s) - fltcond|Ueas : float - Equivalent airspeed (vector, m/s) - fltcond|Utrue : float - True airspeed (vector, m/s) - fltcond|p : float - Pressure (vector, Pa) - fltcond|rho : float - Density (vector, kg/m3) - fltcond|T : float - Temperature (vector, K) - fltcond|q : float - Dynamic pressure (vector, Pa) - fltcond|CL : float - Lift coefficient (vector, dimensionless) - throttle : float - Motor / propeller throttle setting scaled from 0 to 1 or slightly more (vector, dimensionless) - propulsor_active : float - If a multi-propulsor airplane, a failure condition should be modeled in the propulsion model by multiplying throttle by propulsor_active. - It will generally be 1.0 unless a failure condition is being modeled, in which case it will be 0 (vector, dimensionless) - braking : float - Brake friction coefficient (default 0.4 for dry runway braking, 0.03 for resistance unbraked) - Should not be applied in the air or nonphysical effects will result (vector, dimensionless) - lift : float - Lift force (vector, N) - - Outputs - ------- - thrust : float - Total thrust force produced by all propulsors (vector, N) - drag : float - Total drag force in the airplane axis produced by all sources of drag (vector, N) - weight : float - Weight (mass, really) of the airplane at each point in time. (vector, kg) - ac|geom|wing|S_ref - Wing reference area (scalar, m**2) - ac|aero|CLmax_TO - CLmax with flaps in max takeoff position (scalar, dimensionless) - ac|weights|MTOW - Maximum takeoff weight (scalar, kg) - """ - def initialize(self): - self.options.declare('num_nodes',default=1) - self.options.declare('flight_phase',default=None,desc='Phase of flight e.g. v0v1, cruise') - self.options.declare('aircraft_model',default=None) - - def setup(self): - nn = self.options['num_nodes'] - ivcomp = self.add_subsystem('const_settings', IndepVarComp(), promotes_outputs=["*"]) - ivcomp.add_output('propulsor_active', val=np.ones(nn)) - ivcomp.add_output('braking', val=np.zeros(nn)) - # TODO feet fltcond|Ueas as control param - ivcomp.add_output('fltcond|Ueas',val=np.ones((nn,))*90, units='m/s') - # TODO feed fltcond|vs as control param - ivcomp.add_output('fltcond|vs',val=np.ones((nn,))*1, units='m/s') - ivcomp.add_output('zero_accel',val=np.zeros((nn,)),units='m/s**2') - - # TODO take out the integrator - integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, diff_units='s', time_setup='duration', method='simpson'), promotes_inputs=['fltcond|vs', 'fltcond|groundspeed'], promotes_outputs=['fltcond|h', 'range']) - integ.add_integrand('fltcond|h', rate_name='fltcond|vs', val=1.0, units='m') - # TODO Feed fltcond|h as state - - self.add_subsystem('atmos', ComputeAtmosphericProperties(num_nodes=nn, true_airspeed_in=False), promotes_inputs=['*'], promotes_outputs=['*']) - self.add_subsystem('gs',Groundspeeds(num_nodes=nn),promotes_inputs=['*'],promotes_outputs=['*']) - # add the user-defined aircraft model - # TODO Can I promote up ac| quantities? - self.add_subsystem('acmodel',self.options['aircraft_model'](num_nodes=nn, flight_phase=self.options['flight_phase']),promotes_inputs=['*'],promotes_outputs=['*']) - self.add_subsystem('clcomp',SteadyFlightCL(num_nodes=nn), promotes_inputs=['*'],promotes_outputs=['*']) - self.add_subsystem('lift',Lift(num_nodes=nn), promotes_inputs=['*'],promotes_outputs=['*']) - self.add_subsystem('haccel',HorizontalAcceleration(num_nodes=nn), promotes_inputs=['*'],promotes_outputs=['*']) - # TODO add range as a state - integ.add_integrand('range', rate_name='fltcond|groundspeed', val=1.0, units='m') - self.add_subsystem('steadyflt',BalanceComp(name='throttle',val=np.ones((nn,))*0.5,lower=0.01,upper=2.0,units=None,normalize=False,eq_units='m/s**2',rhs_name='accel_horiz',lhs_name='zero_accel',rhs_val=np.zeros((nn,))), - promotes_inputs=['accel_horiz','zero_accel'],promotes_outputs=['throttle']) - # TODO still needs a Newton solver \ No newline at end of file diff --git a/openconcept/analysis/tests/__init__.py b/openconcept/analysis/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/api.py b/openconcept/api.py deleted file mode 100644 index f9dae7d3..00000000 --- a/openconcept/api.py +++ /dev/null @@ -1,6 +0,0 @@ -from openconcept.analysis.trajectories import TrajectoryGroup, PhaseGroup, IntegratorGroup -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from openconcept.utilities.visualization import plot_trajectory -from openconcept.utilities.dvlabel import DVLabel diff --git a/openconcept/atmospherics/__init__.py b/openconcept/atmospherics/__init__.py new file mode 100644 index 00000000..39408bfd --- /dev/null +++ b/openconcept/atmospherics/__init__.py @@ -0,0 +1,8 @@ +from .true_airspeed import TrueAirspeedComp, EquivalentAirspeedComp +from .temperature_comp import TemperatureComp +from .speedofsound_comp import SpeedOfSoundComp +from .pressure_comp import PressureComp +from .mach_number_comp import MachNumberComp +from .dynamic_pressure_comp import DynamicPressureComp +from .density_comp import DensityComp +from .compute_atmos_props import ComputeAtmosphericProperties diff --git a/openconcept/analysis/atmospherics/atmospherics_data.py b/openconcept/atmospherics/atmospherics_data.py similarity index 98% rename from openconcept/analysis/atmospherics/atmospherics_data.py rename to openconcept/atmospherics/atmospherics_data.py index a8a4c02b..91e53a49 100644 --- a/openconcept/analysis/atmospherics/atmospherics_data.py +++ b/openconcept/atmospherics/atmospherics_data.py @@ -6,7 +6,6 @@ 2018 AIAA/ASCE/AHS/ASC Structures, Structural Dynamics, and Materials Conference; AIAA SciTech Forum, January 2018 ''' -from __future__ import division import numpy as np # tropopause diff --git a/openconcept/analysis/atmospherics/compute_atmos_props.py b/openconcept/atmospherics/compute_atmos_props.py similarity index 77% rename from openconcept/analysis/atmospherics/compute_atmos_props.py rename to openconcept/atmospherics/compute_atmos_props.py index e5d9068e..08451f66 100644 --- a/openconcept/analysis/atmospherics/compute_atmos_props.py +++ b/openconcept/atmospherics/compute_atmos_props.py @@ -1,13 +1,5 @@ -from __future__ import division -from openconcept.analysis.atmospherics.temperature_comp import TemperatureComp -from openconcept.analysis.atmospherics.pressure_comp import PressureComp -from openconcept.analysis.atmospherics.density_comp import DensityComp -from openconcept.analysis.atmospherics.dynamic_pressure_comp import DynamicPressureComp -from openconcept.analysis.atmospherics.true_airspeed import TrueAirspeedComp, EquivalentAirspeedComp -from openconcept.analysis.atmospherics.speedofsound_comp import SpeedOfSoundComp -from openconcept.analysis.atmospherics.mach_number_comp import MachNumberComp -import numpy as np -from openmdao.api import ExplicitComponent, Group, Problem, IndepVarComp +from openconcept.atmospherics import TemperatureComp, PressureComp, DensityComp, DynamicPressureComp, TrueAirspeedComp, EquivalentAirspeedComp, SpeedOfSoundComp, MachNumberComp +from openmdao.api import Group class ComputeAtmosphericProperties(Group): diff --git a/openconcept/analysis/atmospherics/density_comp.py b/openconcept/atmospherics/density_comp.py similarity index 98% rename from openconcept/analysis/atmospherics/density_comp.py rename to openconcept/atmospherics/density_comp.py index ab3d077f..a2993751 100644 --- a/openconcept/analysis/atmospherics/density_comp.py +++ b/openconcept/atmospherics/density_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/dynamic_pressure_comp.py b/openconcept/atmospherics/dynamic_pressure_comp.py similarity index 97% rename from openconcept/analysis/atmospherics/dynamic_pressure_comp.py rename to openconcept/atmospherics/dynamic_pressure_comp.py index 9a97ecf8..2e8a8658 100644 --- a/openconcept/analysis/atmospherics/dynamic_pressure_comp.py +++ b/openconcept/atmospherics/dynamic_pressure_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/mach_number_comp.py b/openconcept/atmospherics/mach_number_comp.py similarity index 97% rename from openconcept/analysis/atmospherics/mach_number_comp.py rename to openconcept/atmospherics/mach_number_comp.py index 101d61a0..2c869d06 100644 --- a/openconcept/analysis/atmospherics/mach_number_comp.py +++ b/openconcept/atmospherics/mach_number_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/pressure_comp.py b/openconcept/atmospherics/pressure_comp.py similarity index 98% rename from openconcept/analysis/atmospherics/pressure_comp.py rename to openconcept/atmospherics/pressure_comp.py index 93ce9d32..f6febb4c 100644 --- a/openconcept/analysis/atmospherics/pressure_comp.py +++ b/openconcept/atmospherics/pressure_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/readme.txt b/openconcept/atmospherics/readme.txt similarity index 100% rename from openconcept/analysis/atmospherics/readme.txt rename to openconcept/atmospherics/readme.txt diff --git a/openconcept/analysis/atmospherics/speedofsound_comp.py b/openconcept/atmospherics/speedofsound_comp.py similarity index 97% rename from openconcept/analysis/atmospherics/speedofsound_comp.py rename to openconcept/atmospherics/speedofsound_comp.py index cf99ca1e..fcf35aa7 100644 --- a/openconcept/analysis/atmospherics/speedofsound_comp.py +++ b/openconcept/atmospherics/speedofsound_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/temperature_comp.py b/openconcept/atmospherics/temperature_comp.py similarity index 98% rename from openconcept/analysis/atmospherics/temperature_comp.py rename to openconcept/atmospherics/temperature_comp.py index 4ce87348..b490ce2a 100644 --- a/openconcept/analysis/atmospherics/temperature_comp.py +++ b/openconcept/atmospherics/temperature_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent diff --git a/openconcept/analysis/atmospherics/tests/test_atmospherics.py b/openconcept/atmospherics/tests/test_atmospherics.py similarity index 97% rename from openconcept/analysis/atmospherics/tests/test_atmospherics.py rename to openconcept/atmospherics/tests/test_atmospherics.py index 352188e0..25268db5 100644 --- a/openconcept/analysis/atmospherics/tests/test_atmospherics.py +++ b/openconcept/atmospherics/tests/test_atmospherics.py @@ -1,9 +1,8 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties +from openconcept.atmospherics import ComputeAtmosphericProperties class AtmosTestGroup(Group): """This computes pressure, temperature, and density for a given altitude at ISA condtions. Also true airspeed from equivalent ~ indicated airspeed diff --git a/openconcept/analysis/atmospherics/true_airspeed.py b/openconcept/atmospherics/true_airspeed.py similarity index 98% rename from openconcept/analysis/atmospherics/true_airspeed.py rename to openconcept/atmospherics/true_airspeed.py index 9cb4e5fc..88faf3c1 100644 --- a/openconcept/analysis/atmospherics/true_airspeed.py +++ b/openconcept/atmospherics/true_airspeed.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent @@ -91,4 +90,4 @@ def compute(self, inputs, outputs): def compute_partials(self, inputs, partials): rho_isa_kgm3 = 1.225 partials['fltcond|Ueas', 'fltcond|Utrue'] = np.sqrt(inputs['fltcond|rho'] / rho_isa_kgm3) - partials['fltcond|Ueas', 'fltcond|rho'] = (1/2)*inputs['fltcond|Utrue']*(inputs['fltcond|rho'] / rho_isa_kgm3)**(-1/2) / rho_isa_kgm3 \ No newline at end of file + partials['fltcond|Ueas', 'fltcond|rho'] = (1/2)*inputs['fltcond|Utrue']*(inputs['fltcond|rho'] / rho_isa_kgm3)**(-1/2) / rho_isa_kgm3 diff --git a/openconcept/components/__init__.py b/openconcept/components/__init__.py deleted file mode 100644 index d49a5e6a..00000000 --- a/openconcept/components/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .battery import SimpleBattery -from .generator import SimpleGenerator -from .motor import SimpleMotor -from .propeller import SimplePropeller -from .splitter import PowerSplit, FlowSplit, FlowCombine -from .turboshaft import SimpleTurboshaft diff --git a/openconcept/components/empirical_data/__init__.py b/openconcept/components/empirical_data/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/components/heat_sinks.py b/openconcept/components/heat_sinks.py deleted file mode 100644 index b55591da..00000000 --- a/openconcept/components/heat_sinks.py +++ /dev/null @@ -1,709 +0,0 @@ -import openmdao.api as om -import numpy as np -from openconcept.utilities.math.integrals import Integrator -import warnings - -class BandolierCoolingSystem(om.ExplicitComponent): - """ - Computes battery heat transfer for a parameteric battery - based on Tesla's Model 3 design. - - Assumptions: - Heat generated uniformly in the cell - Weight per cell and thermal resistance stay constant - even as specific energy varies parametrically - (this means that cell count is constant with pack WEIGHT, - not pack ENERGY as technology improves) - Cylindrical cells attached to Tesla-style thermal ribbon - Liquid cooling - Heat transfer through axial direction only (not baseplate) - 2170 cells (21 mm diameter, 70mm tall) - Battery thermal model assumes unsteady cell temperature, - quasi-steady temperature gradients - - Inputs - ------ - q_in : float - Heat generation rate in the battery (vector, W) - T_in : float - Coolant inlet temperature (vector, K) - T_battery : float - Volume averaged battery temperature (vector, K) - mdot_coolant : float - Mass flow rate of coolant through the bandolier (vector, kg/s) - battery_weight : float - Weight of the battery (overall). Default 100kg (scalar) - n_cpb : float - Number of cells long per "bandolier" actual count is 2x (scalar, default 82, Tesla) - t_channel : float - Thickness (width) of the cooling channel in the bandolier - (scalar, default 1mm) - Outputs - ------- - dTdt : float - Time derivative dT/dt (Tbar in the paper) (vector, K/s) - T_surface : float - Surface temp of the battery (vector, K) - T_core : float - Center temp of the battery (vector, K) - q : float - Heat transfer rate from the motor to the fluid (vector, W) - T_out : float - Outlet fluid temperature (vector, K) - - Options - ------- - num_nodes : float - The number of analysis points to run - coolant_specific_heat : float - Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) - fluid_k : float - Thermal conductivity of the coolant (W/m/K) - nusselt : float - Nusselt number of the coolant channel (default 7.54 for uniform surf temp) - cell_kr : float - Thermal conductivity of the cell in the radial direction (W/m/k) - cell_diameter : float - Battery diameter (default 21mm for 2170 cell) - cell_height : float - Battery height (default 70mm for 2170 cell) - cell_mass : float - Battery weight (default 70g for 2170 cell) - cell_specific_heat : float - Mass average specific heat of the battery (default 900, LiIon cylindrical cell) - battery_weight_fraction : float - Fraction of battery by weight that is cells (default 0.72 knocks down Tesla by a bit) - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of analysis points') - self.options.declare('coolant_specific_heat', default=3801, desc='Coolant specific heat in J/kg/K') - self.options.declare('fluid_k', default=0.405, desc='Thermal conductivity of the fluid in W / mK') - self.options.declare('nusselt', default=7.54, desc='Hydraulic diameter Nusselt number') - - self.options.declare('cell_kr', default=0.3) # 0.455 for an 18650 cell, knocked down a bit - self.options.declare('cell_diameter', default=0.021) - self.options.declare('cell_height', default=0.070) - self.options.declare('cell_mass', default=0.070) - self.options.declare('cell_specific_heat', default=875.) - self.options.declare('battery_weight_fraction', default=0.65) - - def setup(self): - nn = self.options['num_nodes'] - self.add_input('q_in', shape=(nn,), units='W', val=0.0) - self.add_input('T_in', shape=(nn,), units='K', val=300.) - self.add_input('T_battery', shape=(nn,), units='K', val=300.) - self.add_input('mdot_coolant', shape=(nn,), units='kg/s', val=0.20) - self.add_input('battery_weight', units='kg', val=478.) - self.add_input('n_cpb', units=None, val=82.) - self.add_input('t_channel', units='m', val=0.0005) - - self.add_output('dTdt', shape=(nn,), units='K/s', tags=['integrate', 'state_name:T_battery', 'state_units:K', 'state_val:300.0', 'state_promotes:True']) - self.add_output('T_surface', shape=(nn,), units='K', lower=1e-10) - self.add_output('T_core', shape=(nn,), units='K', lower=1e-10) - self.add_output('q', shape=(nn,), units='W') - self.add_output('T_out', shape=(nn,), units='K', val=300, lower=1e-10) - - self.declare_partials(['*'], ['*'], method='cs') - - def compute(self, inputs, outputs): - nn = self.options['num_nodes'] - n_cells = inputs['battery_weight'] * self.options['battery_weight_fraction'] / self.options['cell_mass'] - n_bandoliers = n_cells / inputs['n_cpb'] / 2 - - mdot_b = inputs['mdot_coolant'] / n_bandoliers - q_cell = inputs['q_in'] / n_cells - hconv = self.options['nusselt'] * self.options['fluid_k'] / 2 / inputs['t_channel'] - - Hc = self.options['cell_height'] - Dc = self.options['cell_diameter'] - mc = self.options['cell_mass'] - krc = self.options['cell_kr'] - cpc = self.options['cell_specific_heat'] - L_bandolier = inputs['n_cpb'] * Dc - - cpf = self.options['coolant_specific_heat'] # of the coolant - - A_heat_trans = Hc * L_bandolier * 2 # two sides of the tape - NTU = hconv * A_heat_trans / mdot_b / cpf - Kcell = mdot_b * cpf * (1 - np.exp(-NTU)) / 2 / inputs['n_cpb'] # divide out the total bandolier convection by 2 * n_cpb cells - # the convective heat transfer is (Ts - Tin) * Kcell - PI = np.pi - - Tbar = inputs['T_battery'] - Rc = Dc / 2 - - K_cyl = 8*np.pi*Hc*krc - - Ts = (K_cyl * Tbar + Kcell * inputs['T_in']) / (K_cyl + Kcell) - - outputs['T_surface'] = Ts - - q_conv = (Ts - inputs['T_in']) * Kcell * n_cells - outputs['dTdt'] = (q_cell - (Ts - inputs['T_in']) * Kcell) / mc / cpc # todo check that this quantity matches convection - - - outputs['q'] = q_conv - - qcheck = (Tbar - Ts) * K_cyl - # UAcomb = 1/(1/hconv/A_heat_trans+1/K_cyl/2/inputs['n_cpb']) - # qcheck2 = (Tbar - inputs['T_in']) * mdot_b * cpf * (1 - np.exp(-UAcomb/mdot_b/cpf)) / 2 / inputs['n_cpb'] - - # if np.sum(np.abs(qcheck - outputs['q']/n_cells)) > 1e-5: - # # the heat flux across the cell is not equal to the heat flux due to convection - # raise ValueError('The surface temperature solution appears to be wrong') - - outputs['T_out'] = inputs['T_in'] + outputs['q'] / inputs['mdot_coolant'] / cpf - outputs['T_core'] = (Tbar - Ts) + Tbar - -class LiquidCooledBattery(om.Group): - """A battery with liquid cooling - - Inputs - ------ - q_in : float - Heat produced by the operating component (vector, W) - mdot_coolant : float - Coolant mass flow rate (vector, kg/s) - T_in : float - Instantaneous coolant inflow temperature (vector, K) - battery_weight : float - Battery weight (scalar, kg) - n_cpb : float - Number of cells long per "bandolier" actual count is 2x (scalar, default 82, Tesla) - t_channel : float - Thickness (width) of the cooling channel in the bandolier - (scalar, default 1mm) - T_initial : float - Initial temperature of the battery (only required in thermal mass mode) (scalar, K) - duration : float - Duration of mission segment, only required in unsteady mode - - Outputs - ------- - T_out : float - Instantaneous coolant outlet temperature (vector, K) - T: float - Battery volume averaged temperature (vector, K) - T_core : float - Battery core temperature (vector, K) - T_surface : float - Battery surface temperature (vector, K) - - Options - ------- - num_nodes : int - Number of analysis points to run - quasi_steady : bool - Whether or not to treat the component as having thermal mass - num_nodes : float - The number of analysis points to run - coolant_specific_heat : float - Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) - fluid_k : float - Thermal conductivity of the coolant (W/m/K) - nusselt : float - Nusselt number of the coolant channel (default 7.54 for uniform surf temp) - cell_kr : float - Thermal conductivity of the cell in the radial direction (W/m/k) - cell_diameter : float - Battery diameter (default 21mm for 2170 cell) - cell_height : float - Battery height (default 70mm for 2170 cell) - cell_mass : float - Battery weight (default 70g for 2170 cell) - cell_specific_heat : float - Mass average specific heat of the battery (default 900, LiIon cylindrical cell) - battery_weight_fraction : float - Fraction of battery by weight that is cells (default 0.72 knocks down Tesla by a bit) - """ - - def initialize(self): - self.options.declare('quasi_steady', default=False, desc='Treat the component as quasi-steady or with thermal mass') - self.options.declare('num_nodes', default=1, desc='Number of quasi-steady points to runs') - self.options.declare('coolant_specific_heat', default=3801, desc='Coolant specific heat in J/kg/K') - self.options.declare('fluid_k', default=0.405, desc='Thermal conductivity of the fluid in W / mK') - self.options.declare('nusselt', default=7.54, desc='Hydraulic diameter Nusselt number') - - self.options.declare('cell_kr', default=0.3) # 0.455 for an 18650 cell, knocked down a bit - self.options.declare('cell_diameter', default=0.021) - self.options.declare('cell_height', default=0.070) - self.options.declare('cell_mass', default=0.070) - self.options.declare('cell_specific_heat', default=875.) - self.options.declare('battery_weight_fraction', default=0.65) - def setup(self): - nn = self.options['num_nodes'] - quasi_steady = self.options['quasi_steady'] - - self.add_subsystem('hex', BandolierCoolingSystem(num_nodes=nn, - coolant_specific_heat=self.options['coolant_specific_heat'], - fluid_k=self.options['fluid_k'], - nusselt=self.options['nusselt'], - cell_kr=self.options['cell_kr'], - cell_diameter=self.options['cell_diameter'], - cell_height=self.options['cell_height'], - cell_mass=self.options['cell_mass'], - cell_specific_heat=self.options['cell_specific_heat'], - battery_weight_fraction=self.options['battery_weight_fraction']), - promotes_inputs=['q_in', 'mdot_coolant', 'T_in', ('T_battery', 'T'), 'battery_weight', 'n_cpb', 't_channel'], - promotes_outputs=['T_core', 'T_surface', 'T_out', 'dTdt']) - - if not quasi_steady: - ode_integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, diff_units='s', method='simpson', time_setup='duration'), - promotes_outputs=['*'], promotes_inputs=['*']) - ode_integ.add_integrand('T', rate_name='dTdt', units='K', lower=1e-10) - else: - self.add_subsystem('thermal_bal', - om.BalanceComp('T', eq_units='K/s', lhs_name='dTdt', rhs_val=0.0, units='K', lower=1.0, val=299.*np.ones((nn,))), - promotes_inputs=['dTdt'], - promotes_outputs=['T']) - -class MotorCoolingJacket(om.ExplicitComponent): - """ - Computes motor winding temperature assuming - well-designed, high-power-density aerospace motor. - This component is based on the following assumptions: - - 2020 technology level - - 200kW-1MW class inrunner PM motor - - Liquid cooling of the stators - - "Reasonable" coolant flow rates (component will validate this) - - Thermal performance similiar to the Siemens SP200D motor - - The component assumes a constant heat transfer coefficient based - on the surface area of the motor casing (not counting front and rear faces) - The MagniX Magni 250/500 and Siemens SP200D motors were measured - using rough photogrammetry. - - Magni250: 280kW rated power, ~0.559m OD, 0.2m case "depth" (along thrust axis) - Magni500: 560kW rated power, ~0.652m OD, 0.4m case "depth" - Siemens SP200D: 200kW rated power, ~0.63m OD, ~0.16 case "depth" - - Based on these dimensions I assume 650kW per square meter - of casing surface area. This includes only the cylindrical portion, - not the front and rear motor faces. - - Using a thermal FEM image of the SP200D, I estimate - a temperature rise of 23K from coolant inlet temperature (~85C) - to winding max temp (~108C) at the steady state operating point. - With 95% efficiency at 200kW, this is about 1373 W / m^2 casing area / K. - We'll reduce that somewhat since this is a direct oil cooling system, - and assume 1100 W/m^2/K instead. - - Dividing 1.1 kW/m^2/K by 650kWrated/m^2 gives: 1.69e-3 kW / kWrated / K - At full rated power and 95% efficiency, this is 29.5C steady state temp rise - which the right order of magnitude. - - Inputs - ------ - q_in : float - Heat production rate in the motor (vector, W) - T_in : float - Coolant inlet temperature (vector, K) - T : float - Temperature of the motor windings (vector, K) - mdot_coolant : float - Mass flow rate of the coolant (vector, kg/s) - power_rating : float - Rated steady state power of the motor (scalar, W) - motor_weight : float - Weight of electric motor (scalar, kg) - - Outputs - ------- - dTdt : float - Time derivative dT/dt (vector, K/s) - q : float - Heat transfer rate from the motor to the fluid (vector, W) - T_out : float - Outlet fluid temperature (vector, K) - - - Options - ------- - num_nodes : float - The number of analysis points to run - coolant_specific_heat : float - Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) - case_cooling_coefficient : float - Watts of heat transfer per square meter of case surface area per K - temperature differential (default 1100 W/m^2/K) - case_area_coefficient : float - rated motor power per square meter of case surface area - (default 650,000 W / m^2) - motor_specific_heat : float - Specific heat of the motor casing (J/kg/K) (default 921, alu) - """ - - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of analysis points') - self.options.declare('coolant_specific_heat', default=3801, desc='Specific heat in J/kg/K') - self.options.declare('case_cooling_coefficient', default=1100.) - self.options.declare('case_area_coefficient', default=650000.) - self.options.declare('motor_specific_heat', default=921, desc='Specific heat in J/kg/K - default 921 for aluminum') - - def setup(self): - nn = self.options['num_nodes'] - arange = np.arange(nn) - self.add_input('q_in', shape=(nn,), units='W', val=0.0) - self.add_input('T_in', shape=(nn,), units='K', val=330) - self.add_input('T', shape=(nn,), units='K', val=359.546) - self.add_input('mdot_coolant', shape=(nn,), units='kg/s', val=1.0) - self.add_input('power_rating', units='W', val=2e5) - self.add_input('motor_weight', units='kg', val=100) - self.add_output('q', shape=(nn,), units='W') - self.add_output('T_out', shape=(nn,), units='K', val=300, lower=1e-10) - self.add_output('dTdt', shape=(nn,), units='K/s', tags=['integrate', 'state_name:T_motor', 'state_units:K', 'state_val:300.0', 'state_promotes:True']) - - self.declare_partials(['T_out','q','dTdt'], ['power_rating'], rows=arange, cols=np.zeros((nn,))) - self.declare_partials(['dTdt'], ['motor_weight'], rows=arange, cols=np.zeros((nn,))) - - self.declare_partials(['T_out','q','dTdt'], ['T_in', 'T','mdot_coolant'], rows=arange, cols=arange) - self.declare_partials(['dTdt'], ['q_in'], rows=arange, cols=arange) - - def compute(self, inputs, outputs): - const = self.options['case_cooling_coefficient'] / self.options['case_area_coefficient'] - - NTU = const * inputs['power_rating'] / inputs['mdot_coolant'] / self.options['coolant_specific_heat'] - effectiveness = 1 - np.exp(-NTU) - heat_transfer = (inputs['T'] - inputs['T_in']) * effectiveness * inputs['mdot_coolant'] * self.options['coolant_specific_heat'] - outputs['q'] = heat_transfer - outputs['T_out'] = inputs['T_in'] + heat_transfer / inputs['mdot_coolant'] / self.options['coolant_specific_heat'] - outputs['dTdt'] = (inputs['q_in'] - outputs['q']) / inputs['motor_weight'] / self.options['motor_specific_heat'] - - def compute_partials(self, inputs, J): - nn = self.options['num_nodes'] - cp = self.options['coolant_specific_heat'] - mdot = inputs['mdot_coolant'] - const = self.options['case_cooling_coefficient'] / self.options['case_area_coefficient'] - - NTU = const * inputs['power_rating'] / mdot / cp - dNTU_dP = const / mdot / cp - dNTU_dmdot = -const * inputs['power_rating'] / mdot **2 / cp - effectiveness = 1 - np.exp(-NTU) - deff_dP = np.exp(-NTU) * dNTU_dP - deff_dmdot = np.exp(-NTU) * dNTU_dmdot - - heat_transfer = (inputs['T'] - inputs['T_in']) * effectiveness * inputs['mdot_coolant'] * self.options['coolant_specific_heat'] - - J['q', 'T'] = effectiveness * mdot * cp - J['q', 'T_in'] = - effectiveness * mdot * cp - J['q', 'power_rating'] = (inputs['T'] - inputs['T_in']) * deff_dP * mdot * cp - J['q', 'mdot_coolant'] = (inputs['T'] - inputs['T_in']) * cp * (effectiveness + deff_dmdot * mdot) - - J['T_out', 'T'] = J['q','T'] / mdot / cp - J['T_out', 'T_in'] = np.ones(nn) + J['q','T_in'] / mdot / cp - J['T_out', 'power_rating'] = J['q', 'power_rating'] / mdot / cp - J['T_out', 'mdot_coolant'] = (J['q', 'mdot_coolant'] * mdot - heat_transfer) / cp / mdot ** 2 - - J['dTdt', 'q_in'] = 1 / inputs['motor_weight'] / self.options['motor_specific_heat'] - J['dTdt', 'T'] = -J['q', 'T'] / inputs['motor_weight'] / self.options['motor_specific_heat'] - J['dTdt', 'T_in'] = -J['q', 'T_in'] / inputs['motor_weight'] / self.options['motor_specific_heat'] - J['dTdt', 'power_rating'] = -J['q', 'power_rating'] / inputs['motor_weight'] / self.options['motor_specific_heat'] - J['dTdt', 'mdot_coolant'] = -J['q', 'mdot_coolant'] / inputs['motor_weight'] / self.options['motor_specific_heat'] - J['dTdt', 'motor_weight'] = -(inputs['q_in'] - heat_transfer) / inputs['motor_weight']**2 / self.options['motor_specific_heat'] - -class LiquidCooledMotor(om.Group): - """A component (heat producing) with thermal mass - cooled by a cold plate. - - Inputs - ------ - q_in : float - Heat produced by the operating component (vector, W) - mdot_coolant : float - Coolant mass flow rate (vector, kg/s) - T_in : float - Instantaneous coolant inflow temperature (vector, K) - motor_weight : float - Object mass (only required in thermal mass mode) (scalar, kg) - T_initial : float - Initial temperature of the cold plate (only required in thermal mass mode) / object (scalar, K) - duration : float - Duration of mission segment, only required in unsteady mode - power_rating : float - Rated power of the motor (scalar, kW) - - Outputs - ------- - T_out : float - Instantaneous coolant outlet temperature (vector, K) - T: float - Windings temperature (vector, K) - - Options - ------- - motor_specific_heat : float - Specific heat capacity of the object in J / kg / K (default 921 = aluminum) - coolant_specific_heat : float - Specific heat capacity of the coolant in J / kg / K (default 3801, glycol/water) - num_nodes : int - Number of analysis points to run - quasi_steady : bool - Whether or not to treat the component as having thermal mass - case_cooling_coefficient : float - Watts of heat transfer per square meter of case surface area per K - temperature differential (default 1100 W/m^2/K) - """ - - def initialize(self): - self.options.declare('motor_specific_heat', default=921.0, desc='Specific heat in J/kg/K') - self.options.declare('coolant_specific_heat', default=3801, desc='Specific heat in J/kg/K') - self.options.declare('quasi_steady', default=False, desc='Treat the component as quasi-steady or with thermal mass') - self.options.declare('num_nodes', default=1, desc='Number of quasi-steady points to runs') - self.options.declare('case_cooling_coefficient', default=1100.) - - def setup(self): - nn = self.options['num_nodes'] - quasi_steady = self.options['quasi_steady'] - self.add_subsystem('hex', - MotorCoolingJacket(num_nodes=nn, coolant_specific_heat=self.options['coolant_specific_heat'], - motor_specific_heat=self.options['motor_specific_heat'], - case_cooling_coefficient=self.options['case_cooling_coefficient']), - promotes_inputs=['q_in','T_in', 'T','power_rating','mdot_coolant','motor_weight'], - promotes_outputs=['T_out', 'dTdt']) - if not quasi_steady: - ode_integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, diff_units='s', method='simpson', time_setup='duration'), - promotes_outputs=['*'], promotes_inputs=['*']) - ode_integ.add_integrand('T', rate_name='dTdt', units='K', lower=1e-10) - else: - self.add_subsystem('thermal_bal', - om.BalanceComp('T', eq_units='K/s', lhs_name='dTdt', rhs_val=0.0, units='K', lower=1.0, val=299.*np.ones((nn,))), - promotes_inputs=['dTdt'], - promotes_outputs=['T']) - -class SimplePump(om.ExplicitComponent): - """ - A pump that circulates coolant against pressure. - The default parameters are based on a survey of commercial - airplane fuel pumps of a variety of makes and models. - - Inputs - ------ - power_rating : float - Maximum rated electrical power (scalar, W) - mdot_coolant : float - Coolant mass flow rate (vector, kg/s) - rho_coolant : float - Coolant density (vector, kg/m3) - delta_p : float - Pressure rise provided by the pump (vector, kg/s) - - Outputs - ------- - elec_load : float - Electricity used by the pump (vector, W) - component_weight : float - Pump weight (scalar, kg) - component_sizing_margin : float - Fraction of total power rating used via elec_load (vector, dimensionless) - - Options - ------- - num_nodes : int - Number of analysis points to run (sets vec length; default 1) - efficiency : float - Pump electrical + mech efficiency. Sensible range 0.0 to 1.0 (default 0.35) - weight_base : float - Base weight of pump, doesn't change with power rating (default 0) - weight_inc : float - Incremental weight of pump, scales linearly with power rating (default 1/450 kg/W) - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - self.options.declare('efficiency', default=0.35, desc='Efficiency (dimensionless)') - self.options.declare('weight_base', default=0.0, desc='Pump base weight') - self.options.declare('weight_inc', default=1/450, desc='Incremental pump weight (kg/W)') - - def setup(self): - nn = self.options['num_nodes'] - eta = self.options['efficiency'] - weight_inc = self.options['weight_inc'] - - self.add_input('power_rating', units='W', desc='Pump electrical power rating') - self.add_input('mdot_coolant', units='kg/s', desc='Coolant mass flow rate', val=np.ones((nn,))) - self.add_input('delta_p', units='Pa', desc='Pump pressure rise', val=np.ones((nn,))) - self.add_input('rho_coolant', units='kg/m**3', desc='Coolant density', val=np.ones((nn,))) - - self.add_output('elec_load', units='W', desc='Pump electrical load', val=np.ones((nn,))) - self.add_output('component_weight', units='kg', desc='Pump weight') - self.add_output('component_sizing_margin', units=None, val=np.ones((nn,)), desc='Comp sizing margin') - - self.declare_partials(['elec_load','component_sizing_margin'], ['rho_coolant', 'delta_p', 'mdot_coolant'], rows=np.arange(nn), cols=np.arange(nn)) - self.declare_partials(['component_sizing_margin'], ['power_rating'], rows=np.arange(nn), cols=np.zeros(nn)) - self.declare_partials(['component_weight'], ['power_rating'], val=weight_inc) - - - - def compute(self, inputs, outputs): - nn = self.options['num_nodes'] - eta = self.options['efficiency'] - weight_inc = self.options['weight_inc'] - weight_base = self.options['weight_base'] - - outputs['component_weight'] = weight_base + weight_inc * inputs['power_rating'] - - # compute the fluid power - vol_flow_rate = inputs['mdot_coolant'] / inputs['rho_coolant'] # m3/s - fluid_power = vol_flow_rate * inputs['delta_p'] - outputs['elec_load'] = fluid_power / eta - outputs['component_sizing_margin'] = outputs['elec_load'] / inputs['power_rating'] - - - def compute_partials(self, inputs, J): - nn = self.options['num_nodes'] - eta = self.options['efficiency'] - - J['elec_load', 'mdot_coolant'] = inputs['delta_p'] / inputs['rho_coolant'] / eta - J['elec_load', 'delta_p'] = inputs['mdot_coolant'] / inputs['rho_coolant'] / eta - J['elec_load', 'rho_coolant'] = -inputs['mdot_coolant'] * inputs['delta_p'] / inputs['rho_coolant'] ** 2 / eta - for in_var in ['mdot_coolant', 'delta_p', 'rho_coolant']: - J['component_sizing_margin', in_var] = J['elec_load', in_var] / inputs['power_rating'] - J['component_sizing_margin', 'power_rating'] = - inputs['mdot_coolant'] * inputs['delta_p'] / inputs['rho_coolant'] / eta / inputs['power_rating'] ** 2 - -class SimpleHose(om.ExplicitComponent): - """ - A coolant hose used to track pressure drop and weight in long hose runs. - - Inputs - ------ - hose_diameter : float - Inner diameter of the hose (scalar, m) - hose_length - Length of the hose (scalar, m) - hose_design_pressure - Max operating pressure of the hose (scalar, Pa) - mdot_coolant : float - Coolant mass flow rate (vector, kg/s) - rho_coolant : float - Coolant density (vector, kg/m3) - mu_coolant : float - Coolant viscosity (scalar, kg/m/s) - - Outputs - ------- - delta_p : float - Pressure drop in the hose - positive is loss (vector, kg/s) - component_weight : float - Weight of hose AND coolant (scalar, kg) - - Options - ------- - num_nodes : int - Number of analysis points to run (sets vec length; default 1) - hose_operating_stress : float - Hoop stress at design pressure (Pa) set to 300 Psi equivalent per empirical data - hose_density : float - Material density of the hose (kg/m3) set to 0.049 lb/in3 equivalent per empirical data - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') - self.options.declare('hose_operating_stress', default=2.07e6, desc='Hoop stress at max op press in Pa') - self.options.declare('hose_density', default=1356.3, desc='Hose matl density in kg/m3') - - def setup(self): - nn = self.options['num_nodes'] - self.add_input('hose_diameter', val=0.0254, units='m') - self.add_input('hose_length', val=1.0, units='m') - self.add_input('hose_design_pressure', units='Pa', val=1.03e6, desc='Hose max operating pressure') - - - self.add_input('mdot_coolant', units='kg/s', desc='Coolant mass flow rate', val=np.ones((nn,))) - self.add_input('rho_coolant', units='kg/m**3', desc='Coolant density', val=1020.*np.ones((nn,))) - self.add_input('mu_coolant', val=1.68e-3, units='kg/m/s', desc='Coolant viscosity') - - self.add_output('delta_p', units='Pa', desc='Hose pressure drop', val=np.ones((nn,))) - self.add_output('component_weight', units='kg', desc='Pump weight') - - self.declare_partials(['delta_p'], ['rho_coolant', 'mdot_coolant'], rows=np.arange(nn), cols=np.arange(nn)) - self.declare_partials(['delta_p'], ['hose_diameter', 'hose_length', 'mu_coolant'], rows=np.arange(nn), cols=np.zeros(nn)) - self.declare_partials(['component_weight'], ['hose_design_pressure','hose_length','hose_diameter'], rows=[0], cols=[0]) - self.declare_partials(['component_weight'], ['rho_coolant'], rows=[0], cols=[0]) - - - def _compute_pressure_drop(self, inputs): - xs_area = np.pi * (inputs['hose_diameter'] / 2) ** 2 - U = inputs['mdot_coolant'] / inputs['rho_coolant'] / xs_area - Redh = inputs['rho_coolant'] * U * inputs['hose_diameter'] / inputs['mu_coolant'] - # darcy friction from the Blasius correlation - f = 0.3164 * Redh ** (-1/4) - dp = f * inputs['rho_coolant'] * U ** 2 * inputs['hose_length'] / 2 / inputs['hose_diameter'] - return dp - - def compute(self, inputs, outputs): - nn = self.options['num_nodes'] - sigma = self.options['hose_operating_stress'] - rho_hose = self.options['hose_density'] - - outputs['delta_p'] = self._compute_pressure_drop(inputs) - - thickness = inputs['hose_diameter'] * inputs['hose_design_pressure'] / 2 / sigma - - w_hose = (inputs['hose_diameter'] + thickness) * np.pi * thickness * rho_hose * inputs['hose_length'] - w_coolant = (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['rho_coolant'][0] * inputs['hose_length'] - outputs['component_weight'] = w_hose + w_coolant - - - def compute_partials(self, inputs, J): - nn = self.options['num_nodes'] - sigma = self.options['hose_operating_stress'] - rho_hose = self.options['hose_density'] - thickness = inputs['hose_diameter'] * inputs['hose_design_pressure'] / 2 / sigma - - d_thick_d_diam = inputs['hose_design_pressure'] / 2 / sigma - d_thick_d_press = inputs['hose_diameter'] / 2 / sigma - - J['component_weight','rho_coolant'] = (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['hose_length'] - J['component_weight', 'hose_design_pressure'] = (inputs['hose_diameter'] + thickness) * np.pi * d_thick_d_press * \ - rho_hose * inputs['hose_length'] + np.pi * thickness * rho_hose * \ - inputs['hose_length'] * d_thick_d_press - J['component_weight', 'hose_length'] = (inputs['hose_diameter'] + thickness) * np.pi * thickness * rho_hose + \ - (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['rho_coolant'][0] - J['component_weight', 'hose_diameter'] = (inputs['hose_diameter'] + thickness) * np.pi * d_thick_d_diam * rho_hose * \ - inputs['hose_length'] + (1 + d_thick_d_diam) * np.pi * thickness * rho_hose * \ - inputs['hose_length'] + inputs['hose_diameter'] / 2 * np.pi * \ - inputs['rho_coolant'][0] * inputs['hose_length'] - - # use a colored complex step approach - cs_step = 1e-30 - dp_base = self._compute_pressure_drop(inputs) - - cs_inp_list = ['rho_coolant', 'mdot_coolant', 'hose_diameter', 'hose_length', 'mu_coolant'] - fake_inputs = dict() - # make a perturbable, complex copy of the inputs - for inp in cs_inp_list: - fake_inputs[inp] = inputs[inp].astype(np.complex_, copy=True) - - for inp in cs_inp_list: - arr_to_restore = fake_inputs[inp].copy() - fake_inputs[inp] += (0.0+cs_step*1.0j) - dp_perturbed = self._compute_pressure_drop(fake_inputs) - fake_inputs[inp] = arr_to_restore - J['delta_p', inp] = np.imag(dp_perturbed) / cs_step - - -if __name__ == "__main__": - ivg = om.IndepVarComp() - ivg.add_output('mdot_coolant', 6.0, units='kg/s') - ivg.add_output('hose_diameter', 0.033, units='m') - ivg.add_output('rho_coolant', 1020., units='kg/m**3') - ivg.add_output('hose_length', 20., units='m') - ivg.add_output('power_rating', 4035., units='W') - - grp = om.Group() - grp.add_subsystem('ivg', ivg, promotes=['*']) - grp.add_subsystem('hose', SimpleHose(num_nodes=1), promotes_inputs=['*'], promotes_outputs=['delta_p']) - grp.add_subsystem('pump', SimplePump(num_nodes=1), promotes_inputs=['*']) - grp.add_subsystem('motorcool', MotorCoolingJacket(num_nodes=5)) - p = om.Problem(model=grp) - p.setup(force_alloc_complex=True) - - p['motorcool.q_in'] = 50000 - p['motorcool.power_rating'] = 1e6 - p['motorcool.motor_weight'] = 1e6/5000 - p['motorcool.mdot_coolant'] = 0.1 - - p.run_model() - p.model.list_inputs(units=True, print_arrays=True) - - p.model.list_outputs(units=True, print_arrays=True) - p.check_partials(compact_print=True, method='cs') - print(p.get_val('delta_p', units='psi')) \ No newline at end of file diff --git a/openconcept/components/tests/__init__.py b/openconcept/components/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openconcept/components/tests/test_heat_sinks.py b/openconcept/components/tests/test_heat_sinks.py deleted file mode 100644 index 4066d187..00000000 --- a/openconcept/components/tests/test_heat_sinks.py +++ /dev/null @@ -1,413 +0,0 @@ -from __future__ import division -import unittest -import numpy as np -from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials -import openmdao.api as om -from openconcept.components.heat_sinks import LiquidCooledBattery, LiquidCooledMotor, SimpleHose, SimplePump - -class QuasiSteadyBatteryCoolingTestCase(unittest.TestCase): - """ - Test the liquid cooled battery in quasi-steady (massless) mode - """ - def generate_model(self, nn): - prob = om.Problem() - iv = prob.model.add_subsystem('iv', om.IndepVarComp(), promotes_outputs=['*']) - iv.add_output('q_in', val=np.linspace(2000,5000,nn), units='W') - iv.add_output('mdot_coolant', val=1*np.ones((nn,)), units='kg/s') - iv.add_output('T_in', val=25*np.ones((nn,)), units='degC') - iv.add_output('battery_weight', val=100., units='kg') - prob.model.add_subsystem('test', LiquidCooledBattery(num_nodes=nn, quasi_steady=True), promotes=['*']) - prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=True) - prob.model.linear_solver = om.DirectSolver() - prob.setup(check=True, force_alloc_complex=True) - return prob - - def test_scalar(self): - prob = self.generate_model(nn=1) - prob.run_model() - assert_near_equal(prob.get_val('dTdt'), 0.0, tolerance=1e-14) - assert_near_equal(prob.get_val('T_surface', units='K'), 298.94004878, tolerance=1e-10) - assert_near_equal(prob.get_val('T_core', units='K'), 307.10184074, tolerance=1e-10) - assert_near_equal(prob.get_val('test.hex.q', units='W'), 2000.0, tolerance=1e-10) - assert_near_equal(prob.get_val('T_out', units='K'), 298.6761773, tolerance=1e-10) - assert_near_equal(prob.get_val('T', units='K'), 303.02094476, tolerance=1e-10) - - partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) - assert_check_partials(partials) - - def test_vector(self): - prob = self.generate_model(nn=11) - prob.run_model() - assert_near_equal(prob.get_val('dTdt'), np.zeros((11,)), tolerance=1e-14) - assert_near_equal(prob.get_val('T_surface', units='K'), - np.array([333.94004878, 334.0585561 , 334.17706342, 334.29557074, - 334.41407805, 334.53258537, 334.65109269, 334.76960001, - 334.88810732, 335.00661464, 335.12512196])-35., tolerance=1e-10) - assert_near_equal(prob.get_val('T_core', units='K'), - np.array([342.10184074, 343.44461685, 344.78739296, 346.13016907, - 347.47294518, 348.81572129, 350.1584974 , 351.50127351, - 352.84404962, 354.18682573, 355.52960184])-35., tolerance=1e-10) - assert_near_equal(prob.get_val('test.hex.q', units='W'), np.linspace(2000,5000,11), tolerance=1e-10) - assert_near_equal(prob.get_val('T_out', units='K'), - np.array([333.67617732, 333.75510392, 333.83403052, 333.91295712, - 333.99188371, 334.07081031, 334.14973691, 334.22866351, - 334.30759011, 334.38651671, 334.4654433 ])-35., tolerance=1e-10) - assert_near_equal(prob.get_val('T', units='K'), - np.array([338.02094476, 338.75158647, 339.48222819, 340.2128699 , - 340.94351162, 341.67415333, 342.40479505, 343.13543676, - 343.86607847, 344.59672019, 345.3273619])-35., tolerance=1e-10) - - # prob.model.list_outputs(print_arrays=True, units='True') - partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) - assert_check_partials(partials) - -class UnsteadyBatteryCoolingTestCase(unittest.TestCase): - """ - Test the liquid cooled battery in unsteady mode - """ - def generate_model(self, nn): - """ - An example demonstrating unsteady battery cooling - """ - import openconcept.api as oc - import openmdao.api as om - import numpy as np - - class VehicleModel(om.Group): - def initialize(self): - self.options.declare('num_nodes', default=11) - - def setup(self): - num_nodes = self.options['num_nodes'] - ivc = self.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) - ivc.add_output('battery_heat', val=np.ones((num_nodes,))*5000, units='W') - ivc.add_output('coolant_temp', 25.*np.ones((num_nodes,)), units='degC') - ivc.add_output('mdot_coolant', 1.0*np.ones((num_nodes,)), units='kg/s') - ivc.add_output('battery_weight', 100, units='kg') - ivc.add_output('n_cpb', 21) - ivc.add_output('t_channel', 0.0005, units='m') - - - self.add_subsystem('bcs', LiquidCooledBattery(num_nodes=num_nodes, quasi_steady=False)) - self.connect('battery_heat', 'bcs.q_in') - self.connect('coolant_temp', 'bcs.T_in') - self.connect('mdot_coolant', 'bcs.mdot_coolant') - self.connect('battery_weight', 'bcs.battery_weight') - self.connect('n_cpb', 'bcs.n_cpb') - self.connect('t_channel', 'bcs.t_channel') - - class TrajectoryPhase(oc.PhaseGroup): - "An OpenConcept Phase comprises part of a time-based TrajectoryGroup and always needs to have a 'duration' defined" - def setup(self): - self.add_subsystem('ivc', om.IndepVarComp('duration', val=30, units='min'), promotes_outputs=['duration']) - self.add_subsystem('vm', VehicleModel(num_nodes=self.options['num_nodes'])) - - class Trajectory(oc.TrajectoryGroup): - "An OpenConcept TrajectoryGroup consists of one or more phases that may be linked together. This will often be a top-level model" - def setup(self): - self.add_subsystem('phase1', TrajectoryPhase(num_nodes=nn)) - # self.add_subsystem('phase2', TrajectoryPhase(num_nodes=nn)) - # the link_phases directive ensures continuity of state variables across phase boundaries - # self.link_phases(self.phase1, self.phase2) - - prob = om.Problem(Trajectory()) - prob.model.nonlinear_solver = om.NewtonSolver(iprint=2) - prob.model.linear_solver = om.DirectSolver() - prob.model.nonlinear_solver.options['solve_subsystems'] = True - prob.model.nonlinear_solver.options['maxiter'] = 20 - prob.model.nonlinear_solver.options['atol'] = 1e-6 - prob.model.nonlinear_solver.options['rtol'] = 1e-6 - prob.setup(force_alloc_complex=True) - # set the initial value of the state at the beginning of the TrajectoryGroup - prob['phase1.vm.bcs.T_initial'] = 300. - prob.run_model() - # prob.model.list_outputs(print_arrays=True, units=True) - # prob.model.list_inputs(print_arrays=True, units=True) - - return prob - - def test_vector(self): - prob = self.generate_model(nn=11) - prob.run_model() - assert_near_equal(prob.get_val('phase1.vm.bcs.T_surface', units='K'), - np.array([298.45006299, 299.70461767, 299.97097736, 300.08642573, - 300.11093705, 300.121561 , 300.12381662, 300.12479427, - 300.12500184, 300.1250918 , 300.12511091]), tolerance=1e-10) - assert_near_equal(prob.get_val('phase1.vm.bcs.T_core', units='K'), - np.array([301.54993701, 315.76497532, 318.78302876, 320.09114476, - 320.36887627, 320.48925354, 320.51481133, 320.52588886, - 320.52824077, 320.52926016, 320.52947659]), tolerance=1e-10) - assert_near_equal(prob.get_val('phase1.vm.bcs.T_out', units='K'), - np.array([298.34984379, 299.18538488, 299.36278206, 299.43967138, - 299.45599607, 299.46307168, 299.46457394, 299.46522506, - 299.4653633 , 299.46542322, 299.46543594]), tolerance=1e-10) - assert_near_equal(prob.get_val('phase1.vm.bcs.T', units='K'), - np.array([300. , 307.73479649, 309.37700306, 310.08878525, - 310.23990666, 310.30540727, 310.31931397, 310.32534156, - 310.3266213 , 310.32717598, 310.32729375]), tolerance=1e-10) - - # prob.model.list_outputs(print_arrays=True, units='True') - partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) - assert_check_partials(partials) - -class QuasiSteadyMotorCoolingTestCase(unittest.TestCase): - """ - Test the liquid cooled motor in quasi-steady (massless) mode - """ - def generate_model(self, nn): - prob = om.Problem() - ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) - ivc.add_output('q_in', val=np.ones((nn,))*10000, units='W') - ivc.add_output('T_in', 25.*np.ones((nn,)), units='degC') - ivc.add_output('mdot_coolant', 3.0*np.ones((nn,)), units='kg/s') - ivc.add_output('motor_weight', 40, units='kg') - ivc.add_output('power_rating', 200, units='kW') - prob.model.add_subsystem('lcm', LiquidCooledMotor(num_nodes=nn, quasi_steady=True), promotes_inputs=['*']) - prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=True) - prob.model.linear_solver = om.DirectSolver() - prob.setup(check=True, force_alloc_complex=True) - return prob - - def test_scalar(self): - prob = self.generate_model(nn=1) - prob.run_model() - power_rating = 200000 - mdot_coolant = 3.0 - q_generated = power_rating * 0.05 - cp_coolant = 3801 - UA = 1100/650000*power_rating - Cmin = cp_coolant * mdot_coolant # cp * mass flow rate - NTU = UA/Cmin - T_in = 298.15 - effectiveness = 1 - np.exp(-NTU) - delta_T = q_generated / effectiveness / Cmin - assert_near_equal(prob.get_val('lcm.dTdt'), 0.0, tolerance=1e-14) - assert_near_equal(prob.get_val('lcm.T', units='K'), T_in + delta_T, tolerance=1e-10) - assert_near_equal(prob.get_val('lcm.T_out', units='K'), T_in + q_generated / Cmin, tolerance=1e-10) - partials = prob.check_partials(method='cs',compact_print=True) - # prob.model.list_outputs(print_arrays=True, units=True) - assert_check_partials(partials) - - def test_vector(self): - prob = self.generate_model(nn=11) - prob.run_model() - power_rating = 200000 - mdot_coolant = 3.0 - q_generated = power_rating * 0.05 - cp_coolant = 3801 - UA = 1100/650000*power_rating - Cmin = cp_coolant * mdot_coolant # cp * mass flow rate - NTU = UA/Cmin - T_in = 298.15 - effectiveness = 1 - np.exp(-NTU) - delta_T = q_generated / effectiveness / Cmin - assert_near_equal(prob.get_val('lcm.dTdt'), np.zeros((11,)), tolerance=1e-14) - assert_near_equal(prob.get_val('lcm.T', units='K'), np.ones((11,))*(T_in + delta_T), tolerance=1e-10) - assert_near_equal(prob.get_val('lcm.T_out', units='K'), np.ones((11,))*(T_in + q_generated / Cmin), tolerance=1e-10) - # prob.model.list_outputs(print_arrays=True, units='True') - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - -class UnsteadyMotorCoolingTestCase(unittest.TestCase): - """ - Test the liquid cooled motor in unsteady mode - """ - def generate_model(self, nn): - """ - An example demonstrating unsteady motor cooling - """ - import openconcept.api as oc - import openmdao.api as om - import numpy as np - - class VehicleModel(om.Group): - def initialize(self): - self.options.declare('num_nodes', default=11) - - def setup(self): - num_nodes = self.options['num_nodes'] - ivc = self.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) - ivc.add_output('q_in', val=np.ones((num_nodes,))*10000, units='W') - ivc.add_output('T_in', 25.*np.ones((num_nodes,)), units='degC') - ivc.add_output('mdot_coolant', 3.0*np.ones((num_nodes,)), units='kg/s') - ivc.add_output('motor_weight', 40, units='kg') - ivc.add_output('power_rating', 200, units='kW') - self.add_subsystem('lcm', LiquidCooledMotor(num_nodes=num_nodes, quasi_steady=False), promotes_inputs=['*']) - - class TrajectoryPhase(oc.PhaseGroup): - "An OpenConcept Phase comprises part of a time-based TrajectoryGroup and always needs to have a 'duration' defined" - def setup(self): - self.add_subsystem('ivc', om.IndepVarComp('duration', val=20, units='min'), promotes_outputs=['duration']) - self.add_subsystem('vm', VehicleModel(num_nodes=self.options['num_nodes'])) - - class Trajectory(oc.TrajectoryGroup): - "An OpenConcept TrajectoryGroup consists of one or more phases that may be linked together. This will often be a top-level model" - def setup(self): - self.add_subsystem('phase1', TrajectoryPhase(num_nodes=nn)) - # self.add_subsystem('phase2', TrajectoryPhase(num_nodes=nn)) - # the link_phases directive ensures continuity of state variables across phase boundaries - # self.link_phases(self.phase1, self.phase2) - - prob = om.Problem(Trajectory()) - prob.model.nonlinear_solver = om.NewtonSolver(iprint=2) - prob.model.linear_solver = om.DirectSolver() - prob.model.nonlinear_solver.options['solve_subsystems'] = True - prob.model.nonlinear_solver.options['maxiter'] = 20 - prob.model.nonlinear_solver.options['atol'] = 1e-6 - prob.model.nonlinear_solver.options['rtol'] = 1e-6 - prob.setup(force_alloc_complex=True) - # set the initial value of the state at the beginning of the TrajectoryGroup - prob['phase1.vm.T_initial'] = 300. - prob.run_model() - # prob.model.list_outputs(print_arrays=True, units=True) - # prob.model.list_inputs(print_arrays=True, units=True) - - return prob - - def test_vector(self): - prob = self.generate_model(nn=11) - prob.run_model() - power_rating = 200000 - mdot_coolant = 3.0 - q_generated = power_rating * 0.05 - cp_coolant = 3801 - UA = 1100/650000*power_rating - Cmin = cp_coolant * mdot_coolant # cp * mass flow rate - NTU = UA/Cmin - T_in = 298.15 - effectiveness = 1 - np.exp(-NTU) - delta_T = q_generated / effectiveness / Cmin - - assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K'), - np.array([300. , 319.02071102, 324.65196197, 327.0073297 , - 327.7046573 , 327.99632659, 328.08267788, 328.11879579, - 328.12948882, 328.13396137, 328.1352855]), tolerance=1e-10) - assert_near_equal(prob.get_val('phase1.vm.lcm.T_out', units='K'), - np.array([298.2041044 , 298.76037687, 298.92506629, 298.99395048, - 299.01434425, 299.0228743 , 299.0253997 , 299.02645599, - 299.02676872, 299.02689952, 299.02693824]), tolerance=1e-10) - assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K')[0], - np.array([300.]), tolerance=1e-10) - # at the end of the period the unsteady value should be approx the quasi-steady value - assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K')[-1], - np.array([T_in + delta_T]), tolerance=1e-5) - assert_near_equal(prob.get_val('phase1.vm.lcm.T_out', units='K')[-1], - np.array([T_in + q_generated / Cmin]), tolerance=1e-5) - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - -class TestHose(unittest.TestCase): - """ - Test the coolant hose component - """ - - def generate_model(self, nn): - prob = om.Problem() - - hose_diam = 0.02 - hose_length = 16. - hose_design_pressure = 1e6 - mdot_coolant = np.linspace(0.6, 1.2, nn) - rho_coolant = 1020*np.ones((nn,)) - mu_coolant = 1.68e-3 - sigma = 2.07e6 - rho_hose = 1356.3 - - ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) - ivc.add_output('hose_diameter', val=hose_diam, units='m') - ivc.add_output('hose_length', val=hose_length, units='m') - ivc.add_output('hose_design_pressure', val=hose_design_pressure, units='Pa') - ivc.add_output('mdot_coolant', val=mdot_coolant, units='kg/s') - ivc.add_output('rho_coolant', val=rho_coolant, units='kg/m**3') - ivc.add_output('mu_coolant', val=mu_coolant, units='kg/m/s') - prob.model.add_subsystem('hose', SimpleHose(num_nodes=nn), promotes_inputs=['*']) - prob.setup(check=True, force_alloc_complex=True) - - xs_area = np.pi * (hose_diam / 2 )**2 - U = mdot_coolant / rho_coolant / xs_area - Redh = rho_coolant * U * hose_diam / mu_coolant - f = 0.3164 * Redh ** (-1/4) - dp = f * rho_coolant / 2 * hose_length * U ** 2 / hose_diam - - wall_thickness = hose_design_pressure * (hose_diam / 2) / sigma - hose_weight = wall_thickness * np.pi * (hose_diam + wall_thickness) * rho_hose * hose_length - fluid_weight = xs_area * rho_coolant[0] * hose_length - return prob, dp, (hose_weight + fluid_weight) - - def test_scalar(self): - prob, dp, weight = self.generate_model(nn=1) - prob.run_model() - assert_near_equal(prob.get_val('hose.delta_p', units='Pa'), - dp, tolerance=1e-10) - assert_near_equal(prob.get_val('hose.component_weight', units='kg'), - weight, tolerance=1e-10) - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - - def test_vector(self): - prob, dp, weight = self.generate_model(nn=11) - prob.run_model() - assert_near_equal(prob.get_val('hose.delta_p', units='Pa'), - dp, tolerance=1e-10) - assert_near_equal(prob.get_val('hose.component_weight', units='kg'), - weight, tolerance=1e-10) - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - -class TestPump(unittest.TestCase): - """ - Test the coolant pump component - """ - - def generate_model(self, nn): - prob = om.Problem() - - efficiency = 0.35 - spec_power = 1 / 450 - rho_coolant = 1020*np.ones(nn) - mdot_coolant = np.linspace(0.6, 1.2, nn) - delta_p = np.linspace(2e4, 4e4, nn) - power_rating = 1000 - ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) - ivc.add_output('power_rating', val=power_rating, units='W') - ivc.add_output('delta_p', val=delta_p, units='Pa') - ivc.add_output('mdot_coolant', val=mdot_coolant, units='kg/s') - ivc.add_output('rho_coolant', val=rho_coolant, units='kg/m**3') - prob.model.add_subsystem('pump', SimplePump(num_nodes=nn), promotes_inputs=['*']) - prob.setup(check=True, force_alloc_complex=True) - - fluid_power = (mdot_coolant / rho_coolant) * delta_p - weight = power_rating * spec_power - elec_load = fluid_power / efficiency - margin = elec_load / power_rating - - return prob, elec_load, weight, margin - - def test_scalar(self): - prob, elec_load, weight, margin = self.generate_model(nn=1) - prob.run_model() - assert_near_equal(prob.get_val('pump.elec_load', units='W'), - elec_load, tolerance=1e-10) - assert_near_equal(prob.get_val('pump.component_weight', units='kg'), - weight, tolerance=1e-10) - assert_near_equal(prob.get_val('pump.component_sizing_margin', units=None), - margin, tolerance=1e-10) - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - - def test_scalar(self): - prob, elec_load, weight, margin = self.generate_model(nn=11) - prob.run_model() - assert_near_equal(prob.get_val('pump.elec_load', units='W'), - elec_load, tolerance=1e-10) - assert_near_equal(prob.get_val('pump.component_weight', units='kg'), - weight, tolerance=1e-10) - assert_near_equal(prob.get_val('pump.component_sizing_margin', units=None), - margin, tolerance=1e-10) - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - -if __name__ == "__main__": - unittest.main() - - diff --git a/openconcept/costs/__init__.py b/openconcept/costs/__init__.py new file mode 100644 index 00000000..b60213a4 --- /dev/null +++ b/openconcept/costs/__init__.py @@ -0,0 +1 @@ +from .costs_commuter import TurbopropOperatingCost \ No newline at end of file diff --git a/examples/methods/costs_commuter.py b/openconcept/costs/costs_commuter.py similarity index 99% rename from examples/methods/costs_commuter.py rename to openconcept/costs/costs_commuter.py index 7161bab7..a59747cb 100644 --- a/examples/methods/costs_commuter.py +++ b/openconcept/costs/costs_commuter.py @@ -3,12 +3,11 @@ costs, so mission optimization routines will tend to favor slower, longer-time routes than they probably should (could fix in future). """ -from __future__ import division from openmdao.api import ExplicitComponent import numpy as np -class OperatingCost(ExplicitComponent): +class TurbopropOperatingCost(ExplicitComponent): def initialize(self): self.options.declare('n_components',default=1,desc='Number of propulsion components, e.g. engines, motors, generators. Inputs will be numbered "component_1" thru n ') self.options.declare('n_batteries',default=None,desc='Number of batteries. These should NOT be counted as components as they are not to be subtracted from OEW. Numbered "battery_1" through n') diff --git a/openconcept/energy_storage/__init__.py b/openconcept/energy_storage/__init__.py new file mode 100644 index 00000000..56c5d998 --- /dev/null +++ b/openconcept/energy_storage/__init__.py @@ -0,0 +1 @@ +from .battery import SimpleBattery, SOCBattery diff --git a/openconcept/components/battery.py b/openconcept/energy_storage/battery.py similarity index 94% rename from openconcept/components/battery.py rename to openconcept/energy_storage/battery.py index 2aeb1cde..5bb3ffa9 100644 --- a/openconcept/components/battery.py +++ b/openconcept/energy_storage/battery.py @@ -1,10 +1,6 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent, Group -from openmdao.api import Group -from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dvlabel import DVLabel +from openconcept.utilities import ElementMultiplyDivideComp, Integrator class SOCBattery(Group): """ @@ -67,10 +63,6 @@ def setup(self): cost_inc = self.options['cost_inc'] cost_base = self.options['cost_base'] - # defaults = [['SOC_initial', 'batt_SOC_initial', 1, None]] - # self.add_subsystem('defaults', DVLabel(defaults), - # promotes_inputs=["*"], promotes_outputs=["*"]) - self.add_subsystem('batt_base',SimpleBattery(num_nodes=nn, efficiency=eta_b, specific_energy=e_b, specific_power=p_b, cost_inc=cost_inc, cost_base=cost_base), promotes_outputs=['*'],promotes_inputs=['*']) @@ -84,6 +76,7 @@ def setup(self): integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, method='simpson', diff_units='s', time_setup='duration'), promotes_inputs=['*'], promotes_outputs=['*']) integ.add_integrand('SOC', rate_name='dSOCdt', start_name='SOC_initial', end_name='SOC_final', units=None, val=1.0, start_val=1.0) + class SimpleBattery(ExplicitComponent): """ A simple battery which tracks power limits and generates heat. diff --git a/openconcept/energy_storage/tests/test_battery.py b/openconcept/energy_storage/tests/test_battery.py new file mode 100644 index 00000000..95e3ffbb --- /dev/null +++ b/openconcept/energy_storage/tests/test_battery.py @@ -0,0 +1,75 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openmdao.api import IndepVarComp, Group, Problem, IndepVarComp +from openconcept.energy_storage import SimpleBattery + + +class BatteryTestGroup(Group): + """ + Test the battery component + """ + def initialize(self): + self.options.declare('vec_size',default=1,desc="Number of mission analysis points to run") + self.options.declare('efficiency', default=1., desc='Efficiency (dimensionless)') + self.options.declare('p', default=5000., desc='Battery specific power (W/kg)' ) + self.options.declare('e', default=300., desc='Battery spec energy CAREFUL: (Wh/kg)') + self.options.declare('cost_inc', default=50., desc='$ cost per kg') + self.options.declare('cost_base', default=1., desc= '$ cost base') + self.options.declare('use_defaults', default=True) + + def setup(self): + use_defaults = self.options['use_defaults'] + nn = self.options['vec_size'] + if not use_defaults: + eta_b = self.options['efficiency'] + p = self.options['p'] + e = self.options['e'] + ci = self.options['cost_inc'] + cb = self.options['cost_base'] + self.add_subsystem('battery', SimpleBattery(num_nodes=nn, + efficiency=eta_b, + specific_power=p, + specific_energy=e, + cost_inc=ci, + cost_base=cb)) + else: + self.add_subsystem('battery', SimpleBattery(num_nodes=nn)) + + iv = self.add_subsystem('iv', IndepVarComp()) + iv.add_output('battery_weight', val=100, units='kg') + iv.add_output('elec_load', val=np.ones(nn) * 100, units='kW') + self.connect('iv.battery_weight','battery.battery_weight') + self.connect('iv.elec_load','battery.elec_load') + +class SimpleBatteryTestCase(unittest.TestCase): + + def test_default_settings(self): + prob = Problem(BatteryTestGroup(vec_size=10, use_defaults=True)) + prob.setup(check=True,force_alloc_complex=True) + prob.run_model() + assert_near_equal(prob['battery.heat_out'], np.ones(10)*100*0.0, tolerance=1e-15) + assert_near_equal(prob['battery.component_sizing_margin'], np.ones(10)*0.20, tolerance=1e-15) + assert_near_equal(prob['battery.component_cost'], 5001, tolerance=1e-15) + assert_near_equal(prob.get_val('battery.max_energy', units='W*h'), 300*100, tolerance=1e-15) + + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + + def test_nondefault_settings(self): + prob = Problem(BatteryTestGroup(vec_size=10, + use_defaults=False, + efficiency=0.95, + p=3000, + e=500, + cost_inc=100, + cost_base=0)) + prob.setup(check=True,force_alloc_complex=True) + prob.run_model() + assert_near_equal(prob.get_val('battery.heat_out', units='kW'), np.ones(10)*100*0.05, tolerance=1e-15) + assert_near_equal(prob['battery.component_sizing_margin'], np.ones(10)/3, tolerance=1e-15) + assert_near_equal(prob['battery.component_cost'], 10000, tolerance=1e-15) + assert_near_equal(prob.get_val('battery.max_energy', units='W*h'), 500*100, tolerance=1e-15) + + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) diff --git a/examples/B738.py b/openconcept/examples/B738.py similarity index 93% rename from examples/B738.py rename to openconcept/examples/B738.py index 60923f14..f853e15b 100644 --- a/examples/B738.py +++ b/openconcept/examples/B738.py @@ -1,18 +1,15 @@ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) import openmdao.api as om -import openconcept.api as oc +from openconcept.utilities import AddSubtractComp, DictIndepVarComp, plot_trajectory + # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from examples.aircraft_data.B738 import data as acdata -from openconcept.analysis.performance.mission_profiles import MissionWithReserve -from openconcept.components.cfm56 import CFM56 +from openconcept.aerodynamics import PolarDrag +from openconcept.examples.aircraft_data.B738 import data as acdata +from openconcept.mission import MissionWithReserve, IntegratorGroup +from openconcept.propulsion import CFM56 -class B738AirplaneModel(oc.IntegratorGroup): +class B738AirplaneModel(IntegratorGroup): """ A custom model specific to the Boeing 737-800 airplane. This class will be passed in to the mission analysis code. @@ -69,7 +66,7 @@ def setup(self): promotes_inputs=[('x', 'ac|weights|OEW')], promotes_outputs=['OEW']) - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', + self.add_subsystem('weight', AddSubtractComp(output_name='weight', input_names=['ac|weights|MTOW', 'fuel_used'], units='kg', vec_size=[1, nn], scaling_factors=[1, -1]), @@ -82,7 +79,7 @@ def setup(self): nn = 11 # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), + dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), promotes_outputs=["*"]) dv_comp.add_output_from_dict('ac|aero|CLmax_TO') dv_comp.add_output_from_dict('ac|aero|polar|e') @@ -168,7 +165,7 @@ def show_outputs(prob): x_label = 'Range (nmi)' y_labels = ['Altitude (ft)', 'Veas airspeed (knots)', 'Fuel used (lb)', 'Throttle setting', 'Vertical speed (ft/min)', 'Mach number', 'CL'] phases = ['climb', 'cruise', 'descent','reserve_climb','reserve_cruise','reserve_descent','loiter'] - oc.plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, + plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, x_label=x_label, y_labels=y_labels, marker='-', plot_title='737-800 Mission Profile') diff --git a/examples/B738_VLM_drag.py b/openconcept/examples/B738_VLM_drag.py similarity index 93% rename from examples/B738_VLM_drag.py rename to openconcept/examples/B738_VLM_drag.py index 6edaa539..7fbcb68b 100644 --- a/examples/B738_VLM_drag.py +++ b/openconcept/examples/B738_VLM_drag.py @@ -14,21 +14,18 @@ Eytan Adler (Jan 2022) """ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) import openmdao.api as om -import openconcept.api as oc +from openconcept.utilities import AddSubtractComp, DictIndepVarComp, plot_trajectory + # imports for the airplane model itself -from openconcept.analysis.openaerostruct.drag_polar import OASDragPolar -from examples.aircraft_data.B738 import data as acdata -from openconcept.analysis.performance.mission_profiles import MissionWithReserve -from openconcept.components.cfm56 import CFM56 +from openconcept.mission import MissionWithReserve, IntegratorGroup +from openconcept.aerodynamics import VLMDragPolar +from openconcept.examples.aircraft_data.B738 import data as acdata +from openconcept.propulsion import CFM56 -class B738AirplaneModel(oc.IntegratorGroup): +class B738AirplaneModel(IntegratorGroup): """ A custom model specific to the Boeing 737-800 airplane. This class will be passed in to the mission analysis code. @@ -68,7 +65,7 @@ def setup(self): # use a different drag coefficient for takeoff versus cruise oas_surf_dict = {} # options for OpenAeroStruct oas_surf_dict['t_over_c'] = acdata['ac']['geom']['wing']['toverc']['value'] - self.add_subsystem('drag', OASDragPolar(num_nodes=nn, num_x=3, num_y=7, + self.add_subsystem('drag', VLMDragPolar(num_nodes=nn, num_x=3, num_y=7, num_twist=3, surf_options=oas_surf_dict), promotes_inputs=['fltcond|CL', 'fltcond|M', 'fltcond|h', 'fltcond|q', 'ac|geom|wing|S_ref', 'ac|geom|wing|AR', 'ac|geom|wing|taper', 'ac|geom|wing|c4sweep', @@ -86,7 +83,7 @@ def setup(self): promotes_inputs=[('x', 'ac|weights|OEW')], promotes_outputs=['OEW']) - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', + self.add_subsystem('weight', AddSubtractComp(output_name='weight', input_names=['ac|weights|MTOW', 'fuel_used'], units='kg', vec_size=[1, nn], scaling_factors=[1, -1]), @@ -99,7 +96,7 @@ def setup(self): nn = 11 # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), + dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), promotes_outputs=["*"]) dv_comp.add_output_from_dict('ac|aero|CLmax_TO') dv_comp.add_output_from_dict('ac|aero|polar|e') @@ -200,7 +197,7 @@ def show_outputs(prob, plots=True): x_label = 'Range (nmi)' y_labels = ['Altitude (ft)', 'Veas airspeed (knots)', 'Fuel used (lb)', 'Throttle setting', 'Vertical speed (ft/min)', 'Mach number', 'CL'] phases = ['climb', 'cruise', 'descent','reserve_climb','reserve_cruise','reserve_descent','loiter'] - oc.plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, + plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, x_label=x_label, y_labels=y_labels, marker='-', plot_title='737-800 Mission Profile') diff --git a/examples/B738_aerostructural.py b/openconcept/examples/B738_aerostructural.py similarity index 94% rename from examples/B738_aerostructural.py rename to openconcept/examples/B738_aerostructural.py index 1c1e3de2..5b0d0a0b 100644 --- a/examples/B738_aerostructural.py +++ b/openconcept/examples/B738_aerostructural.py @@ -14,23 +14,19 @@ Eytan Adler (Jan 2022) """ -from __future__ import division -import sys -import os -import warnings import numpy as np -sys.path.insert(0, os.getcwd()) import openmdao.api as om -import openconcept.api as oc +from openconcept.utilities import AddSubtractComp, DictIndepVarComp, plot_trajectory + # imports for the airplane model itself -from openconcept.analysis.openaerostruct.aerostructural import OASAerostructDragPolar, OASAerostructDragPolarExact -from examples.aircraft_data.B738 import data as acdata -from openconcept.analysis.performance.mission_profiles import BasicMission -from openconcept.components.cfm56 import CFM56 -from openconcept.analysis.openaerostruct.aerostructural import Aerostruct -from openconcept.analysis.aerodynamics import Lift -from openconcept.analysis.atmospherics.dynamic_pressure_comp import DynamicPressureComp +from openconcept.mission import IntegratorGroup, BasicMission +from openconcept.aerodynamics import AerostructDragPolar +from openconcept.aerodynamics.openaerostruct import Aerostruct, AerostructDragPolarExact +from openconcept.examples.aircraft_data.B738 import data as acdata +from openconcept.propulsion import CFM56 +from openconcept.aerodynamics import Lift +from openconcept.atmospherics import DynamicPressureComp NUM_X = 5 NUM_Y = 15 @@ -40,7 +36,7 @@ NUM_SPAR = 3 USE_SURROGATE = True -class B738AirplaneModel(oc.IntegratorGroup): +class B738AirplaneModel(IntegratorGroup): """ A custom model specific to the Boeing 737-800 airplane. This class will be passed in to the mission analysis code. @@ -81,7 +77,7 @@ def setup(self): # Grid size and number of spline control points (must be same as B738AnalysisGroup) global NUM_X, NUM_Y, NUM_TWIST, NUM_TOVERC, NUM_SKIN, NUM_SPAR, USE_SURROGATE if USE_SURROGATE: - self.add_subsystem('drag', OASAerostructDragPolar(num_nodes=nn, num_x=NUM_X, num_y=NUM_Y, + self.add_subsystem('drag', AerostructDragPolar(num_nodes=nn, num_x=NUM_X, num_y=NUM_Y, num_twist=NUM_TWIST, num_toverc=NUM_TOVERC, num_skin=NUM_SKIN, num_spar=NUM_SPAR, surf_options=oas_surf_dict), @@ -92,7 +88,7 @@ def setup(self): 'ac|aero|CD_nonwing'], promotes_outputs=['drag', 'ac|weights|W_wing', ('failure', 'ac|struct|failure')]) else: - self.add_subsystem('drag', OASAerostructDragPolarExact(num_nodes=nn, num_x=NUM_X, num_y=NUM_Y, + self.add_subsystem('drag', AerostructDragPolarExact(num_nodes=nn, num_x=NUM_X, num_y=NUM_Y, num_twist=NUM_TWIST, num_toverc=NUM_TOVERC, num_skin=NUM_SKIN, num_spar=NUM_SPAR, surf_options=oas_surf_dict), @@ -115,7 +111,7 @@ def setup(self): # Use Raymer as estimate for 737 original wing weight, subtract it # out, then add in OpenAeroStruct wing weight estimate - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', + self.add_subsystem('weight', AddSubtractComp(output_name='weight', input_names=['ac|weights|MTOW', 'fuel_used', 'ac|weights|orig_W_wing', 'ac|weights|W_wing'], @@ -150,7 +146,7 @@ def setup(self): USE_SURROGATE = self.options['use_surrogate'] # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), + dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), promotes_outputs=["*"]) dv_comp.add_output_from_dict('ac|aero|CLmax_TO') dv_comp.add_output_from_dict('ac|aero|polar|e') @@ -316,7 +312,7 @@ def show_outputs(prob, plots=True): x_label = 'Range (nmi)' y_labels = ['Altitude (ft)', 'Veas airspeed (knots)', 'Fuel used (lb)', 'Throttle setting', 'Vertical speed (ft/min)', 'Mach number', 'CL'] phases = ['climb', 'cruise', 'descent'] - oc.plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, + plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, x_label=x_label, y_labels=y_labels, marker='-', plot_title='737-800 Mission Profile') diff --git a/examples/Caravan.py b/openconcept/examples/Caravan.py similarity index 91% rename from examples/Caravan.py rename to openconcept/examples/Caravan.py index 73b58991..1768ccdc 100644 --- a/examples/Caravan.py +++ b/openconcept/examples/Caravan.py @@ -1,20 +1,16 @@ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) -import openmdao.api as om -import openconcept.api as oc +import openmdao.api as om +from openconcept.utilities import Integrator, AddSubtractComp, DictIndepVarComp # imports for the airplane model itself -from examples.aircraft_data.caravan import data as acdata -from examples.propulsion_layouts.simple_turboprop import TurbopropPropulsionSystem -from examples.methods.weights_turboprop import SingleTurboPropEmptyWeight -from examples.methods.costs_commuter import OperatingCost +from openconcept.examples.aircraft_data.caravan import data as acdata +from openconcept.propulsion import TurbopropPropulsionSystem +from openconcept.weights import SingleTurboPropEmptyWeight +from openconcept.costs import TurbopropOperatingCost -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis +from openconcept.aerodynamics import PolarDrag +from openconcept.mission import FullMissionAnalysis class CaravanAirplaneModel(om.Group): """ @@ -63,10 +59,10 @@ def setup(self): # airplanes which consume fuel will need to integrate # fuel usage across the mission and subtract it from TOW - intfuel = self.add_subsystem('intfuel', oc.Integrator(num_nodes=nn, method='simpson', diff_units='s', + intfuel = self.add_subsystem('intfuel', Integrator(num_nodes=nn, method='simpson', diff_units='s', time_setup='duration'), promotes_inputs=['*'], promotes_outputs=['*']) intfuel.add_integrand('fuel_used', rate_name='fuel_flow', val=1.0, units='kg') - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', + self.add_subsystem('weight', AddSubtractComp(output_name='weight', input_names=['ac|weights|MTOW', 'fuel_used'], units='kg', vec_size=[1, nn], scaling_factors=[1, -1]), @@ -82,7 +78,7 @@ def setup(self): nn = 11 # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), + dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), promotes_outputs=["*"]) dv_comp.add_output_from_dict('ac|aero|CLmax_TO') dv_comp.add_output_from_dict('ac|aero|polar|e') diff --git a/examples/ElectricSinglewithThermal.py b/openconcept/examples/ElectricSinglewithThermal.py similarity index 89% rename from examples/ElectricSinglewithThermal.py rename to openconcept/examples/ElectricSinglewithThermal.py index d77b54bd..401d63c8 100644 --- a/examples/ElectricSinglewithThermal.py +++ b/openconcept/examples/ElectricSinglewithThermal.py @@ -1,26 +1,13 @@ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) -from openmdao.api import Problem, Group, ScipyOptimizeDriver -from openmdao.api import DirectSolver, SqliteRecorder, IndepVarComp -from openmdao.api import NewtonSolver,BoundsEnforceLS +from openmdao.api import Problem, Group, ScipyOptimizeDriver, DirectSolver, IndepVarComp, NewtonSolver,BoundsEnforceLS +from openconcept.utilities import DictIndepVarComp, plot_trajectory, LinearInterpolator # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dvlabel import DVLabel -from examples.methods.weights_turboprop import SingleTurboPropEmptyWeight -from examples.propulsion_layouts.simple_all_electric import AllElectricSinglePropulsionSystemWithThermal_Incompressible, AllElectricSinglePropulsionSystemWithThermal_Compressible -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.TBM850 import data as acdata -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis -from openconcept.utilities.visualization import plot_trajectory -from openconcept.utilities.linearinterp import LinearInterpolator +from openconcept.aerodynamics import PolarDrag +from openconcept.propulsion import AllElectricSinglePropulsionSystemWithThermal_Incompressible +from openconcept.examples.aircraft_data.TBM850 import data as acdata +from openconcept.mission import FullMissionAnalysis diff --git a/examples/HybridTwin.py b/openconcept/examples/HybridTwin.py similarity index 95% rename from examples/HybridTwin.py rename to openconcept/examples/HybridTwin.py index 54c69391..5e143371 100644 --- a/examples/HybridTwin.py +++ b/openconcept/examples/HybridTwin.py @@ -1,26 +1,18 @@ -from __future__ import division -import sys import os import logging import numpy as np -sys.path.insert(0, os.getcwd()) + from openmdao.api import Problem, Group, ScipyOptimizeDriver from openmdao.api import BalanceComp, ExplicitComponent, ExecComp, SqliteRecorder from openmdao.api import DirectSolver, IndepVarComp, NewtonSolver, BoundsEnforceLS # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dvlabel import DVLabel -from examples.methods.weights_twin_hybrid import TwinSeriesHybridEmptyWeight -from examples.propulsion_layouts.simple_series_hybrid import TwinSeriesHybridElectricPropulsionSystem -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.KingAirC90GT import data as acdata -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.visualization import plot_trajectory +from openconcept.aerodynamics import PolarDrag +from openconcept.weights import TwinSeriesHybridEmptyWeight +from openconcept.propulsion import TwinSeriesHybridElectricPropulsionSystem +from openconcept.mission import FullMissionAnalysis +from openconcept.examples.aircraft_data.KingAirC90GT import data as acdata +from openconcept.utilities import AddSubtractComp, Integrator, DictIndepVarComp, LinearInterpolator, plot_trajectory class AugmentedFBObjective(ExplicitComponent): def setup(self): diff --git a/examples/HybridTwin_active_thermal.py b/openconcept/examples/HybridTwin_active_thermal.py similarity index 94% rename from examples/HybridTwin_active_thermal.py rename to openconcept/examples/HybridTwin_active_thermal.py index d2b7d342..ce209c52 100644 --- a/examples/HybridTwin_active_thermal.py +++ b/openconcept/examples/HybridTwin_active_thermal.py @@ -1,31 +1,20 @@ -from __future__ import division -import sys import os import logging import numpy as np -sys.path.insert(0, os.getcwd()) -from openmdao.api import Problem, Group, ScipyOptimizeDriver -from openmdao.api import BalanceComp, ExplicitComponent, ExecComp, SqliteRecorder -from openmdao.api import DirectSolver, IndepVarComp, NewtonSolver, BoundsEnforceLS + +from openmdao.api import Problem, Group, ScipyOptimizeDriver, ExplicitComponent, ExecComp, SqliteRecorder, DirectSolver, IndepVarComp, NewtonSolver, BoundsEnforceLS # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.max_min_comp import MaxComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dvlabel import DVLabel -from examples.methods.weights_twin_hybrid import TwinSeriesHybridEmptyWeight -from examples.propulsion_layouts.thermal_series_hybrid import TwinSeriesHybridElectricPropulsionRefrigerated -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.KingAirC90GT import data as acdata -from openconcept.analysis.performance.mission_profiles import BasicMission -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.visualization import plot_trajectory +from openconcept.aerodynamics import PolarDrag +from openconcept.weights import TwinSeriesHybridEmptyWeight +from openconcept.propulsion import TwinSeriesHybridElectricThermalPropulsionRefrigerated +from openconcept.mission import BasicMission +from openconcept.examples.aircraft_data.KingAirC90GT import data as acdata +from openconcept.utilities import AddSubtractComp, MaxComp, Integrator, DictIndepVarComp, LinearInterpolator, plot_trajectory """ WARNING: This example has known convergence problems because of the chiller in the - propulsion layout (TwinSeriesHybridElectricPropulsionRefrigerated) + propulsion layout (TwinSeriesHybridElectricThermalPropulsionRefrigerated) Eytan Adler, 27/10/2021 """ @@ -74,7 +63,7 @@ def setup(self): "ac|weights*", 'duration'] self.add_subsystem('propmodel', - TwinSeriesHybridElectricPropulsionRefrigerated(num_nodes=nn), + TwinSeriesHybridElectricThermalPropulsionRefrigerated(num_nodes=nn), promotes_inputs=propulsion_promotes_inputs, promotes_outputs=propulsion_promotes_outputs) self.connect('proprpm', ['propmodel.prop1.rpm', 'propmodel.prop2.rpm']) diff --git a/examples/HybridTwin_thermal.py b/openconcept/examples/HybridTwin_thermal.py similarity index 95% rename from examples/HybridTwin_thermal.py rename to openconcept/examples/HybridTwin_thermal.py index f11ebd8d..960473ce 100644 --- a/examples/HybridTwin_thermal.py +++ b/openconcept/examples/HybridTwin_thermal.py @@ -1,27 +1,16 @@ -from __future__ import division -import sys import os import logging import numpy as np -sys.path.insert(0, os.getcwd()) -from openmdao.api import Problem, Group, ScipyOptimizeDriver -from openmdao.api import BalanceComp, ExplicitComponent, ExecComp, SqliteRecorder -from openmdao.api import DirectSolver, IndepVarComp, NewtonSolver, BoundsEnforceLS + +from openmdao.api import Problem, Group, ScipyOptimizeDriver, ExplicitComponent, ExecComp, SqliteRecorder, DirectSolver, IndepVarComp, NewtonSolver # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.max_min_comp import MaxComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.dvlabel import DVLabel -from examples.methods.weights_twin_hybrid import TwinSeriesHybridEmptyWeight -from examples.propulsion_layouts.thermal_series_hybrid import TwinSeriesHybridElectricPropulsionSystem -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.KingAirC90GT import data as acdata -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.visualization import plot_trajectory +from openconcept.aerodynamics import PolarDrag +from openconcept.weights import TwinSeriesHybridEmptyWeight +from openconcept.propulsion import TwinSeriesHybridElectricThermalPropulsionSystem +from openconcept.mission import FullMissionAnalysis +from openconcept.examples.aircraft_data.KingAirC90GT import data as acdata +from openconcept.utilities import AddSubtractComp, MaxComp, Integrator, DictIndepVarComp, LinearInterpolator, plot_trajectory class AugmentedFBObjective(ExplicitComponent): def setup(self): @@ -67,7 +56,7 @@ def setup(self): "ac|weights*", 'duration'] self.add_subsystem('propmodel', - TwinSeriesHybridElectricPropulsionSystem(num_nodes=nn), + TwinSeriesHybridElectricThermalPropulsionSystem(num_nodes=nn), promotes_inputs=propulsion_promotes_inputs, promotes_outputs=propulsion_promotes_outputs) self.connect('proprpm', ['propmodel.prop1.rpm', 'propmodel.prop2.rpm']) diff --git a/examples/KingAirC90GT.py b/openconcept/examples/KingAirC90GT.py similarity index 92% rename from examples/KingAirC90GT.py rename to openconcept/examples/KingAirC90GT.py index 1ed156f8..62ef78b3 100644 --- a/examples/KingAirC90GT.py +++ b/openconcept/examples/KingAirC90GT.py @@ -1,24 +1,16 @@ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) from openmdao.api import Problem, Group, ScipyOptimizeDriver from openmdao.api import DirectSolver, SqliteRecorder, IndepVarComp from openmdao.api import NewtonSolver, BoundsEnforceLS # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from openconcept.utilities.math import AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from examples.methods.weights_turboprop import SingleTurboPropEmptyWeight -from examples.propulsion_layouts.simple_turboprop import TwinTurbopropPropulsionSystem -from examples.methods.costs_commuter import OperatingCost -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp -from examples.aircraft_data.KingAirC90GT import data as acdata -from openconcept.analysis.performance.mission_profiles import FullMissionAnalysis -from openconcept.utilities.visualization import plot_trajectory +from openconcept.aerodynamics import PolarDrag +from openconcept.weights import SingleTurboPropEmptyWeight +from openconcept.propulsion import TwinTurbopropPropulsionSystem +from openconcept.mission import FullMissionAnalysis +from openconcept.examples.aircraft_data.KingAirC90GT import data as acdata +from openconcept.utilities import AddSubtractComp, Integrator, DictIndepVarComp, plot_trajectory class KingAirC90GTModel(Group): """ diff --git a/examples/N3_HybridSingleAisle_Refrig.py b/openconcept/examples/N3_HybridSingleAisle_Refrig.py similarity index 95% rename from examples/N3_HybridSingleAisle_Refrig.py rename to openconcept/examples/N3_HybridSingleAisle_Refrig.py index d4383e4c..4c17acd7 100644 --- a/examples/N3_HybridSingleAisle_Refrig.py +++ b/openconcept/examples/N3_HybridSingleAisle_Refrig.py @@ -1,30 +1,26 @@ -from __future__ import division -import sys -import os import numpy as np -sys.path.insert(0, os.getcwd()) import openmdao.api as om -import openconcept.api as oc +from openconcept.utilities import AddSubtractComp, LinearInterpolator, DictIndepVarComp, plot_trajectory + # imports for the airplane model itself -from openconcept.analysis.aerodynamics import PolarDrag -from examples.aircraft_data.HybridSingleAisle import data as acdata -from examples.aircraft_data.HybridSingleAisle import MotorFaultProtection -from openconcept.analysis.performance.mission_profiles import MissionWithReserve, BasicMission -from openconcept.components.N3 import N3Hybrid -from openconcept.components.motor import SimpleMotor -from openconcept.components.battery import SOCBattery -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.math.add_subtract_comp import AddSubtractComp -from openconcept.components.thermal import LiquidCooledComp -from openconcept.components.chiller import HeatPumpWithIntegratedCoolantLoop -from openconcept.components.splitter import FlowSplit, FlowCombine -from openconcept.components.heat_sinks import LiquidCooledMotor, LiquidCooledBattery, SimpleHose, SimplePump -from openconcept.components.ducts import ImplicitCompressibleDuct_ExternalHX -from openconcept.components.heat_exchanger import HXGroup - - -class HybridSingleAisleModel(oc.IntegratorGroup): +from openconcept.aerodynamics import PolarDrag +from openconcept.examples.aircraft_data.HybridSingleAisle import data as acdata +from openconcept.examples.aircraft_data.HybridSingleAisle import MotorFaultProtection +from openconcept.mission import BasicMission, IntegratorGroup +from openconcept.propulsion import N3Hybrid, SimpleMotor +from openconcept.energy_storage import SOCBattery +from openconcept.thermal import ( + HeatPumpWithIntegratedCoolantLoop, + SimpleHose, + SimplePump, + ImplicitCompressibleDuct_ExternalHX, + HXGroup, + LiquidCooledBattery, + LiquidCooledMotor, +) + +class HybridSingleAisleModel(IntegratorGroup): """ Model for NASA N+3 twin hybrid single aisle study @@ -219,7 +215,7 @@ def setup(self): prom_name='OEW', factors=[2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 1.0], vec_size=1, units='kg') - self.add_subsystem('weight', oc.AddSubtractComp(output_name='weight', + self.add_subsystem('weight', AddSubtractComp(output_name='weight', input_names=['ac|design_mission|TOW', 'fuel_used'], units='kg', vec_size=[1, nn], scaling_factors=[1, -1]), promotes_inputs=['*'], @@ -240,7 +236,7 @@ def setup(self): nn = 21 # Define a bunch of design varaiables and airplane-specific parameters - dv_comp = self.add_subsystem('dv_comp', oc.DictIndepVarComp(acdata), + dv_comp = self.add_subsystem('dv_comp', DictIndepVarComp(acdata), promotes_outputs=["*"]) dv_comp.add_output_from_dict('ac|aero|CLmax_TO') dv_comp.add_output_from_dict('ac|aero|polar|e') @@ -386,7 +382,7 @@ def show_outputs(prob): 'Battery Coolant Inflow Temp', 'Batt duct cooling Net Force (lb)', 'Motor Coolant Inflow Temp', 'Motor duct cooling Net Force (lb)','Motor fault prot inflow temp (C)'] phases = ['groundroll','climb', 'cruise', 'descent'] - oc.plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, + plot_trajectory(prob, x_var, x_unit, y_vars, y_units, phases, x_label=x_label, y_labels=y_labels, marker='-', plot_title='Hybrid Single Aisle Mission') # prob.model.list_outputs() diff --git a/openconcept/examples/TBM850.py b/openconcept/examples/TBM850.py new file mode 100644 index 00000000..eb373f8c --- /dev/null +++ b/openconcept/examples/TBM850.py @@ -0,0 +1,313 @@ +# rst Imports (beg) +import numpy as np +import openmdao.api as om +import matplotlib.pyplot as plt + +# OpenConcept imports for the airplane model +from openconcept.propulsion import TurbopropPropulsionSystem +from openconcept.aerodynamics import PolarDrag +from openconcept.weights import SingleTurboPropEmptyWeight +from openconcept.mission import FullMissionAnalysis +from openconcept.examples.aircraft_data.TBM850 import data as acdata +from openconcept.utilities import AddSubtractComp, Integrator, DictIndepVarComp + +# rst Imports (end) + +# rst Aircraft (beg) +class TBM850AirplaneModel(om.Group): + """ + A custom model specific to the TBM 850 airplane + This class will be passed in to the mission analysis code. + + """ + + # rst Options + def initialize(self): + self.options.declare("num_nodes", default=1) + self.options.declare("flight_phase", default=None) + + # rst Setup + def setup(self): + nn = self.options["num_nodes"] + flight_phase = self.options["flight_phase"] + + # ======================================== Propulsion ======================================== + # rst Propulsion (beg) + # A propulsion system needs to be defined in order to provide thrust information + self.add_subsystem( + "propmodel", + TurbopropPropulsionSystem(num_nodes=nn), + promotes_inputs=[ + "fltcond|rho", + "fltcond|Utrue", + "ac|propulsion|engine|rating", + "ac|propulsion|propeller|diameter", + "throttle", + ], + promotes_outputs=["thrust"], + ) + self.set_input_defaults("propmodel.prop1.rpm", val=np.full(nn, 2000.0), units="rpm") + # rst Propulsion (end) + + # ======================================== Aerodynamics ======================================== + # rst Aero (beg) + # Use a different drag coefficient for takeoff versus cruise + if flight_phase not in ["v0v1", "v1v0", "v1vr", "rotate"]: + cd0_source = "ac|aero|polar|CD0_cruise" + else: + cd0_source = "ac|aero|polar|CD0_TO" + + self.add_subsystem( + "drag", + PolarDrag(num_nodes=nn), + promotes_inputs=[ + "fltcond|CL", + "ac|geom|wing|S_ref", + "ac|geom|wing|AR", + ("CD0", cd0_source), + "fltcond|q", + ("e", "ac|aero|polar|e"), + ], + promotes_outputs=["drag"], + ) + # rst Aero (end) + + # ======================================== Weights ======================================== + # rst Weight (beg) + # Empty weight calculation; requires many aircraft inputs, see SingleTurboPropEmptyWeight source for more details. + # This OEW calculation is not used in the weight calculation, but it is a useful output for aircraft design/optimization. + self.add_subsystem( + "OEW", + SingleTurboPropEmptyWeight(), + promotes_inputs=["*", ("P_TO", "ac|propulsion|engine|rating")], + promotes_outputs=["OEW"], + ) + self.connect("propmodel.prop1.component_weight", "W_propeller") + self.connect("propmodel.eng1.component_weight", "W_engine") + + # Airplanes that consume fuel need to integrate fuel usage across the mission and subtract it from TOW + intfuel = self.add_subsystem( + "intfuel", + Integrator(num_nodes=nn, method="simpson", diff_units="s", time_setup="duration"), + promotes_outputs=["fuel_used_final"], + ) + intfuel.add_integrand("fuel_used", rate_name="fuel_flow", val=1.0, units="kg") + self.connect("propmodel.fuel_flow", "intfuel.fuel_flow") + + # Compute weight as MTOW minus fuel burned (assumes takeoff at MTOW) + self.add_subsystem( + "weight", + AddSubtractComp( + output_name="weight", + input_names=["ac|weights|MTOW", "fuel_used"], + units="kg", + vec_size=[1, nn], + scaling_factors=[1, -1], + ), + promotes_inputs=["ac|weights|MTOW"], + promotes_outputs=["weight"], + ) + self.connect("intfuel.fuel_used", "weight.fuel_used") + # rst Weight (end) + + +# rst Mission (beg) +class TBMAnalysisGroup(om.Group): + """ + This is an example of a balanced field takeoff and three-phase mission analysis. + """ + + def initialize(self): + self.options.declare("num_nodes", default=11) + + def setup(self): + nn = self.options["num_nodes"] + + # Define a bunch of design varaiables and airplane-specific parameters + dv_comp = self.add_subsystem("dv_comp", DictIndepVarComp(acdata), promotes_outputs=["*"]) + + # Aerodynamic parameters + dv_comp.add_output_from_dict("ac|aero|CLmax_TO") + dv_comp.add_output_from_dict("ac|aero|polar|e") + dv_comp.add_output_from_dict("ac|aero|polar|CD0_TO") + dv_comp.add_output_from_dict("ac|aero|polar|CD0_cruise") + + # Geometric parameters + dv_comp.add_output_from_dict("ac|geom|wing|S_ref") + dv_comp.add_output_from_dict("ac|geom|wing|AR") + dv_comp.add_output_from_dict("ac|geom|wing|c4sweep") + dv_comp.add_output_from_dict("ac|geom|wing|taper") + dv_comp.add_output_from_dict("ac|geom|wing|toverc") + dv_comp.add_output_from_dict("ac|geom|hstab|S_ref") + dv_comp.add_output_from_dict("ac|geom|hstab|c4_to_wing_c4") + dv_comp.add_output_from_dict("ac|geom|vstab|S_ref") + dv_comp.add_output_from_dict("ac|geom|fuselage|S_wet") + dv_comp.add_output_from_dict("ac|geom|fuselage|width") + dv_comp.add_output_from_dict("ac|geom|fuselage|length") + dv_comp.add_output_from_dict("ac|geom|fuselage|height") + dv_comp.add_output_from_dict("ac|geom|nosegear|length") + dv_comp.add_output_from_dict("ac|geom|maingear|length") + + # Weight parameters + dv_comp.add_output_from_dict("ac|weights|MTOW") + dv_comp.add_output_from_dict("ac|weights|W_fuel_max") + dv_comp.add_output_from_dict("ac|weights|MLW") + + # Propulsion parameters + dv_comp.add_output_from_dict("ac|propulsion|engine|rating") + dv_comp.add_output_from_dict("ac|propulsion|propeller|diameter") + + # Other parameters + dv_comp.add_output_from_dict("ac|num_passengers_max") + dv_comp.add_output_from_dict("ac|q_cruise") + + # Run a full mission analysis including takeoff, climb, cruise, and descent + self.add_subsystem( + "analysis", + FullMissionAnalysis(num_nodes=nn, aircraft_model=TBM850AirplaneModel), + promotes_inputs=["*"], + promotes_outputs=["*"], + ) + # rst Mission (end) + + +# rst Setup problem (beg) +def run_tbm_analysis(): + # Set up OpenMDAO to analyze the airplane + nn = 11 + prob = om.Problem() + prob.model = TBMAnalysisGroup(num_nodes=nn) + prob.model.nonlinear_solver = om.NewtonSolver(iprint=2, solve_subsystems=True) + prob.model.linear_solver = om.DirectSolver() + prob.setup() + + # Set required mission parameters. Each phase needs a vertical speed and airspeed. + # The entire mission needs a cruise altitude and range. + prob.set_val("climb.fltcond|vs", np.full(nn, 1500.0), units="ft/min") + prob.set_val("climb.fltcond|Ueas", np.full(nn, 124.0), units="kn") + prob.set_val("cruise.fltcond|vs", np.full(nn, 0.01), units="ft/min") + prob.set_val("cruise.fltcond|Ueas", np.full(nn, 201.0), units="kn") + prob.set_val("descent.fltcond|vs", np.full(nn, -600.0), units="ft/min") + prob.set_val("descent.fltcond|Ueas", np.full(nn, 140.0), units="kn") + + prob.set_val("cruise|h0", 28e3, units="ft") + prob.set_val("mission_range", 500, units="nmi") + + # Guesses for takeoff speeds to help with convergence + prob.set_val("v0v1.fltcond|Utrue", np.full(nn, 50), units="kn") + prob.set_val("v1vr.fltcond|Utrue", np.full(nn, 85), units="kn") + prob.set_val("v1v0.fltcond|Utrue", np.full(nn, 85), units="kn") + + # Set some airplane-specific values. The throttle edits are to derate the takeoff power of the PT6A + prob["climb.OEW.structural_fudge"] = 1.67 + prob["v0v1.throttle"] = np.full(nn, 0.826) + prob["v1vr.throttle"] = np.full(nn, 0.826) + prob["rotate.throttle"] = np.full(nn, 0.826) + + return prob + # rst Setup problem (end) + + +# rst Run (beg) +if __name__ == "__main__": + # Process command line argument to optionally not show figures and N2 diagram + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "--hide_visuals", + default=False, + action="store_true", + help="Do not show matplotlib figure or open N2 diagram in browser", + ) + hide_viz = parser.parse_args().hide_visuals + + # Run the analysis + prob = run_tbm_analysis() + prob.run_model() + + # Generate N2 diagram + om.n2(prob, outfile="turboprop_n2.html", show_browser=not hide_viz) + + # =============== Print some useful outputs ================ + print_vars = [ + {"var": "ac|weights|MTOW", "name": "MTOW", "units": "lb"}, + {"var": "climb.OEW", "name": "OEW", "units": "lb"}, + {"var": "rotate.fuel_used_final", "name": "Rotate fuel", "units": "lb"}, + {"var": "climb.fuel_used_final", "name": "Climb fuel", "units": "lb"}, + {"var": "cruise.fuel_used_final", "name": "Cruise fuel", "units": "lb"}, + {"var": "descent.fuel_used_final", "name": "Fuel used", "units": "lb"}, + {"var": "rotate.range_final", "name": "TOFL (over 35ft obstacle)", "units": "ft"}, + {"var": "engineoutclimb.gamma", "name": "Climb angle at V2", "units": "deg"}, + ] + print("\n=======================================================================\n") + for var in print_vars: + print(f"{var['name']}: {prob.get_val(var['var'], units=var['units']).item()} {var['units']}") + + # =============== Takeoff plot ================ + takeoff_fig, takeoff_axs = plt.subplots(1, 3, figsize=[9, 2.7], constrained_layout=True) + takeoff_axs = takeoff_axs.flatten() # change 1x3 mtx of axes into 4-element vector + + # Define variables to plot + takeoff_vars = [ + {"var": "fltcond|h", "name": "Altitude", "units": "ft"}, + {"var": "fltcond|Utrue", "name": "True airspeed", "units": "kn"}, + {"var": "throttle", "name": "Throttle", "units": None}, + ] + + for idx_fig, var in enumerate(takeoff_vars): + takeoff_axs[idx_fig].set_xlabel("Range (ft)") + takeoff_axs[idx_fig].set_ylabel(f"{var['name']}" if var["units"] is None else f"{var['name']} ({var['units']})") + + # Loop through each flight phase and plot the current variable from each + colors = ["tab:blue", "tab:orange", "tab:green", "tab:red"] + for i, phase in enumerate(["v0v1", "v1vr", "rotate", "v1v0"]): + takeoff_axs[idx_fig].plot( + prob.get_val(f"{phase}.range", units="ft"), + prob.get_val(f"{phase}.{var['var']}", units=var["units"]), + "-o", + c=colors[i], + markersize=2.0, + ) + + takeoff_fig.legend( + [r"V0 $\rightarrow$ V1", r"V1 $\rightarrow$ Vr", "Rotate", r"V1 $\rightarrow$ V0"], + loc=(0.067, 0.6), + fontsize="small", + ) + takeoff_fig.suptitle("Takeoff phases") + takeoff_fig.savefig("turboprop_takeoff_results.svg", transparent=True) + + # =============== Mission plot ================ + mission_fig, mission_axs = plt.subplots(2, 3, figsize=[9, 4.8], constrained_layout=True) + mission_axs = mission_axs.flatten() # change 2x2 mtx of axes into 4-element vector + + # Define variables to plot + mission_vars = [ + {"var": "fltcond|h", "name": "Altitude", "units": "ft"}, + {"var": "fltcond|vs", "name": "Vertical speed", "units": "ft/min"}, + {"var": "fltcond|Utrue", "name": "True airspeed", "units": "kn"}, + {"var": "throttle", "name": "Throttle", "units": None}, + {"var": "propmodel.fuel_flow", "name": "Fuel flow", "units": "g/s"}, + {"var": "weight", "name": "Weight", "units": "kg"}, + ] + + for idx_fig, var in enumerate(mission_vars): + mission_axs[idx_fig].set_xlabel("Range (nmi)") + mission_axs[idx_fig].set_ylabel(f"{var['name']}" if var["units"] is None else f"{var['name']} ({var['units']})") + + # Loop through each flight phase and plot the current variable from each + for phase in ["climb", "cruise", "descent"]: + mission_axs[idx_fig].plot( + prob.get_val(f"{phase}.range", units="nmi"), + prob.get_val(f"{phase}.{var['var']}", units=var["units"]), + "-o", + c="tab:blue", + markersize=2.0, + ) + + mission_fig.suptitle("Mission") + mission_fig.savefig("turboprop_mission_results.svg", transparent=True) + if not hide_viz: + plt.show() + # rst Run (end) diff --git a/examples/__init__.py b/openconcept/examples/__init__.py similarity index 100% rename from examples/__init__.py rename to openconcept/examples/__init__.py diff --git a/examples/aircraft_data/B738.py b/openconcept/examples/aircraft_data/B738.py similarity index 98% rename from examples/aircraft_data/B738.py rename to openconcept/examples/aircraft_data/B738.py index f3ace054..341b999b 100644 --- a/examples/aircraft_data/B738.py +++ b/openconcept/examples/aircraft_data/B738.py @@ -1,7 +1,6 @@ # DATA FOR TBM T80 # Collected from various sources # including SOCATA pilot manual -from __future__ import division data = dict() ac = dict() diff --git a/examples/aircraft_data/HybridSingleAisle.py b/openconcept/examples/aircraft_data/HybridSingleAisle.py similarity index 93% rename from examples/aircraft_data/HybridSingleAisle.py rename to openconcept/examples/aircraft_data/HybridSingleAisle.py index 179b577c..80821332 100644 --- a/examples/aircraft_data/HybridSingleAisle.py +++ b/openconcept/examples/aircraft_data/HybridSingleAisle.py @@ -1,12 +1,11 @@ # DATA FOR TBM T80 # Collected from various sources # including SOCATA pilot manual -from __future__ import division -import openconcept.api as oc + import openmdao.api as om -from openconcept.components.thermal import PerfectHeatTransferComp -from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp -from openconcept.utilities.math.add_subtract_comp import AddSubtractComp +from openconcept.mission import IntegratorGroup +from openconcept.thermal import PerfectHeatTransferComp +from openconcept.utilities import ElementMultiplyDivideComp, AddSubtractComp data = dict() ac = dict() @@ -116,7 +115,7 @@ ac['design_mission'] = design_mission data['ac'] = ac -class MotorFaultProtection(oc.IntegratorGroup): +class MotorFaultProtection(IntegratorGroup): """ The fault protection at the motor consumes power and produces heat It consumes glycol/water at 3gpm and needs 40C inflow temp diff --git a/examples/aircraft_data/KingAirC90GT.py b/openconcept/examples/aircraft_data/KingAirC90GT.py similarity index 98% rename from examples/aircraft_data/KingAirC90GT.py rename to openconcept/examples/aircraft_data/KingAirC90GT.py index 38447c45..d6260dab 100644 --- a/examples/aircraft_data/KingAirC90GT.py +++ b/openconcept/examples/aircraft_data/KingAirC90GT.py @@ -1,7 +1,6 @@ # DATA FOR King Air C90GT # Collected from AOPA Pilot article # and rough photogrammetry -from __future__ import division data = dict() ac = dict() diff --git a/examples/aircraft_data/TBM850.py b/openconcept/examples/aircraft_data/TBM850.py similarity index 98% rename from examples/aircraft_data/TBM850.py rename to openconcept/examples/aircraft_data/TBM850.py index e893668a..736d81ac 100644 --- a/examples/aircraft_data/TBM850.py +++ b/openconcept/examples/aircraft_data/TBM850.py @@ -1,7 +1,6 @@ # DATA FOR TBM T80 # Collected from various sources # including SOCATA pilot manual -from __future__ import division data = dict() ac = dict() diff --git a/examples/aircraft_data/__init__.py b/openconcept/examples/aircraft_data/__init__.py similarity index 100% rename from examples/aircraft_data/__init__.py rename to openconcept/examples/aircraft_data/__init__.py diff --git a/examples/aircraft_data/caravan.py b/openconcept/examples/aircraft_data/caravan.py similarity index 98% rename from examples/aircraft_data/caravan.py rename to openconcept/examples/aircraft_data/caravan.py index eb1f366c..8168e751 100644 --- a/examples/aircraft_data/caravan.py +++ b/openconcept/examples/aircraft_data/caravan.py @@ -1,7 +1,6 @@ # https://www.globalair.com/aircraft-for-sale/Specifications?specid=1273 # http://www.airliners.net/aircraft-data/cessna-208-caravan-i-grand-caravan-cargomaster/158 # http://b.org.za/fly/C208Bproc.pdf -from __future__ import division data = dict() ac = dict() diff --git a/openconcept/examples/minimal.py b/openconcept/examples/minimal.py new file mode 100644 index 00000000..9fed8d7f --- /dev/null +++ b/openconcept/examples/minimal.py @@ -0,0 +1,169 @@ +""" +This is a minimal example meant to show the absolute +simplest aircraft model and mission analysis you can +set up. This does not use OpenConcept's models for +the aircraft propulsion and aerodynamics, but it +does use the mission analysis methods. +""" +# rst Imports (beg) +import openmdao.api as om +from openconcept.mission import BasicMission +import numpy as np +import matplotlib.pyplot as plt + +# rst Imports (end) + +# rst Aircraft (beg) +class Aircraft(om.ExplicitComponent): + """ + An overly simplified aircraft model. This one is defined as an explicit component to use simple equations + to compute weight, drag, and thrust. In practice, it would be an OpenMDAO group that integrates models for + propulsion, aerodynamics, weights, etc. + """ + + # rst Options + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + self.options.declare("flight_phase", default=None) # required by OpenConcept but unused in this example + + # rst Setup + def setup(self): + nn = self.options["num_nodes"] + + # ======== Inputs passed from the mission analysis ======== + # These are required by OpenConcept + self.add_input("fltcond|CL", shape=nn) # lift coefficient + self.add_input("throttle", shape=nn) # throttle from 0 to 1 + + # These are additional inputs used by the model + self.add_input("fltcond|q", shape=nn, units="Pa") # dynamic pressure + self.add_input("ac|geom|wing|S_ref", shape=1, units="m**2") # wing planform area + self.add_input("ac|weights|TOW", shape=1, units="kg") # constant weight value + self.add_input("ac|propulsion|max_thrust", shape=1, units="N") + self.add_input("ac|aero|L_over_D", shape=1) + + # ======== Outputs sent back to the mission analysis ======== + self.add_output("weight", shape=nn, units="kg") + self.add_output("drag", shape=nn, units="N") + self.add_output("thrust", shape=nn, units="N") + + # ======== Use complex step for this simple example ======== + self.declare_partials(["*"], ["*"], method="cs") + + # rst Compute + def compute(self, inputs, outputs): + outputs["weight"] = inputs["ac|weights|TOW"] + outputs["thrust"] = inputs["throttle"] * inputs["ac|propulsion|max_thrust"] + outputs["drag"] = ( + inputs["fltcond|q"] * inputs["fltcond|CL"] * inputs["ac|geom|wing|S_ref"] / inputs["ac|aero|L_over_D"] + ) + # rst Aircraft (end) + + +# rst Mission (beg) +class MissionAnalysis(om.Group): + """ + OpenMDAO group for basic three-phase climb, cruise, descent mission. + The only top-level aircraft variable that the aircraft model uses is + the wing area, so that must be defined. + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + + def setup(self): + iv = self.add_subsystem("ac_vars", om.IndepVarComp(), promotes_outputs=["*"]) + iv.add_output("ac|geom|wing|S_ref", val=25.0, units="m**2") + iv.add_output("ac|weights|TOW", val=5e3, units="kg") + iv.add_output("ac|propulsion|max_thrust", val=1e4, units="N") + iv.add_output("ac|aero|L_over_D", val=10.0) + + # Define the mission + self.add_subsystem( + "mission", + BasicMission(aircraft_model=Aircraft, num_nodes=self.options["num_nodes"]), + promotes_inputs=["ac|*"], + ) + # rst Mission (end) + + +# rst Setup problem (beg) +def setup_problem(model=MissionAnalysis): + """ + Define the OpenMDAO problem + """ + nn = 11 + prob = om.Problem() + prob.model = model(num_nodes=nn) + + # Set up the solver + prob.model.nonlinear_solver = om.NewtonSolver(iprint=2, solve_subsystems=True) + prob.model.linear_solver = om.DirectSolver() + + # Set up the problem + prob.setup() + + # Define the mission profile by setting vertical speed and airspeed for each segment + prob.set_val("mission.climb.fltcond|vs", np.full(nn, 500.0), units="ft/min") + prob.set_val("mission.cruise.fltcond|vs", np.full(nn, 0.0), units="ft/min") + prob.set_val("mission.descent.fltcond|vs", np.full(nn, -500.0), units="ft/min") + prob.set_val("mission.climb.fltcond|Ueas", np.full(nn, 150.0), units="kn") + prob.set_val("mission.cruise.fltcond|Ueas", np.full(nn, 200.0), units="kn") + prob.set_val("mission.descent.fltcond|Ueas", np.full(nn, 150.0), units="kn") + + # The mission also needs the cruise altitude and the range + prob.set_val("mission.cruise|h0", 15e3, units="ft") + prob.set_val("mission.mission_range", 400.0, units="nmi") + + return prob + # rst Setup problem (end) + + +# rst Run (beg) +if __name__ == "__main__": + # Process command line argument to optionally not show figures and N2 diagram + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--hide_visuals", + default=False, + action="store_true", + help="Do not show matplotlib figure or open N2 diagram in browser") + hide_viz = parser.parse_args().hide_visuals + + # Setup the problem and run the analysis + prob = setup_problem() + prob.run_model() + + # Generate N2 diagram + om.n2(prob, outfile="minimal_example_n2.html", show_browser=not hide_viz) + + # Create plot with results + fig, axs = plt.subplots(2, 2, constrained_layout=True) + axs = axs.flatten() # change 2x2 mtx of axes into 4-element vector + + # Define variables to plot + vars = [ + {"var": "fltcond|h", "name": "Altitude", "units": "ft"}, + {"var": "fltcond|vs", "name": "Vertical speed", "units": "ft/min"}, + {"var": "fltcond|Utrue", "name": "True airspeed", "units": "kn"}, + {"var": "throttle", "name": "Throttle", "units": None}, + ] + + for idx_fig, var in enumerate(vars): + axs[idx_fig].set_xlabel("Range (nmi)") + axs[idx_fig].set_ylabel(f"{var['name']}" if var["units"] is None else f"{var['name']} ({var['units']})") + + # Loop through each flight phase and plot the current variable from each + for phase in ["climb", "cruise", "descent"]: + axs[idx_fig].plot( + prob.get_val(f"mission.{phase}.range", units="nmi"), + prob.get_val(f"mission.{phase}.{var['var']}", units=var["units"]), + "-o", + c="tab:blue", + markersize=2., + ) + + fig.savefig("minimal_example_results.svg", transparent=True) + if not hide_viz: + plt.show() +# rst Run (end) diff --git a/openconcept/examples/minimal_integrator.py b/openconcept/examples/minimal_integrator.py new file mode 100644 index 00000000..c662be7c --- /dev/null +++ b/openconcept/examples/minimal_integrator.py @@ -0,0 +1,168 @@ +""" +This example builds off the original minimal example, +but adds a numerical integrator to integrate fuel burn +and update the weight accordingly. +""" +# rst Imports (beg) +import openmdao.api as om +from openconcept.examples.minimal import Aircraft, setup_problem # build off this aircraft model +from openconcept.mission import BasicMission +from openconcept.utilities import Integrator +import matplotlib.pyplot as plt + +# rst Imports (end) + +# rst Aircraft (beg) +class AircraftWithFuelBurn(om.Group): + """ + This model takes the simplified aircraft model from the minimal example, but adds + a fuel flow computation using TSFC and integrates it to compute fuel burn. + """ + + # rst Options + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + self.options.declare("flight_phase", default=None) # required by OpenConcept but unused in this example + + # rst Setup + def setup(self): + nn = self.options["num_nodes"] + + # Add the aircraft model from the minimal example to build off of. + # Don't promote the weight from this model because we're going to compute a new + # one using the fuel burn. + # rst Simple aircraft (beg) + self.add_subsystem( + "simple_aircraft", + Aircraft(num_nodes=nn), + promotes_inputs=[ + "fltcond|CL", + "throttle", + "fltcond|q", + "ac|geom|wing|S_ref", + "ac|weights|TOW", + "ac|propulsion|max_thrust", + "ac|aero|L_over_D", + ], + promotes_outputs=["thrust", "drag"], + ) + # rst Simple aircraft (end) + + # Use an OpenMDAO ExecComp to compute the fuel flow rate using the thrust and TSFC + # rst Fuel flow (beg) + self.add_subsystem( + "fuel_flow_calc", + om.ExecComp( + "fuel_flow = TSFC * thrust", + fuel_flow={"units": "kg/s", "shape": nn}, + TSFC={"units": "kg/N/s", "shape": 1}, + thrust={"units": "N", "shape": nn}, + ), + promotes_inputs=[("TSFC", "ac|propulsion|TSFC"), "thrust"], + ) + # rst Fuel flow (end) + + # Integrate the fuel flow rate to compute fuel burn + # rst Integrator (beg) + integ = self.add_subsystem("fuel_integrator", Integrator(num_nodes=nn, diff_units="s", time_setup="duration", method="simpson")) + integ.add_integrand("fuel_burned", rate_name="fuel_flow", units="kg") + + self.connect("fuel_flow_calc.fuel_flow", "fuel_integrator.fuel_flow") + # rst Integrator (end) + + # Compute the current weight by subtracting the fuel burned from the takeoff weight + # rst Weight (beg) + self.add_subsystem( + "weight_calc", + om.ExecComp( + "weight = TOW - fuel_burned", + units="kg", + weight={"shape": nn}, + TOW={"shape": 1}, + fuel_burned={"shape": nn}, + ), + promotes_inputs=[("TOW", "ac|weights|TOW")], + promotes_outputs=["weight"], + ) + self.connect("fuel_integrator.fuel_burned", "weight_calc.fuel_burned") + # rst Weight (end) + + +# rst Mission (beg) +class MissionAnalysisWithFuelBurn(om.Group): + """ + OpenMDAO group for basic three-phase climb, cruise, descent mission. + The only top-level aircraft variable that the aircraft model uses is + the wing area, so that must be defined. + """ + + def initialize(self): + self.options.declare("num_nodes", default=1, desc="Number of analysis points per phase") + + def setup(self): + iv = self.add_subsystem("ac_vars", om.IndepVarComp(), promotes_outputs=["*"]) + iv.add_output("ac|geom|wing|S_ref", val=25.0, units="m**2") + iv.add_output("ac|weights|TOW", val=5e3, units="kg") + iv.add_output("ac|propulsion|max_thrust", val=1e4, units="N") + iv.add_output("ac|propulsion|TSFC", val=20.0, units="g/kN/s") + iv.add_output("ac|aero|L_over_D", val=10.0) + + # Define the mission + self.add_subsystem( + "mission", + BasicMission(aircraft_model=AircraftWithFuelBurn, num_nodes=self.options["num_nodes"]), + promotes_inputs=["ac|*"], + ) + # rst Mission (end) + + +# rst Run (beg) +if __name__ == "__main__": + # Process command line argument to optionally not show figures and N2 diagram + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--hide_visuals", + default=False, + action="store_true", + help="Do not show matplotlib figure or open N2 diagram in browser") + hide_viz = parser.parse_args().hide_visuals + + # Setup the problem and run the analysis + prob = setup_problem(model=MissionAnalysisWithFuelBurn) + prob.run_model() + + # Generate N2 diagram + om.n2(prob, outfile="minimal_integrator_n2.html", show_browser=not hide_viz) + + # Create plot with results + fig, axs = plt.subplots(2, 3, figsize=[9, 4.8], constrained_layout=True) + axs = axs.flatten() # change 2x3 mtx of axes into 4-element vector + + # Define variables to plot + vars = [ + {"var": "fltcond|h", "name": "Altitude", "units": "ft"}, + {"var": "fltcond|vs", "name": "Vertical speed", "units": "ft/min"}, + {"var": "fltcond|Utrue", "name": "True airspeed", "units": "kn"}, + {"var": "throttle", "name": "Throttle", "units": None}, + {"var": "fuel_flow_calc.fuel_flow", "name": "Fuel flow", "units": "g/s"}, + {"var": "weight", "name": "Weight", "units": "kg"}, + ] + + for idx_fig, var in enumerate(vars): + axs[idx_fig].set_xlabel("Range (nmi)") + axs[idx_fig].set_ylabel(f"{var['name']}" if var["units"] is None else f"{var['name']} ({var['units']})") + + # Loop through each flight phase and plot the current variable from each + for phase in ["climb", "cruise", "descent"]: + axs[idx_fig].plot( + prob.get_val(f"mission.{phase}.range", units="nmi"), + prob.get_val(f"mission.{phase}.{var['var']}", units=var["units"]), + "-o", + c="tab:blue", + markersize=2.0, + ) + + fig.savefig("minimal_integrator_results.svg", transparent=True) + if not hide_viz: + plt.show() +# rst Run (end) diff --git a/examples/tests/test_example_aircraft.py b/openconcept/examples/tests/test_example_aircraft.py similarity index 79% rename from examples/tests/test_example_aircraft.py rename to openconcept/examples/tests/test_example_aircraft.py index aa388be3..9245e318 100644 --- a/examples/tests/test_example_aircraft.py +++ b/openconcept/examples/tests/test_example_aircraft.py @@ -1,23 +1,22 @@ -from __future__ import division import unittest import numpy as np -from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials -from openmdao.api import IndepVarComp, Group, Problem -from examples.B738 import run_738_analysis -from examples.TBM850 import run_tbm_analysis -from examples.HybridTwin_thermal import run_hybrid_twin_thermal_analysis -from examples.HybridTwin_active_thermal import run_hybrid_twin_active_thermal_analysis -from examples.HybridTwin import run_hybrid_twin_analysis -from examples.Caravan import run_caravan_analysis -from examples.KingAirC90GT import run_kingair_analysis -from examples.ElectricSinglewithThermal import run_electricsingle_analysis -from examples.N3_HybridSingleAisle_Refrig import run_hybrid_sa_analysis +from openmdao.utils.assert_utils import assert_near_equal +from openconcept.examples.B738 import run_738_analysis +from openconcept.examples.TBM850 import run_tbm_analysis +from openconcept.examples.HybridTwin_thermal import run_hybrid_twin_thermal_analysis +from openconcept.examples.HybridTwin_active_thermal import run_hybrid_twin_active_thermal_analysis +from openconcept.examples.HybridTwin import run_hybrid_twin_analysis +from openconcept.examples.Caravan import run_caravan_analysis +from openconcept.examples.KingAirC90GT import run_kingair_analysis +from openconcept.examples.ElectricSinglewithThermal import run_electricsingle_analysis +from openconcept.examples.N3_HybridSingleAisle_Refrig import run_hybrid_sa_analysis +from openconcept.examples.minimal import setup_problem as setup_minimal_problem +from openconcept.examples.minimal_integrator import MissionAnalysisWithFuelBurn as MinimalIntegratorMissionAnalysis try: - from examples.B738_VLM_drag import run_738_analysis as run_738VLM_analysis - from openconcept.analysis.openaerostruct.drag_polar import VLMDataGen - from examples.B738_aerostructural import run_738_analysis as run_738Aerostruct_analysis - from openconcept.analysis.openaerostruct.aerostructural import OASDataGen + from openconcept.examples.B738_VLM_drag import run_738_analysis as run_738VLM_analysis + from openconcept.aerodynamics.openaerostruct import VLMDataGen, OASDataGen + from openconcept.examples.B738_aerostructural import run_738_analysis as run_738Aerostruct_analysis OAS_installed = True except: OAS_installed = False @@ -26,13 +25,14 @@ class TBMAnalysisTestCase(unittest.TestCase): def setUp(self): self.prob = run_tbm_analysis() + self.prob.run_model() def test_values_TBM(self): prob = self.prob assert_near_equal(prob.get_val('climb.OEW', units='lb'), 4756.772140709275, tolerance=1e-5) - assert_near_equal(prob.get_val('rotate.range_final', units='ft'), 2489.49501148, tolerance=1e-5) + assert_near_equal(prob.get_val('rotate.range_final', units='ft'), 2490.89174399, tolerance=1e-5) assert_near_equal(prob.get_val('engineoutclimb.gamma',units='deg'), 8.78263, tolerance=1e-5) - assert_near_equal(prob.get_val('descent.fuel_used_final', units='lb'), 1607.84846911, tolerance=1e-5) + assert_near_equal(prob.get_val('descent.fuel_used_final', units='lb'), 633.58800032, tolerance=1e-5) class CaravanAnalysisTestCase(unittest.TestCase): def setUp(self): @@ -188,5 +188,26 @@ def test_values_N3HSA(self): # block fuel (no reserve, since the N+3 HSA uses the basic 3-phase mission) assert_near_equal(prob.get_val('descent.fuel_used_final', units='lbm'), 9006.52397811, tolerance=1e-5) +class MinimalTestCase(unittest.TestCase): + def setUp(self): + self.prob = setup_minimal_problem() + self.prob.run_model() + + def test_values_minimal(self): + # No fuel burn, so check the throttle from the three phases + assert_near_equal(self.prob.get_val('mission.climb.throttle'), np.array([0.651459, 0.647949, 0.644480, 0.641052, 0.637664, 0.634317, 0.631010, 0.627744, 0.624519, 0.621333, 0.618189]), tolerance=1e-5) + assert_near_equal(self.prob.get_val('mission.cruise.throttle'), np.full(11, 0.490333), tolerance=1e-5) + assert_near_equal(self.prob.get_val('mission.descent.throttle'), np.array([0.362142, 0.358981, 0.355778, 0.352535, 0.349250, 0.345924, 0.342557, 0.339149, 0.335699, 0.332207, 0.328674]), tolerance=1e-5) + + +class MinimalIntegratorTestCase(unittest.TestCase): + def setUp(self): + self.prob = setup_minimal_problem(model=MinimalIntegratorMissionAnalysis) + self.prob.run_model() + + def test_values_minimal(self): + assert_near_equal(self.prob.get_val('mission.descent.fuel_integrator.fuel_burned_final'), 633.350, tolerance=1e-5) + + if __name__=="__main__": unittest.main() diff --git a/openconcept/mission/__init__.py b/openconcept/mission/__init__.py new file mode 100644 index 00000000..5c0071e1 --- /dev/null +++ b/openconcept/mission/__init__.py @@ -0,0 +1,18 @@ +from .profiles import MissionWithReserve, FullMissionAnalysis, BasicMission +from .phases import ( + ClimbAngleComp, + BFLImplicitSolve, + Groundspeeds, + HorizontalAcceleration, + VerticalAcceleration, + SteadyFlightCL, + GroundRollPhase, + RotationPhase, + SteadyFlightPhase, + ClimbAnglePhase, + TakeoffTransition, + TakeoffClimb, + RobustRotationPhase, + FlipVectorComp, +) +from .mission_groups import PhaseGroup, IntegratorGroup, TrajectoryGroup diff --git a/openconcept/analysis/trajectories.py b/openconcept/mission/mission_groups.py similarity index 98% rename from openconcept/analysis/trajectories.py rename to openconcept/mission/mission_groups.py index 29ab0bff..d7b43da5 100644 --- a/openconcept/analysis/trajectories.py +++ b/openconcept/mission/mission_groups.py @@ -1,8 +1,6 @@ import openmdao.api as om import numpy as np -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.math.add_subtract_comp import AddSubtractComp -from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp +from openconcept.utilities import Integrator, AddSubtractComp, ElementMultiplyDivideComp import warnings diff --git a/openconcept/analysis/performance/solver_phases.py b/openconcept/mission/phases.py similarity index 94% rename from openconcept/analysis/performance/solver_phases.py rename to openconcept/mission/phases.py index 0c8bde9f..a2728489 100644 --- a/openconcept/analysis/performance/solver_phases.py +++ b/openconcept/mission/phases.py @@ -1,14 +1,10 @@ -from __future__ import division from openmdao.api import Group, ExplicitComponent, IndepVarComp, BalanceComp, ImplicitComponent -import openconcept.api as oc -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties -from openconcept.analysis.aerodynamics import Lift, StallSpeed -from openconcept.utilities.math import ElementMultiplyDivideComp, AddSubtractComp -from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.utilities.math.integrals import Integrator +from .mission_groups import PhaseGroup +from openconcept.atmospherics import ComputeAtmosphericProperties +from openconcept.aerodynamics import Lift, StallSpeed +from openconcept.utilities import ElementMultiplyDivideComp, AddSubtractComp, Integrator, LinearInterpolator +from openconcept.utilities.constants import GRAV_CONST import numpy as np -import copy class ClimbAngleComp(ExplicitComponent): """ @@ -49,16 +45,14 @@ def setup(self): self.declare_partials(['gamma'], ['weight','thrust','drag'], cols=np.arange(0,nn), rows=np.arange(0,nn)) def compute(self, inputs, outputs): - g = 9.80665 #m/s^2 - outputs['gamma'] = np.arcsin((inputs['thrust']-inputs['drag'])/inputs['weight']/g) + outputs['gamma'] = np.arcsin((inputs['thrust']-inputs['drag'])/inputs['weight']/GRAV_CONST) def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 - interior_qty = (inputs['thrust']-inputs['drag'])/inputs['weight']/g + interior_qty = (inputs['thrust']-inputs['drag'])/inputs['weight']/GRAV_CONST d_arcsin = 1/np.sqrt(1-interior_qty**2) - J['gamma','thrust'] = d_arcsin/inputs['weight']/g - J['gamma','drag'] = -d_arcsin/inputs['weight']/g - J['gamma','weight'] = -d_arcsin*(inputs['thrust']-inputs['drag'])/inputs['weight']**2/g + J['gamma','thrust'] = d_arcsin/inputs['weight']/GRAV_CONST + J['gamma','drag'] = -d_arcsin/inputs['weight']/GRAV_CONST + J['gamma','weight'] = -d_arcsin*(inputs['thrust']-inputs['drag'])/inputs['weight']**2/GRAV_CONST class FlipVectorComp(ExplicitComponent): @@ -282,7 +276,6 @@ def initialize(self): def setup(self): nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 self.add_input('weight', units='kg', shape=(nn,)) self.add_input('drag', units='N',shape=(nn,)) self.add_input('lift', units='N',shape=(nn,)) @@ -293,24 +286,22 @@ def setup(self): self.add_output('accel_horiz', units='m/s**2', shape=(nn,)) arange=np.arange(nn) self.declare_partials(['accel_horiz'], ['weight','drag','lift','thrust','braking'], rows=arange, cols=arange) - self.declare_partials(['accel_horiz'], ['fltcond|singamma'], rows=arange, cols=arange, val=-g*np.ones((nn,))) + self.declare_partials(['accel_horiz'], ['fltcond|singamma'], rows=arange, cols=arange, val=-GRAV_CONST*np.ones((nn,))) def compute(self, inputs, outputs): nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 m = inputs['weight'] - floor_vec = np.where(np.less((g-inputs['lift']/m),0.0),0.0,1.0) - accel = inputs['thrust']/m - inputs['drag']/m - floor_vec*inputs['braking']*(g-inputs['lift']/m) - g*inputs['fltcond|singamma'] + floor_vec = np.where(np.less((GRAV_CONST-inputs['lift']/m),0.0),0.0,1.0) + accel = inputs['thrust']/m - inputs['drag']/m - floor_vec*inputs['braking']*(GRAV_CONST-inputs['lift']/m) - GRAV_CONST*inputs['fltcond|singamma'] outputs['accel_horiz'] = accel def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 m = inputs['weight'] - floor_vec = np.where(np.less((g-inputs['lift']/m),0.0),0.0,1.0) + floor_vec = np.where(np.less((GRAV_CONST-inputs['lift']/m),0.0),0.0,1.0) J['accel_horiz','thrust'] = 1/m J['accel_horiz','drag'] = -1/m - J['accel_horiz','braking'] = -floor_vec*(g-inputs['lift']/m) + J['accel_horiz','braking'] = -floor_vec*(GRAV_CONST-inputs['lift']/m) J['accel_horiz','lift'] = floor_vec*inputs['braking']/m J['accel_horiz','weight'] = (inputs['drag']-inputs['thrust']-floor_vec*inputs['braking']*inputs['lift'])/m**2 @@ -349,7 +340,6 @@ def initialize(self): def setup(self): nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 self.add_input('weight', units='kg', shape=(nn,)) self.add_input('drag', units='N',shape=(nn,)) self.add_input('lift', units='N',shape=(nn,)) @@ -357,22 +347,20 @@ def setup(self): self.add_input('fltcond|singamma',shape=(nn,)) self.add_input('fltcond|cosgamma',shape=(nn,)) - self.add_output('accel_vert', units='m/s**2', shape=(nn,),upper=2.5*g,lower=-1*g) + self.add_output('accel_vert', units='m/s**2', shape=(nn,),upper=2.5*GRAV_CONST,lower=-1*GRAV_CONST) arange=np.arange(nn) self.declare_partials(['accel_vert'], ['weight','drag','lift','thrust','fltcond|singamma','fltcond|cosgamma'], rows=arange, cols=arange) def compute(self, inputs, outputs): nn = self.options['num_nodes'] - g = 9.80665 #m/s^2 cosg = inputs['fltcond|cosgamma'] sing = inputs['fltcond|singamma'] - accel = (inputs['lift']*cosg + (inputs['thrust']-inputs['drag'])*sing - g*inputs['weight'])/inputs['weight'] - accel = np.clip(accel, -g, 2.5*g) + accel = (inputs['lift']*cosg + (inputs['thrust']-inputs['drag'])*sing - GRAV_CONST*inputs['weight'])/inputs['weight'] + accel = np.clip(accel, -GRAV_CONST, 2.5*GRAV_CONST) outputs['accel_vert'] = accel def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 m = inputs['weight'] cosg = inputs['fltcond|cosgamma'] sing = inputs['fltcond|singamma'] @@ -411,13 +399,11 @@ class SteadyFlightCL(ExplicitComponent): ------- num_nodes : int Number of analysis nodes to run - mission_segments : list - The list of mission segments to track """ def initialize(self): self.options.declare('num_nodes',default=5,desc="Number of Simpson intervals to use per seg (eg. climb, cruise, descend). Number of analysis points is 2N+1") - self.options.declare('mission_segments',default=['climb','cruise','descent']) + def setup(self): nn = self.options['num_nodes'] arange = np.arange(nn) @@ -430,22 +416,20 @@ def setup(self): self.declare_partials(['fltcond|CL'], ['ac|geom|wing|S_ref'], rows=arange, cols=np.zeros(nn)) def compute(self, inputs, outputs): - g = 9.80665 #m/s^2 - outputs['fltcond|CL'] = inputs['fltcond|cosgamma']*g*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] + outputs['fltcond|CL'] = inputs['fltcond|cosgamma']*GRAV_CONST*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] def compute_partials(self, inputs, J): - g = 9.80665 #m/s^2 - J['fltcond|CL','weight'] = inputs['fltcond|cosgamma']*g/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] - J['fltcond|CL','fltcond|q'] = - inputs['fltcond|cosgamma']*g*inputs['weight'] / inputs['fltcond|q']**2 / inputs['ac|geom|wing|S_ref'] - J['fltcond|CL','ac|geom|wing|S_ref'] = - inputs['fltcond|cosgamma']*g*inputs['weight'] / inputs['fltcond|q'] / inputs['ac|geom|wing|S_ref']**2 - J['fltcond|CL','fltcond|cosgamma'] = g*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] + J['fltcond|CL','weight'] = inputs['fltcond|cosgamma']*GRAV_CONST/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] + J['fltcond|CL','fltcond|q'] = - inputs['fltcond|cosgamma']*GRAV_CONST*inputs['weight'] / inputs['fltcond|q']**2 / inputs['ac|geom|wing|S_ref'] + J['fltcond|CL','ac|geom|wing|S_ref'] = - inputs['fltcond|cosgamma']*GRAV_CONST*inputs['weight'] / inputs['fltcond|q'] / inputs['ac|geom|wing|S_ref']**2 + J['fltcond|CL','fltcond|cosgamma'] = GRAV_CONST*inputs['weight']/inputs['fltcond|q']/inputs['ac|geom|wing|S_ref'] -class GroundRollPhase(oc.PhaseGroup): +class GroundRollPhase(PhaseGroup): """ This component group models the ground roll phase of a takeoff (acceleration before flight) User-settable parameters include: throttle (default 100 percent) - rolling friction coeff (default 0.03 for accelerating segments and 0.4 for braking) + rolling friction coeff (default 0.03 for accelerating phases and 0.4 for braking) propulsor_active (default 1 for v0 to v1, 0 for v1 to vr and braking) to model engine failure altitude (fltcond|h) @@ -563,7 +547,7 @@ def setup(self): self.add_subsystem('v0constraint',BalanceComp(name='duration',units='s',eq_units='m/s',rhs_name='fltcond|Utrue_initial',lhs_name='takeoff|v1',val=10.,upper=100.,lower=1.), promotes_inputs=['*'],promotes_outputs=['duration']) else: - # forward shooting for these acceleration segmentes + # forward shooting for these acceleration phases ode_integ = self.add_subsystem('ode_integ_phase', Integrator(num_nodes=nn, method='simpson', diff_units='s',time_setup='duration'), promotes_inputs=['*'], promotes_outputs=['*']) ode_integ.add_integrand('fltcond|Utrue', units='m/s', rate_name='accel_horiz', start_name='fltcond|Utrue_initial', end_name='fltcond|Utrue_final', lower=1.5) if flight_phase == 'v0v1': @@ -579,14 +563,14 @@ def setup(self): else: ode_integ.add_integrand('range', rate_name='fltcond|groundspeed', units='m') -class RotationPhase(oc.PhaseGroup): +class RotationPhase(PhaseGroup): """ This group models the transition from ground roll to climb out during a takeoff using force balance in the vertical and horizontal directions. User-settable parameters include: throttle (default 100 percent) - rolling friction coeff (default 0.03 for accelerating segments and 0.4 for braking) + rolling friction coeff (default 0.03 for accelerating phases and 0.4 for braking) propulsor_active (default 1 for v0 to v1, 0 for v1 to vr and braking) to model engine failure altitude (fltcond|h) obstacle clearance hight (h_obs) default 35 feet per FAR 25 @@ -685,13 +669,13 @@ def setup(self): int4 = self.add_subsystem('inth', Integrator(num_nodes=nn, method='simpson',diff_units='s',time_setup='duration'), promotes_outputs=['*'], promotes_inputs=['*']) int4.add_integrand('fltcond|h', rate_name='fltcond|vs', units='m', zero_start=True) -class SteadyFlightPhase(oc.PhaseGroup): +class SteadyFlightPhase(PhaseGroup): """ This component group models steady flight conditions. Settable mission parameters include: Airspeed (fltcond|Ueas) Vertical speed (fltcond|vs) - Duration of the segment (duration) + Duration of the phase (duration) Throttle is set automatically to ensure steady flight @@ -911,11 +895,10 @@ def setup(self): def compute(self, inputs, outputs): hobs = self.options['h_obstacle'] nfactor = self.options['load_factor'] - 1 - g = 9.80665 #m/s^2 gam = inputs['gamma'] ut = inputs['fltcond|Utrue'] - R = ut**2/nfactor/g + R = ut**2/nfactor/GRAV_CONST st = R*np.sin(gam) ht = R*(1-np.cos(gam)) #alternate formula if the obstacle is cleared during transition @@ -929,11 +912,10 @@ def compute(self, inputs, outputs): def compute_partials(self, inputs, J): hobs = self.options['h_obstacle'] nfactor = self.options['load_factor'] - 1 - g = 9.80665 #m/s^2 gam = inputs['gamma'] ut = inputs['fltcond|Utrue'] - R = ut**2/nfactor/g - dRdut = 2*ut/nfactor/g + R = ut**2/nfactor/GRAV_CONST + dRdut = 2*ut/nfactor/GRAV_CONST st = R*np.sin(gam) ht = R*(1-np.cos(gam)) #alternate formula if the obstacle is cleared during transition @@ -1013,7 +995,7 @@ def compute_partials(self, inputs, J): J['t_climb','fltcond|Utrue'] = - sc / ut ** 2 -class RobustRotationPhase(oc.PhaseGroup): +class RobustRotationPhase(PhaseGroup): """ This adds general mission analysis capabilities to an existing airplane model. The BaseAircraftGroup object is passed in. It should be built to accept the following inputs and return the following outputs. diff --git a/openconcept/analysis/performance/mission_profiles.py b/openconcept/mission/profiles.py similarity index 93% rename from openconcept/analysis/performance/mission_profiles.py rename to openconcept/mission/profiles.py index 5c5c48e1..e6fdee98 100644 --- a/openconcept/analysis/performance/mission_profiles.py +++ b/openconcept/mission/profiles.py @@ -1,11 +1,12 @@ import openmdao.api as om -import openconcept.api as oc -from openconcept.analysis.performance.solver_phases import BFLImplicitSolve, GroundRollPhase, RotationPhase, RobustRotationPhase, ClimbAnglePhase, SteadyFlightPhase +from openconcept.utilities import DVLabel +from .phases import BFLImplicitSolve, GroundRollPhase, RotationPhase, RobustRotationPhase, ClimbAnglePhase, SteadyFlightPhase +from .mission_groups import TrajectoryGroup -class MissionWithReserve(oc.TrajectoryGroup): +class MissionWithReserve(TrajectoryGroup): """ This analysis group is set up to compute all the major parameters - of a fixed wing mission, including climb, cruise, and descent as well as Part 25 reserve fuel segments. + of a fixed wing mission, including climb, cruise, and descent as well as Part 25 reserve fuel phases. The 5% of block fuel is not accounted for here. To use this analysis, pass in an aircraft model following OpenConcept interface. @@ -46,11 +47,11 @@ class MissionWithReserve(oc.TrajectoryGroup): aircraft_model : class An aircraft model class with the standard OpenConcept interfaces promoted correctly num_nodes : int - Number of analysis points per segment. Higher is more accurate but more expensive + Number of analysis points per phase. Higher is more accurate but more expensive """ def initialize(self): - self.options.declare('num_nodes', default=9, desc="Number of points per segment. Needs to be 2N + 1 due to simpson's rule") + self.options.declare('num_nodes', default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule") self.options.declare('aircraft_model', default=None, desc="OpenConcept-compliant airplane model") def setup(self): @@ -67,7 +68,7 @@ def setup(self): mp.add_output('loiter_duration', val=30.*60., units='s') mp.add_output('payload',val=1000.,units='lbm') - # add the climb, cruise, and descent segments + # add the climb, cruise, and descent phases phase1 = self.add_subsystem('climb',SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='climb'),promotes_inputs=['ac|*']) # set the climb time such that the specified initial cruise altitude is exactly reached phase1.add_subsystem('climbdt',om.BalanceComp(name='duration',units='s',eq_units='m',val=120,upper=2000,lower=0,rhs_name='cruise|h0',lhs_name='fltcond|h_final'),promotes_outputs=['duration']) @@ -85,7 +86,7 @@ def setup(self): self.connect('takeoff|h', 'descent.descentdt.takeoff|h') phase3.connect('ode_integ_phase.fltcond|h_final','descentdt.fltcond|h_final') - # add the climb, cruise, and descent segments for the reserve mission + # add the climb, cruise, and descent phases for the reserve mission phase4 = self.add_subsystem('reserve_climb',SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='reserve_climb'),promotes_inputs=['ac|*']) # set the climb time such that the specified initial cruise altitude is exactly reached phase4.add_subsystem('reserve_climbdt',om.BalanceComp(name='duration',units='s',eq_units='m',val=120,upper=2000,lower=0,rhs_name='reserve|h0',lhs_name='fltcond|h_final'),promotes_outputs=['duration']) @@ -115,7 +116,7 @@ def setup(self): phase7 = self.add_subsystem('loiter',SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='loiter'),promotes_inputs=['ac|*']) dvlist = [['duration_in', 'duration', 300, 's']] - phase7.add_subsystem('loiter_dt', oc.DVLabel(dvlist), promotes_inputs=["*"], promotes_outputs=["*"]) + phase7.add_subsystem('loiter_dt', DVLabel(dvlist), promotes_inputs=["*"], promotes_outputs=["*"]) self.connect('loiter|h0','loiter.ode_integ_phase.fltcond|h_initial') self.connect('loiter_duration','loiter.duration_in') @@ -126,7 +127,7 @@ def setup(self): self.link_phases(phase5, phase6) self.link_phases(phase6, phase7, states_to_skip=['ode_integ_phase.fltcond|h']) -class BasicMission(oc.TrajectoryGroup): +class BasicMission(TrajectoryGroup): """ This analysis group is set up to compute all the major parameters of a fixed wing mission, including climb, cruise, and descent but no Part 25 reserves @@ -165,11 +166,11 @@ class BasicMission(oc.TrajectoryGroup): aircraft_model : class An aircraft model class with the standard OpenConcept interfaces promoted correctly num_nodes : int - Number of analysis points per segment. Higher is more accurate but more expensive + Number of analysis points per phase. Higher is more accurate but more expensive """ def initialize(self): - self.options.declare('num_nodes', default=9, desc="Number of points per segment. Needs to be 2N + 1 due to simpson's rule") + self.options.declare('num_nodes', default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule") self.options.declare('aircraft_model', default=None, desc="OpenConcept-compliant airplane model") self.options.declare('include_ground_roll', default=False, desc='Whether to include groundroll phase') @@ -190,7 +191,7 @@ def setup(self): phase0 = self.add_subsystem('groundroll', GroundRollPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='v0v1'), promotes_inputs=['ac|*']) self.connect('takeoff|v2', 'groundroll.takeoff|v1') - # add the climb, cruise, and descent segments + # add the climb, cruise, and descent phases phase1 = self.add_subsystem('climb',SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='climb'),promotes_inputs=['ac|*']) # set the climb time such that the specified initial cruise altitude is exactly reached phase1.add_subsystem('climbdt',om.BalanceComp(name='duration',units='s',eq_units='m',val=120,upper=2000,lower=0,rhs_name='cruise|h0',lhs_name='fltcond|h_final'),promotes_outputs=['duration']) @@ -215,7 +216,7 @@ def setup(self): self.link_phases(phase1, phase2) self.link_phases(phase2, phase3) -class FullMissionAnalysis(oc.TrajectoryGroup): +class FullMissionAnalysis(TrajectoryGroup): """ This analysis group is set up to compute all the major parameters of a fixed wing mission, including balanced-field takeoff, climb, cruise, and descent. @@ -263,7 +264,7 @@ class FullMissionAnalysis(oc.TrajectoryGroup): aircraft_model : class An aircraft model class with the standard OpenConcept interfaces promoted correctly num_nodes : int - Number of analysis points per segment. Higher is more accurate but more expensive + Number of analysis points per phase. Higher is more accurate but more expensive transition_method : str Analysis method to compute distance, altitude, and time during transition Default "simplified" is the Raymer circular arc method and is more robust @@ -271,7 +272,7 @@ class FullMissionAnalysis(oc.TrajectoryGroup): """ def initialize(self): - self.options.declare('num_nodes', default=9, desc="Number of points per segment. Needs to be 2N + 1 due to simpson's rule") + self.options.declare('num_nodes', default=9, desc="Number of points per phase. Needs to be 2N + 1 due to simpson's rule") self.options.declare('aircraft_model', default=None, desc="OpenConcept-compliant airplane model") self.options.declare('transition_method', default='simplified', desc="Method to use for computing transition") @@ -280,7 +281,7 @@ def setup(self): acmodelclass = self.options['aircraft_model'] transition_method = self.options['transition_method'] - # add the four balanced field length takeoff segments and the implicit v1 solver + # add the four balanced field length takeoff phases and the implicit v1 solver # v0v1 - from a rolling start to v1 speed # v1vr - from the decision speed to rotation # rotate - in the air following rotation in 2DOF @@ -312,7 +313,7 @@ def setup(self): self.connect('v1v0.range_final','bfl.distance_abort') self.add_subsystem('engineoutclimb',ClimbAnglePhase(num_nodes=1, aircraft_model=acmodelclass, flight_phase='EngineOutClimbAngle'), promotes_inputs=['ac|*']) - # add the climb, cruise, and descent segments + # add the climb, cruise, and descent phases climb = self.add_subsystem('climb',SteadyFlightPhase(num_nodes=nn, aircraft_model=acmodelclass, flight_phase='climb'),promotes_inputs=['ac|*']) # set the climb time such that the specified initial cruise altitude is exactly reached climb.add_subsystem('climbdt',om.BalanceComp(name='duration',units='s',eq_units='m',val=120,lower=0,upper=3000,rhs_name='cruise|h0',lhs_name='fltcond|h_final'), promotes_outputs=['duration']) @@ -331,7 +332,7 @@ def setup(self): self.connect('descent.ode_integ_phase.range_final','cruise.cruisedt.range_final') self.connect('descent.ode_integ_phase.fltcond|h_final','descent.descentdt.fltcond|h_final') - # connect range, fuel burn, and altitude from the end of each segment to the beginning of the next, in order + # connect range, fuel burn, and altitude from the end of each phase to the beginning of the next, in order self.link_phases(v0v1, v1vr, states_to_skip=['fltcond|Utrue','range']) self.link_phases(v1vr, rotate, states_to_skip=['fltcond|Utrue','range']) self.link_phases(v0v1, v1v0, states_to_skip=['fltcond|Utrue','range']) diff --git a/openconcept/analysis/performance/tests/test_solver_phase_helpers.py b/openconcept/mission/tests/test_solver_phase_helpers.py similarity index 94% rename from openconcept/analysis/performance/tests/test_solver_phase_helpers.py rename to openconcept/mission/tests/test_solver_phase_helpers.py index 1955ebf4..101c9243 100644 --- a/openconcept/analysis/performance/tests/test_solver_phase_helpers.py +++ b/openconcept/mission/tests/test_solver_phase_helpers.py @@ -1,11 +1,9 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.analysis.performance.solver_phases import ClimbAngleComp, FlipVectorComp, Groundspeeds, HorizontalAcceleration, VerticalAcceleration, SteadyFlightCL, TakeoffTransition - -g = 9.80665 #m/s^2 +from openconcept.mission import ClimbAngleComp, Groundspeeds, HorizontalAcceleration, VerticalAcceleration, SteadyFlightCL, FlipVectorComp, TakeoffTransition +from openconcept.utilities.constants import GRAV_CONST # TESTS FOR ClimbAngleComp =================================== @@ -35,7 +33,7 @@ def test_level_flight(self): def test_climb_flight(self): self.prob['thrust'] = np.ones((1,))*1200 self.prob.run_model() - assert_near_equal(self.prob['gamma'][0], np.arcsin(200 / 1000 / g), tolerance=1e-10) + assert_near_equal(self.prob['gamma'][0], np.arcsin(200 / 1000 / GRAV_CONST), tolerance=1e-10) def test_partials(self): partials = self.prob.check_partials(method='cs', out_stream=None) @@ -169,7 +167,7 @@ class HorizontalAccelerationTestCase_SteadyClimb(unittest.TestCase): def setUp(self): self.prob = Problem(HorizontalAccelerationTestGroup(num_nodes=9)) self.prob.setup(check=True, force_alloc_complex=True) - self.prob['thrust'] = np.ones((9,)) * (100 + 100 * g * 0.02) + self.prob['thrust'] = np.ones((9,)) * (100 + 100 * GRAV_CONST * 0.02) self.prob['fltcond|singamma'] = np.ones((9,)) * 0.02 self.prob.run_model() @@ -184,7 +182,7 @@ class HorizontalAccelerationTestCase_SteadyClimb(unittest.TestCase): def setUp(self): self.prob = Problem(HorizontalAccelerationTestGroup(num_nodes=9)) self.prob.setup(check=True, force_alloc_complex=True) - self.prob['thrust'] = np.ones((9,)) * (100 + 100 * g * 0.02) + self.prob['thrust'] = np.ones((9,)) * (100 + 100 * GRAV_CONST * 0.02) self.prob['fltcond|singamma'] = np.ones((9,)) * 0.02 self.prob.run_model() @@ -200,7 +198,7 @@ def setUp(self): self.prob = Problem(HorizontalAccelerationTestGroup(num_nodes=9)) self.prob.setup(check=True, force_alloc_complex=True) self.prob['braking'] = np.ones((9,)) * 0.03 - self.prob['lift'] = np.linspace(0, 150, 9) * g + self.prob['lift'] = np.linspace(0, 150, 9) * GRAV_CONST self.prob['drag'] = np.ones((9,)) * 50 self.prob.run_model() @@ -209,7 +207,7 @@ def test_accel_with_braking(self): thrust = 100.0 lift = 0.0 mass = 100 - weight = mass*g + weight = mass*GRAV_CONST singamma = 0.0 brakeforce = 0.03 * (weight-lift) slopeforce = weight * singamma @@ -220,7 +218,7 @@ def test_accel_with_braking_and_lift(self): drag = 50.0 thrust = 100.0 mass = 100 - weight = mass*g + weight = mass*GRAV_CONST singamma = 0.0 lift = weight*0.75 brakeforce = 0.03 * (weight-lift) @@ -232,7 +230,7 @@ def test_accel_lift_exceeds_weight(self): drag = 50.0 thrust = 100.0 mass = 100 - weight = mass*g + weight = mass*GRAV_CONST singamma = 0.0 # if lift exceeds weight (as it does here) no braking force is applied brakeforce = 0.0 @@ -254,7 +252,7 @@ def setup(self): nn = self.options['num_nodes'] iv = self.add_subsystem('conditions', IndepVarComp(), promotes_outputs=['*']) iv.add_output('weight', val=np.ones((nn,))*100, units='kg') - iv.add_output('lift', val=np.ones((nn,))*100*g, units='N') + iv.add_output('lift', val=np.ones((nn,))*100*GRAV_CONST, units='N') iv.add_output('thrust', val=np.ones((nn,))*100, units='N') iv.add_output('drag', val=np.ones((nn,))*100, units='N') iv.add_output('fltcond|singamma', val=np.zeros((nn,)), units=None) @@ -280,7 +278,7 @@ def setUp(self): self.prob.setup(check=True, force_alloc_complex=True) self.prob['fltcond|singamma'] = np.ones((9,)) * np.sin(0.02) self.prob['fltcond|cosgamma'] = np.ones((9,)) * np.cos(0.02) - self.prob['lift'] = np.ones((9,)) * 100 * g / np.cos(0.02) + self.prob['lift'] = np.ones((9,)) * 100 * GRAV_CONST / np.cos(0.02) self.prob.run_model() @@ -295,7 +293,7 @@ class VerticalAccelerationTestCase_UnsteadyPullUp(unittest.TestCase): def setUp(self): self.prob = Problem(VerticalAccelerationTestGroup(num_nodes=9)) self.prob.setup(check=True, force_alloc_complex=True) - self.prob['lift'] = np.ones((9,)) * 100 * g + 100 + self.prob['lift'] = np.ones((9,)) * 100 * GRAV_CONST + 100 self.prob.run_model() def test_unsteady_pullup(self): @@ -327,7 +325,7 @@ def setUp(self): self.prob.run_model() def test_steady_level_flights(self): - assert_near_equal(self.prob['fltcond|CL'], np.ones((9,))*100*g/1000./10./1.0, tolerance=1e-10) + assert_near_equal(self.prob['fltcond|CL'], np.ones((9,))*100*GRAV_CONST/1000./10./1.0, tolerance=1e-10) def test_partials(self): partials = self.prob.check_partials(method='cs', out_stream=None) @@ -341,7 +339,7 @@ def setUp(self): self.prob.run_model() def test_steady_level_flights(self): - assert_near_equal(self.prob['fltcond|CL'], np.ones((9,))*100*g/1000./10.*0.98, tolerance=1e-10) + assert_near_equal(self.prob['fltcond|CL'], np.ones((9,))*100*GRAV_CONST/1000./10.*0.98, tolerance=1e-10) def test_partials(self): partials = self.prob.check_partials(method='cs', out_stream=None) diff --git a/openconcept/analysis/tests/test_trajectories.py b/openconcept/mission/tests/test_trajectories.py similarity index 96% rename from openconcept/analysis/tests/test_trajectories.py rename to openconcept/mission/tests/test_trajectories.py index 15d67386..70418ef9 100644 --- a/openconcept/analysis/tests/test_trajectories.py +++ b/openconcept/mission/tests/test_trajectories.py @@ -1,9 +1,8 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials -import openconcept.api as oc import openmdao.api as om +from openconcept.mission import IntegratorGroup, PhaseGroup, TrajectoryGroup """ What are all the pieces I'm trying to test here? @@ -40,7 +39,6 @@ def trajectory_example(self): It simulates the deceleration of a vehicle under aerodynamic drag. """ - import openconcept.api as oc import openmdao.api as om import numpy as np @@ -79,7 +77,7 @@ def setup(self): def compute(self, inputs, outputs): outputs['force'] = -0.10 * inputs['velocity'] ** 2 - class VehicleModel(oc.IntegratorGroup): + class VehicleModel(IntegratorGroup): """ A user wishing to integrate an ODE rate will need to subclass this IntegratorGroup instead of the default OpenMDAO Group @@ -101,13 +99,13 @@ def setup(self): self.connect('velocity', 'drag.velocity') self.connect('drag.force','nsl.force') - class MyPhase(oc.PhaseGroup): + class MyPhase(PhaseGroup): "An OpenConcept Phase comprises part of a time-based TrajectoryGroup and always needs to have a 'duration' defined" def setup(self): self.add_subsystem('ivc', om.IndepVarComp('duration', val=5.0, units='s'), promotes_outputs=['duration']) self.add_subsystem('vm', VehicleModel(time_units='min', num_nodes=self.options['num_nodes'])) - class MyTraj(oc.TrajectoryGroup): + class MyTraj(TrajectoryGroup): "An OpenConcept TrajectoryGroup consists of one or more phases that may be linked together. This will often be a top-level model" def setup(self): self.add_subsystem('phase1', MyPhase(num_nodes=11)) @@ -133,7 +131,7 @@ def setup(self): # ============== IntegratorGroup Tests ========== # -class IntegratorGroupTestBase(oc.IntegratorGroup): +class IntegratorGroupTestBase(IntegratorGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -150,7 +148,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ode_integ']) class TestIntegratorSingleState(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -192,7 +190,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ec2', 'ode_integ']) class TestIntegratorMultipleState(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -238,7 +236,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ec2', 'ode_integ']) class TestIntegratorPromotes(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -285,7 +283,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ec2', 'ode_integ']) class TestIntegratorValLimits(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -329,7 +327,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ec2', 'ode_integ']) class TestIntegratorDuplicateRateName(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -360,7 +358,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ec2', 'ode_integ']) class TestIntegratorDuplicateStateName(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -392,9 +390,9 @@ def test_asserts(self): class TestIntegratorNoIntegratedState(unittest.TestCase): def setUp(self): self.nn = 5 - grp = oc.IntegratorGroup() + grp = IntegratorGroup() grp.add_subsystem('iv', om.IndepVarComp('a', val=1.0)) - phase = oc.PhaseGroup() + phase = PhaseGroup() phase.add_subsystem('iv', om.IndepVarComp('duration', val=3.0, units='s'), promotes_outputs=['*']) phase.add_subsystem('grp', grp) self.p = om.Problem(model=phase) @@ -410,7 +408,7 @@ def setup(self): class TestIntegratorWithGroup(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -439,7 +437,7 @@ def test_partials(self): assert_check_partials(partials) -class IntegratorGroupTestPromotedRate(oc.IntegratorGroup): +class IntegratorGroupTestPromotedRate(IntegratorGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -456,7 +454,7 @@ def setup(self): self.set_order(['iv', 'ec', 'ode_integ']) class TestIntegratorSingleStatePromotedRate(unittest.TestCase): - class TestPhase(oc.PhaseGroup): + class TestPhase(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -489,9 +487,9 @@ def test_partials(self): class TestPhaseNoTime(unittest.TestCase): def setUp(self): self.nn = 5 - grp = oc.IntegratorGroup() + grp = IntegratorGroup() grp.add_subsystem('iv', om.IndepVarComp('a', val=1.0)) - phase = oc.PhaseGroup() + phase = PhaseGroup() phase.add_subsystem('grp', grp) self.p = om.Problem(model=phase) @@ -506,7 +504,7 @@ def setUp(self): grp2 = om.Group() grp2a = grp2.add_subsystem('a', IntegratorGroupTestBase(num_nodes=self.nn)) grp2b = grp2.add_subsystem('b', IntegratorGroupTestBase(num_nodes=self.nn)) - phase = oc.PhaseGroup(num_nodes=self.nn) + phase = PhaseGroup(num_nodes=self.nn) phase.add_subsystem('iv', om.IndepVarComp('duration', val=5.0, units='s'), promotes_outputs=['*']) phase.add_subsystem('grp1', grp1) phase.add_subsystem('grp2', grp2) @@ -537,7 +535,7 @@ def setUp(self): grp2 = om.Group() grp2a = grp2.add_subsystem('a', IntegratorGroupTestBase(num_nodes=self.nn)) grp2b = grp2.add_subsystem('b', IntegratorGroupTestBase(num_nodes=self.nn)) - phase = oc.PhaseGroup(num_nodes=self.nn) + phase = PhaseGroup(num_nodes=self.nn) phase.add_subsystem('iv', om.IndepVarComp('duration', val=5.0, units='s'), promotes_outputs=['*']) phase.add_subsystem('grp1', grp1) phase.add_subsystem('grp2', grp2) @@ -565,7 +563,7 @@ def test_partials(self): # ============ Trajectory Tests ============ # -class PhaseForTrajTest(oc.PhaseGroup): +class PhaseForTrajTest(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -575,7 +573,7 @@ def setup(self): a = self.add_subsystem('a', IntegratorGroupTestBase(num_nodes=nn)) b = self.add_subsystem('b', IntegratorTestMultipleOutputs(num_nodes=nn)) -class PhaseForTrajTestWithPromotion(oc.PhaseGroup): +class PhaseForTrajTestWithPromotion(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -586,7 +584,7 @@ def setup(self): # promote the outputs of b b = self.add_subsystem('b', IntegratorTestMultipleOutputs(num_nodes=nn), promotes_outputs=['*f2*'], promotes_inputs=['*df2']) -class PhaseForTrajTestWithPromotionNamesCollide(oc.PhaseGroup): +class PhaseForTrajTestWithPromotionNamesCollide(PhaseGroup): def initialize(self): self.options.declare('num_nodes', default=1) @@ -600,7 +598,7 @@ def setup(self): class TestTrajectoryAllPhaseConnect(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTest(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTest(num_nodes=5)) @@ -641,7 +639,7 @@ def test_partials(self): class TestTrajectoryAllPhaseConnectWithVarPromotion(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTestWithPromotion(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTestWithPromotion(num_nodes=5)) @@ -682,7 +680,7 @@ def test_partials(self): class TestTrajectorySkipPromotedVar(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTestWithPromotion(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTestWithPromotion(num_nodes=5)) @@ -725,7 +723,7 @@ class TestTrajectoryAllPhaseConnectWithVarPromotionODEIntegCollide(unittest.Test # When this happens duplicate connections can occur def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTestWithPromotionNamesCollide(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTestWithPromotionNamesCollide(num_nodes=5)) @@ -766,7 +764,7 @@ def test_partials(self): class TestTrajectoryTwoPhaseConnect(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTest(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTest(num_nodes=5)) @@ -801,7 +799,7 @@ def test_results(self): class TestTrajectorySkipState(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTest(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTest(num_nodes=5)) @@ -837,7 +835,7 @@ def test_results(self): class TestTrajectoryLinkPhaseStrings(unittest.TestCase): def test_raises(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTest(num_nodes=5)) @@ -847,7 +845,7 @@ def test_raises(self): class TestBuryTrajectoryOneLevelDown(unittest.TestCase): def setUp(self): self.nn = 5 - traj = oc.TrajectoryGroup() + traj = TrajectoryGroup() phase1 = traj.add_subsystem('phase1', PhaseForTrajTest(num_nodes=5)) phase2 = traj.add_subsystem('phase2', PhaseForTrajTest(num_nodes=5)) diff --git a/openconcept/components/N3.py b/openconcept/propulsion/N3.py similarity index 98% rename from openconcept/components/N3.py rename to openconcept/propulsion/N3.py index 0798608f..bd28d3a0 100644 --- a/openconcept/components/N3.py +++ b/openconcept/propulsion/N3.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np import openmdao.api as om import openconcept @@ -39,7 +38,7 @@ def N3Hybrid(num_nodes=1, plot=False): num_nodes : int Number of analysis points to run (sets vec length; default 1) """ - file_root = openconcept.__path__[0] + r'/components/empirical_data/n+3_hybrid/' + file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/n+3_hybrid/' thrustdata = np.load(file_root + r'/power_off/thrust.npy') fuelburndata_0 = np.load(file_root + r'/power_off/wf.npy') smwdata_0 = np.load(file_root + r'/power_off/SMW.npy') @@ -269,7 +268,7 @@ def N3(num_nodes=1, plot=False): num_nodes : int Number of analysis points to run (sets vec length; default 1) """ - file_root = openconcept.__path__[0] + r'/components/empirical_data/n+3/' + file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/n+3/' thrustdata = np.load(file_root + r'/power_off/thrust.npy') fuelburndata_0 = np.load(file_root + r'/power_off/wf.npy') smwdata_0 = np.load(file_root + r'/power_off/SMW.npy') @@ -387,7 +386,7 @@ def N3(num_nodes=1, plot=False): def compare_thrust_decks(): import matplotlib.pyplot as plt prob = om.Problem() - from openconcept.components.N3 import N3, N3Hybrid + from openconcept.propulsion import N3, N3Hybrid prob.model.add_subsystem('n3', N3(num_nodes=1)) prob.model.add_subsystem('n3hybrid', N3Hybrid(num_nodes=1)) diff --git a/openconcept/propulsion/__init__.py b/openconcept/propulsion/__init__.py new file mode 100644 index 00000000..8f9bac97 --- /dev/null +++ b/openconcept/propulsion/__init__.py @@ -0,0 +1,21 @@ +# Components +from .cfm56 import CFM56 +from .generator import SimpleGenerator +from .motor import SimpleMotor +from .N3 import N3, N3Hybrid +from .propeller import SimplePropeller, WeightCalc, ThrustCalc, PropCoefficients +from .splitter import PowerSplit +from .turboshaft import SimpleTurboshaft + +# Pre-made propulsion systems +from .systems import ( + AllElectricSinglePropulsionSystemWithThermal_Compressible, + AllElectricSinglePropulsionSystemWithThermal_Incompressible, + SeriesHybridElectricPropulsionSystem, + SingleSeriesHybridElectricPropulsionSystem, + TwinSeriesHybridElectricPropulsionSystem, + TurbopropPropulsionSystem, + TwinTurbopropPropulsionSystem, + TwinSeriesHybridElectricThermalPropulsionSystem, + TwinSeriesHybridElectricThermalPropulsionRefrigerated, +) diff --git a/openconcept/components/cfm56.py b/openconcept/propulsion/cfm56.py similarity index 97% rename from openconcept/components/cfm56.py rename to openconcept/propulsion/cfm56.py index 972f4fea..f6f3ccb9 100644 --- a/openconcept/components/cfm56.py +++ b/openconcept/propulsion/cfm56.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np import openmdao.api as om import openconcept @@ -36,7 +35,7 @@ def CFM56(num_nodes=1, plot=False): num_nodes : int Number of analysis points to run (sets vec length; default 1) """ - file_root = openconcept.__path__[0] + r'/components/empirical_data/cfm56/' + file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/cfm56/' thrustdata = np.load(file_root + 'cfm56thrust.npy') fuelburndata = np.load(file_root + 'cfm56wf.npy') t4data = np.load(file_root + r'cfm56t4.npy') diff --git a/examples/methods/__init__.py b/openconcept/propulsion/empirical_data/__init__.py similarity index 100% rename from examples/methods/__init__.py rename to openconcept/propulsion/empirical_data/__init__.py diff --git a/openconcept/components/empirical_data/cfm56/cfm56smn.npy b/openconcept/propulsion/empirical_data/cfm56/cfm56smn.npy similarity index 100% rename from openconcept/components/empirical_data/cfm56/cfm56smn.npy rename to openconcept/propulsion/empirical_data/cfm56/cfm56smn.npy diff --git a/openconcept/components/empirical_data/cfm56/cfm56smw.npy b/openconcept/propulsion/empirical_data/cfm56/cfm56smw.npy similarity index 100% rename from openconcept/components/empirical_data/cfm56/cfm56smw.npy rename to openconcept/propulsion/empirical_data/cfm56/cfm56smw.npy diff --git a/openconcept/components/empirical_data/cfm56/cfm56t4.npy b/openconcept/propulsion/empirical_data/cfm56/cfm56t4.npy similarity index 100% rename from openconcept/components/empirical_data/cfm56/cfm56t4.npy rename to openconcept/propulsion/empirical_data/cfm56/cfm56t4.npy diff --git a/openconcept/components/empirical_data/cfm56/cfm56thrust.npy b/openconcept/propulsion/empirical_data/cfm56/cfm56thrust.npy similarity index 100% rename from openconcept/components/empirical_data/cfm56/cfm56thrust.npy rename to openconcept/propulsion/empirical_data/cfm56/cfm56thrust.npy diff --git a/openconcept/components/empirical_data/cfm56/cfm56wf.npy b/openconcept/propulsion/empirical_data/cfm56/cfm56wf.npy similarity index 100% rename from openconcept/components/empirical_data/cfm56/cfm56wf.npy rename to openconcept/propulsion/empirical_data/cfm56/cfm56wf.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/SMN.npy b/openconcept/propulsion/empirical_data/n+3/power_off/SMN.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/SMN.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/SMN.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/SMW.npy b/openconcept/propulsion/empirical_data/n+3/power_off/SMW.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/SMW.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/SMW.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/T4.npy b/openconcept/propulsion/empirical_data/n+3/power_off/T4.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/T4.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/T4.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/alt.npy b/openconcept/propulsion/empirical_data/n+3/power_off/alt.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/alt.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/alt.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/mach.npy b/openconcept/propulsion/empirical_data/n+3/power_off/mach.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/mach.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/mach.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/throttle.npy b/openconcept/propulsion/empirical_data/n+3/power_off/throttle.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/throttle.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/throttle.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/thrust.npy b/openconcept/propulsion/empirical_data/n+3/power_off/thrust.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/thrust.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/thrust.npy diff --git a/openconcept/components/empirical_data/n+3/power_off/wf.npy b/openconcept/propulsion/empirical_data/n+3/power_off/wf.npy similarity index 100% rename from openconcept/components/empirical_data/n+3/power_off/wf.npy rename to openconcept/propulsion/empirical_data/n+3/power_off/wf.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/SMW.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/SMW.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/SMW.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/SMW.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/alt.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/alt.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/alt.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/alt.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/mach.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/mach.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/mach.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/mach.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/throttle.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/throttle.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/throttle.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/throttle.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/thrust.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/thrust.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/thrust.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/thrust.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_off/wf.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_off/wf.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_off/wf.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_off/wf.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_on_1MW/SMW.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_on_1MW/SMW.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_on_1MW/SMW.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_on_1MW/SMW.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_on_1MW/wf.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_on_1MW/wf.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_on_1MW/wf.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_on_1MW/wf.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_on_500kW/SMW.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_on_500kW/SMW.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_on_500kW/SMW.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_on_500kW/SMW.npy diff --git a/openconcept/components/empirical_data/n+3_hybrid/power_on_500kW/wf.npy b/openconcept/propulsion/empirical_data/n+3_hybrid/power_on_500kW/wf.npy similarity index 100% rename from openconcept/components/empirical_data/n+3_hybrid/power_on_500kW/wf.npy rename to openconcept/propulsion/empirical_data/n+3_hybrid/power_on_500kW/wf.npy diff --git a/openconcept/components/empirical_data/prop_maps.py b/openconcept/propulsion/empirical_data/prop_maps.py similarity index 99% rename from openconcept/components/empirical_data/prop_maps.py rename to openconcept/propulsion/empirical_data/prop_maps.py index a5fff06b..7e400a40 100644 --- a/openconcept/components/empirical_data/prop_maps.py +++ b/openconcept/propulsion/empirical_data/prop_maps.py @@ -1,5 +1,4 @@ -from __future__ import division import numpy as np from openmdao.api import Group, Problem, IndepVarComp, ExplicitComponent from openmdao.components.meta_model_structured_comp import MetaModelStructuredComp diff --git a/openconcept/components/empirical_data/propmap_exetended.png b/openconcept/propulsion/empirical_data/propmap_exetended.png similarity index 100% rename from openconcept/components/empirical_data/propmap_exetended.png rename to openconcept/propulsion/empirical_data/propmap_exetended.png diff --git a/openconcept/components/generator.py b/openconcept/propulsion/generator.py similarity index 98% rename from openconcept/components/generator.py rename to openconcept/propulsion/generator.py index d3cbf44e..bdb9f2bd 100644 --- a/openconcept/components/generator.py +++ b/openconcept/propulsion/generator.py @@ -1,7 +1,5 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent -from openmdao.api import Group class SimpleGenerator(ExplicitComponent): diff --git a/openconcept/components/motor.py b/openconcept/propulsion/motor.py similarity index 98% rename from openconcept/components/motor.py rename to openconcept/propulsion/motor.py index 6ae6a6eb..dd7ea292 100644 --- a/openconcept/components/motor.py +++ b/openconcept/propulsion/motor.py @@ -1,7 +1,5 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent -from openmdao.api import Group class SimpleMotor(ExplicitComponent): diff --git a/openconcept/components/propeller.py b/openconcept/propulsion/propeller.py similarity index 99% rename from openconcept/components/propeller.py rename to openconcept/propulsion/propeller.py index 56c4ca62..dfedb25e 100644 --- a/openconcept/components/propeller.py +++ b/openconcept/propulsion/propeller.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent from openmdao.api import Group diff --git a/openconcept/components/splitter.py b/openconcept/propulsion/splitter.py similarity index 64% rename from openconcept/components/splitter.py rename to openconcept/propulsion/splitter.py index f72d4fd3..bdd5c5fe 100644 --- a/openconcept/components/splitter.py +++ b/openconcept/propulsion/splitter.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent from openmdao.api import Group @@ -182,126 +181,3 @@ def compute_partials(self, inputs, J): J['component_sizing_margin', 'power_in'] = 1 / inputs['power_rating'] J['component_sizing_margin', 'power_rating'] = - (inputs['power_in'] / inputs['power_rating'] ** 2) - - -class FlowSplit(ExplicitComponent): - """ - Split incoming flow from one inlet into two outlets at a fractional ratio. - - Inputs - ------ - mdot_in : float - Mass flow rate of incoming fluid (vector, kg/s) - mdot_split_fraction : float - Fraction of incoming mass flow directed to output A, must be in - range 0-1 inclusive (vector, dimensionless) - - Outputs - ------- - mdot_out_A : float - Mass flow rate directed to first output (vector, kg/s) - mdot_out_B : float - Mass flow rate directed to second output (vector, kg/s) - - Options - ------- - num_nodes : int - Number of analysis points to run (sets vec length; default 1) - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of analysis points') - - def setup(self): - nn = self.options['num_nodes'] - rng = np.arange(0, nn) - - self.add_input('mdot_in', units='kg/s', shape=(nn,)) - self.add_input('mdot_split_fraction', units=None, shape=(nn,), val=0.5) - - self.add_output('mdot_out_A', units='kg/s', shape=(nn,)) - self.add_output('mdot_out_B', units='kg/s', shape=(nn,)) - - self.declare_partials(['mdot_out_A'], ['mdot_in', 'mdot_split_fraction'], rows=rng, cols=rng) - self.declare_partials(['mdot_out_B'], ['mdot_in', 'mdot_split_fraction'], rows=rng, cols=rng) - - def compute(self, inputs, outputs): - if np.any(inputs['mdot_split_fraction'] < 0) or np.any(inputs['mdot_split_fraction'] > 1): - raise RuntimeWarning(f"mdot_split_fraction of {inputs['mdot_split_fraction']} has at least one element out of range [0, 1]") - outputs['mdot_out_A'] = inputs['mdot_in'] * inputs['mdot_split_fraction'] - outputs['mdot_out_B'] = inputs['mdot_in'] * (1 - inputs['mdot_split_fraction']) - - def compute_partials(self, inputs, J): - J['mdot_out_A', 'mdot_in'] = inputs['mdot_split_fraction'] - J['mdot_out_A', 'mdot_split_fraction'] = inputs['mdot_in'] - - J['mdot_out_B', 'mdot_in'] = 1 - inputs['mdot_split_fraction'] - J['mdot_out_B', 'mdot_split_fraction'] = - inputs['mdot_in'] - - -class FlowCombine(ExplicitComponent): - """ - Combines two incoming flows into a single outgoing flow and does a weighted average - of their temperatures based on the mass flow rate of each to compute the outlet temp. - - Inputs - ------ - mdot_in_A : float - Mass flow rate of fluid from first inlet, should be nonegative (vector, kg/s) - mdot_in_B : float - Mass flow rate of fluid from second inlet, should be nonnegative (vector, kg/s) - T_in_A : float - Temperature of fluid from first inlet (vector, K) - T_in_B : float - Temperature of fluid from second inlet (vector, K) - - Outputs - ------- - mdot_out : float - Outgoing fluid mass flow rate (vector, kg/s) - T_out : float - Outgoing fluid temperature (vector, K) - - Options - ------- - num_nodes : int - Number of analysis points (scalar, default 1) - """ - def initialize(self): - self.options.declare('num_nodes', default=1, desc='Number of analysis points') - - def setup(self): - nn = self.options['num_nodes'] - rng = np.arange(0, nn) - - self.add_input('mdot_in_A', units='kg/s', shape=(nn,)) - self.add_input('mdot_in_B', units='kg/s', shape=(nn,)) - self.add_input('T_in_A', units='K', shape=(nn,)) - self.add_input('T_in_B', units='K', shape=(nn,)) - - self.add_output('mdot_out', units='kg/s', shape=(nn,)) - self.add_output('T_out', units='K', shape=(nn,)) - - self.declare_partials(['mdot_out'], ['mdot_in_A', 'mdot_in_B'], rows=rng, cols=rng) - self.declare_partials(['T_out'], ['mdot_in_A', 'mdot_in_B', 'T_in_A', 'T_in_B'], rows=rng, cols=rng) - - def compute(self, inputs, outputs): - mdot_A = inputs['mdot_in_A'] - mdot_B = inputs['mdot_in_B'] - outputs['mdot_out'] = mdot_A + mdot_B - # Weighted average of temperatures for output temperature - outputs['T_out'] = (mdot_A * inputs['T_in_A'] + mdot_B * inputs['T_in_B']) / (mdot_A + mdot_B) - - def compute_partials(self, inputs, J): - nn = self.options['num_nodes'] - J['mdot_out', 'mdot_in_A'] = np.ones((nn,)) - J['mdot_out', 'mdot_in_B'] = np.ones((nn,)) - - mdot_A = inputs['mdot_in_A'] - mdot_B = inputs['mdot_in_B'] - mdot = mdot_A + mdot_B - T_A = inputs['T_in_A'] - T_B = inputs['T_in_B'] - J['T_out', 'mdot_in_A'] = (mdot * T_A - mdot_A * T_A - mdot_B * T_B) / (mdot**2) - J['T_out', 'mdot_in_B'] = (mdot * T_B - mdot_A * T_A - mdot_B * T_B) / (mdot**2) - J['T_out', 'T_in_A'] = mdot_A / mdot - J['T_out', 'T_in_B'] = mdot_B / mdot \ No newline at end of file diff --git a/openconcept/propulsion/systems/__init__.py b/openconcept/propulsion/systems/__init__.py new file mode 100644 index 00000000..8042d401 --- /dev/null +++ b/openconcept/propulsion/systems/__init__.py @@ -0,0 +1,14 @@ +from .simple_all_electric import ( + AllElectricSinglePropulsionSystemWithThermal_Compressible, + AllElectricSinglePropulsionSystemWithThermal_Incompressible, +) +from .simple_series_hybrid import ( + SeriesHybridElectricPropulsionSystem, + SingleSeriesHybridElectricPropulsionSystem, + TwinSeriesHybridElectricPropulsionSystem, +) +from .simple_turboprop import TurbopropPropulsionSystem, TwinTurbopropPropulsionSystem +from .thermal_series_hybrid import ( + TwinSeriesHybridElectricThermalPropulsionSystem, + TwinSeriesHybridElectricThermalPropulsionRefrigerated, +) diff --git a/examples/propulsion_layouts/simple_all_electric.py b/openconcept/propulsion/systems/simple_all_electric.py similarity index 93% rename from examples/propulsion_layouts/simple_all_electric.py rename to openconcept/propulsion/systems/simple_all_electric.py index f18a7756..7417bd21 100644 --- a/examples/propulsion_layouts/simple_all_electric.py +++ b/openconcept/propulsion/systems/simple_all_electric.py @@ -1,13 +1,8 @@ -from __future__ import division -from openconcept.components.motor import SimpleMotor -from openconcept.components.propeller import SimplePropeller -from openconcept.components.battery import SOCBattery -from openconcept.utilities.dvlabel import DVLabel -from openconcept.utilities.math import AddSubtractComp -from openmdao.api import Group, IndepVarComp, ExplicitComponent -from openconcept.components.thermal import LiquidCooledComp, CoolantReservoir -from openconcept.components.ducts import ImplicitCompressibleDuct, ExplicitIncompressibleDuct -from openconcept.components.heat_exchanger import HXGroup +from openconcept.propulsion import SimpleMotor, SimplePropeller +from openconcept.energy_storage import SOCBattery +from openconcept.utilities import DVLabel +from openmdao.api import Group, IndepVarComp +from openconcept.thermal import LiquidCooledComp, CoolantReservoir, ImplicitCompressibleDuct, ExplicitIncompressibleDuct, HXGroup import numpy as np class AllElectricSinglePropulsionSystemWithThermal_Compressible(Group): diff --git a/examples/propulsion_layouts/simple_series_hybrid.py b/openconcept/propulsion/systems/simple_series_hybrid.py similarity index 72% rename from examples/propulsion_layouts/simple_series_hybrid.py rename to openconcept/propulsion/systems/simple_series_hybrid.py index 3d6134ec..7e107127 100644 --- a/examples/propulsion_layouts/simple_series_hybrid.py +++ b/openconcept/propulsion/systems/simple_series_hybrid.py @@ -1,33 +1,132 @@ -from __future__ import division -from openconcept.components.motor import SimpleMotor -from openconcept.components.splitter import PowerSplit -from openconcept.components.generator import SimpleGenerator -from openconcept.components.turboshaft import SimpleTurboshaft -from openconcept.components.battery import SimpleBattery, SOCBattery -from openconcept.components.propeller import SimplePropeller -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties -from openconcept.utilities.dvlabel import DVLabel -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp - -from openmdao.api import Problem, Group, IndepVarComp, BalanceComp, DirectSolver, NewtonSolver, ScipyKrylov +from openconcept.propulsion import SimpleMotor, PowerSplit, SimpleGenerator, SimpleTurboshaft, SimplePropeller +from openconcept.energy_storage import SimpleBattery, SOCBattery +from openconcept.utilities import DVLabel, AddSubtractComp, ElementMultiplyDivideComp +from openmdao.api import Problem, Group, IndepVarComp, BalanceComp import numpy as np -from openmdao.api import ExplicitComponent +class TwinSeriesHybridElectricPropulsionSystem(Group): + """ + This is an example model of a series-hybrid propulsion system. One motor + draws electrical load from two sources in a fractional split| a battery pack, + and a turbogenerator setup. The control inputs are the power split fraction and the + motor throttle setting; the turboshaft throttle matches the power level necessary + to drive the generator at the required power level. + Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. -class SeriesHybridElectricPropulsionSystem(Group): - """This is an example model of a series-hybrid propulsion system. One motor - draws electrical load from two sources in a fractional split| a battery pack, - and a turbogenerator setup. The control inputs are the power split fraction and the - motor throttle setting; the turboshaft throttle matches the power level necessary - to drive the generator at the required power level. + The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used + by varying the power_split_fraction + + This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: + - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A + + The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. + SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) + + Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. + + .. code:: + + eng1.throttle hybrid_split.power_split_fraction motor1.throttle + || || || + eng1 --shaft_power_out--> gen1 --elec_power_out--> {IMPLICIT GAP} <--power_out_B || <--elec_load-- motor1 --shaft_power_out --> prop1 -->thrust + || hybrid_split <--elec_load ++ + || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust + V V || + fuel_flow (integrate over time) elec_load (integrate over time to obtain SOC) motor2.throttle + + """ + def initialize(self): + self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") + + + def setup(self): + nn = self.options['num_nodes'] + + #define design variables that are independent of flight condition or control states + dvlist = [['ac|propulsion|engine|rating','eng_rating',260.0,'kW'], + ['ac|propulsion|propeller|diameter','prop_diameter',2.5,'m'], + ['ac|propulsion|motor|rating','motor_rating',240.0,'kW'], + ['ac|propulsion|generator|rating','gen_rating',250.0,'kW'], + ['ac|weights|W_battery','batt_weight',2000,'kg'], + ['ac|propulsion|battery|specific_energy','specific_energy',300,'W*h/kg'] + ] + + self.add_subsystem('dvs',DVLabel(dvlist),promotes_inputs=["*"],promotes_outputs=["*"]) + #introduce model components + self.add_subsystem('motor1', SimpleMotor(efficiency=0.97,num_nodes=nn),promotes_inputs=['throttle']) + self.add_subsystem('prop1',SimplePropeller(num_nodes=nn),promotes_inputs=["fltcond|*"]) + self.connect('motor1.shaft_power_out','prop1.shaft_power_in') + + #propulsion models expect a high-level 'throttle' parameter and a 'propulsor_active' flag to set individual throttles + failedengine = ElementMultiplyDivideComp() + failedengine.add_equation('motor2throttle',input_names=['throttle','propulsor_active'],vec_size=nn) + self.add_subsystem('failedmotor', failedengine, + promotes_inputs=['throttle', 'propulsor_active']) + + self.add_subsystem('motor2', SimpleMotor(efficiency=0.97,num_nodes=nn)) + self.add_subsystem('prop2',SimplePropeller(num_nodes=nn),promotes_inputs=["fltcond|*"]) + self.connect('motor2.shaft_power_out','prop2.shaft_power_in') + self.connect('failedmotor.motor2throttle','motor2.throttle') - Fuel flows and prop thrust should be fairly accurate. - Heat constraints have not yet been incorporated. + + addpower = AddSubtractComp(output_name='motors_elec_load',input_names=['motor1_elec_load','motor2_elec_load'], units='kW',vec_size=nn) + addpower.add_equation(output_name='thrust',input_names=['prop1_thrust','prop2_thrust'], units='N',vec_size=nn) + self.add_subsystem('add_power',subsys=addpower,promotes_outputs=['*']) + self.connect('motor1.elec_load','add_power.motor1_elec_load') + self.connect('motor2.elec_load','add_power.motor2_elec_load') + self.connect('prop1.thrust','add_power.prop1_thrust') + self.connect('prop2.thrust','add_power.prop2_thrust') + + self.add_subsystem('hybrid_split',PowerSplit(rule='fraction',num_nodes=nn)) + self.connect('motors_elec_load','hybrid_split.power_in') + + self.add_subsystem('eng1',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104),promotes_outputs=["fuel_flow"]) + self.add_subsystem('gen1',SimpleGenerator(efficiency=0.97,num_nodes=nn)) + + self.connect('eng1.shaft_power_out','gen1.shaft_power_in') + + self.add_subsystem('batt1', SOCBattery(num_nodes=nn, efficiency=0.97),promotes_inputs=["duration", "specific_energy"]) + self.connect('hybrid_split.power_out_A','batt1.elec_load') + self.add_subsystem('eng_throttle_set',BalanceComp(name='eng_throttle', val=np.ones((nn,))*0.5, units=None, eq_units='kW', rhs_name='gen_power_required',lhs_name='gen_power_available')) + self.connect('hybrid_split.power_out_B','eng_throttle_set.gen_power_required') + self.connect('gen1.elec_power_out','eng_throttle_set.gen_power_available') + self.connect('eng_throttle_set.eng_throttle','eng1.throttle') + + addweights = AddSubtractComp(output_name='motors_weight',input_names=['motor1_weight','motor2_weight'], units='kg') + addweights.add_equation(output_name='propellers_weight',input_names=['prop1_weight','prop2_weight'], units='kg') + self.add_subsystem('add_weights',subsys=addweights,promotes_inputs=['*'],promotes_outputs=['*']) + relabel = [['hybrid_split_A_in','battery_load',np.ones(nn)*260.0,'kW']] + self.add_subsystem('relabel',DVLabel(relabel),promotes_outputs=["battery_load"]) + self.connect('hybrid_split.power_out_A','relabel.hybrid_split_A_in') + + self.connect('motor1.component_weight','motor1_weight') + self.connect('motor2.component_weight','motor2_weight') + self.connect('prop1.component_weight','prop1_weight') + self.connect('prop2.component_weight','prop2_weight') + + #connect design variables to model component inputs + self.connect('eng_rating','eng1.shaft_power_rating') + self.connect('prop_diameter',['prop1.diameter','prop2.diameter']) + self.connect('motor_rating',['motor1.elec_power_rating','motor2.elec_power_rating']) + self.connect('motor_rating',['prop1.power_rating','prop2.power_rating']) + self.connect('gen_rating','gen1.elec_power_rating') + self.connect('batt_weight','batt1.battery_weight') + + +class SeriesHybridElectricPropulsionSystem(Group): + """ + This is an example model of a series-hybrid propulsion system. One motor + draws electrical load from two sources in a fractional split| a battery pack, + and a turbogenerator setup. The control inputs are the power split fraction and the + motor throttle setting; the turboshaft throttle matches the power level necessary + to drive the generator at the required power level. + Fuel flows and prop thrust should be fairly accurate. + Heat constraints have not yet been incorporated. """ def initialize(self): self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") @@ -84,56 +183,57 @@ def setup(self): class SingleSeriesHybridElectricPropulsionSystem(Group): - """This is an example model of a series-hybrid propulsion system. One motor - draws electrical load from two sources in a fractional split| a battery pack, - and a turbogenerator setup. The control inputs are the power split fraction and the - motor throttle setting; the turboshaft throttle matches the power level necessary - to drive the generator at the required power level. - - Fuel flows and prop thrust should be fairly accurate. - Heat constraints haven't yet been incorporated. - - The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. - She may also vary the percentage of battery versus fuel being - used by varying the power_split_fraction. - - This module alone cannot produce accurate fuel flows, battery loads, etc. - You must do the following, either with an implicit solver or with the optimizer: - - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A - - The battery does not track its own state of charge (SOC); - it is connected to elec_load simply so that the discharge rate can be compared to - the discharge rate capability of the battery. SOC and fuel flows should be time-integrated - at a higher level (in the mission analysis codes). - - Inputs - ------ - ac|propulsion|engine|rating : float - Turboshaft range extender power rating (scalar, kW) - ac|propulsion|propeller|diameter : float - Propeller diameter (scalar, m) - ac|propulsion|motor|rating : float - Motor power rating (scalar, kW) - ac|propulsion|generator|rating : float - Range extender elec gen rating (scalar, kW) - ac|weights|W_battery : float - Battery weight (scalar, kg) - - TODO list all the control inputs - - Outputs - ------- - thrust : float - Propulsion system total thrust (vector, N) - fuel_flow : float - Fuel flow consumed by the turboshaft (vector, kg/s) - - Options - ------- - num_nodes : float - Number of analysis points to run (default 1) - specific_energy : float - Battery specific energy (default 300 Wh/kg) + """ + This is an example model of a series-hybrid propulsion system. One motor + draws electrical load from two sources in a fractional split| a battery pack, + and a turbogenerator setup. The control inputs are the power split fraction and the + motor throttle setting; the turboshaft throttle matches the power level necessary + to drive the generator at the required power level. + + Fuel flows and prop thrust should be fairly accurate. + Heat constraints haven't yet been incorporated. + + The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. + She may also vary the percentage of battery versus fuel being + used by varying the power_split_fraction. + + This module alone cannot produce accurate fuel flows, battery loads, etc. + You must do the following, either with an implicit solver or with the optimizer: + - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A + + The battery does not track its own state of charge (SOC); + it is connected to elec_load simply so that the discharge rate can be compared to + the discharge rate capability of the battery. SOC and fuel flows should be time-integrated + at a higher level (in the mission analysis codes). + + Inputs + ------ + ac|propulsion|engine|rating : float + Turboshaft range extender power rating (scalar, kW) + ac|propulsion|propeller|diameter : float + Propeller diameter (scalar, m) + ac|propulsion|motor|rating : float + Motor power rating (scalar, kW) + ac|propulsion|generator|rating : float + Range extender elec gen rating (scalar, kW) + ac|weights|W_battery : float + Battery weight (scalar, kg) + + TODO list all the control inputs + + Outputs + ------- + thrust : float + Propulsion system total thrust (vector, N) + fuel_flow : float + Fuel flow consumed by the turboshaft (vector, kg/s) + + Options + ------- + num_nodes : float + Number of analysis points to run (default 1) + specific_energy : float + Battery specific energy (default 300 Wh/kg) """ def initialize(self): self.options.declare('num_nodes', default=1, desc="Number of mission analysis points to run") @@ -205,116 +305,6 @@ def setup(self): self.connect('gen_rating', 'gen1.elec_power_rating') self.connect('batt_weight', 'batt1.battery_weight') -class TwinSeriesHybridElectricPropulsionSystem(Group): - """This is an example model of a series-hybrid propulsion system. One motor - draws electrical load from two sources in a fractional split| a battery pack, - and a turbogenerator setup. The control inputs are the power split fraction and the - motor throttle setting; the turboshaft throttle matches the power level necessary - to drive the generator at the required power level. - - Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. - - The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used - by varying the power_split_fraction - - This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: - - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A - - The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. - SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) - - Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. - - eng1.throttle hybrid_split.power_split_fraction motor1.throttle - || || || - eng1 --shaft_power_out--> gen1 --elec_power_out--> {IMPLICIT GAP} <--power_out_B || <--elec_load-- motor1 --shaft_power_out --> prop1 -->thrust - || hybrid_split <--elec_load ++ - || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust - V V || - fuel_flow (integrate over time) elec_load (integrate over time to obtain SOC) motor2.throttle - - - """ - def initialize(self): - self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") - - - def setup(self): - nn = self.options['num_nodes'] - - #define design variables that are independent of flight condition or control states - dvlist = [['ac|propulsion|engine|rating','eng_rating',260.0,'kW'], - ['ac|propulsion|propeller|diameter','prop_diameter',2.5,'m'], - ['ac|propulsion|motor|rating','motor_rating',240.0,'kW'], - ['ac|propulsion|generator|rating','gen_rating',250.0,'kW'], - ['ac|weights|W_battery','batt_weight',2000,'kg'], - ['ac|propulsion|battery|specific_energy','specific_energy',300,'W*h/kg'] - ] - - self.add_subsystem('dvs',DVLabel(dvlist),promotes_inputs=["*"],promotes_outputs=["*"]) - #introduce model components - self.add_subsystem('motor1', SimpleMotor(efficiency=0.97,num_nodes=nn),promotes_inputs=['throttle']) - self.add_subsystem('prop1',SimplePropeller(num_nodes=nn),promotes_inputs=["fltcond|*"]) - self.connect('motor1.shaft_power_out','prop1.shaft_power_in') - - #propulsion models expect a high-level 'throttle' parameter and a 'propulsor_active' flag to set individual throttles - failedengine = ElementMultiplyDivideComp() - failedengine.add_equation('motor2throttle',input_names=['throttle','propulsor_active'],vec_size=nn) - self.add_subsystem('failedmotor', failedengine, - promotes_inputs=['throttle', 'propulsor_active']) - - self.add_subsystem('motor2', SimpleMotor(efficiency=0.97,num_nodes=nn)) - self.add_subsystem('prop2',SimplePropeller(num_nodes=nn),promotes_inputs=["fltcond|*"]) - self.connect('motor2.shaft_power_out','prop2.shaft_power_in') - self.connect('failedmotor.motor2throttle','motor2.throttle') - - - - addpower = AddSubtractComp(output_name='motors_elec_load',input_names=['motor1_elec_load','motor2_elec_load'], units='kW',vec_size=nn) - addpower.add_equation(output_name='thrust',input_names=['prop1_thrust','prop2_thrust'], units='N',vec_size=nn) - self.add_subsystem('add_power',subsys=addpower,promotes_outputs=['*']) - self.connect('motor1.elec_load','add_power.motor1_elec_load') - self.connect('motor2.elec_load','add_power.motor2_elec_load') - self.connect('prop1.thrust','add_power.prop1_thrust') - self.connect('prop2.thrust','add_power.prop2_thrust') - - self.add_subsystem('hybrid_split',PowerSplit(rule='fraction',num_nodes=nn)) - self.connect('motors_elec_load','hybrid_split.power_in') - - self.add_subsystem('eng1',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104),promotes_outputs=["fuel_flow"]) - self.add_subsystem('gen1',SimpleGenerator(efficiency=0.97,num_nodes=nn)) - - self.connect('eng1.shaft_power_out','gen1.shaft_power_in') - - self.add_subsystem('batt1', SOCBattery(num_nodes=nn, efficiency=0.97),promotes_inputs=["duration", "specific_energy"]) - self.connect('hybrid_split.power_out_A','batt1.elec_load') - # TODO set val= right number of nn - self.add_subsystem('eng_throttle_set',BalanceComp(name='eng_throttle', val=np.ones((nn,))*0.5, units=None, eq_units='kW', rhs_name='gen_power_required',lhs_name='gen_power_available')) - #need to use the optimizer to drive hybrid_split.power_out_B to the same value as gen1.elec_power_out - self.connect('hybrid_split.power_out_B','eng_throttle_set.gen_power_required') - self.connect('gen1.elec_power_out','eng_throttle_set.gen_power_available') - self.connect('eng_throttle_set.eng_throttle','eng1.throttle') - - addweights = AddSubtractComp(output_name='motors_weight',input_names=['motor1_weight','motor2_weight'], units='kg') - addweights.add_equation(output_name='propellers_weight',input_names=['prop1_weight','prop2_weight'], units='kg') - self.add_subsystem('add_weights',subsys=addweights,promotes_inputs=['*'],promotes_outputs=['*']) - relabel = [['hybrid_split_A_in','battery_load',np.ones(nn)*260.0,'kW']] - self.add_subsystem('relabel',DVLabel(relabel),promotes_outputs=["battery_load"]) - self.connect('hybrid_split.power_out_A','relabel.hybrid_split_A_in') - - self.connect('motor1.component_weight','motor1_weight') - self.connect('motor2.component_weight','motor2_weight') - self.connect('prop1.component_weight','prop1_weight') - self.connect('prop2.component_weight','prop2_weight') - - #connect design variables to model component inputs - self.connect('eng_rating','eng1.shaft_power_rating') - self.connect('prop_diameter',['prop1.diameter','prop2.diameter']) - self.connect('motor_rating',['motor1.elec_power_rating','motor2.elec_power_rating']) - self.connect('motor_rating',['prop1.power_rating','prop2.power_rating']) - self.connect('gen_rating','gen1.elec_power_rating') - self.connect('batt_weight','batt1.battery_weight') - class VehicleSizingModel(Group): def setup(self): @@ -329,11 +319,11 @@ def setup(self): if __name__ == "__main__": + import matplotlib.pyplot as plt - from openconcept.simple_series_hybrid import VehicleSizingModel as VSM prob = Problem() - prob.model= VSM() + prob.model= VehicleSizingModel() prob.setup() prob.run_model() diff --git a/openconcept/propulsion/systems/simple_turboprop.py b/openconcept/propulsion/systems/simple_turboprop.py new file mode 100644 index 00000000..9d34e247 --- /dev/null +++ b/openconcept/propulsion/systems/simple_turboprop.py @@ -0,0 +1,142 @@ +from openconcept.propulsion import SimpleTurboshaft, SimplePropeller +from openconcept.utilities import DVLabel, AddSubtractComp, ElementMultiplyDivideComp +from openmdao.api import Group + +class TurbopropPropulsionSystem(Group): + """ + This is an example model of the simplest possible propulsion system + consisting of a constant-speed prop and a turboshaft. + + This is the Pratt and Whitney Canada PT6A-66D with 4-bladed + propeller used by the SOCATA-DAHER TBM-850. + + Inputs + ------ + ac|propulsion|engine|rating : float + The maximum rated shaft power of the engine (scalar, default 850 hp) + ac|propulsion|propeller|diameter : float + Diameter of the propeller (scalar, default 2.3 m) + throttle : float + Throttle for the turboshaft (vector) + fltcond|rho : float + Air density (vector, kg/m**3) + fltcond|Utrue : float + True airspeed (vector, m/s) + + Outputs + ------- + thrust : float + Thrust force (vector, N) + fuel_flow : float + Fuel mass flow rate (vector, kg/s) + + Options + ------- + num_nodes : float + Number of analysis points to run (default 1) + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc="Number of mission analysis points to run") + + def setup(self): + nn = self.options['num_nodes'] + + # rename incoming design variables + dvlist = [['ac|propulsion|engine|rating', 'eng1_rating', 850, 'hp'], + ['ac|propulsion|propeller|diameter', 'prop1_diameter', 2.3, 'm']] + self.add_subsystem('dvs', DVLabel(dvlist), + promotes_inputs=["*"], promotes_outputs=["*"]) + + # introduce model components + self.add_subsystem('eng1', + SimpleTurboshaft(num_nodes=nn, weight_inc=0.14 / 1000, weight_base=104), + promotes_inputs=["throttle", ("shaft_power_rating", "ac|propulsion|engine|rating")], promotes_outputs=["fuel_flow"]) + self.add_subsystem('prop1', + SimplePropeller(num_nodes=nn, num_blades=4, + design_J=2.2, design_cp=0.55), + promotes_inputs=["fltcond|*", ("power_rating", "ac|propulsion|engine|rating"), + ("diameter", "ac|propulsion|propeller|diameter")], + promotes_outputs=["thrust"]) + + # Set default values for the engine rating and prop diameter + self.set_input_defaults("ac|propulsion|engine|rating", 850., units="hp") + self.set_input_defaults("ac|propulsion|propeller|diameter", 2.3, units="m") + + # Connect shaft power from turboshaft to propeller + self.connect('eng1.shaft_power_out', 'prop1.shaft_power_in') + + +class TwinTurbopropPropulsionSystem(Group): + """ + This is an example model multiple constant-speed props and turboshafts. + These are two P&W Canada PT6A-135A with 4-bladed Hartzell propellers used by the Beechcraft King Air C90GT + https://www.easa.europa.eu/sites/default/files/dfu/TCDS_EASA-IM-A-503_C90-Series%20issue%206.pdf + + Inputs + ------ + ac|propulsion|engine|rating : float + The maximum rated shaft power of the engine (scalar, default 850 hp) + ac|propulsion|propeller|diameter : float + Diameter of the propeller (scalar, default 2.3 m) + throttle : float + Throttle for the turboshaft (vector) + fltcond|rho : float + Air density (vector, kg/m**3) + fltcond|Utrue : float + True airspeed (vector, m/s) + propulsor_active : float + 1 if second propulsor is active or 0 if not (vector) + + Outputs + ------- + thrust : float + Thrust force (vector, N) + fuel_flow : float + Fuel mass flow rate (vector, kg/s) + + Options + ------- + num_nodes : float + Number of analysis points to run (default 1) + """ + def initialize(self): + self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") + + def setup(self): + nn = self.options['num_nodes'] + + # Introduce turboshaft and propeller components (one for each side) + self.add_subsystem('eng1',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104),promotes_inputs=['throttle', ("shaft_power_rating", "ac|propulsion|engine|rating")]) + self.add_subsystem('prop1',SimplePropeller(num_nodes=nn,num_blades=4,design_J=2.2,design_cp=0.55),promotes_inputs=["fltcond|*", ("power_rating", "ac|propulsion|engine|rating"), ("diameter", "ac|propulsion|propeller|diameter")]) + self.add_subsystem('eng2',SimpleTurboshaft(num_nodes=nn,weight_inc=0.14/1000,weight_base=104),promotes_inputs=[("shaft_power_rating", "ac|propulsion|engine|rating")]) + self.add_subsystem('prop2',SimplePropeller(num_nodes=nn,num_blades=4,design_J=2.2,design_cp=0.55),promotes_inputs=["fltcond|*", ("power_rating", "ac|propulsion|engine|rating"), ("diameter", "ac|propulsion|propeller|diameter")]) + + # Set default values for the engine rating and prop diameter + self.set_input_defaults("ac|propulsion|engine|rating", 750., units="hp") + self.set_input_defaults("ac|propulsion|propeller|diameter", 2.28, units="m") + + # Propulsion models expect a high-level 'throttle' parameter and a 'propulsor_active' flag to set individual throttles + failedengine = ElementMultiplyDivideComp() + failedengine.add_equation('eng2throttle',input_names=['throttle','propulsor_active'],vec_size=nn) + self.add_subsystem('failedengine', failedengine, + promotes_inputs=['throttle', 'propulsor_active']) + self.connect('failedengine.eng2throttle','eng2.throttle') + + # Connect components to each other + self.connect('eng1.shaft_power_out','prop1.shaft_power_in') + self.connect('eng2.shaft_power_out','prop2.shaft_power_in') + + # Add up the weights, thrusts and fuel flows + add1 = AddSubtractComp(output_name='fuel_flow',input_names=['eng1_fuel_flow','eng2_fuel_flow'],vec_size=nn, units='kg/s') + add1.add_equation(output_name='thrust',input_names=['prop1_thrust','prop2_thrust'],vec_size=nn, units='N') + add1.add_equation(output_name='engines_weight',input_names=['eng1_weight','eng2_weight'], units='kg') + add1.add_equation(output_name='propellers_weight',input_names=['prop1_weight','prop2_weight'], units='kg') + self.add_subsystem('adder',subsys=add1,promotes_inputs=["*"],promotes_outputs=["*"]) + self.connect('prop1.thrust','prop1_thrust') + self.connect('prop2.thrust','prop2_thrust') + self.connect('eng1.fuel_flow','eng1_fuel_flow') + self.connect('eng2.fuel_flow','eng2_fuel_flow') + self.connect('prop1.component_weight','prop1_weight') + self.connect('prop2.component_weight','prop2_weight') + self.connect('eng1.component_weight','eng1_weight') + self.connect('eng2.component_weight','eng2_weight') diff --git a/examples/propulsion_layouts/thermal_series_hybrid.py b/openconcept/propulsion/systems/thermal_series_hybrid.py similarity index 79% rename from examples/propulsion_layouts/thermal_series_hybrid.py rename to openconcept/propulsion/systems/thermal_series_hybrid.py index 88601b35..eb8b6cbb 100644 --- a/examples/propulsion_layouts/thermal_series_hybrid.py +++ b/openconcept/propulsion/systems/thermal_series_hybrid.py @@ -1,56 +1,50 @@ -from __future__ import division -from openconcept.components.motor import SimpleMotor -from openconcept.components.splitter import PowerSplit -from openconcept.components.generator import SimpleGenerator -from openconcept.components.turboshaft import SimpleTurboshaft +from openconcept.propulsion import SimpleMotor, PowerSplit, SimpleGenerator, SimpleTurboshaft, SimplePropeller # I had to move specific energy into a design variable to get this outer loop to work correctly -from openconcept.components.battery import SimpleBattery, SOCBattery -from openconcept.components.propeller import SimplePropeller -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties -from openconcept.utilities.dvlabel import DVLabel -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp -from openconcept.components.thermal import LiquidCooledComp, CoolantReservoir, ConstantSurfaceTemperatureColdPlate_NTU -from openconcept.components.chiller import HeatPumpWithIntegratedCoolantLoop -from openconcept.components.ducts import ImplicitCompressibleDuct, ExplicitIncompressibleDuct -from openconcept.components.heat_exchanger import HXGroup - - -from openmdao.api import Problem, Group, IndepVarComp, BalanceComp, DirectSolver, NewtonSolver, ScipyKrylov - +from openconcept.energy_storage import SOCBattery +from openconcept.utilities import DVLabel, AddSubtractComp, ElementMultiplyDivideComp +from openconcept.thermal import ( + LiquidCooledComp, + CoolantReservoir, + HeatPumpWithIntegratedCoolantLoop, + ExplicitIncompressibleDuct, + HXGroup, +) + +from openmdao.api import Problem, Group, IndepVarComp, BalanceComp import numpy as np -from openmdao.api import ExplicitComponent, ExecComp +class TwinSeriesHybridElectricThermalPropulsionSystem(Group): + """ + This is an example model of a series-hybrid propulsion system. One motor + draws electrical load from two sources in a fractional split| a battery pack, + and a turbogenerator setup. The control inputs are the power split fraction and the + motor throttle setting; the turboshaft throttle matches the power level necessary + to drive the generator at the required power level. -class TwinSeriesHybridElectricPropulsionSystem(Group): - """This is an example model of a series-hybrid propulsion system. One motor - draws electrical load from two sources in a fractional split| a battery pack, - and a turbogenerator setup. The control inputs are the power split fraction and the - motor throttle setting; the turboshaft throttle matches the power level necessary - to drive the generator at the required power level. + Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. - Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. + The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used + by varying the power_split_fraction - The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used - by varying the power_split_fraction + This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: + - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A - This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: - - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A + The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. + SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) - The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. - SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) + Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. - Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. + .. code:: eng1.throttle hybrid_split.power_split_fraction motor1.throttle || || || eng1 --shaft_power_out--> gen1 --elec_power_out--> {IMPLICIT GAP} <--power_out_B || <--elec_load-- motor1 --shaft_power_out --> prop1 -->thrust - || hybrid_split <--elec_load ++ - || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust + || hybrid_split <--elec_load ++ + || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust V V || fuel_flow (integrate over time) elec_load (integrate over time to obtain SOC) motor2.throttle - """ def initialize(self): self.options.declare('num_nodes',default=1,desc="Number of mission analysis points to run") @@ -59,6 +53,8 @@ def initialize(self): def setup(self): nn = self.options['num_nodes'] + print(nn) + #define design variables that are independent of flight condition or control states dvlist = [['ac|propulsion|engine|rating','eng_rating',260.0,'kW'], ['ac|propulsion|propeller|diameter','prop_diameter',2.5,'m'], @@ -112,9 +108,7 @@ def setup(self): self.add_subsystem('batt1', SOCBattery(num_nodes=nn, efficiency=0.97),promotes_inputs=["duration",'specific_energy']) self.connect('hybrid_split.power_out_A','batt1.elec_load') - # TODO set val= right number of nn self.add_subsystem('eng_throttle_set',BalanceComp(name='eng_throttle', val=np.ones((nn,))*0.5, units=None, eq_units='kW', rhs_name='gen_power_required',lhs_name='gen_power_available')) - #need to use the optimizer to drive hybrid_split.power_out_B to the same value as gen1.elec_power_out self.connect('hybrid_split.power_out_B','eng_throttle_set.gen_power_required') self.connect('gen1.elec_power_out','eng_throttle_set.gen_power_available') self.connect('eng_throttle_set.eng_throttle','eng1.throttle') @@ -184,34 +178,37 @@ def setup(self): 'reservoir.mdot_coolant']) -class TwinSeriesHybridElectricPropulsionRefrigerated(Group): - """This is an example model of a series-hybrid propulsion system that uses active - refrigeration to cool the electrical components. Other than the addition of - a refrigerator in the coolant loop, this model is identical to - TwinSeriesHybridElectricPropulsionSystem. One motor draws electrical - load from two sources in a fractional split| a battery pack, and a - turbogenerator setup. The control inputs are the power split fraction and the - motor throttle setting; the turboshaft throttle matches the power level necessary - to drive the generator at the required power level. +class TwinSeriesHybridElectricThermalPropulsionRefrigerated(Group): + """ + This is an example model of a series-hybrid propulsion system that uses active + refrigeration to cool the electrical components. Other than the addition of + a refrigerator in the coolant loop, this model is identical to + TwinSeriesHybridElectricPropulsionSystem. One motor draws electrical + load from two sources in a fractional split| a battery pack, and a + turbogenerator setup. The control inputs are the power split fraction and the + motor throttle setting; the turboshaft throttle matches the power level necessary + to drive the generator at the required power level. + + Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. - Fuel flows and prop thrust should be fairly accurate. Heat constraints haven't yet been incorporated. + The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used + by varying the power_split_fraction - The "pilot" controls thrust by varying the motor throttles from 0 to 100+% of rated power. She may also vary the percentage of battery versus fuel being used - by varying the power_split_fraction + This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: + - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A - This module alone cannot produce accurate fuel flows, battery loads, etc. You must do the following, either with an implicit solver or with the optimizer: - - Set eng1.throttle such that gen1.elec_power_out = hybrid_split.power_out_A + The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. + SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) - The battery does not track its own state of charge (SOC); it is connected to elec_load simply so that the discharge rate can be compared to the discharge rate capability of the battery. - SOC and fuel flows should be time-integrated at a higher level (in the mission analysis codes) + Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. - Arrows show flow of information. In openConcept, mechanical power operates on a 'push' basis, while electrical load operates on a 'pull' basis. We reconcile these flows across an implicit gap by driving a residual to 0 using a solver. + .. code:: eng1.throttle hybrid_split.power_split_fraction motor1.throttle || || || eng1 --shaft_power_out--> gen1 --elec_power_out--> {IMPLICIT GAP} <--power_out_B || <--elec_load-- motor1 --shaft_power_out --> prop1 -->thrust - || hybrid_split <--elec_load ++ - || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust + || hybrid_split <--elec_load ++ + || batt1.elec_load <--power_out_A <--elec_load-- motor2 --shaft_power_out --> prop2 -->thrust V V || fuel_flow (integrate over time) elec_load (integrate over time to obtain SOC) motor2.throttle @@ -382,11 +379,11 @@ def setup(self): if __name__ == "__main__": + import matplotlib.pyplot as plt - from openconcept.simple_series_hybrid import VehicleSizingModel as VSM prob = Problem() - prob.model= VSM() + prob.model= VehicleSizingModel() prob.setup() prob.run_model() diff --git a/openconcept/components/tests/test_N3.py b/openconcept/propulsion/tests/test_N3.py similarity index 95% rename from openconcept/components/tests/test_N3.py rename to openconcept/propulsion/tests/test_N3.py index ec45a6ca..4947f01a 100644 --- a/openconcept/components/tests/test_N3.py +++ b/openconcept/propulsion/tests/test_N3.py @@ -1,15 +1,14 @@ -from __future__ import division import unittest import os import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem import openconcept -from openconcept.components.N3 import N3, N3Hybrid +from openconcept.propulsion import N3, N3Hybrid # Skip these test cases if the cached surrogate files don't exist # N+3 hybrid -hybrid_file_root = openconcept.__path__[0] + r'/components/empirical_data/n+3_hybrid/' +hybrid_file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/n+3_hybrid/' hybrid_cached_thrust = os.path.exists(hybrid_file_root + r'/n3_hybrid_thrust_trained.zip') hybrid_cached_fuelburn = os.path.exists(hybrid_file_root + r'n3_hybrid_fuelflow_trained.zip') hybrid_cached_surge = os.path.exists(hybrid_file_root + 'n3_hybrid_smw_trained.zip') @@ -18,7 +17,7 @@ hybrid_skip_tests = False # N+3 -file_root = openconcept.__path__[0] + r'/components/empirical_data/n+3/' +file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/n+3/' cached_thrust = os.path.exists(file_root + r'/n3_thrust_trained.zip') cached_fuelburn = os.path.exists(file_root + r'n3_fuelflow_trained.zip') cached_T4 = os.path.exists(file_root + r'n3_smw_trained.zip') diff --git a/openconcept/components/tests/test_cfm56.py b/openconcept/propulsion/tests/test_cfm56.py similarity index 94% rename from openconcept/components/tests/test_cfm56.py rename to openconcept/propulsion/tests/test_cfm56.py index 35befe2a..ae00c6eb 100644 --- a/openconcept/components/tests/test_cfm56.py +++ b/openconcept/propulsion/tests/test_cfm56.py @@ -1,14 +1,13 @@ -from __future__ import division import unittest import os import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem import openconcept -from openconcept.components.cfm56 import CFM56 +from openconcept.propulsion import CFM56 # Skip these test cases if the cached surrogate files don't exist -file_root = openconcept.__path__[0] + r'/components/empirical_data/cfm56/' +file_root = openconcept.__path__[0] + r'/propulsion/empirical_data/cfm56/' cached_thrust = os.path.exists(file_root + 'cfm56thrust_trained.zip') cached_fuelburn = os.path.exists(file_root + 'cfm56fuelburn_trained.zip') cached_T4 = os.path.exists(file_root + 'cfm56T4_trained.zip') diff --git a/examples/propulsion_layouts/__init__.py b/openconcept/propulsion/tests/test_motor.py similarity index 100% rename from examples/propulsion_layouts/__init__.py rename to openconcept/propulsion/tests/test_motor.py diff --git a/openconcept/components/tests/test_simple_comps.py b/openconcept/propulsion/tests/test_simple_comps.py similarity index 76% rename from openconcept/components/tests/test_simple_comps.py rename to openconcept/propulsion/tests/test_simple_comps.py index d69f13c1..fb2c2603 100644 --- a/openconcept/components/tests/test_simple_comps.py +++ b/openconcept/propulsion/tests/test_simple_comps.py @@ -1,46 +1,8 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.components import SimpleBattery, SimpleGenerator, SimpleMotor, SimplePropeller, SimpleTurboshaft, PowerSplit - -class BatteryTestGroup(Group): - """ - Test the battery component - """ - def initialize(self): - self.options.declare('vec_size',default=1,desc="Number of mission analysis points to run") - self.options.declare('efficiency', default=1., desc='Efficiency (dimensionless)') - self.options.declare('p', default=5000., desc='Battery specific power (W/kg)' ) - self.options.declare('e', default=300., desc='Battery spec energy CAREFUL: (Wh/kg)') - self.options.declare('cost_inc', default=50., desc='$ cost per kg') - self.options.declare('cost_base', default=1., desc= '$ cost base') - self.options.declare('use_defaults', default=True) - - def setup(self): - use_defaults = self.options['use_defaults'] - nn = self.options['vec_size'] - if not use_defaults: - eta_b = self.options['efficiency'] - p = self.options['p'] - e = self.options['e'] - ci = self.options['cost_inc'] - cb = self.options['cost_base'] - self.add_subsystem('battery', SimpleBattery(num_nodes=nn, - efficiency=eta_b, - specific_power=p, - specific_energy=e, - cost_inc=ci, - cost_base=cb)) - else: - self.add_subsystem('battery', SimpleBattery(num_nodes=nn)) - - iv = self.add_subsystem('iv', IndepVarComp()) - iv.add_output('battery_weight', val=100, units='kg') - iv.add_output('elec_load', val=np.ones(nn) * 100, units='kW') - self.connect('iv.battery_weight','battery.battery_weight') - self.connect('iv.elec_load','battery.elec_load') +from openconcept.propulsion import SimpleGenerator, SimpleMotor, SimplePropeller, SimpleTurboshaft, PowerSplit class MotorTestGroup(Group): """ @@ -153,38 +115,6 @@ def setup(self): self.connect('iv.shaft_power_rating','turboshaft.shaft_power_rating') self.connect('iv.throttle','turboshaft.throttle') -class SimpleBatteryTestCase(unittest.TestCase): - - def test_default_settings(self): - prob = Problem(BatteryTestGroup(vec_size=10, use_defaults=True)) - prob.setup(check=True,force_alloc_complex=True) - prob.run_model() - assert_near_equal(prob['battery.heat_out'], np.ones(10)*100*0.0, tolerance=1e-15) - assert_near_equal(prob['battery.component_sizing_margin'], np.ones(10)*0.20, tolerance=1e-15) - assert_near_equal(prob['battery.component_cost'], 5001, tolerance=1e-15) - assert_near_equal(prob.get_val('battery.max_energy', units='W*h'), 300*100, tolerance=1e-15) - - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - - def test_nondefault_settings(self): - prob = Problem(BatteryTestGroup(vec_size=10, - use_defaults=False, - efficiency=0.95, - p=3000, - e=500, - cost_inc=100, - cost_base=0)) - prob.setup(check=True,force_alloc_complex=True) - prob.run_model() - assert_near_equal(prob.get_val('battery.heat_out', units='kW'), np.ones(10)*100*0.05, tolerance=1e-15) - assert_near_equal(prob['battery.component_sizing_margin'], np.ones(10)/3, tolerance=1e-15) - assert_near_equal(prob['battery.component_cost'], 10000, tolerance=1e-15) - assert_near_equal(prob.get_val('battery.max_energy', units='W*h'), 500*100, tolerance=1e-15) - - partials = prob.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - class SimpleMotorTestCase(unittest.TestCase): def test_default_settings(self): diff --git a/openconcept/components/tests/test_splitter_comps.py b/openconcept/propulsion/tests/test_splitter_comps.py similarity index 51% rename from openconcept/components/tests/test_splitter_comps.py rename to openconcept/propulsion/tests/test_splitter_comps.py index 13cd476c..c7a3fefc 100644 --- a/openconcept/components/tests/test_splitter_comps.py +++ b/openconcept/propulsion/tests/test_splitter_comps.py @@ -1,9 +1,9 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.components import PowerSplit, FlowSplit, FlowCombine +from openconcept.propulsion import PowerSplit + class PowerSplitTestCase(unittest.TestCase): def test_default_settings(self): @@ -66,79 +66,3 @@ def test_fixed(self): partials = p.check_partials(method='fd',compact_print=True) # for some reason this one # doesn't work with complex step assert_check_partials(partials) - -class FlowSplitTestCase(unittest.TestCase): - """ - Test the FlowSplit component - """ - def test_default_settings(self): - p = Problem() - p.model.add_subsystem('test', FlowSplit(), promotes=['*']) - p.setup(check=True, force_alloc_complex=True) - p.run_model() - assert_near_equal(p['mdot_out_A'], np.array([0.5])) - assert_near_equal(p['mdot_out_B'], np.array([0.5])) - - partials = p.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - - def test_nondefault_settings(self): - nn = 4 - p = Problem() - p.model.add_subsystem('test', FlowSplit(num_nodes=nn), promotes=['*']) - p.setup(check=True, force_alloc_complex=True) - - p['mdot_in'] = np.array([-10., 0., 10., 10.]) - p['mdot_split_fraction'] = np.array([0., 0.4, 0.4, 1.]) - - p.run_model() - - assert_near_equal(p['mdot_out_A'], np.array([0., 0., 4., 10.])) - assert_near_equal(p['mdot_out_B'], np.array([-10., 0., 6., 0.])) - - def test_warnings(self): - nn = 4 - p = Problem() - p.model.add_subsystem('test', FlowSplit(num_nodes=nn), promotes=['*']) - p.setup(check=True, force_alloc_complex=True) - - p['mdot_in'] = np.array([-10., 0., 10., 10.]) - p['mdot_split_fraction'] = np.array([-0.0001, 0.4, 0.4, 1.]) - with self.assertRaises(RuntimeWarning): - p.run_model() - - - p['mdot_split_fraction'] = np.array([1.0001, 0.4, 0.4, 1.]) - with self.assertRaises(RuntimeWarning): - p.run_model() - -class FlowCombineTestCase(unittest.TestCase): - """ - Test the FlowCombine component - """ - def test_default_settings(self): - p = Problem() - p.model.add_subsystem('test', FlowCombine(), promotes=['*']) - p.setup(check=True, force_alloc_complex=True) - p.run_model() - assert_near_equal(p['mdot_out'], np.array([2.])) - assert_near_equal(p['T_out'], np.array([1.])) - - partials = p.check_partials(method='cs',compact_print=True) - assert_check_partials(partials) - - def test_nondefault_settings(self): - nn = 4 - p = Problem() - p.model.add_subsystem('test', FlowCombine(num_nodes=nn), promotes=['*']) - p.setup(check=True, force_alloc_complex=True) - - p['mdot_in_A'] = np.array([0., 5., 10., 10.]) - p['mdot_in_B'] = np.array([1., 0., 5., 10.]) - p['T_in_A'] = np.array([1., 10., 30., 500.]) - p['T_in_B'] = np.array([1., 150., 60., 100.]) - - p.run_model() - - assert_near_equal(p['mdot_out'], np.array([1., 5., 15., 20.])) - assert_near_equal(p['T_out'], np.array([1., 10., 40., 300.])) diff --git a/openconcept/components/turboshaft.py b/openconcept/propulsion/turboshaft.py similarity index 99% rename from openconcept/components/turboshaft.py rename to openconcept/propulsion/turboshaft.py index 7c23cf38..4bb3fff6 100644 --- a/openconcept/components/turboshaft.py +++ b/openconcept/propulsion/turboshaft.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent from openmdao.api import Group diff --git a/openconcept/thermal/__init__.py b/openconcept/thermal/__init__.py new file mode 100644 index 00000000..e821e747 --- /dev/null +++ b/openconcept/thermal/__init__.py @@ -0,0 +1,18 @@ +from .chiller import HeatPumpWithIntegratedCoolantLoop +from .ducts import ExplicitIncompressibleDuct, ImplicitCompressibleDuct, ImplicitCompressibleDuct_ExternalHX +from .heat_exchanger import HXGroup +from .heat_pipe import HeatPipe +from .battery_cooling import BandolierCoolingSystem, LiquidCooledBattery +from .motor_cooling import MotorCoolingJacket, LiquidCooledMotor +from .pump import SimplePump +from .hose import SimpleHose +from .manifold import FlowSplit, FlowCombine +from .thermal import ( + PerfectHeatTransferComp, + ThermalComponentWithMass, + ThermalComponentMassless, + ConstantSurfaceTemperatureColdPlate_NTU, + LiquidCooledComp, + CoolantReservoir, + CoolantReservoirRate, +) diff --git a/openconcept/thermal/battery_cooling.py b/openconcept/thermal/battery_cooling.py new file mode 100644 index 00000000..21591a96 --- /dev/null +++ b/openconcept/thermal/battery_cooling.py @@ -0,0 +1,263 @@ +import openmdao.api as om +import numpy as np +from openconcept.utilities import Integrator + + +class LiquidCooledBattery(om.Group): + """A battery with liquid cooling + + Inputs + ------ + q_in : float + Heat produced by the operating component (vector, W) + mdot_coolant : float + Coolant mass flow rate (vector, kg/s) + T_in : float + Instantaneous coolant inflow temperature (vector, K) + battery_weight : float + Battery weight (scalar, kg) + n_cpb : float + Number of cells long per "bandolier" actual count is 2x (scalar, default 82, Tesla) + t_channel : float + Thickness (width) of the cooling channel in the bandolier + (scalar, default 1mm) + T_initial : float + Initial temperature of the battery (only required in thermal mass mode) (scalar, K) + duration : float + Duration of mission segment, only required in unsteady mode + + Outputs + ------- + T_out : float + Instantaneous coolant outlet temperature (vector, K) + T: float + Battery volume averaged temperature (vector, K) + T_core : float + Battery core temperature (vector, K) + T_surface : float + Battery surface temperature (vector, K) + + Options + ------- + num_nodes : int + Number of analysis points to run + quasi_steady : bool + Whether or not to treat the component as having thermal mass + num_nodes : float + The number of analysis points to run + coolant_specific_heat : float + Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) + fluid_k : float + Thermal conductivity of the coolant (W/m/K) + nusselt : float + Nusselt number of the coolant channel (default 7.54 for uniform surf temp) + cell_kr : float + Thermal conductivity of the cell in the radial direction (W/m/k) + cell_diameter : float + Battery diameter (default 21mm for 2170 cell) + cell_height : float + Battery height (default 70mm for 2170 cell) + cell_mass : float + Battery weight (default 70g for 2170 cell) + cell_specific_heat : float + Mass average specific heat of the battery (default 900, LiIon cylindrical cell) + battery_weight_fraction : float + Fraction of battery by weight that is cells (default 0.72 knocks down Tesla by a bit) + """ + + def initialize(self): + self.options.declare('quasi_steady', default=False, desc='Treat the component as quasi-steady or with thermal mass') + self.options.declare('num_nodes', default=1, desc='Number of quasi-steady points to runs') + self.options.declare('coolant_specific_heat', default=3801, desc='Coolant specific heat in J/kg/K') + self.options.declare('fluid_k', default=0.405, desc='Thermal conductivity of the fluid in W / mK') + self.options.declare('nusselt', default=7.54, desc='Hydraulic diameter Nusselt number') + + self.options.declare('cell_kr', default=0.3) # 0.455 for an 18650 cell, knocked down a bit + self.options.declare('cell_diameter', default=0.021) + self.options.declare('cell_height', default=0.070) + self.options.declare('cell_mass', default=0.070) + self.options.declare('cell_specific_heat', default=875.) + self.options.declare('battery_weight_fraction', default=0.65) + def setup(self): + nn = self.options['num_nodes'] + quasi_steady = self.options['quasi_steady'] + + self.add_subsystem('hex', BandolierCoolingSystem(num_nodes=nn, + coolant_specific_heat=self.options['coolant_specific_heat'], + fluid_k=self.options['fluid_k'], + nusselt=self.options['nusselt'], + cell_kr=self.options['cell_kr'], + cell_diameter=self.options['cell_diameter'], + cell_height=self.options['cell_height'], + cell_mass=self.options['cell_mass'], + cell_specific_heat=self.options['cell_specific_heat'], + battery_weight_fraction=self.options['battery_weight_fraction']), + promotes_inputs=['q_in', 'mdot_coolant', 'T_in', ('T_battery', 'T'), 'battery_weight', 'n_cpb', 't_channel'], + promotes_outputs=['T_core', 'T_surface', 'T_out', 'dTdt']) + + if not quasi_steady: + ode_integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, diff_units='s', method='simpson', time_setup='duration'), + promotes_outputs=['*'], promotes_inputs=['*']) + ode_integ.add_integrand('T', rate_name='dTdt', units='K', lower=1e-10) + else: + self.add_subsystem('thermal_bal', + om.BalanceComp('T', eq_units='K/s', lhs_name='dTdt', rhs_val=0.0, units='K', lower=1.0, val=299.*np.ones((nn,))), + promotes_inputs=['dTdt'], + promotes_outputs=['T']) + + +class BandolierCoolingSystem(om.ExplicitComponent): + """ + Computes battery heat transfer for a parameteric battery + based on Tesla's Model 3 design. + + Assumptions: + Heat generated uniformly in the cell + Weight per cell and thermal resistance stay constant + even as specific energy varies parametrically + (this means that cell count is constant with pack WEIGHT, + not pack ENERGY as technology improves) + Cylindrical cells attached to Tesla-style thermal ribbon + Liquid cooling + Heat transfer through axial direction only (not baseplate) + 2170 cells (21 mm diameter, 70mm tall) + Battery thermal model assumes unsteady cell temperature, + quasi-steady temperature gradients + + .. note:: + See the ``LiquidCooledBattery`` for a group that already integrates + this component with a battery. + + Inputs + ------ + q_in : float + Heat generation rate in the battery (vector, W) + T_in : float + Coolant inlet temperature (vector, K) + T_battery : float + Volume averaged battery temperature (vector, K) + mdot_coolant : float + Mass flow rate of coolant through the bandolier (vector, kg/s) + battery_weight : float + Weight of the battery (overall). Default 100kg (scalar) + n_cpb : float + Number of cells long per "bandolier" actual count is 2x (scalar, default 82, Tesla) + t_channel : float + Thickness (width) of the cooling channel in the bandolier + (scalar, default 1mm) + Outputs + ------- + dTdt : float + Time derivative dT/dt (Tbar in the paper) (vector, K/s) + T_surface : float + Surface temp of the battery (vector, K) + T_core : float + Center temp of the battery (vector, K) + q : float + Heat transfer rate from the motor to the fluid (vector, W) + T_out : float + Outlet fluid temperature (vector, K) + + Options + ------- + num_nodes : float + The number of analysis points to run + coolant_specific_heat : float + Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) + fluid_k : float + Thermal conductivity of the coolant (W/m/K) + nusselt : float + Nusselt number of the coolant channel (default 7.54 for uniform surf temp) + cell_kr : float + Thermal conductivity of the cell in the radial direction (W/m/k) + cell_diameter : float + Battery diameter (default 21mm for 2170 cell) + cell_height : float + Battery height (default 70mm for 2170 cell) + cell_mass : float + Battery weight (default 70g for 2170 cell) + cell_specific_heat : float + Mass average specific heat of the battery (default 900, LiIon cylindrical cell) + battery_weight_fraction : float + Fraction of battery by weight that is cells (default 0.72 knocks down Tesla by a bit) + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of analysis points') + self.options.declare('coolant_specific_heat', default=3801, desc='Coolant specific heat in J/kg/K') + self.options.declare('fluid_k', default=0.405, desc='Thermal conductivity of the fluid in W / mK') + self.options.declare('nusselt', default=7.54, desc='Hydraulic diameter Nusselt number') + + self.options.declare('cell_kr', default=0.3) # 0.455 for an 18650 cell, knocked down a bit + self.options.declare('cell_diameter', default=0.021) + self.options.declare('cell_height', default=0.070) + self.options.declare('cell_mass', default=0.070) + self.options.declare('cell_specific_heat', default=875.) + self.options.declare('battery_weight_fraction', default=0.65) + + def setup(self): + nn = self.options['num_nodes'] + self.add_input('q_in', shape=(nn,), units='W', val=0.0) + self.add_input('T_in', shape=(nn,), units='K', val=300.) + self.add_input('T_battery', shape=(nn,), units='K', val=300.) + self.add_input('mdot_coolant', shape=(nn,), units='kg/s', val=0.20) + self.add_input('battery_weight', units='kg', val=478.) + self.add_input('n_cpb', units=None, val=82.) + self.add_input('t_channel', units='m', val=0.0005) + + self.add_output('dTdt', shape=(nn,), units='K/s', tags=['integrate', 'state_name:T_battery', 'state_units:K', 'state_val:300.0', 'state_promotes:True']) + self.add_output('T_surface', shape=(nn,), units='K', lower=1e-10) + self.add_output('T_core', shape=(nn,), units='K', lower=1e-10) + self.add_output('q', shape=(nn,), units='W') + self.add_output('T_out', shape=(nn,), units='K', val=300, lower=1e-10) + + self.declare_partials(['*'], ['*'], method='cs') + + def compute(self, inputs, outputs): + nn = self.options['num_nodes'] + n_cells = inputs['battery_weight'] * self.options['battery_weight_fraction'] / self.options['cell_mass'] + n_bandoliers = n_cells / inputs['n_cpb'] / 2 + + mdot_b = inputs['mdot_coolant'] / n_bandoliers + q_cell = inputs['q_in'] / n_cells + hconv = self.options['nusselt'] * self.options['fluid_k'] / 2 / inputs['t_channel'] + + Hc = self.options['cell_height'] + Dc = self.options['cell_diameter'] + mc = self.options['cell_mass'] + krc = self.options['cell_kr'] + cpc = self.options['cell_specific_heat'] + L_bandolier = inputs['n_cpb'] * Dc + + cpf = self.options['coolant_specific_heat'] # of the coolant + + A_heat_trans = Hc * L_bandolier * 2 # two sides of the tape + NTU = hconv * A_heat_trans / mdot_b / cpf + Kcell = mdot_b * cpf * (1 - np.exp(-NTU)) / 2 / inputs['n_cpb'] # divide out the total bandolier convection by 2 * n_cpb cells + # the convective heat transfer is (Ts - Tin) * Kcell + PI = np.pi + + Tbar = inputs['T_battery'] + Rc = Dc / 2 + + K_cyl = 8*np.pi*Hc*krc + + Ts = (K_cyl * Tbar + Kcell * inputs['T_in']) / (K_cyl + Kcell) + + outputs['T_surface'] = Ts + + q_conv = (Ts - inputs['T_in']) * Kcell * n_cells + outputs['dTdt'] = (q_cell - (Ts - inputs['T_in']) * Kcell) / mc / cpc # todo check that this quantity matches convection + + + outputs['q'] = q_conv + + qcheck = (Tbar - Ts) * K_cyl + # UAcomb = 1/(1/hconv/A_heat_trans+1/K_cyl/2/inputs['n_cpb']) + # qcheck2 = (Tbar - inputs['T_in']) * mdot_b * cpf * (1 - np.exp(-UAcomb/mdot_b/cpf)) / 2 / inputs['n_cpb'] + + # if np.sum(np.abs(qcheck - outputs['q']/n_cells)) > 1e-5: + # # the heat flux across the cell is not equal to the heat flux due to convection + # raise ValueError('The surface temperature solution appears to be wrong') + + outputs['T_out'] = inputs['T_in'] + outputs['q'] / inputs['mdot_coolant'] / cpf + outputs['T_core'] = (Tbar - Ts) + Tbar diff --git a/openconcept/components/chiller.py b/openconcept/thermal/chiller.py similarity index 98% rename from openconcept/components/chiller.py rename to openconcept/thermal/chiller.py index bca57d97..3e6d01ee 100644 --- a/openconcept/components/chiller.py +++ b/openconcept/thermal/chiller.py @@ -1,10 +1,8 @@ -from __future__ import division import openmdao.api as om import numpy as np -import sys, os -sys.path.insert(0,os.getcwd()) -from openconcept.utilities.linearinterp import LinearInterpolator -from openconcept.components.thermal import PerfectHeatTransferComp + +from openconcept.utilities import LinearInterpolator +from .thermal import PerfectHeatTransferComp class LinearSelector(om.ExplicitComponent): """ diff --git a/openconcept/components/ducts.py b/openconcept/thermal/ducts.py similarity index 98% rename from openconcept/components/ducts.py rename to openconcept/thermal/ducts.py index c6936ada..09443dfb 100644 --- a/openconcept/components/ducts.py +++ b/openconcept/thermal/ducts.py @@ -1,12 +1,8 @@ -from __future__ import division import numpy as np -from openmdao.api import ExplicitComponent, Problem, ImplicitComponent, NewtonSolver, DirectSolver, IndepVarComp, ExecComp -from openmdao.api import Group, ScipyOptimizeDriver, BoundsEnforceLS -import sys, os -sys.path.insert(0,os.getcwd()) -from openconcept.components.heat_exchanger import HXGroup -from openconcept.utilities.math.add_subtract_comp import AddSubtractComp -from openconcept.utilities.dvlabel import DVLabel +from openmdao.api import ExplicitComponent, ImplicitComponent, IndepVarComp, ExecComp, Group + +from .heat_exchanger import HXGroup +from openconcept.utilities import AddSubtractComp, DVLabel class ExplicitIncompressibleDuct(ExplicitComponent): """ diff --git a/openconcept/components/heat_exchanger.py b/openconcept/thermal/heat_exchanger.py similarity index 99% rename from openconcept/components/heat_exchanger.py rename to openconcept/thermal/heat_exchanger.py index c64f6e27..007d16a5 100644 --- a/openconcept/components/heat_exchanger.py +++ b/openconcept/thermal/heat_exchanger.py @@ -1,8 +1,6 @@ -from __future__ import division import numpy as np -from openmdao.api import ExplicitComponent, Problem, IndepVarComp -from openmdao.api import Group, ScipyOptimizeDriver -from openconcept.utilities.dvlabel import DVLabel +from openmdao.api import ExplicitComponent, IndepVarComp, Group +from openconcept.utilities import DVLabel class OffsetStripFinGeometry(ExplicitComponent): """ diff --git a/openconcept/components/heat_pipe.py b/openconcept/thermal/heat_pipe.py similarity index 96% rename from openconcept/components/heat_pipe.py rename to openconcept/thermal/heat_pipe.py index 724330e3..0bb9333e 100644 --- a/openconcept/components/heat_pipe.py +++ b/openconcept/thermal/heat_pipe.py @@ -1,7 +1,7 @@ -from __future__ import division import numpy as np -from openmdao.api import ExplicitComponent, Problem, NewtonSolver, Group, MetaModelStructuredComp, ExecComp, n2 -from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp +from openmdao.api import ExplicitComponent, Group, MetaModelStructuredComp, ExecComp +from openconcept.utilities import ElementMultiplyDivideComp +from openconcept.utilities.constants import GRAV_CONST import warnings class HeatPipe(Group): @@ -447,8 +447,6 @@ class QMaxHeatPipe(Group): ------- num_nodes : int Number of analysis points to run, default 1 (scalar, dimensionless) - g : float - Gravitational acceleration, default 9.807 m/s^2 (scalar, m/s^2) theta : float Tilt from vertical, default 0 deg (scalar, deg) yield_stress : float @@ -460,7 +458,6 @@ class QMaxHeatPipe(Group): """ def initialize(self): self.options.declare('num_nodes', default=1, desc='Number of analysis points') - self.options.declare('g', default=9.807, desc='Gravitational acceleration m/s^2') self.options.declare('theta', default=0., desc='Tilt from vertical degrees') self.options.declare('yield_stress', default=572., desc='Wall yield stress in MPa (default 7075)') self.options.declare('rho_wall', default=2810., desc='Wall matl density in kg/m3 (default 7075)') @@ -478,7 +475,7 @@ def setup(self): # Take in the densities from the surrogate model and use analytical expressions to get Q max self.add_subsystem('q_max_calc', - QMaxAnalyticalPart(num_nodes=nn, g=self.options['g'], theta=self.options['theta']), + QMaxAnalyticalPart(num_nodes=nn, theta=self.options['theta']), promotes_inputs=['inner_diam', 'temp'], promotes_outputs=['q_max']) @@ -496,8 +493,9 @@ class QMaxAnalyticalPart(ExplicitComponent): Computes the analytical part of the Q max calculation. For the overall Q max calculation, use the QMaxHeatPipe group, not this component. - Equations from https://www.1-act.com/resources/heat-pipe-performance/flooding-limit-thermosyphons/ - Surface tension data from page 16 of http://web.iiar.org/membersonly/PDF/CO/databook_ch2.pdf + Equations from https://www.1-act.com/resources/heat-pipe-performance/. + Surface tension data from page 16 of http://web.iiar.org/membersonly/PDF/CO/databook_ch2.pdf. + Both accessed on Aug 9, 2022. Inputs ------ @@ -519,8 +517,6 @@ class QMaxAnalyticalPart(ExplicitComponent): ------- num_nodes : int Number of analysis points to run, default 1 (scalar, dimensionless) - g : float - Gravitational acceleration, default 9.807 m/s^2 (scalar, m/s^2) theta : float Tilt from vertical, default 0 deg (scalar, deg) latent_heat : float @@ -533,7 +529,6 @@ class QMaxAnalyticalPart(ExplicitComponent): """ def initialize(self): self.options.declare('num_nodes', default=1, desc='Number of analysis points') - self.options.declare('g', default=9.807, desc='Gravitational acceleration m/s^2') self.options.declare('theta', default=0., desc='Tilt from vertical degrees') self.options.declare('latent_heat', default=1371.2e3, desc='Latent heat of vaporization J/kg') self.options.declare('surface_tension_base', default=0.026, desc='Surface tension at 0 deg C N/m') @@ -554,18 +549,17 @@ def setup(self): def compute(self, inputs, outputs): rho_L = inputs['rho_liquid'] rho_V = inputs['rho_vapor'] - g = self.options['g'] A_vapor = np.pi/4 * inputs['inner_diam']**2 # heat pipe cross sectional area latent = self.options['latent_heat'] th_rad = self.options['theta'] * np.pi / 180 # rad - # Use linear estimate for surface tension, see p. 16 of http://web.iiar.org/membersonly/PDF/CO/databook_ch2.pdf + # Use linear estimate for surface tension, see p. 16 of http://web.iiar.org/membersonly/PDF/CO/databook_ch2.pdf (accessed Aug 9 2022) surface_tension = self.options['surface_tension_base'] + self.options['surface_tension_incr'] * inputs['temp'] - # Compute Q max using equations from https://www.1-act.com/resources/heat-pipe-performance/flooding-limit-thermosyphons/ - bond_number = inputs['inner_diam'] * np.sqrt(g/surface_tension * (rho_L - rho_V)) + # Compute Q max using equations from https://www.1-act.com/resources/heat-pipe-performance/ (accessed Aug 9 2022) + bond_number = inputs['inner_diam'] * np.sqrt(GRAV_CONST/surface_tension * (rho_L - rho_V)) k_flooding = (rho_L/rho_V)**0.14 * np.tanh(bond_number**0.25)**2 - q_max_numer = k_flooding * A_vapor * latent * (g * np.sin(np.pi/2 - th_rad) * surface_tension * (rho_L - rho_V))**0.25 + q_max_numer = k_flooding * A_vapor * latent * (GRAV_CONST * np.sin(np.pi/2 - th_rad) * surface_tension * (rho_L - rho_V))**0.25 q_max_denom = (rho_L**-0.25 + rho_V**-0.25)**2 outputs['q_max'] = q_max_numer / q_max_denom diff --git a/openconcept/thermal/hose.py b/openconcept/thermal/hose.py new file mode 100644 index 00000000..a91dfe40 --- /dev/null +++ b/openconcept/thermal/hose.py @@ -0,0 +1,122 @@ +import openmdao.api as om +import numpy as np + +class SimpleHose(om.ExplicitComponent): + """ + A coolant hose used to track pressure drop and weight in long hose runs. + + Inputs + ------ + hose_diameter : float + Inner diameter of the hose (scalar, m) + hose_length + Length of the hose (scalar, m) + hose_design_pressure + Max operating pressure of the hose (scalar, Pa) + mdot_coolant : float + Coolant mass flow rate (vector, kg/s) + rho_coolant : float + Coolant density (vector, kg/m3) + mu_coolant : float + Coolant viscosity (scalar, kg/m/s) + + Outputs + ------- + delta_p : float + Pressure drop in the hose - positive is loss (vector, kg/s) + component_weight : float + Weight of hose AND coolant (scalar, kg) + + Options + ------- + num_nodes : int + Number of analysis points to run (sets vec length; default 1) + hose_operating_stress : float + Hoop stress at design pressure (Pa) set to 300 Psi equivalent per empirical data + hose_density : float + Material density of the hose (kg/m3) set to 0.049 lb/in3 equivalent per empirical data + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') + self.options.declare('hose_operating_stress', default=2.07e6, desc='Hoop stress at max op press in Pa') + self.options.declare('hose_density', default=1356.3, desc='Hose matl density in kg/m3') + + def setup(self): + nn = self.options['num_nodes'] + self.add_input('hose_diameter', val=0.0254, units='m') + self.add_input('hose_length', val=1.0, units='m') + self.add_input('hose_design_pressure', units='Pa', val=1.03e6, desc='Hose max operating pressure') + + + self.add_input('mdot_coolant', units='kg/s', desc='Coolant mass flow rate', val=np.ones((nn,))) + self.add_input('rho_coolant', units='kg/m**3', desc='Coolant density', val=1020.*np.ones((nn,))) + self.add_input('mu_coolant', val=1.68e-3, units='kg/m/s', desc='Coolant viscosity') + + self.add_output('delta_p', units='Pa', desc='Hose pressure drop', val=np.ones((nn,))) + self.add_output('component_weight', units='kg', desc='Pump weight') + + self.declare_partials(['delta_p'], ['rho_coolant', 'mdot_coolant'], rows=np.arange(nn), cols=np.arange(nn)) + self.declare_partials(['delta_p'], ['hose_diameter', 'hose_length', 'mu_coolant'], rows=np.arange(nn), cols=np.zeros(nn)) + self.declare_partials(['component_weight'], ['hose_design_pressure','hose_length','hose_diameter'], rows=[0], cols=[0]) + self.declare_partials(['component_weight'], ['rho_coolant'], rows=[0], cols=[0]) + + + def _compute_pressure_drop(self, inputs): + xs_area = np.pi * (inputs['hose_diameter'] / 2) ** 2 + U = inputs['mdot_coolant'] / inputs['rho_coolant'] / xs_area + Redh = inputs['rho_coolant'] * U * inputs['hose_diameter'] / inputs['mu_coolant'] + # darcy friction from the Blasius correlation + f = 0.3164 * Redh ** (-1/4) + dp = f * inputs['rho_coolant'] * U ** 2 * inputs['hose_length'] / 2 / inputs['hose_diameter'] + return dp + + def compute(self, inputs, outputs): + nn = self.options['num_nodes'] + sigma = self.options['hose_operating_stress'] + rho_hose = self.options['hose_density'] + + outputs['delta_p'] = self._compute_pressure_drop(inputs) + + thickness = inputs['hose_diameter'] * inputs['hose_design_pressure'] / 2 / sigma + + w_hose = (inputs['hose_diameter'] + thickness) * np.pi * thickness * rho_hose * inputs['hose_length'] + w_coolant = (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['rho_coolant'][0] * inputs['hose_length'] + outputs['component_weight'] = w_hose + w_coolant + + + def compute_partials(self, inputs, J): + nn = self.options['num_nodes'] + sigma = self.options['hose_operating_stress'] + rho_hose = self.options['hose_density'] + thickness = inputs['hose_diameter'] * inputs['hose_design_pressure'] / 2 / sigma + + d_thick_d_diam = inputs['hose_design_pressure'] / 2 / sigma + d_thick_d_press = inputs['hose_diameter'] / 2 / sigma + + J['component_weight','rho_coolant'] = (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['hose_length'] + J['component_weight', 'hose_design_pressure'] = (inputs['hose_diameter'] + thickness) * np.pi * d_thick_d_press * \ + rho_hose * inputs['hose_length'] + np.pi * thickness * rho_hose * \ + inputs['hose_length'] * d_thick_d_press + J['component_weight', 'hose_length'] = (inputs['hose_diameter'] + thickness) * np.pi * thickness * rho_hose + \ + (inputs['hose_diameter'] / 2) ** 2 * np.pi * inputs['rho_coolant'][0] + J['component_weight', 'hose_diameter'] = (inputs['hose_diameter'] + thickness) * np.pi * d_thick_d_diam * rho_hose * \ + inputs['hose_length'] + (1 + d_thick_d_diam) * np.pi * thickness * rho_hose * \ + inputs['hose_length'] + inputs['hose_diameter'] / 2 * np.pi * \ + inputs['rho_coolant'][0] * inputs['hose_length'] + + # use a colored complex step approach + cs_step = 1e-30 + dp_base = self._compute_pressure_drop(inputs) + + cs_inp_list = ['rho_coolant', 'mdot_coolant', 'hose_diameter', 'hose_length', 'mu_coolant'] + fake_inputs = dict() + # make a perturbable, complex copy of the inputs + for inp in cs_inp_list: + fake_inputs[inp] = inputs[inp].astype(np.complex_, copy=True) + + for inp in cs_inp_list: + arr_to_restore = fake_inputs[inp].copy() + fake_inputs[inp] += (0.0+cs_step*1.0j) + dp_perturbed = self._compute_pressure_drop(fake_inputs) + fake_inputs[inp] = arr_to_restore + J['delta_p', inp] = np.imag(dp_perturbed) / cs_step diff --git a/openconcept/thermal/manifold.py b/openconcept/thermal/manifold.py new file mode 100644 index 00000000..a3a84312 --- /dev/null +++ b/openconcept/thermal/manifold.py @@ -0,0 +1,125 @@ +import numpy as np +from openmdao.api import ExplicitComponent + + +class FlowSplit(ExplicitComponent): + """ + Split incoming flow from one inlet into two outlets at a fractional ratio. + + Inputs + ------ + mdot_in : float + Mass flow rate of incoming fluid (vector, kg/s) + mdot_split_fraction : float + Fraction of incoming mass flow directed to output A, must be in + range 0-1 inclusive (vector, dimensionless) + + Outputs + ------- + mdot_out_A : float + Mass flow rate directed to first output (vector, kg/s) + mdot_out_B : float + Mass flow rate directed to second output (vector, kg/s) + + Options + ------- + num_nodes : int + Number of analysis points to run (sets vec length; default 1) + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of analysis points') + + def setup(self): + nn = self.options['num_nodes'] + rng = np.arange(0, nn) + + self.add_input('mdot_in', units='kg/s', shape=(nn,)) + self.add_input('mdot_split_fraction', units=None, shape=(nn,), val=0.5) + + self.add_output('mdot_out_A', units='kg/s', shape=(nn,)) + self.add_output('mdot_out_B', units='kg/s', shape=(nn,)) + + self.declare_partials(['mdot_out_A'], ['mdot_in', 'mdot_split_fraction'], rows=rng, cols=rng) + self.declare_partials(['mdot_out_B'], ['mdot_in', 'mdot_split_fraction'], rows=rng, cols=rng) + + def compute(self, inputs, outputs): + if np.any(inputs['mdot_split_fraction'] < 0) or np.any(inputs['mdot_split_fraction'] > 1): + raise RuntimeWarning(f"mdot_split_fraction of {inputs['mdot_split_fraction']} has at least one element out of range [0, 1]") + outputs['mdot_out_A'] = inputs['mdot_in'] * inputs['mdot_split_fraction'] + outputs['mdot_out_B'] = inputs['mdot_in'] * (1 - inputs['mdot_split_fraction']) + + def compute_partials(self, inputs, J): + J['mdot_out_A', 'mdot_in'] = inputs['mdot_split_fraction'] + J['mdot_out_A', 'mdot_split_fraction'] = inputs['mdot_in'] + + J['mdot_out_B', 'mdot_in'] = 1 - inputs['mdot_split_fraction'] + J['mdot_out_B', 'mdot_split_fraction'] = - inputs['mdot_in'] + + +class FlowCombine(ExplicitComponent): + """ + Combines two incoming flows into a single outgoing flow and does a weighted average + of their temperatures based on the mass flow rate of each to compute the outlet temp. + + Inputs + ------ + mdot_in_A : float + Mass flow rate of fluid from first inlet, should be nonegative (vector, kg/s) + mdot_in_B : float + Mass flow rate of fluid from second inlet, should be nonnegative (vector, kg/s) + T_in_A : float + Temperature of fluid from first inlet (vector, K) + T_in_B : float + Temperature of fluid from second inlet (vector, K) + + Outputs + ------- + mdot_out : float + Outgoing fluid mass flow rate (vector, kg/s) + T_out : float + Outgoing fluid temperature (vector, K) + + Options + ------- + num_nodes : int + Number of analysis points (scalar, default 1) + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of analysis points') + + def setup(self): + nn = self.options['num_nodes'] + rng = np.arange(0, nn) + + self.add_input('mdot_in_A', units='kg/s', shape=(nn,)) + self.add_input('mdot_in_B', units='kg/s', shape=(nn,)) + self.add_input('T_in_A', units='K', shape=(nn,)) + self.add_input('T_in_B', units='K', shape=(nn,)) + + self.add_output('mdot_out', units='kg/s', shape=(nn,)) + self.add_output('T_out', units='K', shape=(nn,)) + + self.declare_partials(['mdot_out'], ['mdot_in_A', 'mdot_in_B'], rows=rng, cols=rng) + self.declare_partials(['T_out'], ['mdot_in_A', 'mdot_in_B', 'T_in_A', 'T_in_B'], rows=rng, cols=rng) + + def compute(self, inputs, outputs): + mdot_A = inputs['mdot_in_A'] + mdot_B = inputs['mdot_in_B'] + outputs['mdot_out'] = mdot_A + mdot_B + # Weighted average of temperatures for output temperature + outputs['T_out'] = (mdot_A * inputs['T_in_A'] + mdot_B * inputs['T_in_B']) / (mdot_A + mdot_B) + + def compute_partials(self, inputs, J): + nn = self.options['num_nodes'] + J['mdot_out', 'mdot_in_A'] = np.ones((nn,)) + J['mdot_out', 'mdot_in_B'] = np.ones((nn,)) + + mdot_A = inputs['mdot_in_A'] + mdot_B = inputs['mdot_in_B'] + mdot = mdot_A + mdot_B + T_A = inputs['T_in_A'] + T_B = inputs['T_in_B'] + J['T_out', 'mdot_in_A'] = (mdot * T_A - mdot_A * T_A - mdot_B * T_B) / (mdot**2) + J['T_out', 'mdot_in_B'] = (mdot * T_B - mdot_A * T_A - mdot_B * T_B) / (mdot**2) + J['T_out', 'T_in_A'] = mdot_A / mdot + J['T_out', 'T_in_B'] = mdot_B / mdot \ No newline at end of file diff --git a/openconcept/thermal/motor_cooling.py b/openconcept/thermal/motor_cooling.py new file mode 100644 index 00000000..614100cf --- /dev/null +++ b/openconcept/thermal/motor_cooling.py @@ -0,0 +1,221 @@ +import openmdao.api as om +import numpy as np +from openconcept.utilities import Integrator + +class LiquidCooledMotor(om.Group): + """A component (heat producing) with thermal mass + cooled by a cold plate. + + Inputs + ------ + q_in : float + Heat produced by the operating component (vector, W) + mdot_coolant : float + Coolant mass flow rate (vector, kg/s) + T_in : float + Instantaneous coolant inflow temperature (vector, K) + motor_weight : float + Object mass (only required in thermal mass mode) (scalar, kg) + T_initial : float + Initial temperature of the cold plate (only required in thermal mass mode) / object (scalar, K) + duration : float + Duration of mission segment, only required in unsteady mode + power_rating : float + Rated power of the motor (scalar, kW) + + Outputs + ------- + T_out : float + Instantaneous coolant outlet temperature (vector, K) + T: float + Windings temperature (vector, K) + + Options + ------- + motor_specific_heat : float + Specific heat capacity of the object in J / kg / K (default 921 = aluminum) + coolant_specific_heat : float + Specific heat capacity of the coolant in J / kg / K (default 3801, glycol/water) + num_nodes : int + Number of analysis points to run + quasi_steady : bool + Whether or not to treat the component as having thermal mass + case_cooling_coefficient : float + Watts of heat transfer per square meter of case surface area per K + temperature differential (default 1100 W/m^2/K) + """ + + def initialize(self): + self.options.declare('motor_specific_heat', default=921.0, desc='Specific heat in J/kg/K') + self.options.declare('coolant_specific_heat', default=3801, desc='Specific heat in J/kg/K') + self.options.declare('quasi_steady', default=False, desc='Treat the component as quasi-steady or with thermal mass') + self.options.declare('num_nodes', default=1, desc='Number of quasi-steady points to runs') + self.options.declare('case_cooling_coefficient', default=1100.) + + def setup(self): + nn = self.options['num_nodes'] + quasi_steady = self.options['quasi_steady'] + self.add_subsystem('hex', + MotorCoolingJacket(num_nodes=nn, coolant_specific_heat=self.options['coolant_specific_heat'], + motor_specific_heat=self.options['motor_specific_heat'], + case_cooling_coefficient=self.options['case_cooling_coefficient']), + promotes_inputs=['q_in','T_in', 'T','power_rating','mdot_coolant','motor_weight'], + promotes_outputs=['T_out', 'dTdt']) + if not quasi_steady: + ode_integ = self.add_subsystem('ode_integ', Integrator(num_nodes=nn, diff_units='s', method='simpson', time_setup='duration'), + promotes_outputs=['*'], promotes_inputs=['*']) + ode_integ.add_integrand('T', rate_name='dTdt', units='K', lower=1e-10) + else: + self.add_subsystem('thermal_bal', + om.BalanceComp('T', eq_units='K/s', lhs_name='dTdt', rhs_val=0.0, units='K', lower=1.0, val=299.*np.ones((nn,))), + promotes_inputs=['dTdt'], + promotes_outputs=['T']) + + +class MotorCoolingJacket(om.ExplicitComponent): + """ + Computes motor winding temperature assuming + well-designed, high-power-density aerospace motor. + This component is based on the following assumptions: + - 2020 technology level + - 200kW-1MW class inrunner PM motor + - Liquid cooling of the stators + - "Reasonable" coolant flow rates (component will validate this) + - Thermal performance similiar to the Siemens SP200D motor + + The component assumes a constant heat transfer coefficient based + on the surface area of the motor casing (not counting front and rear faces) + The MagniX Magni 250/500 and Siemens SP200D motors were measured + using rough photogrammetry. + + Magni250: 280kW rated power, ~0.559m OD, 0.2m case "depth" (along thrust axis) + Magni500: 560kW rated power, ~0.652m OD, 0.4m case "depth" + Siemens SP200D: 200kW rated power, ~0.63m OD, ~0.16 case "depth" + + Based on these dimensions I assume 650kW per square meter + of casing surface area. This includes only the cylindrical portion, + not the front and rear motor faces. + + Using a thermal FEM image of the SP200D, I estimate + a temperature rise of 23K from coolant inlet temperature (~85C) + to winding max temp (~108C) at the steady state operating point. + With 95% efficiency at 200kW, this is about 1373 W / m^2 casing area / K. + We'll reduce that somewhat since this is a direct oil cooling system, + and assume 1100 W/m^2/K instead. + + Dividing 1.1 kW/m^2/K by 650kWrated/m^2 gives: 1.69e-3 kW / kWrated / K + At full rated power and 95% efficiency, this is 29.5C steady state temp rise + which the right order of magnitude. + + .. note:: + See the ``LiquidCooledMotor`` for a group that already integrates + this component with an electric motor. + + Inputs + ------ + q_in : float + Heat production rate in the motor (vector, W) + T_in : float + Coolant inlet temperature (vector, K) + T : float + Temperature of the motor windings (vector, K) + mdot_coolant : float + Mass flow rate of the coolant (vector, kg/s) + power_rating : float + Rated steady state power of the motor (scalar, W) + motor_weight : float + Weight of electric motor (scalar, kg) + + Outputs + ------- + dTdt : float + Time derivative dT/dt (vector, K/s) + q : float + Heat transfer rate from the motor to the fluid (vector, W) + T_out : float + Outlet fluid temperature (vector, K) + + + Options + ------- + num_nodes : float + The number of analysis points to run + coolant_specific_heat : float + Specific heat of the coolant (J/kg/K) (default 3801, glycol/water) + case_cooling_coefficient : float + Watts of heat transfer per square meter of case surface area per K + temperature differential (default 1100 W/m^2/K) + case_area_coefficient : float + rated motor power per square meter of case surface area + (default 650,000 W / m^2) + motor_specific_heat : float + Specific heat of the motor casing (J/kg/K) (default 921, alu) + """ + + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of analysis points') + self.options.declare('coolant_specific_heat', default=3801, desc='Specific heat in J/kg/K') + self.options.declare('case_cooling_coefficient', default=1100.) + self.options.declare('case_area_coefficient', default=650000.) + self.options.declare('motor_specific_heat', default=921, desc='Specific heat in J/kg/K - default 921 for aluminum') + + def setup(self): + nn = self.options['num_nodes'] + arange = np.arange(nn) + self.add_input('q_in', shape=(nn,), units='W', val=0.0) + self.add_input('T_in', shape=(nn,), units='K', val=330) + self.add_input('T', shape=(nn,), units='K', val=359.546) + self.add_input('mdot_coolant', shape=(nn,), units='kg/s', val=1.0) + self.add_input('power_rating', units='W', val=2e5) + self.add_input('motor_weight', units='kg', val=100) + self.add_output('q', shape=(nn,), units='W') + self.add_output('T_out', shape=(nn,), units='K', val=300, lower=1e-10) + self.add_output('dTdt', shape=(nn,), units='K/s', tags=['integrate', 'state_name:T_motor', 'state_units:K', 'state_val:300.0', 'state_promotes:True']) + + self.declare_partials(['T_out','q','dTdt'], ['power_rating'], rows=arange, cols=np.zeros((nn,))) + self.declare_partials(['dTdt'], ['motor_weight'], rows=arange, cols=np.zeros((nn,))) + + self.declare_partials(['T_out','q','dTdt'], ['T_in', 'T','mdot_coolant'], rows=arange, cols=arange) + self.declare_partials(['dTdt'], ['q_in'], rows=arange, cols=arange) + + def compute(self, inputs, outputs): + const = self.options['case_cooling_coefficient'] / self.options['case_area_coefficient'] + + NTU = const * inputs['power_rating'] / inputs['mdot_coolant'] / self.options['coolant_specific_heat'] + effectiveness = 1 - np.exp(-NTU) + heat_transfer = (inputs['T'] - inputs['T_in']) * effectiveness * inputs['mdot_coolant'] * self.options['coolant_specific_heat'] + outputs['q'] = heat_transfer + outputs['T_out'] = inputs['T_in'] + heat_transfer / inputs['mdot_coolant'] / self.options['coolant_specific_heat'] + outputs['dTdt'] = (inputs['q_in'] - outputs['q']) / inputs['motor_weight'] / self.options['motor_specific_heat'] + + def compute_partials(self, inputs, J): + nn = self.options['num_nodes'] + cp = self.options['coolant_specific_heat'] + mdot = inputs['mdot_coolant'] + const = self.options['case_cooling_coefficient'] / self.options['case_area_coefficient'] + + NTU = const * inputs['power_rating'] / mdot / cp + dNTU_dP = const / mdot / cp + dNTU_dmdot = -const * inputs['power_rating'] / mdot **2 / cp + effectiveness = 1 - np.exp(-NTU) + deff_dP = np.exp(-NTU) * dNTU_dP + deff_dmdot = np.exp(-NTU) * dNTU_dmdot + + heat_transfer = (inputs['T'] - inputs['T_in']) * effectiveness * inputs['mdot_coolant'] * self.options['coolant_specific_heat'] + + J['q', 'T'] = effectiveness * mdot * cp + J['q', 'T_in'] = - effectiveness * mdot * cp + J['q', 'power_rating'] = (inputs['T'] - inputs['T_in']) * deff_dP * mdot * cp + J['q', 'mdot_coolant'] = (inputs['T'] - inputs['T_in']) * cp * (effectiveness + deff_dmdot * mdot) + + J['T_out', 'T'] = J['q','T'] / mdot / cp + J['T_out', 'T_in'] = np.ones(nn) + J['q','T_in'] / mdot / cp + J['T_out', 'power_rating'] = J['q', 'power_rating'] / mdot / cp + J['T_out', 'mdot_coolant'] = (J['q', 'mdot_coolant'] * mdot - heat_transfer) / cp / mdot ** 2 + + J['dTdt', 'q_in'] = 1 / inputs['motor_weight'] / self.options['motor_specific_heat'] + J['dTdt', 'T'] = -J['q', 'T'] / inputs['motor_weight'] / self.options['motor_specific_heat'] + J['dTdt', 'T_in'] = -J['q', 'T_in'] / inputs['motor_weight'] / self.options['motor_specific_heat'] + J['dTdt', 'power_rating'] = -J['q', 'power_rating'] / inputs['motor_weight'] / self.options['motor_specific_heat'] + J['dTdt', 'mdot_coolant'] = -J['q', 'mdot_coolant'] / inputs['motor_weight'] / self.options['motor_specific_heat'] + J['dTdt', 'motor_weight'] = -(inputs['q_in'] - heat_transfer) / inputs['motor_weight']**2 / self.options['motor_specific_heat'] diff --git a/openconcept/thermal/pump.py b/openconcept/thermal/pump.py new file mode 100644 index 00000000..6e334ede --- /dev/null +++ b/openconcept/thermal/pump.py @@ -0,0 +1,91 @@ +import openmdao.api as om +import numpy as np + +class SimplePump(om.ExplicitComponent): + """ + A pump that circulates coolant against pressure. + The default parameters are based on a survey of commercial + airplane fuel pumps of a variety of makes and models. + + Inputs + ------ + power_rating : float + Maximum rated electrical power (scalar, W) + mdot_coolant : float + Coolant mass flow rate (vector, kg/s) + rho_coolant : float + Coolant density (vector, kg/m3) + delta_p : float + Pressure rise provided by the pump (vector, kg/s) + + Outputs + ------- + elec_load : float + Electricity used by the pump (vector, W) + component_weight : float + Pump weight (scalar, kg) + component_sizing_margin : float + Fraction of total power rating used via elec_load (vector, dimensionless) + + Options + ------- + num_nodes : int + Number of analysis points to run (sets vec length; default 1) + efficiency : float + Pump electrical + mech efficiency. Sensible range 0.0 to 1.0 (default 0.35) + weight_base : float + Base weight of pump, doesn't change with power rating (default 0) + weight_inc : float + Incremental weight of pump, scales linearly with power rating (default 1/450 kg/W) + """ + def initialize(self): + self.options.declare('num_nodes', default=1, desc='Number of flight/control conditions') + self.options.declare('efficiency', default=0.35, desc='Efficiency (dimensionless)') + self.options.declare('weight_base', default=0.0, desc='Pump base weight') + self.options.declare('weight_inc', default=1/450, desc='Incremental pump weight (kg/W)') + + def setup(self): + nn = self.options['num_nodes'] + eta = self.options['efficiency'] + weight_inc = self.options['weight_inc'] + + self.add_input('power_rating', units='W', desc='Pump electrical power rating') + self.add_input('mdot_coolant', units='kg/s', desc='Coolant mass flow rate', val=np.ones((nn,))) + self.add_input('delta_p', units='Pa', desc='Pump pressure rise', val=np.ones((nn,))) + self.add_input('rho_coolant', units='kg/m**3', desc='Coolant density', val=np.ones((nn,))) + + self.add_output('elec_load', units='W', desc='Pump electrical load', val=np.ones((nn,))) + self.add_output('component_weight', units='kg', desc='Pump weight') + self.add_output('component_sizing_margin', units=None, val=np.ones((nn,)), desc='Comp sizing margin') + + self.declare_partials(['elec_load','component_sizing_margin'], ['rho_coolant', 'delta_p', 'mdot_coolant'], rows=np.arange(nn), cols=np.arange(nn)) + self.declare_partials(['component_sizing_margin'], ['power_rating'], rows=np.arange(nn), cols=np.zeros(nn)) + self.declare_partials(['component_weight'], ['power_rating'], val=weight_inc) + + + + def compute(self, inputs, outputs): + nn = self.options['num_nodes'] + eta = self.options['efficiency'] + weight_inc = self.options['weight_inc'] + weight_base = self.options['weight_base'] + + outputs['component_weight'] = weight_base + weight_inc * inputs['power_rating'] + + # compute the fluid power + vol_flow_rate = inputs['mdot_coolant'] / inputs['rho_coolant'] # m3/s + fluid_power = vol_flow_rate * inputs['delta_p'] + outputs['elec_load'] = fluid_power / eta + outputs['component_sizing_margin'] = outputs['elec_load'] / inputs['power_rating'] + + + def compute_partials(self, inputs, J): + nn = self.options['num_nodes'] + eta = self.options['efficiency'] + + J['elec_load', 'mdot_coolant'] = inputs['delta_p'] / inputs['rho_coolant'] / eta + J['elec_load', 'delta_p'] = inputs['mdot_coolant'] / inputs['rho_coolant'] / eta + J['elec_load', 'rho_coolant'] = -inputs['mdot_coolant'] * inputs['delta_p'] / inputs['rho_coolant'] ** 2 / eta + for in_var in ['mdot_coolant', 'delta_p', 'rho_coolant']: + J['component_sizing_margin', in_var] = J['elec_load', in_var] / inputs['power_rating'] + J['component_sizing_margin', 'power_rating'] = - inputs['mdot_coolant'] * inputs['delta_p'] / inputs['rho_coolant'] / eta / inputs['power_rating'] ** 2 diff --git a/openconcept/thermal/tests/test_battery_cooling.py b/openconcept/thermal/tests/test_battery_cooling.py new file mode 100644 index 00000000..aa3a395d --- /dev/null +++ b/openconcept/thermal/tests/test_battery_cooling.py @@ -0,0 +1,151 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openmdao.api import IndepVarComp, Group, Problem, DirectSolver, NewtonSolver, IndepVarComp +from openconcept.thermal import LiquidCooledBattery + + +class QuasiSteadyBatteryCoolingTestCase(unittest.TestCase): + """ + Test the liquid cooled battery in quasi-steady (massless) mode + """ + def generate_model(self, nn): + prob = Problem() + iv = prob.model.add_subsystem('iv', IndepVarComp(), promotes_outputs=['*']) + iv.add_output('q_in', val=np.linspace(2000,5000,nn), units='W') + iv.add_output('mdot_coolant', val=1*np.ones((nn,)), units='kg/s') + iv.add_output('T_in', val=25*np.ones((nn,)), units='degC') + iv.add_output('battery_weight', val=100., units='kg') + prob.model.add_subsystem('test', LiquidCooledBattery(num_nodes=nn, quasi_steady=True), promotes=['*']) + prob.model.nonlinear_solver = NewtonSolver(solve_subsystems=True) + prob.model.linear_solver = DirectSolver() + prob.setup(check=True, force_alloc_complex=True) + return prob + + def test_scalar(self): + prob = self.generate_model(nn=1) + prob.run_model() + assert_near_equal(prob.get_val('dTdt'), 0.0, tolerance=1e-14) + assert_near_equal(prob.get_val('T_surface', units='K'), 298.94004878, tolerance=1e-10) + assert_near_equal(prob.get_val('T_core', units='K'), 307.10184074, tolerance=1e-10) + assert_near_equal(prob.get_val('test.hex.q', units='W'), 2000.0, tolerance=1e-10) + assert_near_equal(prob.get_val('T_out', units='K'), 298.6761773, tolerance=1e-10) + assert_near_equal(prob.get_val('T', units='K'), 303.02094476, tolerance=1e-10) + + partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) + assert_check_partials(partials) + + def test_vector(self): + prob = self.generate_model(nn=11) + prob.run_model() + assert_near_equal(prob.get_val('dTdt'), np.zeros((11,)), tolerance=1e-14) + assert_near_equal(prob.get_val('T_surface', units='K'), + np.array([333.94004878, 334.0585561 , 334.17706342, 334.29557074, + 334.41407805, 334.53258537, 334.65109269, 334.76960001, + 334.88810732, 335.00661464, 335.12512196])-35., tolerance=1e-10) + assert_near_equal(prob.get_val('T_core', units='K'), + np.array([342.10184074, 343.44461685, 344.78739296, 346.13016907, + 347.47294518, 348.81572129, 350.1584974 , 351.50127351, + 352.84404962, 354.18682573, 355.52960184])-35., tolerance=1e-10) + assert_near_equal(prob.get_val('test.hex.q', units='W'), np.linspace(2000,5000,11), tolerance=1e-10) + assert_near_equal(prob.get_val('T_out', units='K'), + np.array([333.67617732, 333.75510392, 333.83403052, 333.91295712, + 333.99188371, 334.07081031, 334.14973691, 334.22866351, + 334.30759011, 334.38651671, 334.4654433 ])-35., tolerance=1e-10) + assert_near_equal(prob.get_val('T', units='K'), + np.array([338.02094476, 338.75158647, 339.48222819, 340.2128699 , + 340.94351162, 341.67415333, 342.40479505, 343.13543676, + 343.86607847, 344.59672019, 345.3273619])-35., tolerance=1e-10) + + # prob.model.list_outputs(print_arrays=True, units='True') + partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) + assert_check_partials(partials) + +class UnsteadyBatteryCoolingTestCase(unittest.TestCase): + """ + Test the liquid cooled battery in unsteady mode + """ + def generate_model(self, nn): + """ + An example demonstrating unsteady battery cooling + """ + from openconcept.mission import PhaseGroup, TrajectoryGroup + import openmdao.api as om + import numpy as np + + class VehicleModel(Group): + def initialize(self): + self.options.declare('num_nodes', default=11) + + def setup(self): + num_nodes = self.options['num_nodes'] + ivc = self.add_subsystem('ivc', IndepVarComp(), promotes_outputs=['*']) + ivc.add_output('battery_heat', val=np.ones((num_nodes,))*5000, units='W') + ivc.add_output('coolant_temp', 25.*np.ones((num_nodes,)), units='degC') + ivc.add_output('mdot_coolant', 1.0*np.ones((num_nodes,)), units='kg/s') + ivc.add_output('battery_weight', 100, units='kg') + ivc.add_output('n_cpb', 21) + ivc.add_output('t_channel', 0.0005, units='m') + + + self.add_subsystem('bcs', LiquidCooledBattery(num_nodes=num_nodes, quasi_steady=False)) + self.connect('battery_heat', 'bcs.q_in') + self.connect('coolant_temp', 'bcs.T_in') + self.connect('mdot_coolant', 'bcs.mdot_coolant') + self.connect('battery_weight', 'bcs.battery_weight') + self.connect('n_cpb', 'bcs.n_cpb') + self.connect('t_channel', 'bcs.t_channel') + + class TrajectoryPhase(PhaseGroup): + "An OpenConcept Phase comprises part of a time-based TrajectoryGroup and always needs to have a 'duration' defined" + def setup(self): + self.add_subsystem('ivc', IndepVarComp('duration', val=30, units='min'), promotes_outputs=['duration']) + self.add_subsystem('vm', VehicleModel(num_nodes=self.options['num_nodes'])) + + class Trajectory(TrajectoryGroup): + "An OpenConcept TrajectoryGroup consists of one or more phases that may be linked together. This will often be a top-level model" + def setup(self): + self.add_subsystem('phase1', TrajectoryPhase(num_nodes=nn)) + # self.add_subsystem('phase2', TrajectoryPhase(num_nodes=nn)) + # the link_phases directive ensures continuity of state variables across phase boundaries + # self.link_phases(self.phase1, self.phase2) + + prob = Problem(Trajectory()) + prob.model.nonlinear_solver = NewtonSolver(iprint=2) + prob.model.linear_solver = DirectSolver() + prob.model.nonlinear_solver.options['solve_subsystems'] = True + prob.model.nonlinear_solver.options['maxiter'] = 20 + prob.model.nonlinear_solver.options['atol'] = 1e-6 + prob.model.nonlinear_solver.options['rtol'] = 1e-6 + prob.setup(force_alloc_complex=True) + # set the initial value of the state at the beginning of the TrajectoryGroup + prob['phase1.vm.bcs.T_initial'] = 300. + prob.run_model() + # prob.model.list_outputs(print_arrays=True, units=True) + # prob.model.list_inputs(print_arrays=True, units=True) + + return prob + + def test_vector(self): + prob = self.generate_model(nn=11) + prob.run_model() + assert_near_equal(prob.get_val('phase1.vm.bcs.T_surface', units='K'), + np.array([298.45006299, 299.70461767, 299.97097736, 300.08642573, + 300.11093705, 300.121561 , 300.12381662, 300.12479427, + 300.12500184, 300.1250918 , 300.12511091]), tolerance=1e-10) + assert_near_equal(prob.get_val('phase1.vm.bcs.T_core', units='K'), + np.array([301.54993701, 315.76497532, 318.78302876, 320.09114476, + 320.36887627, 320.48925354, 320.51481133, 320.52588886, + 320.52824077, 320.52926016, 320.52947659]), tolerance=1e-10) + assert_near_equal(prob.get_val('phase1.vm.bcs.T_out', units='K'), + np.array([298.34984379, 299.18538488, 299.36278206, 299.43967138, + 299.45599607, 299.46307168, 299.46457394, 299.46522506, + 299.4653633 , 299.46542322, 299.46543594]), tolerance=1e-10) + assert_near_equal(prob.get_val('phase1.vm.bcs.T', units='K'), + np.array([300. , 307.73479649, 309.37700306, 310.08878525, + 310.23990666, 310.30540727, 310.31931397, 310.32534156, + 310.3266213 , 310.32717598, 310.32729375]), tolerance=1e-10) + + # prob.model.list_outputs(print_arrays=True, units='True') + partials = prob.check_partials(method='cs',compact_print=True, step=1e-50) + assert_check_partials(partials) diff --git a/openconcept/components/tests/test_chiller.py b/openconcept/thermal/tests/test_chiller.py similarity index 97% rename from openconcept/components/tests/test_chiller.py rename to openconcept/thermal/tests/test_chiller.py index 95b0b4c4..d311cbe0 100644 --- a/openconcept/components/tests/test_chiller.py +++ b/openconcept/thermal/tests/test_chiller.py @@ -1,10 +1,9 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem, NewtonSolver, DirectSolver -from openconcept.components.chiller import (LinearSelector, COPHeatPump, HeatPumpWeight, - HeatPumpWithIntegratedCoolantLoop, COPExplicit) +from openconcept.thermal.chiller import (LinearSelector, COPHeatPump, HeatPumpWeight, + HeatPumpWithIntegratedCoolantLoop, COPExplicit) class LinearSelectorTestCase(unittest.TestCase): def test_bypass(self): diff --git a/openconcept/components/tests/test_ducts.py b/openconcept/thermal/tests/test_ducts.py similarity index 99% rename from openconcept/components/tests/test_ducts.py rename to openconcept/thermal/tests/test_ducts.py index 063b6ddd..bc38c271 100644 --- a/openconcept/components/tests/test_ducts.py +++ b/openconcept/thermal/tests/test_ducts.py @@ -1,8 +1,7 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials -from openconcept.components.ducts import ImplicitCompressibleDuct_ExternalHX +from openconcept.thermal import ImplicitCompressibleDuct_ExternalHX import openmdao.api as om import warnings diff --git a/openconcept/components/tests/test_heat_exchanger.py b/openconcept/thermal/tests/test_heat_exchanger.py similarity index 98% rename from openconcept/components/tests/test_heat_exchanger.py rename to openconcept/thermal/tests/test_heat_exchanger.py index 5c05f195..d69b6015 100644 --- a/openconcept/components/tests/test_heat_exchanger.py +++ b/openconcept/thermal/tests/test_heat_exchanger.py @@ -1,10 +1,21 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.components.heat_exchanger import OffsetStripFinGeometry, OffsetStripFinData, HydraulicDiameterReynoldsNumber, OutletTemperatures, PressureDrop -from openconcept.components.heat_exchanger import NusseltFromColburnJ, ConvectiveCoefficient, FinEfficiency, UAOverall, NTUMethod, CrossFlowNTUEffectiveness, NTUEffectivenessActualHeatTransfer +from openconcept.thermal.heat_exchanger import ( + OffsetStripFinGeometry, + HydraulicDiameterReynoldsNumber, + OffsetStripFinData, + NusseltFromColburnJ, + ConvectiveCoefficient, + FinEfficiency, + UAOverall, + NTUMethod, + CrossFlowNTUEffectiveness, + NTUEffectivenessActualHeatTransfer, + OutletTemperatures, + PressureDrop +) class OSFGeometryTestGroup(Group): """ diff --git a/openconcept/components/tests/test_heat_pipe.py b/openconcept/thermal/tests/test_heat_pipe.py similarity index 83% rename from openconcept/components/tests/test_heat_pipe.py rename to openconcept/thermal/tests/test_heat_pipe.py index db71980c..c2131ac9 100644 --- a/openconcept/components/tests/test_heat_pipe.py +++ b/openconcept/thermal/tests/test_heat_pipe.py @@ -1,9 +1,16 @@ -from __future__ import division import unittest import numpy as np -from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials, assert_warning -from openmdao.api import Problem, NewtonSolver, DirectSolver -import openconcept.components.heat_pipe as hp +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openmdao.api import Problem +from openconcept.thermal.heat_pipe import ( + HeatPipe, + HeatPipeThermalResistance, + HeatPipeVaporTempDrop, + HeatPipeWeight, + AmmoniaProperties, + QMaxHeatPipe, + QMaxAnalyticalPart, +) class HeatPipeIntegrationTestCase(unittest.TestCase): """ @@ -13,7 +20,7 @@ def test_simple_scalar(self): nn = 1 theta = 84. prob = Problem() - pipe = prob.model.add_subsystem('test', hp.HeatPipe(num_nodes=nn, theta=theta), promotes=['*']) + pipe = prob.model.add_subsystem('test', HeatPipe(num_nodes=nn, theta=theta), promotes=['*']) pipe.set_input_defaults('T_evap', units='degC', val=np.linspace(30, 30, nn)) pipe.set_input_defaults('q', units='W', val=np.linspace(400, 400, nn)) pipe.set_input_defaults('length', units='m', val=10.22) @@ -24,7 +31,7 @@ def test_simple_scalar(self): prob.setup(check=True, force_alloc_complex=True) prob.run_model() - assert_near_equal(prob['q_max'], np.ones(nn)*2807.04869547, tolerance=1e-5) + assert_near_equal(prob['q_max'], np.ones(nn)*2807.01928115, tolerance=1e-5) assert_near_equal(prob['weight'], 0.51463886, tolerance=1e-5) assert_near_equal(prob['T_cond'], np.ones(nn)*29.9441105, tolerance=1e-5) @@ -34,7 +41,7 @@ def test_simple_scalar(self): def test_simple_vector(self): nn = 5 prob = Problem() - pipe = prob.model.add_subsystem('test', hp.HeatPipe(num_nodes=nn), promotes=['*']) + pipe = prob.model.add_subsystem('test', HeatPipe(num_nodes=nn), promotes=['*']) pipe.set_input_defaults('T_evap', units='degC', val=np.linspace(30, 60, nn)) pipe.set_input_defaults('q', units='W', val=np.linspace(400, 1000, nn)) pipe.set_input_defaults('length', units='m', val=10.22) @@ -45,7 +52,7 @@ def test_simple_vector(self): prob.setup(check=True, force_alloc_complex=True) prob.run_model() - assert_near_equal(prob['q_max'], np.array([4936.75193703, 5022.29454826, 5074.04485581, 5095.29546816, 5081.04977578]), tolerance=1e-5) + assert_near_equal(prob['q_max'], np.array([4936.70020611, 5022.24211291, 5073.99208835, 5095.24271287, 5080.99742858]), tolerance=1e-5) assert_near_equal(prob['weight'], 0.51463886, tolerance=1e-5) assert_near_equal(prob['T_cond'], np.array([29.9441105, 37.4231564, 44.90220466, 52.38125436, 59.86030483]), tolerance=1e-5) @@ -58,7 +65,7 @@ def test_two_pipes(self): # Run one and two pipes to compare results one = Problem() - pipe = one.model.add_subsystem('test', hp.HeatPipe(num_nodes=nn), promotes=['*']) + pipe = one.model.add_subsystem('test', HeatPipe(num_nodes=nn), promotes=['*']) pipe.set_input_defaults('T_evap', units='degC', val=np.linspace(30, 30, nn)) pipe.set_input_defaults('q', units='W', val=np.linspace(200, 200, nn)) pipe.set_input_defaults('length', units='m', val=10.22) @@ -74,7 +81,7 @@ def test_two_pipes(self): # Twice as many pipes with twice as much heat two = Problem() - pipe = two.model.add_subsystem('test', hp.HeatPipe(num_nodes=nn), promotes=['*']) + pipe = two.model.add_subsystem('test', HeatPipe(num_nodes=nn), promotes=['*']) pipe.set_input_defaults('T_evap', units='degC', val=np.linspace(30, 30, nn)) pipe.set_input_defaults('q', units='W', val=np.linspace(400, 400, nn)) pipe.set_input_defaults('length', units='m', val=10.22) @@ -99,7 +106,7 @@ class HeatPipeThermalResistanceTestCase(unittest.TestCase): def test_default_settings(self): nn = 3 p = Problem() - p.model.add_subsystem('test', hp.HeatPipeThermalResistance(num_nodes=nn), promotes=['*']) + p.model.add_subsystem('test', HeatPipeThermalResistance(num_nodes=nn), promotes=['*']) p.setup(check=True, force_alloc_complex=True) p.run_model() @@ -115,7 +122,7 @@ class HeatPipeVaporTempDropTestCase(unittest.TestCase): def test_default_settings(self): nn = 3 p = Problem() - p.model.add_subsystem('test', hp.HeatPipeVaporTempDrop(num_nodes=nn), promotes=['*']) + p.model.add_subsystem('test', HeatPipeVaporTempDrop(num_nodes=nn), promotes=['*']) p.setup(check=True, force_alloc_complex=True) p.run_model() @@ -130,7 +137,7 @@ class HeatPipeWeightTestCase(unittest.TestCase): """ def test_default_settings(self): p = Problem() - p.model.add_subsystem('test', hp.HeatPipeWeight(), promotes=['*']) + p.model.add_subsystem('test', HeatPipeWeight(), promotes=['*']) p.setup(check=True, force_alloc_complex=True) p.run_model() @@ -146,7 +153,7 @@ class AmmoniaPropertiesTestCase(unittest.TestCase): def test_on_data(self): nn = 3 p = Problem() - comp = p.model.add_subsystem('test', hp.AmmoniaProperties(num_nodes=nn), promotes=['*']) + comp = p.model.add_subsystem('test', AmmoniaProperties(num_nodes=nn), promotes=['*']) comp.set_input_defaults('temp', units='degC', val=np.ones(nn)*90.) p.setup(check=True, force_alloc_complex=True) p.run_model() @@ -160,7 +167,7 @@ def test_on_data(self): def test_interpolated(self): nn = 6 p = Problem() - comp = p.model.add_subsystem('test', hp.AmmoniaProperties(num_nodes=nn), promotes=['*']) + comp = p.model.add_subsystem('test', AmmoniaProperties(num_nodes=nn), promotes=['*']) comp.set_input_defaults('temp', units='degC', val=np.linspace(-7., 78., nn)) p.setup(check=True, force_alloc_complex=True) p.run_model() @@ -178,7 +185,7 @@ class QMaxHeatPipeTestCase(unittest.TestCase): def test_default_settings(self): nn = 3 p = Problem() - comp = p.model.add_subsystem('test', hp.QMaxHeatPipe(num_nodes=nn), promotes=['*']) + comp = p.model.add_subsystem('test', QMaxHeatPipe(num_nodes=nn), promotes=['*']) comp.set_input_defaults('temp', units='degC', val=np.linspace(30, 60, nn)) comp.set_input_defaults('length', units='m', val=10.22) comp.set_input_defaults('inner_diam', units='inch', val=.902) @@ -186,7 +193,7 @@ def test_default_settings(self): p.setup(check=True, force_alloc_complex=True) p.run_model() - assert_near_equal(p['q_max'], np.array([4936.75193703, 5074.04485581, 5081.04977578]), tolerance=1e-5) + assert_near_equal(p['q_max'], np.array([4936.70020611, 5073.99208835, 5080.99742858]), tolerance=1e-5) assert_near_equal(p['heat_pipe_weight'], 0.51463886, tolerance=1e-5) partials = p.check_partials(method='cs',compact_print=True, step=1e-50) @@ -199,11 +206,14 @@ class QMaxAnalyticalPartTestCase(unittest.TestCase): def test_default_settings(self): nn = 3 p = Problem() - p.model.add_subsystem('test', hp.QMaxAnalyticalPart(num_nodes=nn), promotes=['*']) + p.model.add_subsystem('test', QMaxAnalyticalPart(num_nodes=nn), promotes=['*']) p.setup(check=True, force_alloc_complex=True) p.run_model() - assert_near_equal(p['q_max'], np.ones(nn)*875.86211677, tolerance=1e-5) + assert_near_equal(p['q_max'], np.ones(nn)*875.85211163, tolerance=1e-5) partials = p.check_partials(method='cs',compact_print=True, step=1e-50) assert_check_partials(partials) + +if __name__=="__main__": + unittest.main() diff --git a/openconcept/thermal/tests/test_hose.py b/openconcept/thermal/tests/test_hose.py new file mode 100644 index 00000000..34db35dc --- /dev/null +++ b/openconcept/thermal/tests/test_hose.py @@ -0,0 +1,67 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +import openmdao.api as om +from openconcept.thermal import SimpleHose + + +class TestHose(unittest.TestCase): + """ + Test the coolant hose component + """ + + def generate_model(self, nn): + prob = om.Problem() + + hose_diam = 0.02 + hose_length = 16. + hose_design_pressure = 1e6 + mdot_coolant = np.linspace(0.6, 1.2, nn) + rho_coolant = 1020*np.ones((nn,)) + mu_coolant = 1.68e-3 + sigma = 2.07e6 + rho_hose = 1356.3 + + ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) + ivc.add_output('hose_diameter', val=hose_diam, units='m') + ivc.add_output('hose_length', val=hose_length, units='m') + ivc.add_output('hose_design_pressure', val=hose_design_pressure, units='Pa') + ivc.add_output('mdot_coolant', val=mdot_coolant, units='kg/s') + ivc.add_output('rho_coolant', val=rho_coolant, units='kg/m**3') + ivc.add_output('mu_coolant', val=mu_coolant, units='kg/m/s') + prob.model.add_subsystem('hose', SimpleHose(num_nodes=nn), promotes_inputs=['*']) + prob.setup(check=True, force_alloc_complex=True) + + xs_area = np.pi * (hose_diam / 2 )**2 + U = mdot_coolant / rho_coolant / xs_area + Redh = rho_coolant * U * hose_diam / mu_coolant + f = 0.3164 * Redh ** (-1/4) + dp = f * rho_coolant / 2 * hose_length * U ** 2 / hose_diam + + wall_thickness = hose_design_pressure * (hose_diam / 2) / sigma + hose_weight = wall_thickness * np.pi * (hose_diam + wall_thickness) * rho_hose * hose_length + fluid_weight = xs_area * rho_coolant[0] * hose_length + return prob, dp, (hose_weight + fluid_weight) + + def test_scalar(self): + prob, dp, weight = self.generate_model(nn=1) + prob.run_model() + assert_near_equal(prob.get_val('hose.delta_p', units='Pa'), + dp, tolerance=1e-10) + assert_near_equal(prob.get_val('hose.component_weight', units='kg'), + weight, tolerance=1e-10) + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + + def test_vector(self): + prob, dp, weight = self.generate_model(nn=11) + prob.run_model() + assert_near_equal(prob.get_val('hose.delta_p', units='Pa'), + dp, tolerance=1e-10) + assert_near_equal(prob.get_val('hose.component_weight', units='kg'), + weight, tolerance=1e-10) + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + +if __name__ == "__main__": + unittest.main() diff --git a/openconcept/thermal/tests/test_manifold.py b/openconcept/thermal/tests/test_manifold.py new file mode 100644 index 00000000..7ed11df7 --- /dev/null +++ b/openconcept/thermal/tests/test_manifold.py @@ -0,0 +1,82 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +from openmdao.api import IndepVarComp, Group, Problem +from openconcept.thermal import FlowSplit, FlowCombine + + +class FlowSplitTestCase(unittest.TestCase): + """ + Test the FlowSplit component + """ + def test_default_settings(self): + p = Problem() + p.model.add_subsystem('test', FlowSplit(), promotes=['*']) + p.setup(check=True, force_alloc_complex=True) + p.run_model() + assert_near_equal(p['mdot_out_A'], np.array([0.5])) + assert_near_equal(p['mdot_out_B'], np.array([0.5])) + + partials = p.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + + def test_nondefault_settings(self): + nn = 4 + p = Problem() + p.model.add_subsystem('test', FlowSplit(num_nodes=nn), promotes=['*']) + p.setup(check=True, force_alloc_complex=True) + + p['mdot_in'] = np.array([-10., 0., 10., 10.]) + p['mdot_split_fraction'] = np.array([0., 0.4, 0.4, 1.]) + + p.run_model() + + assert_near_equal(p['mdot_out_A'], np.array([0., 0., 4., 10.])) + assert_near_equal(p['mdot_out_B'], np.array([-10., 0., 6., 0.])) + + def test_warnings(self): + nn = 4 + p = Problem() + p.model.add_subsystem('test', FlowSplit(num_nodes=nn), promotes=['*']) + p.setup(check=True, force_alloc_complex=True) + + p['mdot_in'] = np.array([-10., 0., 10., 10.]) + p['mdot_split_fraction'] = np.array([-0.0001, 0.4, 0.4, 1.]) + with self.assertRaises(RuntimeWarning): + p.run_model() + + + p['mdot_split_fraction'] = np.array([1.0001, 0.4, 0.4, 1.]) + with self.assertRaises(RuntimeWarning): + p.run_model() + +class FlowCombineTestCase(unittest.TestCase): + """ + Test the FlowCombine component + """ + def test_default_settings(self): + p = Problem() + p.model.add_subsystem('test', FlowCombine(), promotes=['*']) + p.setup(check=True, force_alloc_complex=True) + p.run_model() + assert_near_equal(p['mdot_out'], np.array([2.])) + assert_near_equal(p['T_out'], np.array([1.])) + + partials = p.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + + def test_nondefault_settings(self): + nn = 4 + p = Problem() + p.model.add_subsystem('test', FlowCombine(num_nodes=nn), promotes=['*']) + p.setup(check=True, force_alloc_complex=True) + + p['mdot_in_A'] = np.array([0., 5., 10., 10.]) + p['mdot_in_B'] = np.array([1., 0., 5., 10.]) + p['T_in_A'] = np.array([1., 10., 30., 500.]) + p['T_in_B'] = np.array([1., 150., 60., 100.]) + + p.run_model() + + assert_near_equal(p['mdot_out'], np.array([1., 5., 15., 20.])) + assert_near_equal(p['T_out'], np.array([1., 10., 40., 300.])) diff --git a/openconcept/thermal/tests/test_motor_cooling.py b/openconcept/thermal/tests/test_motor_cooling.py new file mode 100644 index 00000000..7dce19ed --- /dev/null +++ b/openconcept/thermal/tests/test_motor_cooling.py @@ -0,0 +1,151 @@ +import unittest +import openmdao.api as om +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +import numpy as np +from openconcept.thermal import LiquidCooledMotor + +class QuasiSteadyMotorCoolingTestCase(unittest.TestCase): + """ + Test the liquid cooled motor in quasi-steady (massless) mode + """ + def generate_model(self, nn): + prob = om.Problem() + ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) + ivc.add_output('q_in', val=np.ones((nn,))*10000, units='W') + ivc.add_output('T_in', 25.*np.ones((nn,)), units='degC') + ivc.add_output('mdot_coolant', 3.0*np.ones((nn,)), units='kg/s') + ivc.add_output('motor_weight', 40, units='kg') + ivc.add_output('power_rating', 200, units='kW') + prob.model.add_subsystem('lcm', LiquidCooledMotor(num_nodes=nn, quasi_steady=True), promotes_inputs=['*']) + prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=True) + prob.model.linear_solver = om.DirectSolver() + prob.setup(check=True, force_alloc_complex=True) + return prob + + def test_scalar(self): + prob = self.generate_model(nn=1) + prob.run_model() + power_rating = 200000 + mdot_coolant = 3.0 + q_generated = power_rating * 0.05 + cp_coolant = 3801 + UA = 1100/650000*power_rating + Cmin = cp_coolant * mdot_coolant # cp * mass flow rate + NTU = UA/Cmin + T_in = 298.15 + effectiveness = 1 - np.exp(-NTU) + delta_T = q_generated / effectiveness / Cmin + assert_near_equal(prob.get_val('lcm.dTdt'), 0.0, tolerance=1e-14) + assert_near_equal(prob.get_val('lcm.T', units='K'), T_in + delta_T, tolerance=1e-10) + assert_near_equal(prob.get_val('lcm.T_out', units='K'), T_in + q_generated / Cmin, tolerance=1e-10) + partials = prob.check_partials(method='cs',compact_print=True) + # prob.model.list_outputs(print_arrays=True, units=True) + assert_check_partials(partials) + + def test_vector(self): + prob = self.generate_model(nn=11) + prob.run_model() + power_rating = 200000 + mdot_coolant = 3.0 + q_generated = power_rating * 0.05 + cp_coolant = 3801 + UA = 1100/650000*power_rating + Cmin = cp_coolant * mdot_coolant # cp * mass flow rate + NTU = UA/Cmin + T_in = 298.15 + effectiveness = 1 - np.exp(-NTU) + delta_T = q_generated / effectiveness / Cmin + assert_near_equal(prob.get_val('lcm.dTdt'), np.zeros((11,)), tolerance=1e-14) + assert_near_equal(prob.get_val('lcm.T', units='K'), np.ones((11,))*(T_in + delta_T), tolerance=1e-10) + assert_near_equal(prob.get_val('lcm.T_out', units='K'), np.ones((11,))*(T_in + q_generated / Cmin), tolerance=1e-10) + # prob.model.list_outputs(print_arrays=True, units='True') + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + +class UnsteadyMotorCoolingTestCase(unittest.TestCase): + """ + Test the liquid cooled motor in unsteady mode + """ + def generate_model(self, nn): + """ + An example demonstrating unsteady motor cooling + """ + from openconcept.mission import PhaseGroup, TrajectoryGroup + import openmdao.api as om + import numpy as np + + class VehicleModel(om.Group): + def initialize(self): + self.options.declare('num_nodes', default=11) + + def setup(self): + num_nodes = self.options['num_nodes'] + ivc = self.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) + ivc.add_output('q_in', val=np.ones((num_nodes,))*10000, units='W') + ivc.add_output('T_in', 25.*np.ones((num_nodes,)), units='degC') + ivc.add_output('mdot_coolant', 3.0*np.ones((num_nodes,)), units='kg/s') + ivc.add_output('motor_weight', 40, units='kg') + ivc.add_output('power_rating', 200, units='kW') + self.add_subsystem('lcm', LiquidCooledMotor(num_nodes=num_nodes, quasi_steady=False), promotes_inputs=['*']) + + class TrajectoryPhase(PhaseGroup): + "An OpenConcept Phase comprises part of a time-based TrajectoryGroup and always needs to have a 'duration' defined" + def setup(self): + self.add_subsystem('ivc', om.IndepVarComp('duration', val=20, units='min'), promotes_outputs=['duration']) + self.add_subsystem('vm', VehicleModel(num_nodes=self.options['num_nodes'])) + + class Trajectory(TrajectoryGroup): + "An OpenConcept TrajectoryGroup consists of one or more phases that may be linked together. This will often be a top-level model" + def setup(self): + self.add_subsystem('phase1', TrajectoryPhase(num_nodes=nn)) + # self.add_subsystem('phase2', TrajectoryPhase(num_nodes=nn)) + # the link_phases directive ensures continuity of state variables across phase boundaries + # self.link_phases(self.phase1, self.phase2) + + prob = om.Problem(Trajectory()) + prob.model.nonlinear_solver = om.NewtonSolver(iprint=2) + prob.model.linear_solver = om.DirectSolver() + prob.model.nonlinear_solver.options['solve_subsystems'] = True + prob.model.nonlinear_solver.options['maxiter'] = 20 + prob.model.nonlinear_solver.options['atol'] = 1e-6 + prob.model.nonlinear_solver.options['rtol'] = 1e-6 + prob.setup(force_alloc_complex=True) + # set the initial value of the state at the beginning of the TrajectoryGroup + prob['phase1.vm.T_initial'] = 300. + prob.run_model() + # prob.model.list_outputs(print_arrays=True, units=True) + # prob.model.list_inputs(print_arrays=True, units=True) + + return prob + + def test_vector(self): + prob = self.generate_model(nn=11) + prob.run_model() + power_rating = 200000 + mdot_coolant = 3.0 + q_generated = power_rating * 0.05 + cp_coolant = 3801 + UA = 1100/650000*power_rating + Cmin = cp_coolant * mdot_coolant # cp * mass flow rate + NTU = UA/Cmin + T_in = 298.15 + effectiveness = 1 - np.exp(-NTU) + delta_T = q_generated / effectiveness / Cmin + + assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K'), + np.array([300. , 319.02071102, 324.65196197, 327.0073297 , + 327.7046573 , 327.99632659, 328.08267788, 328.11879579, + 328.12948882, 328.13396137, 328.1352855]), tolerance=1e-10) + assert_near_equal(prob.get_val('phase1.vm.lcm.T_out', units='K'), + np.array([298.2041044 , 298.76037687, 298.92506629, 298.99395048, + 299.01434425, 299.0228743 , 299.0253997 , 299.02645599, + 299.02676872, 299.02689952, 299.02693824]), tolerance=1e-10) + assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K')[0], + np.array([300.]), tolerance=1e-10) + # at the end of the period the unsteady value should be approx the quasi-steady value + assert_near_equal(prob.get_val('phase1.vm.lcm.T', units='K')[-1], + np.array([T_in + delta_T]), tolerance=1e-5) + assert_near_equal(prob.get_val('phase1.vm.lcm.T_out', units='K')[-1], + np.array([T_in + q_generated / Cmin]), tolerance=1e-5) + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) diff --git a/openconcept/thermal/tests/test_pump.py b/openconcept/thermal/tests/test_pump.py new file mode 100644 index 00000000..859bc7ec --- /dev/null +++ b/openconcept/thermal/tests/test_pump.py @@ -0,0 +1,64 @@ +import unittest +import numpy as np +from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials +import openmdao.api as om +from openconcept.thermal import SimplePump + + +class TestPump(unittest.TestCase): + """ + Test the coolant pump component + """ + + def generate_model(self, nn): + prob = om.Problem() + + efficiency = 0.35 + spec_power = 1 / 450 + rho_coolant = 1020*np.ones(nn) + mdot_coolant = np.linspace(0.6, 1.2, nn) + delta_p = np.linspace(2e4, 4e4, nn) + power_rating = 1000 + ivc = prob.model.add_subsystem('ivc', om.IndepVarComp(), promotes_outputs=['*']) + ivc.add_output('power_rating', val=power_rating, units='W') + ivc.add_output('delta_p', val=delta_p, units='Pa') + ivc.add_output('mdot_coolant', val=mdot_coolant, units='kg/s') + ivc.add_output('rho_coolant', val=rho_coolant, units='kg/m**3') + prob.model.add_subsystem('pump', SimplePump(num_nodes=nn), promotes_inputs=['*']) + prob.setup(check=True, force_alloc_complex=True) + + fluid_power = (mdot_coolant / rho_coolant) * delta_p + weight = power_rating * spec_power + elec_load = fluid_power / efficiency + margin = elec_load / power_rating + + return prob, elec_load, weight, margin + + def test_scalar(self): + prob, elec_load, weight, margin = self.generate_model(nn=1) + prob.run_model() + assert_near_equal(prob.get_val('pump.elec_load', units='W'), + elec_load, tolerance=1e-10) + assert_near_equal(prob.get_val('pump.component_weight', units='kg'), + weight, tolerance=1e-10) + assert_near_equal(prob.get_val('pump.component_sizing_margin', units=None), + margin, tolerance=1e-10) + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + + def test_scalar(self): + prob, elec_load, weight, margin = self.generate_model(nn=11) + prob.run_model() + assert_near_equal(prob.get_val('pump.elec_load', units='W'), + elec_load, tolerance=1e-10) + assert_near_equal(prob.get_val('pump.component_weight', units='kg'), + weight, tolerance=1e-10) + assert_near_equal(prob.get_val('pump.component_sizing_margin', units=None), + margin, tolerance=1e-10) + partials = prob.check_partials(method='cs',compact_print=True) + assert_check_partials(partials) + +if __name__ == "__main__": + unittest.main() + + diff --git a/openconcept/components/tests/test_thermal_comps.py b/openconcept/thermal/tests/test_thermal_comps.py similarity index 87% rename from openconcept/components/tests/test_thermal_comps.py rename to openconcept/thermal/tests/test_thermal_comps.py index d55423a2..74995135 100644 --- a/openconcept/components/tests/test_thermal_comps.py +++ b/openconcept/thermal/tests/test_thermal_comps.py @@ -1,9 +1,15 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem, NewtonSolver, DirectSolver -import openconcept.components.thermal as thermal +from openconcept.thermal import ( + PerfectHeatTransferComp, + ThermalComponentWithMass, + ConstantSurfaceTemperatureColdPlate_NTU, + LiquidCooledComp, + CoolantReservoirRate, + CoolantReservoir, +) class PerfectHeatTransferCompTestCase(unittest.TestCase): """ @@ -12,7 +18,7 @@ class PerfectHeatTransferCompTestCase(unittest.TestCase): def test_comp(self): num_nodes = 3 prob = Problem() - prob.model.add_subsystem('test', thermal.PerfectHeatTransferComp(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', PerfectHeatTransferComp(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values @@ -37,7 +43,7 @@ class ThermalComponentWithMassTestCase(unittest.TestCase): def test_comp(self): num_nodes = 3 prob = Problem() - prob.model.add_subsystem('test', thermal.ThermalComponentWithMass(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', ThermalComponentWithMass(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values @@ -59,7 +65,7 @@ class ColdPlateTestCase(unittest.TestCase): def test_comp(self): num_nodes = 3 prob = Problem() - prob.model.add_subsystem('test', thermal.ConstantSurfaceTemperatureColdPlate_NTU(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', ConstantSurfaceTemperatureColdPlate_NTU(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values @@ -86,7 +92,7 @@ def test_comp(self): prob.model.nonlinear_solver=NewtonSolver() prob.model.linear_solver = DirectSolver() prob.model.nonlinear_solver.options['solve_subsystems'] = True - prob.model.add_subsystem('test', thermal.LiquidCooledComp(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', LiquidCooledComp(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values @@ -116,7 +122,7 @@ class CoolantReservoirRateTestCase(unittest.TestCase): def test_comp(self): num_nodes = 3 prob = Problem() - prob.model.add_subsystem('test', thermal.CoolantReservoirRate(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', CoolantReservoirRate(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values @@ -142,7 +148,7 @@ def test_comp(self): prob.model.nonlinear_solver=NewtonSolver() prob.model.linear_solver = DirectSolver() prob.model.nonlinear_solver.options['solve_subsystems'] = True - prob.model.add_subsystem('test', thermal.CoolantReservoir(num_nodes=num_nodes), promotes=['*']) + prob.model.add_subsystem('test', CoolantReservoir(num_nodes=num_nodes), promotes=['*']) prob.setup(check=True, force_alloc_complex=True) # Set the values diff --git a/openconcept/components/thermal.py b/openconcept/thermal/thermal.py similarity index 95% rename from openconcept/components/thermal.py rename to openconcept/thermal/thermal.py index e01eb5d8..83e37e06 100644 --- a/openconcept/components/thermal.py +++ b/openconcept/thermal/thermal.py @@ -1,19 +1,6 @@ -from __future__ import division -from openmdao.api import Problem, Group, IndepVarComp, BalanceComp, NewtonSolver, DirectSolver, BoundsEnforceLS -from openmdao.api import ScipyOptimizeDriver, ExplicitComponent, ImplicitComponent, ExecComp - +from openmdao.api import Group, ExplicitComponent, ImplicitComponent import numpy as np -import scipy.sparse as sp -import sys, os -sys.path.insert(0,os.getcwd()) -from openconcept.components.ducts import ImplicitCompressibleDuct -from openconcept.components.motor import SimpleMotor -from openconcept.utilities.selector import SelectorComp from openconcept.utilities.math.integrals import Integrator -from openconcept.utilities.math.derivatives import FirstDerivative -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp, VectorConcatenateComp, VectorSplitComp -from openconcept.analysis.atmospherics.compute_atmos_props import ComputeAtmosphericProperties -from openconcept.utilities.linearinterp import LinearInterpolator class PerfectHeatTransferComp(ExplicitComponent): """ diff --git a/openconcept/utilities/__init__.py b/openconcept/utilities/__init__.py index e69de29b..bfc6bff8 100644 --- a/openconcept/utilities/__init__.py +++ b/openconcept/utilities/__init__.py @@ -0,0 +1,17 @@ +from .dict_indepvarcomp import DictIndepVarComp, DymosDesignParamsFromDict +from .dvlabel import DVLabel +from .linearinterp import LinearInterpolator +from .selector import SelectorComp +from .visualization import plot_trajectory, plot_trajectory_grid + +# Math utilities +from .math import ( + AddSubtractComp, + VectorConcatenateComp, + VectorSplitComp, + FirstDerivative, + Integrator, + MaxComp, + MinComp, + ElementMultiplyDivideComp, +) diff --git a/openconcept/utilities/constants.py b/openconcept/utilities/constants.py new file mode 100644 index 00000000..dc34bca9 --- /dev/null +++ b/openconcept/utilities/constants.py @@ -0,0 +1 @@ +GRAV_CONST = 9.80665 # m/s^2 diff --git a/openconcept/utilities/dict_indepvarcomp.py b/openconcept/utilities/dict_indepvarcomp.py index 3be1b28d..79cd88f6 100644 --- a/openconcept/utilities/dict_indepvarcomp.py +++ b/openconcept/utilities/dict_indepvarcomp.py @@ -1,4 +1,3 @@ -from __future__ import division from openmdao.api import IndepVarComp import numpy as np import numbers diff --git a/openconcept/utilities/dvlabel.py b/openconcept/utilities/dvlabel.py index 7b371e58..32a9108b 100644 --- a/openconcept/utilities/dvlabel.py +++ b/openconcept/utilities/dvlabel.py @@ -1,4 +1,3 @@ -from __future__ import division from openmdao.api import ExplicitComponent import numpy as np diff --git a/openconcept/utilities/linearinterp.py b/openconcept/utilities/linearinterp.py index 26d04152..8380ddf0 100644 --- a/openconcept/utilities/linearinterp.py +++ b/openconcept/utilities/linearinterp.py @@ -1,4 +1,3 @@ -from __future__ import division from openmdao.api import ExplicitComponent import numpy as np diff --git a/openconcept/utilities/math/__init__.py b/openconcept/utilities/math/__init__.py index 0fed178a..96b5c8b9 100644 --- a/openconcept/utilities/math/__init__.py +++ b/openconcept/utilities/math/__init__.py @@ -1,4 +1,6 @@ from .add_subtract_comp import AddSubtractComp from .combine_split_comp import VectorConcatenateComp, VectorSplitComp +from .derivatives import FirstDerivative +from .integrals import Integrator +from .max_min_comp import MaxComp, MinComp from .multiply_divide_comp import ElementMultiplyDivideComp -from .sum_comp import SumComp diff --git a/openconcept/utilities/math/add_subtract_comp.py b/openconcept/utilities/math/add_subtract_comp.py index 585b262b..edcec185 100644 --- a/openconcept/utilities/math/add_subtract_comp.py +++ b/openconcept/utilities/math/add_subtract_comp.py @@ -1,8 +1,6 @@ """Definition of the Add/Subtract Component.""" import numpy as np -from scipy import sparse as sp -from six import string_types from collections.abc import Iterable from openmdao.core.explicitcomponent import ExplicitComponent @@ -75,7 +73,7 @@ def __init__(self, output_name=None, input_names=None, vec_size=1, length=1, self._add_systems = [] - if isinstance(output_name, string_types): + if isinstance(output_name, str): self._add_systems.append((output_name, input_names, vec_size, length, val, scaling_factors, kwargs)) elif isinstance(output_name, Iterable): @@ -166,7 +164,7 @@ def setup(self): """ for (output_name, input_names, vec_size, length, val, scaling_factors, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] if scaling_factors is None: @@ -232,7 +230,7 @@ def compute(self, inputs, outputs): """ for (output_name, input_names, vec_size, length, val, scaling_factors, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] if isinstance(vec_size, Iterable): diff --git a/openconcept/utilities/math/combine_split_comp.py b/openconcept/utilities/math/combine_split_comp.py index a2c64d66..a7f6e6c4 100644 --- a/openconcept/utilities/math/combine_split_comp.py +++ b/openconcept/utilities/math/combine_split_comp.py @@ -2,8 +2,6 @@ from collections.abc import Iterable import numpy as np -from scipy import sparse as sp -from six import string_types from openmdao.core.explicitcomponent import ExplicitComponent @@ -55,7 +53,7 @@ def __init__(self, output_name=None, input_names=None, vec_sizes=None, length=1, self._add_systems = [] - if isinstance(output_name, string_types): + if isinstance(output_name, str): if (not isinstance(input_names, Iterable) or not isinstance(vec_sizes, Iterable)): raise ValueError('User must provide list of input name(s)' @@ -148,7 +146,7 @@ def setup(self): """ for (output_name, input_names, vec_sizes, length, val, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] units = kwargs.get('units', None) @@ -197,7 +195,7 @@ def compute(self, inputs, outputs): """ for (output_name, input_names, vec_sizes, length, val, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] if self.under_complex_step: @@ -263,7 +261,7 @@ def __init__(self, output_names=None, input_name=None, vec_sizes=None, length=1, self._add_systems = [] - if isinstance(input_name, string_types): + if isinstance(input_name, str): if (not isinstance(output_names, Iterable) or not isinstance(vec_sizes, Iterable)): raise ValueError('User must provide list of output name(s)' @@ -356,7 +354,7 @@ def setup(self): """ for (output_names, input_name, vec_sizes, length, val, kwargs) in self._add_systems: - if isinstance(output_names, string_types): + if isinstance(output_names, str): output_names = [output_names] units = kwargs.get('units', None) @@ -404,7 +402,7 @@ def compute(self, inputs, outputs): """ for (output_names, input_name, vec_sizes, length, val, kwargs) in self._add_systems: - if isinstance(output_names, string_types): + if isinstance(output_names, str): output_names = [output_names] if self.under_complex_step: diff --git a/openconcept/utilities/math/derivatives.py b/openconcept/utilities/math/derivatives.py index 5cc9884b..8cd5b061 100644 --- a/openconcept/utilities/math/derivatives.py +++ b/openconcept/utilities/math/derivatives.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np import scipy.sparse as sp from openmdao.api import ExplicitComponent @@ -205,7 +204,7 @@ def first_deriv_partials(dts, q, n_segments=1, n_simpson_intervals_per_segment=2 class FirstDerivative(ExplicitComponent): """ - This component integrates differentiates a vector using a second or fourth order finite difference approximation + This component differentiates a vector using a second or fourth order finite difference approximation Inputs ------ diff --git a/openconcept/utilities/math/integrals.py b/openconcept/utilities/math/integrals.py index 42930249..05a1ee37 100644 --- a/openconcept/utilities/math/integrals.py +++ b/openconcept/utilities/math/integrals.py @@ -1,4 +1,3 @@ -from __future__ import division import numpy as np import scipy.sparse as sp from openmdao.api import ExplicitComponent @@ -505,7 +504,7 @@ class Integrator(ExplicitComponent): diff_units : str The units of the integrand (none by default) method : str - Numerical method (default 'bdf3'; alternatively, 'simpson) + Numerical method (default 'bdf3'; alternatively, 'simpson') time_setup : str Time configuration (default 'dt') 'dt' creates input 'dt' @@ -562,6 +561,8 @@ def add_integrand(self, name, rate_name=None, start_name=None, end_name=None, va val : float Default value for the integrated output (default 0.0) Can be scalar or shape num_nodes + start_val : float + Default value for the initial value input (default 0.0) upper : float Upper bound on integrated quantity lower : float diff --git a/openconcept/utilities/math/max_min_comp.py b/openconcept/utilities/math/max_min_comp.py index ec433d23..9a8dead9 100644 --- a/openconcept/utilities/math/max_min_comp.py +++ b/openconcept/utilities/math/max_min_comp.py @@ -1,4 +1,3 @@ -from __future__ import division import openmdao.api as om import numpy as np diff --git a/openconcept/utilities/math/multiply_divide_comp.py b/openconcept/utilities/math/multiply_divide_comp.py index 3ea94ee1..bf952ae9 100644 --- a/openconcept/utilities/math/multiply_divide_comp.py +++ b/openconcept/utilities/math/multiply_divide_comp.py @@ -1,8 +1,6 @@ """Definition of the Element Multiply Component.""" import numpy as np -from scipy import sparse as sp -from six import string_types from collections.abc import Iterable from openmdao.core.explicitcomponent import ExplicitComponent @@ -79,7 +77,7 @@ def __init__(self, output_name=None, input_names=None, vec_size=1, length=1, self._add_systems = [] - if isinstance(output_name, string_types): + if isinstance(output_name, str): self._add_systems.append((output_name, input_names, vec_size, length, val, scaling_factor, divide, input_units, kwargs)) elif isinstance(output_name, Iterable): @@ -174,7 +172,7 @@ def setup(self): """ for (output_name, input_names, vec_size, length, val, scaling_factor, divide, input_units, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] desc = kwargs.get('desc', '') @@ -263,7 +261,7 @@ def compute(self, inputs, outputs): """ for (output_name, input_names, vec_size, length, val, scaling_factor, divide, input_units, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] if divide is None: @@ -296,7 +294,7 @@ def compute(self, inputs, outputs): def compute_partials(self, inputs, J): for (output_name, input_names, vec_size, length, val, scaling_factor, divide, input_units, kwargs) in self._add_systems: - if isinstance(input_names, string_types): + if isinstance(input_names, str): input_names = [input_names] if isinstance(vec_size, Iterable): diff --git a/openconcept/utilities/math/sum_comp.py b/openconcept/utilities/math/sum_comp.py deleted file mode 100644 index 2665d7ae..00000000 --- a/openconcept/utilities/math/sum_comp.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Definition of the Element Summation Component.""" - -from collections.abc import Iterable -import numpy as np -from scipy import sparse as sp -from six import string_types - -from openmdao.core.explicitcomponent import ExplicitComponent - - -class SumComp(ExplicitComponent): - r""" - Compute a vectorized summation. - - Use the add_equation method to define any number of summations - User defines the names of the input and output variables using - add_equation(output_name='my_output', input_name='my_input') - - Use option axis = None to sum over all array elements. Default - behavior sums along the columns. - - .. math:: - - \textrm{result}_j = \sum_{i=1} ^\text{vec_size} a_{ij} * \textrm{scaling factor} - - where - - a is shape (vec_size, n) - - b is of shape (vec_size, n) - - c is of shape (vec_size, n) - - Result is of shape (1, n) or (1, ) - - Attributes - ---------- - _add_systems : list - List of equation systems to be initialized with the system. - """ - - def __init__(self, output_name=None, input_name=None, vec_size=1, length=1, - val=1.0, scaling_factor=1, **kwargs): - """ - Allow user to create an multiplication system with one-liner. - - Parameters - ---------- - output_name : str - (required) name of the result variable in this component's namespace. - input_name : str - (required) name of the input variable for this system - vec_size : int - Length of the first dimension of the input and output vectors - (i.e number of rows, or vector length for a 1D vector) - Default is 1 - length : int - Length of the second dimension of the input and ouptut vectors (i.e. number of columns) - Default is 1 which results in input/output vectors of size (vec_size,) - scaling_factor : numeric - Scaling factor to apply to the whole system - Default is 1 - val : float or list or tuple or ndarray - The initial value of the variable being added in user-defined units. Default is 1.0. - **kwargs : str - Any other arguments to pass to the addition system - (same as add_output method for ExplicitComponent) - Examples include units (str or None), desc (str) - """ - axis = kwargs.pop('axis', 0) - super(SumComp, self).__init__(axis=axis) - - self._add_systems = [] - - if isinstance(output_name, string_types): - self._add_systems.append((output_name, input_name, vec_size, length, val, - scaling_factor, kwargs)) - elif isinstance(output_name, Iterable): - raise NotImplementedError('Declaring multiple systems ' - 'on initiation is not implemented.' - 'Use a string to name a single addition relationship or use ' - 'multiple add_equation calls') - elif output_name is None: - pass - else: - raise ValueError( - "first argument to init must be either of type " - "'str' or 'None'") - - def initialize(self): - """ - Declare options. - - Parameters - ---------- - axis : int or None - Sum along this axis. Default 0 sums along first dimension. - None sums all elements into a scalar. - 1 sums along rows. - """ - self.options.declare('axis', default=0, - desc="Axis along which to sum") - - def add_equation(self, output_name, input_name, vec_size=1, length=1, val=1.0, - units=None, res_units=None, desc='', lower=None, upper=None, ref=1.0, - ref0=0.0, res_ref=None, scaling_factor=1): - """ - Add a multiplication relation. - - Parameters - ---------- - output_name : str - (required) name of the result variable in this component's namespace. - input_name : iterable of str - (required) names of the input variables for this system - vec_size : int - Length of the first dimension of the input and output vectors - (i.e number of rows, or vector length for a 1D vector) - Default is 1 - length : int - Length of the second dimension of the input and ouptut vectors (i.e. number of columns) - Default is 1 which results in input/output vectors of size (vec_size,) - scaling_factor : numeric - Scaling factor to apply to the whole system - Default is 1 - val : float or list or tuple or ndarray - The initial value of the variable being added in user-defined units. Default is 1.0. - units : str or None - Units in which the output variables will be provided to the component during execution. - Default is None, which means it has no units. - res_units : str or None - Units in which the residuals of this output will be given to the user when requested. - Default is None, which means it has no units. - desc : str - description of the variable. - lower : float or list or tuple or ndarray or Iterable or None - lower bound(s) in user-defined units. It can be (1) a float, (2) an array_like - consistent with the shape arg (if given), or (3) an array_like matching the shape of - val, if val is array_like. A value of None means this output has no lower bound. - Default is None. - upper : float or list or tuple or ndarray or or Iterable None - upper bound(s) in user-defined units. It can be (1) a float, (2) an array_like - consistent with the shape arg (if given), or (3) an array_like matching the shape of - val, if val is array_like. A value of None means this output has no upper bound. - Default is None. - ref : float or ndarray - Scaling parameter. The value in the user-defined units of this output variable when - the scaled value is 1. Default is 1. - ref0 : float or ndarray - Scaling parameter. The value in the user-defined units of this output variable when - the scaled value is 0. Default is 0. - res_ref : float or ndarray - Scaling parameter. The value in the user-defined res_units of this output's residual - when the scaled value is 1. Default is 1. - """ - kwargs = {'units': units, 'res_units': res_units, 'desc': desc, - 'lower': lower, 'upper': upper, 'ref': ref, 'ref0': ref0, - 'res_ref': res_ref} - self._add_systems.append((output_name, input_name, vec_size, length, val, - scaling_factor, kwargs)) - - def add_output(self): - """ - Use add_equation instead of add_output to define equation systems. - """ - raise NotImplementedError('Use add_equation method, not add_output method' - 'to create an multliplication/division relation') - - def setup(self): - """ - Set up the addition/subtraction system at run time. - """ - axis = self.options['axis'] - - for (output_name, input_name, vec_size, length, val, - scaling_factor, kwargs) in self._add_systems: - - units = kwargs.get('units', None) - desc = kwargs.get('desc', '') - - if length == 1: - shape = (vec_size,) - else: - shape = (vec_size, length) - - self.add_input(input_name, shape=shape, units=units, - desc=desc + '_inp_' + input_name) - if axis is None: - rowidx = np.zeros(vec_size * length) - output_shape = (1,) - elif axis == 0: - output_arange = np.arange(0, length) - rowidx = np.tile(output_arange, vec_size) - if length == 1: - output_shape = (1,) - else: - output_shape = (1, length) - elif axis == 1: - output_arange = np.arange(0, vec_size) - rowidx = np.repeat(output_arange, length) - output_shape = (vec_size,) - else: - raise ValueError('Summation is allowed only over axis=0, 1 or None') - - colidx = np.arange(0, vec_size * length) - self.declare_partials([output_name], [input_name], - rows=rowidx, cols=colidx, - val=scaling_factor * np.ones(vec_size * length)) - super(SumComp, self).add_output(output_name, val, - shape=output_shape, - **kwargs) - - def compute(self, inputs, outputs): - """ - Compute the summation using numpy. - - Parameters - ---------- - inputs : Vector - unscaled, dimensional input variables read via inputs[key] - outputs : Vector - unscaled, dimensional output variables read via outputs[key] - """ - axis = self.options['axis'] - - for (output_name, input_name, vec_size, length, val, scaling_factor, - kwargs) in self._add_systems: - - if axis is None: - output_shape = (1,) - elif axis == 0: - if length == 1: - output_shape = (1,) - else: - output_shape = (1, length) - elif axis == 1: - output_shape = (vec_size,) - - result = np.sum(inputs[input_name], axis=axis) * scaling_factor - outputs[output_name] = result.reshape(output_shape) diff --git a/openconcept/utilities/math/tests/test_add_subtract_comp.py b/openconcept/utilities/math/tests/test_add_subtract_comp.py index 73c8c7b2..9e1ca1d8 100644 --- a/openconcept/utilities/math/tests/test_add_subtract_comp.py +++ b/openconcept/utilities/math/tests/test_add_subtract_comp.py @@ -5,8 +5,7 @@ import numpy as np from openmdao.api import Problem, Group, IndepVarComp -#from openmdao.components.add_subtract_comp import AddSubtractComp -from openconcept.utilities.math.add_subtract_comp import AddSubtractComp +from openconcept.utilities import AddSubtractComp from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials class TestAddSubtractCompScalars(unittest.TestCase): @@ -333,8 +332,7 @@ def test(self): A simple example to compute the resultant force on an aircraft and demonstrate the AddSubtract component """ import numpy as np - #from openmdao.api import Problem, Group, IndepVarComp - from openconcept.utilities.math.add_subtract_comp import AddSubtractComp + from openconcept.utilities import AddSubtractComp from openmdao.utils.assert_utils import assert_near_equal n = 3 diff --git a/openconcept/utilities/math/tests/test_combine_split.py b/openconcept/utilities/math/tests/test_combine_split.py index 95b4672e..4624ccd4 100644 --- a/openconcept/utilities/math/tests/test_combine_split.py +++ b/openconcept/utilities/math/tests/test_combine_split.py @@ -5,7 +5,7 @@ import numpy as np from openmdao.api import Problem, Group, IndepVarComp -from openconcept.utilities.math.combine_split_comp import VectorConcatenateComp, VectorSplitComp +from openconcept.utilities import VectorConcatenateComp, VectorSplitComp from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials class TestConcatenateScalars(unittest.TestCase): @@ -606,7 +606,7 @@ def test(self): import numpy as np from openmdao.api import Problem, Group, IndepVarComp from openmdao.utils.assert_utils import assert_near_equal - from openconcept.utilities.math.combine_split_comp import VectorConcatenateComp, VectorSplitComp + from openconcept.utilities import VectorConcatenateComp, VectorSplitComp n_takeoff_pts = 3 n_cruise_pts = 5 diff --git a/openconcept/utilities/math/tests/test_integrals.py b/openconcept/utilities/math/tests/test_integrals.py index 997faec3..8977eae0 100644 --- a/openconcept/utilities/math/tests/test_integrals.py +++ b/openconcept/utilities/math/tests/test_integrals.py @@ -1,10 +1,9 @@ -from __future__ import division import unittest import pytest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.utilities.math.integrals import Integrator +from openconcept.utilities import Integrator class IntegratorTestGroup(Group): """An OpenMDAO group to test the every-node integrator component diff --git a/openconcept/utilities/math/tests/test_math.py b/openconcept/utilities/math/tests/test_math.py index 5dd62e10..aa1d3a52 100644 --- a/openconcept/utilities/math/tests/test_math.py +++ b/openconcept/utilities/math/tests/test_math.py @@ -1,10 +1,9 @@ -from __future__ import division import unittest import pytest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import IndepVarComp, Group, Problem -from openconcept.utilities.math.derivatives import FirstDerivative +from openconcept.utilities import FirstDerivative class FirstDerivativeTestGroup(Group): """An OpenMDAO group to test the differentiation tools diff --git a/openconcept/utilities/math/tests/test_min_max_comp.py b/openconcept/utilities/math/tests/test_min_max_comp.py index b67e0fd0..d574b479 100644 --- a/openconcept/utilities/math/tests/test_min_max_comp.py +++ b/openconcept/utilities/math/tests/test_min_max_comp.py @@ -1,9 +1,8 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem -from openconcept.utilities.math.max_min_comp import MaxComp, MinComp +from openconcept.utilities import MaxComp, MinComp class MaxCompTestCase(unittest.TestCase): """ diff --git a/openconcept/utilities/math/tests/test_multiply_divide_comp.py b/openconcept/utilities/math/tests/test_multiply_divide_comp.py index 312ad801..90d62e5e 100644 --- a/openconcept/utilities/math/tests/test_multiply_divide_comp.py +++ b/openconcept/utilities/math/tests/test_multiply_divide_comp.py @@ -5,8 +5,7 @@ import numpy as np from openmdao.api import Problem, Group, IndepVarComp -#from openmdao.components.multiply_divide_comp import ElementMultiplyDivideComp -from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp +from openconcept.utilities import ElementMultiplyDivideComp from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials class TestElementMultiplyDivideCompScalars(unittest.TestCase): @@ -452,8 +451,7 @@ def test(self): a number of analysis points (F_inertial = - m*a) """ import numpy as np - #from openmdao.api import Problem, Group, IndepVarComp - from openconcept.utilities.math.multiply_divide_comp import ElementMultiplyDivideComp + from openconcept.utilities import ElementMultiplyDivideComp from openmdao.utils.assert_utils import assert_near_equal n = 5 diff --git a/openconcept/utilities/math/tests/test_old_integrals.py b/openconcept/utilities/math/tests/test_old_integrals.py index 15f17dff..61e9f5cf 100644 --- a/openconcept/utilities/math/tests/test_old_integrals.py +++ b/openconcept/utilities/math/tests/test_old_integrals.py @@ -1,4 +1,3 @@ -from __future__ import division import unittest import pytest import numpy as np diff --git a/openconcept/utilities/math/tests/test_summation_comp.py b/openconcept/utilities/math/tests/test_summation_comp.py deleted file mode 100644 index f6ca84ca..00000000 --- a/openconcept/utilities/math/tests/test_summation_comp.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import print_function, division, absolute_import - -import unittest - -import numpy as np - -from openmdao.api import Problem, Group, IndepVarComp -#from openmdao.components.sum_comp import SumComp -from openconcept.utilities.math.sum_comp import SumComp -from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials - -class TestSummation1x1(unittest.TestCase): - # this test case is nonsensical but should still pass - def setUp(self): - self.nn = 1 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp()) - multi.add_equation('sum_output','sum_input') - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = np.sum(a, axis=0) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummation1x1AxisNone(unittest.TestCase): - # this test case is nonsensical but should still pass - def setUp(self): - self.nn = 1 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp(axis=None)) - multi.add_equation('sum_output','sum_input') - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = np.sum(a, axis=None) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx1(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp()) - multi.add_equation('sum_output','sum_input',vec_size=self.nn) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = np.sum(a, axis=0) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx1AxisNone(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp(axis=None)) - multi.add_equation('sum_output','sum_input',vec_size=self.nn) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = np.sum(a, axis=None) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx3(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.sf = -2 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp()) - multi.add_equation('sum_output','sum_input',vec_size=self.nn,length=self.length,scaling_factor=self.sf) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,self.length) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = self.sf*np.sum(a, axis=0) - expected = expected.reshape((1,self.length)) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx3Axis1(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.sf = -2 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp(axis=1)) - multi.add_equation('sum_output','sum_input',vec_size=self.nn,length=self.length,scaling_factor=self.sf) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,self.length) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = self.sf*np.sum(a, axis=1) - expected = expected.reshape((self.nn,)) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx3AxisNone(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.sf = -2 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp(axis=None)) - multi.add_equation('sum_output','sum_input',vec_size=self.nn,length=self.length,scaling_factor=self.sf) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,self.length) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = self.sf*np.sum(a, axis=None) - expected = expected.reshape((1,)) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestSummationNx3UnitsMultipleSystems(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length), units='m') - ivc.add_output(name='b', shape=(self.nn,self.length), units='kg') - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a','b']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp()) - multi.add_equation('sum_output1','sum_input_a',vec_size=self.nn,length=self.length, units='m') - multi.add_equation('sum_output2','sum_input_b',vec_size=self.nn,length=self.length, units='kg') - - self.p.model.connect('a', 'sum_comp.sum_input_a') - self.p.model.connect('b', 'sum_comp.sum_input_b') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,self.length) - self.p['b'] = np.random.rand(self.nn,self.length) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - b = self.p['b'] - out_1 = self.p.get_val('sum_comp.sum_output1', units='km') - out_2 = self.p.get_val('sum_comp.sum_output2', units='g') - - - expected_1 = np.sum(a, axis=0) / 1000 - expected_1 = expected_1.reshape((1,self.length)) - expected_2 = np.sum(b, axis=0) * 1000 - expected_2 = expected_2.reshape((1,self.length)) - assert_near_equal(out_1, expected_1,1e-15) - assert_near_equal(out_2, expected_2,1e-15) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestBadAxisRaisesError(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp(axis=2)) - multi.add_equation('sum_output','sum_input',vec_size=self.nn,length=self.length) - - self.p.model.connect('a', 'sum_comp.sum_input') - - def test_for_exception(self): - self.assertRaises(ValueError,self.p.setup) - -class TestSummationNx3OnInit(unittest.TestCase): - def setUp(self): - self.nn = 5 - self.length = 3 - self.p = Problem(model=Group()) - ivc = IndepVarComp() - ivc.add_output(name='a', shape=(self.nn,self.length)) - - self.p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['a']) - - multi=self.p.model.add_subsystem(name='sum_comp', - subsys=SumComp('sum_output','sum_input',vec_size=self.nn,length=self.length)) - - self.p.model.connect('a', 'sum_comp.sum_input') - - self.p.setup(force_alloc_complex=True) - - self.p['a'] = np.random.rand(self.nn,self.length) - - self.p.run_model() - - def test_results(self): - a = self.p['a'] - out = self.p['sum_comp.sum_output'] - expected = np.sum(a, axis=0) - expected = expected.reshape((1,self.length)) - assert_near_equal(out, expected,1e-16) - - def test_partials(self): - partials = self.p.check_partials(method='cs', out_stream=None) - assert_check_partials(partials) - -class TestForDocs(unittest.TestCase): - - def test(self): - """ - A simple example to compute total fuel burn over an aircraft mission - """ - import numpy as np - #from openmdao.api import Problem, Group, IndepVarComp - from openconcept.utilities.math.sum_comp import SumComp - from openmdao.utils.assert_utils import assert_near_equal - - n = 10 - length = 1 - p = Problem(model=Group()) - - ivc = IndepVarComp() - #the vector represents fuel burns over several mission segments - ivc.add_output(name='fuel_burn_by_seg', shape=(n,), units='kg') - p.model.add_subsystem(name='ivc', - subsys=ivc, - promotes_outputs=['fuel_burn_by_seg']) - - # construct a summation component here - # axis=0 sums along the vector - total = SumComp(axis=0) - total.add_equation('total_fuel','fuel_burn_by_seg',vec_size=n, units='kg') - - p.model.add_subsystem(name='totalfuelcomp', subsys=total, promotes_inputs=['*']) - - p.setup() - - # create a vector of fuel burns - p['fuel_burn_by_seg'] = np.random.uniform(low=20,high=30,size=(n,)) - - p.run_model() - - # print(p.get_val('totalforcecomp.total_force', units='kN')) - - # Verify the results - expected_i = np.sum(p['fuel_burn_by_seg'],axis=0) - expected_i = expected_i.reshape((1,)) - assert_near_equal(p.get_val('totalfuelcomp.total_fuel', units='kg'), expected_i) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/openconcept/utilities/selector.py b/openconcept/utilities/selector.py index 9c7e977c..7a5f6fb2 100644 --- a/openconcept/utilities/selector.py +++ b/openconcept/utilities/selector.py @@ -1,4 +1,3 @@ -from __future__ import division import openmdao.api as om import numpy as np diff --git a/openconcept/utilities/tests/test_dict_indepvarcomp.py b/openconcept/utilities/tests/test_dict_indepvarcomp.py index 4add7f34..a17029d6 100644 --- a/openconcept/utilities/tests/test_dict_indepvarcomp.py +++ b/openconcept/utilities/tests/test_dict_indepvarcomp.py @@ -4,8 +4,7 @@ import numpy as np from openmdao.api import Problem, Group -#from openmdao.components.add_subtract_comp import AddSubtractComp -from openconcept.utilities.dict_indepvarcomp import DictIndepVarComp +from openconcept.utilities import DictIndepVarComp from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials # Test data to load diff --git a/openconcept/utilities/tests/test_dvlabel.py b/openconcept/utilities/tests/test_dvlabel.py index 7d426410..51de7383 100644 --- a/openconcept/utilities/tests/test_dvlabel.py +++ b/openconcept/utilities/tests/test_dvlabel.py @@ -5,7 +5,7 @@ import numpy as np from openmdao.api import Problem, Group, IndepVarComp -from openconcept.utilities.dvlabel import DVLabel +from openconcept.utilities import DVLabel from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials class TestBasic(unittest.TestCase): diff --git a/openconcept/utilities/tests/test_selector.py b/openconcept/utilities/tests/test_selector.py index 082b9d38..fd96433e 100644 --- a/openconcept/utilities/tests/test_selector.py +++ b/openconcept/utilities/tests/test_selector.py @@ -1,9 +1,8 @@ -from __future__ import division import unittest import numpy as np from openmdao.utils.assert_utils import assert_near_equal, assert_check_partials from openmdao.api import Problem -from openconcept.utilities.selector import SelectorComp +from openconcept.utilities import SelectorComp class SelectorCompTestCase(unittest.TestCase): """ diff --git a/openconcept/weights/__init__.py b/openconcept/weights/__init__.py new file mode 100644 index 00000000..460a676f --- /dev/null +++ b/openconcept/weights/__init__.py @@ -0,0 +1,12 @@ +from .weights_turboprop import ( + SingleTurboPropEmptyWeight, + WingWeight_SmallTurboprop, + EmpennageWeight_SmallTurboprop, + FuselageWeight_SmallTurboprop, + NacelleWeight_SmallSingleTurboprop, + NacelleWeight_MultiTurboprop, + LandingGearWeight_SmallTurboprop, + FuelSystemWeight_SmallTurboprop, + EquipmentWeight_SmallTurboprop, +) +from .weights_twin_hybrid import TwinSeriesHybridEmptyWeight diff --git a/examples/methods/weights_turboprop.py b/openconcept/weights/weights_turboprop.py similarity index 99% rename from examples/methods/weights_turboprop.py rename to openconcept/weights/weights_turboprop.py index 57df7a4e..6cde9519 100644 --- a/examples/methods/weights_turboprop.py +++ b/openconcept/weights/weights_turboprop.py @@ -1,8 +1,7 @@ -from __future__ import division import numpy as np from openmdao.api import ExplicitComponent, IndepVarComp from openmdao.api import Group -from openconcept.utilities.math import AddSubtractComp, ElementMultiplyDivideComp +from openconcept.utilities import AddSubtractComp, ElementMultiplyDivideComp import math ##TODO: add fuel system weight back in (depends on Wf, which depends on MTOW and We, and We depends on fuel system weight) diff --git a/openconcept/weights/weights_twin_hybrid.py b/openconcept/weights/weights_twin_hybrid.py new file mode 100644 index 00000000..54ef288c --- /dev/null +++ b/openconcept/weights/weights_twin_hybrid.py @@ -0,0 +1,27 @@ +from openmdao.api import IndepVarComp, Group +from openconcept.utilities import AddSubtractComp, ElementMultiplyDivideComp +from .weights_turboprop import ( + WingWeight_SmallTurboprop, + EmpennageWeight_SmallTurboprop, + FuselageWeight_SmallTurboprop, + NacelleWeight_SmallSingleTurboprop, + LandingGearWeight_SmallTurboprop, + FuelSystemWeight_SmallTurboprop, + EquipmentWeight_SmallTurboprop, +) + +class TwinSeriesHybridEmptyWeight(Group): + def setup(self): + const = self.add_subsystem('const',IndepVarComp(),promotes_outputs=["*"]) + const.add_output('W_fluids', val=20, units='kg') + const.add_output('structural_fudge', val=1.6, units='m/m') + self.add_subsystem('wing',WingWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('empennage',EmpennageWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('fuselage',FuselageWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('nacelle',NacelleWeight_SmallSingleTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('gear',LandingGearWeight_SmallTurboprop(),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('fuelsystem', FuelSystemWeight_SmallTurboprop(), promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('equipment',EquipmentWeight_SmallTurboprop(), promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('structural',AddSubtractComp(output_name='W_structure',input_names=['W_wing','W_fuselage','W_nacelle','W_empennage','W_gear'], units='lb'),promotes_outputs=['*'],promotes_inputs=["*"]) + self.add_subsystem('structural_fudge',ElementMultiplyDivideComp(output_name='W_structure_adjusted',input_names=['W_structure','structural_fudge'],input_units=['lb','m/m']),promotes_inputs=["*"],promotes_outputs=["*"]) + self.add_subsystem('totalempty',AddSubtractComp(output_name='OEW',input_names=['W_structure_adjusted','W_fuelsystem','W_equipment','W_engine','W_motors','W_generator','W_propeller','W_fluids'], units='lb'),promotes_outputs=['*'],promotes_inputs=["*"]) diff --git a/readme.md b/readme.md index 43ad5b0c..12d2f0b9 100644 --- a/readme.md +++ b/readme.md @@ -10,9 +10,16 @@ OpenConcept is a new toolkit for the conceptual design of aircraft. OpenConcept was developed in order to model and optimize aircraft with electric propulsion at low computational cost. The tools are built on top of NASA Glenn's [OpenMDAO](http://openmdao.org/) framework, which in turn is written in Python. +OpenConcept is capable of modeling a wide range of propulsion systems, including detailed thermal management systems. +The following figure (from [this paper](https://doi.org/10.3390/aerospace9050243)) shows one such system that is modeled in the `N3_HybridSingleAisle_Refrig.py` example. + +

+ +

+ The following charts show more than 250 individually optimized hybrid-electric light twin aircraft (similar to a King Air C90GT). Optimizing hundreds of configurations can be done in a couple of hours on a standard laptop computer. -![Example charts](https://raw.githubusercontent.com/mdolab/openconcept/main/docs/_static/images/readme_charts.png) +![Example charts](/docs/_static/images/readme_charts.png) The reason for OpenConcept's efficiency is the analytic derivatives built into each analysis routine and component. Accurate, efficient derivatives enable the use of Newton nonlinear equation solutions and gradient-based optimization at low computational cost. @@ -35,16 +42,25 @@ To run the examples or edit the source code: 2. Navigate to the root `openconcept` folder 3. Run `pip install -e .` to install the package (the `-e` can be omitted if not editing the source) -Get started by running the `TBM850` example: -1. Navigate to the `examples` folder -2. Run `python TBM850.py` to test OpenConcept on a single-engine turboprop aircraft (the TBM 850) -3. Look at the `examples/aircraft data/TBM850.py` file to play with the assumptions / config / geometry and see the effects on the output result - -`examples/HybridTwin.py` is set up to do MDO in a grid of specific energies and design ranges and save the results to disk. Visualization utilities will be added soon (to produce contour plots as shown in this Readme) +Get started by following the tutorials in the documentation to learn the most important parts of OpenConcept. +The features section of the documentation describes most of the components and system models available in OpenConcept. ### Requirements -This toolkit requires the use of [OpenMDAO](https://openmdao.org/) 3.10.0 or later. OpenMDAO requires a late NumPy and SciPy. + + +This toolkit requires the use of [OpenMDAO](https://openmdao.org/) 3.10.0 or later. +OpenMDAO requires a late NumPy and SciPy. + +The latest versions all tests have passed on are + +| Package | Version | +| ------- | ------- | +| Python | 3.10.4 | +| OpenMDAO | 3.16.0 | +| NumPy | 1.22.4 | +| SciPy | 1.7.3 | +| OpenAeroStruct | 2.5.1 | ## Citation @@ -78,6 +94,3 @@ Eytan J. Adler and Joaquim R.R.A. Martins, "Aerostructural wing design optimizat year = {2022} } ``` - -## Contributing -A contributor's guide is coming third (after completing documentation and automatic testing). I'm open to pull requests and issues in the meantime. Stay tuned. diff --git a/setup.py b/setup.py index 4e458a33..b78a82da 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup +from setuptools import setup, find_packages import re import os @@ -36,20 +36,8 @@ url='https://github.com/mdolab/openconcept', download_url='https://github.com/mdolab/openconcept', license='MIT License', - packages=[ - 'openconcept', - 'openconcept.analysis', - 'openconcept.analysis.atmospherics', - 'openconcept.analysis.openaerostruct', - 'openconcept.analysis.performance', - 'openconcept.analysis.tests', - 'openconcept.components', - 'openconcept.components.empirical_data', - 'openconcept.utilities', - 'openconcept.utilities.math' - ], + packages=find_packages(include=["openconcept*"]), install_requires=[ - 'six', 'scipy>=1.0.0', 'numpy>=1.14.0', 'openmdao>=3.10.0',