├── assets ├── casing.png ├── shaft.png └── stage_casing.png ├── turbodesigner ├── units.py ├── attachments │ ├── __init__.py │ └── firetree.py ├── airfoils │ ├── __init__.py │ ├── common.py │ └── DCA.py ├── blade │ ├── __init__.py │ ├── metal_angle_methods │ │ ├── __init__.py │ │ └── johnsen_bullock.py │ ├── vortex │ │ ├── __init__.py │ │ ├── free_vortex.py │ │ └── common.py │ ├── metal_angles.py │ └── row.py ├── cad │ ├── __init__.py │ ├── blade.py │ ├── common.py │ ├── shaft.py │ └── casing.py ├── __init__.py ├── visualizer.py ├── stage.py ├── turbomachinery.py ├── flow_station.py └── exporter.py ├── MANIFEST.in ├── requirements.txt ├── pyproject.toml ├── tests ├── designs.py ├── compressor_blade_test.py ├── designs │ ├── mark1.json │ └── base_design.json ├── compressor_stage_test.py ├── compressor_vortex_test.py ├── compressor_turbomachinery_test.py ├── compressor_deviation_test.py └── compressor_flow_station_test.py ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── .gitpod.yml ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── .github └── workflows │ └── python-publish.yml ├── LICENSE ├── setup.py ├── README.md └── .gitignore /assets/casing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenOrion/turbodesigner/HEAD/assets/casing.png -------------------------------------------------------------------------------- /assets/shaft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenOrion/turbodesigner/HEAD/assets/shaft.png -------------------------------------------------------------------------------- /turbodesigner/units.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | MM = 1000 4 | DEG = 180/np.pi 5 | BAR = 1E-5 -------------------------------------------------------------------------------- /assets/stage_casing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenOrion/turbodesigner/HEAD/assets/stage_casing.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements.txt 4 | recursive-include turbodesigner *.json 5 | recursive-include turbodesigner *.py -------------------------------------------------------------------------------- /turbodesigner/attachments/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner Attachments Module - Contains attachment design functionality 3 | """ 4 | 5 | from turbodesigner.attachments.firetree import * 6 | 7 | __all__ = [ 8 | "firetree", 9 | ] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core dependencies 2 | kaleido 3 | plotly==5.11.0 4 | ipywidgets>=7.6 5 | jupyterlab 6 | matplotlib 7 | numpy 8 | dacite 9 | pandas 10 | 11 | # CAD dependencies 12 | cadquery 13 | git+https://github.com/gumyr/cq_warehouse.git 14 | 15 | -------------------------------------------------------------------------------- /turbodesigner/airfoils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner Airfoils Module - Contains airfoil design functionality 3 | """ 4 | 5 | from turbodesigner.airfoils.common import * 6 | from turbodesigner.airfoils.DCA import * 7 | 8 | __all__ = [ 9 | "common", 10 | "DCA", 11 | ] -------------------------------------------------------------------------------- /turbodesigner/blade/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner Blade Module - Contains blade design functionality 3 | """ 4 | 5 | from turbodesigner.blade.metal_angles import * 6 | from turbodesigner.blade.row import * 7 | 8 | __all__ = [ 9 | "metal_angles", 10 | "row", 11 | ] -------------------------------------------------------------------------------- /turbodesigner/blade/metal_angle_methods/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner Metal Angle Methods Module - Contains methods for calculating metal angles 3 | """ 4 | 5 | from turbodesigner.blade.metal_angle_methods.johnsen_bullock import * 6 | 7 | __all__ = [ 8 | "johnsen_bullock", 9 | ] -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 88 7 | target-version = ['py38'] 8 | include = '\.pyi?$' 9 | 10 | [tool.isort] 11 | profile = "black" 12 | multi_line_output = 3 -------------------------------------------------------------------------------- /tests/designs.py: -------------------------------------------------------------------------------- 1 | import os 2 | from turbodesigner.turbomachinery import Turbomachinery 3 | 4 | TEST_DIR = os.path.dirname(os.path.abspath(__file__)) 5 | base_design = Turbomachinery.from_file(f"{TEST_DIR}/designs/base_design.json") 6 | mark1 = Turbomachinery.from_file(f"{TEST_DIR}/designs/mark1.json") 7 | -------------------------------------------------------------------------------- /turbodesigner/blade/vortex/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner Vortex Module - Contains vortex design functionality 3 | """ 4 | 5 | from turbodesigner.blade.vortex.common import * 6 | from turbodesigner.blade.vortex.free_vortex import * 7 | 8 | __all__ = [ 9 | "common", 10 | "free_vortex", 11 | ] -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | 2 | [MESSAGES CONTROL] 3 | disable=import-error, 4 | invalid-name, 5 | non-ascii-name, 6 | missing-function-docstring, 7 | missing-module-docstring, 8 | missing-class-docstring, 9 | line-too-long, 10 | dangerous-default-value, 11 | line-too-long, 12 | unnecessary-lambda -------------------------------------------------------------------------------- /turbodesigner/cad/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner CAD Module - Contains CAD functionality 3 | """ 4 | 5 | from turbodesigner.cad.blade import * 6 | from turbodesigner.cad.casing import * 7 | from turbodesigner.cad.common import * 8 | from turbodesigner.cad.shaft import * 9 | 10 | __all__ = [ 11 | "blade", 12 | "casing", 13 | "common", 14 | "shaft", 15 | ] -------------------------------------------------------------------------------- /turbodesigner/blade/vortex/free_vortex.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import numpy as np 3 | from turbodesigner.blade.vortex.common import Vortex 4 | 5 | class FreeVortex(Vortex): 6 | def ctheta(self, r: Union[float, np.ndarray], is_rotating: bool): 7 | mu = r/self.rm 8 | a = self.Um*(1-self.Rm) 9 | b = (1/2)*self.psi_m*self.Um 10 | sign = -1 if is_rotating else 1 11 | return (a/mu) + sign*(b/mu) -------------------------------------------------------------------------------- /turbodesigner/airfoils/common.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import numpy as np 3 | 4 | class AirfoilType(Enum): 5 | NACA65 = 1 6 | DCA = 2 7 | C4 = 2 8 | 9 | def get_staggered_coords(coords: np.ndarray, stagger_angle: float): 10 | x = coords[:, 0] 11 | y = coords[:, 1] 12 | 13 | return np.array([ 14 | x*np.cos(stagger_angle) - y*np.sin(stagger_angle), 15 | x*np.sin(stagger_angle) + y*np.cos(stagger_angle), 16 | ]).T 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | image: 5 | file: .devcontainer/Dockerfile 6 | 7 | github: 8 | prebuilds: 9 | # enable for the master/default branch (defaults to true) 10 | master: true 11 | # enable for pull requests coming from this repo (defaults to true) 12 | pullRequests: false 13 | # add a "Review in Gitpod" button as a comment to pull requests (defaults to true) 14 | addComment: false 15 | 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": "..", 6 | "args": { 7 | // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local on arm64/Apple Silicon. 10 | "VARIANT": "3.10-bullseye", 11 | // Options 12 | "NODE_VERSION": "lts/*" 13 | } 14 | }, 15 | // Add the IDs of extensions you want installed when the container is created. 16 | "extensions": [ 17 | "ms-python.python", 18 | "ms-python.vscode-pylance", 19 | "eamodio.gitlens" 20 | ], 21 | "remoteUser": "vscode" 22 | } -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: '3.x' 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install build 19 | 20 | - name: Build package 21 | run: python -m build 22 | - name: Publish package 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | with: 25 | password: ${{ secrets.PYPI_API_TOKEN }} 26 | packages_dir: dist/ -------------------------------------------------------------------------------- /tests/compressor_blade_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from tests.designs import base_design 4 | 5 | class CompressorDesignTest(unittest.TestCase): 6 | def test_flow_angles(self): 7 | alpha2 = base_design.stages[0].stator.flow_station.alpha 8 | beta1 = base_design.stages[0].rotor.flow_station.beta 9 | beta2 = base_design.stages[0].stator.flow_station.beta 10 | 11 | np.testing.assert_allclose(np.degrees(alpha2), np.array([33.89769494, 25.41478326, 20.1796603 ])) 12 | np.testing.assert_allclose(np.degrees(beta1), np.array([-50.78297859, -60.99475883, -67.28513168])) 13 | np.testing.assert_allclose(np.degrees(beta2), np.array([-30.28750731, -52.45117333, -62.59349512])) -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT="3.10-bullseye" 2 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 3 | 4 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 5 | ARG NODE_VERSION="none" 6 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 7 | 8 | RUN apt-get update -y && \ 9 | apt install -y libgl1-mesa-glx && \ 10 | apt-get clean && \ 11 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 12 | 13 | 14 | # [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. 15 | # COPY requirements.txt /tmp/pip-tmp/ 16 | # RUN pip install -r /tmp/pip-tmp/requirements.txt && rm -rf /tmp/pip-tmp 17 | -------------------------------------------------------------------------------- /turbodesigner/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TurboDesigner - The open-source turbomachinery designer 3 | """ 4 | 5 | __version__ = "0.1.0" 6 | 7 | # Import main modules for easier access 8 | from turbodesigner.flow_station import FlowStation 9 | from turbodesigner.stage import Stage 10 | from turbodesigner.turbomachinery import Turbomachinery 11 | from turbodesigner.units import MM, DEG, BAR 12 | from turbodesigner.visualizer import TurbomachineryVisualizer 13 | from turbodesigner.exporter import TurbomachineryExporter 14 | 15 | # Make these modules available at the package level 16 | __all__ = [ 17 | "FlowStation", 18 | "Stage", 19 | "Turbomachinery", 20 | "MM", 21 | "DEG", 22 | "BAR", 23 | "TurbomachineryVisualizer", 24 | "TurbomachineryExporter", 25 | ] -------------------------------------------------------------------------------- /tests/designs/mark1.json: -------------------------------------------------------------------------------- 1 | { 2 | "gamma": 1.4, 3 | "cx": 136, 4 | "N": 10000, 5 | "Rs": 287, 6 | "mdot": 4.374433141191892, 7 | "PR": 3, 8 | "Pt": 101000, 9 | "Tt": 288, 10 | "eta_isen": 0.87848151, 11 | "N_stg": 5, 12 | "Delta_T0_stg": "equal", 13 | "R_stg": [0.5, 0.5, 0.5, 0.5, 0.5], 14 | "B_in": 0.0, 15 | "B_out": 0.0, 16 | "ht": 0.5, 17 | "N_stream": 9, 18 | "AR": {"rotor": 3.0, "stator": 3.25}, 19 | "sc": {"rotor": 1.0, "stator": 1.0}, 20 | "tbc": {"rotor": 0.1, "stator": 0.1}, 21 | "rgc": 0.25, 22 | "sgc": 0.5 23 | } 24 | 25 | -------------------------------------------------------------------------------- /turbodesigner/blade/vortex/common.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from functools import cached_property 3 | from typing import Union 4 | import numpy as np 5 | 6 | @dataclass 7 | class Vortex: 8 | Um: float 9 | "mean blade velocity (m/s)" 10 | 11 | Vm: float 12 | "meridional flow velocity (m/s)" 13 | 14 | Rm: float 15 | "mean reaction rate (dimensionless)" 16 | 17 | psi_m: float 18 | "mean loading coefficient (dimensionless)" 19 | 20 | rm: float 21 | "mean radius (m)" 22 | 23 | def __post_init__(self): 24 | self.phi_m = self.Vm/self.Um 25 | 26 | def ctheta(self, r: Union[float, np.ndarray], is_rotating: bool): 27 | "absolute tangential velocity (m/s)" 28 | return np.nan 29 | 30 | def alpha(self, r: Union[float, np.ndarray], is_rotating: bool): 31 | "absolute flow angle (rad)" 32 | return np.arctan(self.ctheta(r, is_rotating)/self.Vm) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Open Orion LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /turbodesigner/blade/metal_angles.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Union 3 | import numpy as np 4 | from turbodesigner.units import DEG 5 | 6 | @dataclass 7 | class MetalAngles: 8 | 9 | beta1: Union[float, np.ndarray] 10 | "blade inlet flow angle (rad)" 11 | 12 | beta2: Union[float, np.ndarray] 13 | "blade outlet flow angle (rad)" 14 | 15 | i: Union[float, np.ndarray] 16 | "blade incidence (rad)" 17 | 18 | delta: Union[float, np.ndarray] 19 | "blade deviation (rad)" 20 | 21 | kappa1: np.ndarray = field(init=False) 22 | "inlet metal angle (rad)" 23 | 24 | kappa2: np.ndarray = field(init=False) 25 | "outlet metal angle (rad)" 26 | 27 | theta: np.ndarray = field(init=False) 28 | "camber angle (rad)" 29 | 30 | xi: np.ndarray = field(init=False) 31 | "stagger angle (rad)" 32 | 33 | def __post_init__(self): 34 | self.kappa1 = np.asarray(self.beta1 - self.i) 35 | self.kappa2 = np.asarray(self.beta2 - self.delta) 36 | self.theta = np.asarray(self.kappa1-self.kappa2) 37 | self.xi = np.asarray((self.kappa1 + self.kappa2)/2) -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestArgs": [ 3 | "-v", 4 | "-s", 5 | "./tests", 6 | "-p", 7 | "*_test.py" 8 | ], 9 | "python.testing.pytestEnabled": false, 10 | "python.testing.unittestEnabled": true, 11 | "python.linting.enabled": false, 12 | "python.defaultInterpreterPath": "/opt/conda/bin/python", 13 | "python.linting.pylintEnabled": true, 14 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 15 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 16 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 17 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 18 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 19 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 20 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 21 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 22 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 23 | "jupyter.notebookFileRoot": "${workspaceFolder}", 24 | "python.formatting.autopep8Args": [ 25 | "--max-line-length=1000" 26 | ], 27 | "python.linting.pylintArgs": [ 28 | "--extension-pkg-whitelist=numpy" 29 | ], 30 | "python.analysis.typeCheckingMode": "basic", 31 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | # Read the contents of README.md 5 | with open("README.md", "r", encoding="utf-8") as fh: 6 | long_description = fh.read() 7 | 8 | # Read requirements.txt 9 | with open("requirements.txt", "r", encoding="utf-8") as f: 10 | requirements = [] 11 | for line in f: 12 | line = line.strip() 13 | # Skip comments and empty lines 14 | if not line or line.startswith("#"): 15 | continue 16 | # Skip git+ dependencies for PyPI compatibility 17 | if line.startswith("git+"): 18 | # Git dependencies aren't directly supported by PyPI 19 | # They'll be handled through extras_require 20 | continue 21 | 22 | requirements.append(line) 23 | 24 | setup( 25 | name="turbodesigner", 26 | version="1.0.0", 27 | author="Open Orion, Inc.", 28 | description="An open-source turbomachinery designer", 29 | long_description=long_description, 30 | long_description_content_type="text/markdown", 31 | url="https://github.com/OpenOrion/turbodesigner", 32 | packages=find_packages(), 33 | classifiers=[ 34 | "Programming Language :: Python :: 3", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | "Topic :: Scientific/Engineering :: Physics", 38 | "Topic :: Scientific/Engineering :: Visualization", 39 | ], 40 | python_requires=">=3.8", 41 | install_requires=requirements, 42 | include_package_data=True, 43 | package_data={ 44 | "turbodesigner": ["**/*.json"], 45 | }, 46 | ) -------------------------------------------------------------------------------- /tests/designs/base_design.json: -------------------------------------------------------------------------------- 1 | { 2 | "gamma": 1.4, 3 | "cx": 150, 4 | "N": 15000, 5 | "Rs": 287, 6 | "mdot": 20, 7 | "PR": 4.15, 8 | "Pt": 101000, 9 | "Tt": 288, 10 | "eta_isen": 0.87848151, 11 | "N_stg": 7, 12 | "Delta_T0_stg": [20, 25, 25, 25, 25, 25, 20], 13 | "R_stg": [0.874, 0.7, 0.5, 0.5, 0.5, 0.5, 0.5], 14 | "B_in": 0, 15 | "B_out": 0, 16 | "ht": 0.5, 17 | "N_stream": 3, 18 | "AR": [ 19 | {"rotor": 0.5, "stator": 0.5}, 20 | {"rotor": 0.5, "stator": 0.5}, 21 | {"rotor": 0.5, "stator": 0.5}, 22 | {"rotor": 0.5, "stator": 0.5}, 23 | {"rotor": 0.5, "stator": 0.5}, 24 | {"rotor": 0.5, "stator": 0.5}, 25 | {"rotor": 0.5, "stator": 0.5} 26 | ], 27 | "sc": [ 28 | {"rotor": 1.0, "stator": 1.0}, 29 | {"rotor": 1.0, "stator": 1.0}, 30 | {"rotor": 1.0, "stator": 1.0}, 31 | {"rotor": 1.0, "stator": 1.0}, 32 | {"rotor": 1.0, "stator": 1.0}, 33 | {"rotor": 1.0, "stator": 1.0}, 34 | {"rotor": 1.0, "stator": 1.0} 35 | ], 36 | "tbc": [ 37 | {"rotor": 0.1, "stator": 0.1}, 38 | {"rotor": 0.1, "stator": 0.1}, 39 | {"rotor": 0.1, "stator": 0.1}, 40 | {"rotor": 0.1, "stator": 0.1}, 41 | {"rotor": 0.1, "stator": 0.1}, 42 | {"rotor": 0.1, "stator": 0.1}, 43 | {"rotor": 0.1, "stator": 0.1} 44 | ], 45 | "rgc": 0.25, 46 | "sgc": 0.5 47 | 48 | } 49 | 50 | -------------------------------------------------------------------------------- /tests/compressor_stage_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from tests.designs import base_design 4 | 5 | 6 | class CompressorStageTest(unittest.TestCase): 7 | def test_first_stage_base_design(self): 8 | next_stage = base_design.stages[1] 9 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.Tt, 308) 10 | np.testing.assert_almost_equal(next_stage.mid_flow_station.Tt, 333) 11 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.T, 296.265, 3) 12 | np.testing.assert_almost_equal(next_stage.mid_flow_station.T, 313.76513, 5) 13 | 14 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.Pt, 124787.12942, 5) 15 | np.testing.assert_almost_equal(next_stage.mid_flow_station.Pt, 159563.80095, 5) 16 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.P, 108924.147, 3) 17 | np.testing.assert_almost_equal(next_stage.mid_flow_station.P, 129567.45266, 5) 18 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.rho, 1.281, 3) 19 | np.testing.assert_almost_equal(next_stage.mid_flow_station.rho, 1.43883, 5) 20 | 21 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.inner_radius, 0.121, 3) 22 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.outer_radius, 0.218, 3) 23 | np.testing.assert_almost_equal(next_stage.inlet_flow_station.radius, 0.1696031) 24 | np.testing.assert_almost_equal(next_stage.mid_flow_station.inner_radius, 0.12612, 5) 25 | np.testing.assert_almost_equal(next_stage.mid_flow_station.outer_radius, 0.21308, 5) 26 | np.testing.assert_almost_equal(next_stage.mid_flow_station.radius, 0.1696031) 27 | 28 | 29 | if __name__ == '__main__': 30 | unittest.main() 31 | -------------------------------------------------------------------------------- /tests/compressor_vortex_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from turbodesigner.flow_station import FlowStation 4 | from turbodesigner.blade.vortex.free_vortex import FreeVortex 5 | 6 | 7 | class VelocityTriangleTest(unittest.TestCase): 8 | def test_free_vortex_angles(self): 9 | N = 6000 # RPM 10 | rm = 0.475 # m 11 | Um = (1/30)*np.pi*N*rm # m/s 12 | cx = 136 # m/s 13 | Rm = 1-0.5*(0.5/0.475)**2 14 | ctheta1_m = 78.6 * (0.5/.475) 15 | ctheta2_m = 235.6 * (0.5/.475) 16 | psi_m = (ctheta2_m - ctheta1_m) / Um 17 | vortex = FreeVortex( 18 | Um=Um, # m/s 19 | Vm=cx, # m/s 20 | Rm=Rm, # dimensionless 21 | psi_m=psi_m, # dimensionless 22 | rm=rm # m 23 | ) 24 | 25 | ctheta1_hub = vortex.ctheta(r=0.45, is_rotating=True) 26 | ctheta2_hub = vortex.ctheta(r=0.45, is_rotating=False) 27 | alpha1_hub = vortex.alpha(r=0.45, is_rotating=True) 28 | alpha2_hub = vortex.alpha(r=0.45, is_rotating=False) 29 | 30 | ctheta1_tip = vortex.ctheta(r=0.5, is_rotating=True) 31 | ctheta2_tip = vortex.ctheta(r=0.5, is_rotating=False) 32 | alpha1_tip = vortex.alpha(r=0.5, is_rotating=True) 33 | alpha2_tip = vortex.alpha(r=0.5, is_rotating=False) 34 | 35 | # Hub 36 | np.testing.assert_almost_equal(ctheta1_hub, 87.31, 2) 37 | np.testing.assert_almost_equal(ctheta2_hub, 261.755, 3) 38 | np.testing.assert_almost_equal(np.degrees(alpha1_hub), 32.70, 2) 39 | np.testing.assert_almost_equal(np.degrees(alpha2_hub), 62.54, 2) 40 | 41 | # Tip 42 | np.testing.assert_almost_equal(ctheta1_tip, 78.57, 2) 43 | np.testing.assert_almost_equal(ctheta2_tip, 235.58, 2) 44 | np.testing.assert_almost_equal(np.degrees(alpha1_tip), 30.02, 2) 45 | np.testing.assert_almost_equal(np.degrees(alpha2_tip), 60.00, 2) 46 | 47 | 48 | if __name__ == '__main__': 49 | unittest.main() 50 | -------------------------------------------------------------------------------- /tests/compressor_turbomachinery_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from tests.designs import base_design 4 | from turbodesigner.flow_station import FlowStation 5 | from turbodesigner.stage import Stage 6 | 7 | 8 | class CompressorDesignTest(unittest.TestCase): 9 | def test_flow_station_base_design(self): 10 | np.testing.assert_almost_equal(base_design.outlet_flow_station.Pt, 419150) 11 | np.testing.assert_almost_equal(base_design.inlet_flow_station.P, 87908.56, 2) 12 | np.testing.assert_almost_equal(base_design.outlet_flow_station.P, 383948.29, 2) 13 | np.testing.assert_almost_equal(base_design.inlet_flow_station.T, 276.80039821) 14 | np.testing.assert_almost_equal(base_design.outlet_flow_station.T, 441.27919465) 15 | np.testing.assert_almost_equal(base_design.inlet_flow_station.rho, 1.10657931) 16 | np.testing.assert_almost_equal(base_design.outlet_flow_station.rho, 3.03163833) 17 | np.testing.assert_almost_equal(base_design.inlet_flow_station.A_flow, 0.12049144) 18 | np.testing.assert_almost_equal(base_design.inlet_flow_station.A_phys, 0.12049144) 19 | np.testing.assert_almost_equal(base_design.outlet_flow_station.A_flow, 0.04398062) 20 | np.testing.assert_almost_equal(base_design.outlet_flow_station.A_phys, 0.044, 3) 21 | np.testing.assert_almost_equal(base_design.outlet_flow_station.Tt, 452.47879644) 22 | np.testing.assert_almost_equal(base_design.Delta_T0, 164.47879644) 23 | np.testing.assert_almost_equal(base_design.inlet_flow_station.inner_radius, 0.11307, 5) 24 | np.testing.assert_almost_equal(base_design.outlet_flow_station.inner_radius, 0.14897, 5) 25 | np.testing.assert_almost_equal(base_design.inlet_flow_station.outer_radius, 0.22614, 5) 26 | np.testing.assert_almost_equal(base_design.outlet_flow_station.outer_radius, 0.19024, 5) 27 | np.testing.assert_almost_equal(base_design.inlet_flow_station.radius, 0.16960, 5) 28 | np.testing.assert_almost_equal(base_design.outlet_flow_station.radius, 0.16960, 5) 29 | np.testing.assert_almost_equal(base_design.inlet_flow_station.N, 15000) 30 | 31 | 32 | if __name__ == '__main__': 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/compressor_deviation_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from turbodesigner.blade.deviation.johnsen_bullock import JohnsenBullockBladeDeviation, AirfoilType 3 | import numpy as np 4 | 5 | 6 | class AungierDeviationTest(unittest.TestCase): 7 | 8 | def test_aungier_deviation(self): 9 | deviation = JohnsenBullockBladeDeviation( 10 | beta1=np.radians(70), # rad 11 | beta2=np.radians(20), # rad 12 | sigma=2.0, # dimensionless 13 | tbc=0.1, # dimensionless 14 | airfoil_type=AirfoilType.NACA65 15 | ) 16 | 17 | metal_angles = deviation.get_metal_angles(100) 18 | 19 | 20 | np.testing.assert_almost_equal(np.degrees(metal_angles.kappa1), 73.9657, 4) 21 | np.testing.assert_almost_equal(np.degrees(metal_angles.kappa2), -0.4597, 4) 22 | 23 | 24 | 25 | def test_aungier_zero_camber_sigma_2(self): 26 | deviation = JohnsenBullockBladeDeviation( 27 | beta1=np.radians(70), # rad 28 | beta2=np.radians(70), # rad 29 | sigma=2.0, # dimensionless 30 | tbc=0.1, # dimensionless 31 | airfoil_type=AirfoilType.NACA65 32 | ) 33 | 34 | metal_angles = deviation.get_metal_angles(1) 35 | np.testing.assert_almost_equal(np.degrees(metal_angles.i), 10.1975, 4) 36 | np.testing.assert_almost_equal(np.degrees(metal_angles.delta), 4.7296, 4) 37 | 38 | def test_aungier_zero_camber_sigma_1(self): 39 | deviation = JohnsenBullockBladeDeviation( 40 | beta1=np.radians(70), # rad 41 | beta2=np.radians(70), # rad 42 | sigma=1.0, # dimensionless 43 | tbc=0.1, # dimensionless 44 | airfoil_type=AirfoilType.NACA65 45 | ) 46 | 47 | metal_angles = deviation.get_metal_angles(1) 48 | np.testing.assert_almost_equal(np.degrees(metal_angles.i), 5.0897, 4) 49 | np.testing.assert_almost_equal(np.degrees(metal_angles.delta), 2.5691, 4) 50 | 51 | 52 | 53 | if __name__ == '__main__': 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /turbodesigner/visualizer.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from turbodesigner.turbomachinery import Turbomachinery 3 | import plotly.graph_objects as go 4 | from IPython.display import Image, display 5 | 6 | 7 | class TurbomachineryVisualizer: 8 | @staticmethod 9 | def visualize_annulus(turbomachinery: Turbomachinery, is_interactive=False): 10 | rotors = [ 11 | np.array([ 12 | [(stage.stage_number-1.), stage.rotor.rt], 13 | [(stage.stage_number-1.)+0.5, stage.stator.rt], 14 | [(stage.stage_number-1.)+0.5, stage.stator.rh], 15 | [(stage.stage_number-1.), stage.rotor.rh], 16 | [(stage.stage_number-1.), stage.rotor.rt], 17 | ]) 18 | for stage in turbomachinery.stages 19 | ] 20 | 21 | stators = [ 22 | np.array([ 23 | [(stage.stage_number-1.)+0.5, stage.stator.rt], 24 | [stage.stage_number, (stage.next_stage.rotor if stage.next_stage else stage.stator).rt], 25 | [stage.stage_number, (stage.next_stage.rotor if stage.next_stage else stage.stator).rh], 26 | [(stage.stage_number-1.)+0.5, stage.stator.rh], 27 | [(stage.stage_number-1.)+0.5, stage.stator.rt], 28 | ]) 29 | for stage in turbomachinery.stages 30 | ] 31 | 32 | 33 | fig = go.Figure( 34 | layout=go.Layout( 35 | title=go.layout.Title(text="Annulus"), 36 | ) 37 | ) 38 | for (i, rotor) in enumerate(rotors): 39 | fig.add_trace(go.Scatter( 40 | x=rotor[:, 0], 41 | y=rotor[:, 1], 42 | fill="toself", 43 | fillcolor="red", 44 | line_color="red", 45 | legendgroup="rotor", 46 | legendgrouptitle_text="Rotor", 47 | name=f"Rotor {i+1}" 48 | )) 49 | 50 | for (i, stator) in enumerate(stators): 51 | fig.add_trace(go.Scatter( 52 | x=stator[:, 0], 53 | y=stator[:, 1], 54 | fill="toself", 55 | fillcolor="blue", 56 | line_color="blue", 57 | legendgroup="stator", 58 | legendgrouptitle_text="Stator", 59 | name=f"Stator {i+1}" 60 | )) 61 | 62 | 63 | 64 | if is_interactive: 65 | fig.show() 66 | else: 67 | image = Image(fig.to_image(format="png", width=800, height=500, scale=2)) 68 | display(image) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TurboDesigner 2 | the open-source turbomachinery designer 3 | 4 | 5 | ![assets/shaft.png](assets/shaft.png) 6 |

Axial Shaft

7 | 8 | ![assets/stage_casing.png](assets/stage_casing.png) 9 |

Axial Stage Casing

10 | 11 | # About 12 | Turbodesigner is a tool that given [parameters](https://github.com/Turbodesigner/turbodesigner/blob/main/tests/designs/mark1.json) such as pressure ratio and mass flow rate can generate designs using mean-line design, blade flow analysis, and at the end generate a CAD model that can be exported to STL and STEP files. 13 | 14 | Currently this generates axial compressors and with further tweaks axial turbopumps for liquid rocket engines 15 | 16 | # Assumptions 17 | To avoid feature creep or due to lack of development the following assumption are made: 18 | * `Turbomachinery` is an axial compressor (will suport more in later versions) 19 | * `FlowStation` assumes an Ideal Gas 20 | * Mean line is constant and is based on hub to tip ratio 21 | * Blade calculations are base on the `mean (rm)` station 22 | * Stagger angles are generated with `FreeVortex` (will support more in the future) 23 | * Blade `airfoil` is only a Double Circular Arc at the moment since other geometries haven't been implemented 24 | * `incidence (i)` and `deviation (delta)` values are defaulted 0 (will get Johnson Method working, but at the moment it is disabled) 25 | 26 | There are plans later to make the classes that make calculations 27 | extendable for certain circumstances 28 | 29 | 30 | ## Installation 31 | ``` 32 | # For now git+ is the only way to get cq_warehouse 33 | pip install git+https://github.com/gumyr/cq_warehouse.git 34 | 35 | pip install turbodesigner 36 | 37 | # Optional: To display but there is the basic Jupyter Viewer and others 38 | pip install jupyter-cadquery==3.4.0 cadquery-massembly==1.0.0rc0 # for viewing in Jupyter 39 | ``` 40 | 41 | 42 | # Setup 43 | 44 | ## Open in Gitpod 45 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/github.com/`/turbodesigner) 46 | 47 | or 48 | 49 | ``` 50 | git clone https://github.com/Turbodesigner/turbodesigner.git 51 | cd turbodesigner 52 | pip install git+https://github.com/gumyr/cq_warehouse.git 53 | pip install jupyter-cadquery==3.4.0 cadquery-massembly==1.0.0rc0 # for viewing in Jupyter 54 | ``` 55 | 56 | # Help Wanted 57 | Right now there are some items such as verifying calculations, CFD analysis, and adding additional logic for blade analysis. View [Projects](https://github.com/orgs/Turbodesigner/projects/1) tab for specific asks. Please join the [Discord](https://discord.gg/H7qRauGkQ6) for project communications and collaboration. Please consider donating to the [Patreon](https://www.patreon.com/openorion) to support future work on this project. 58 | -------------------------------------------------------------------------------- /tests/compressor_flow_station_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from turbodesigner.flow_station import FlowStation 4 | from turbodesigner.stage import Stage 5 | 6 | 7 | class CompressorFlowStationTest(unittest.TestCase): 8 | def test_stage_flow_station(self): 9 | inlet_flow_station = FlowStation( 10 | gamma=1.4, # dimensionless 11 | Rs=287, # J/kg/K 12 | Tt=450, # K 13 | Pt=2.80E5, # Pa 14 | mdot=2.2, # kg/s 15 | Vm=263.95, # m/s 16 | alpha=0, # rad 17 | N=36000, # rpm 18 | radius=0.08, # m 19 | ) 20 | 21 | np.testing.assert_almost_equal(inlet_flow_station.beta, np.radians(-48.808), 3) 22 | np.testing.assert_almost_equal(inlet_flow_station.U, 301.593, 3) 23 | np.testing.assert_almost_equal(inlet_flow_station.V, 263.95, 3) 24 | np.testing.assert_almost_equal(inlet_flow_station.Vcr, 388.169, 3) 25 | np.testing.assert_almost_equal(inlet_flow_station.Ttr, 495.275, 3) 26 | np.testing.assert_almost_equal(inlet_flow_station.Ptr, 391631.736, 3) 27 | np.testing.assert_almost_equal(inlet_flow_station.Vtheta, 0.00, 3) 28 | 29 | mid_flow_station = FlowStation( 30 | gamma=1.4, # dimensionless 31 | Rs=287, # J/kg/K 32 | Tt=504.6, # K 33 | Pt=3.60E5, # Pa 34 | mdot=2.2, # kg/s 35 | Vm=263.95, # m/s 36 | alpha=0.593, # rad 37 | N=36000, # rpm 38 | radius=0.08, # m 39 | ) 40 | 41 | np.testing.assert_almost_equal(mid_flow_station.Wtheta, -123.715, 3) 42 | np.testing.assert_almost_equal(mid_flow_station.Ttr, 496.469, 3) 43 | np.testing.assert_almost_equal(mid_flow_station.Ptr,340102.039, 3) 44 | 45 | 46 | def test_velocity_triangle(self): 47 | inlet_flow_station = FlowStation( 48 | Vm=150, # m/s 49 | N=15000, # RPM 50 | radius=0.1696031, # m 51 | alpha=0, # rad 52 | ) 53 | 54 | outlet_flow_station = FlowStation( 55 | Vm=150, # m/s 56 | N=15000, # RPM 57 | radius=0.1696031, # m 58 | alpha=np.radians(26.69005971), # rad 59 | ) 60 | 61 | # Inlet Flow Station 62 | np.testing.assert_almost_equal(np.degrees(inlet_flow_station.alpha), 0.0) 63 | np.testing.assert_almost_equal(np.degrees(inlet_flow_station.beta), -60.61884197) 64 | np.testing.assert_almost_equal(inlet_flow_station.Vm, 150.0) 65 | np.testing.assert_almost_equal(inlet_flow_station.Vtheta, 0.0) 66 | np.testing.assert_almost_equal(inlet_flow_station.V, 150.0) 67 | np.testing.assert_almost_equal(inlet_flow_station.U, 266.41192649) 68 | np.testing.assert_almost_equal(inlet_flow_station.Wtheta, -266.41192649) 69 | np.testing.assert_almost_equal(inlet_flow_station.W, 305.73732938) 70 | 71 | # Outlet Flow Station 72 | np.testing.assert_almost_equal(np.degrees(outlet_flow_station.alpha), 26.69005971) 73 | np.testing.assert_almost_equal(np.degrees(outlet_flow_station.beta), -51.85637225) 74 | np.testing.assert_almost_equal(outlet_flow_station.Vm, 150.0) 75 | np.testing.assert_almost_equal(outlet_flow_station.Vtheta, 75.40953689) 76 | np.testing.assert_almost_equal(outlet_flow_station.V, 167.88864838) 77 | np.testing.assert_almost_equal(outlet_flow_station.Wtheta, -191.0023896) 78 | np.testing.assert_almost_equal(outlet_flow_station.W, 242.86192133) 79 | 80 | 81 | if __name__ == '__main__': 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /turbodesigner/blade/metal_angle_methods/johnsen_bullock.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import cached_property 3 | from typing import Union 4 | import numpy as np 5 | from turbodesigner.airfoils import AirfoilType 6 | from turbodesigner.blade.metal_angles import MetalAngles 7 | 8 | 9 | @dataclass 10 | class MetalAngleOffset: 11 | i: Union[float, np.ndarray] 12 | "blade incidence (rad)" 13 | 14 | delta: Union[float, np.ndarray] 15 | "blade deviation (rad)" 16 | 17 | 18 | @dataclass 19 | class JohnsenBullockMetalAngleMethod: 20 | "Johnsen and Bullock 1965" 21 | 22 | beta1: Union[float, np.ndarray] 23 | "inlet flow angle (rad)" 24 | 25 | beta2: Union[float, np.ndarray] 26 | "outlet flow angle (rad)" 27 | 28 | sigma: float 29 | "spacing between blades (m)" 30 | 31 | tbc: float 32 | "max thickness to chord (dimensionless)" 33 | 34 | airfoil_type: AirfoilType 35 | "airfoil type (AirfoilType)" 36 | 37 | def __post_init__(self): 38 | self.beta1_deg = np.abs(np.degrees(self.beta1)) 39 | self.beta2_deg = np.abs(np.degrees(self.beta2)) 40 | 41 | @cached_property 42 | def Ksh(self): 43 | "blade shape paramter (dimensionless)" 44 | if self.airfoil_type == AirfoilType.NACA65: 45 | return 1.0 46 | elif self.airfoil_type == AirfoilType.DCA: 47 | return 0.7 48 | elif self.airfoil_type == AirfoilType.C4: 49 | return 1.1 50 | else: 51 | return 1.0 # Default case 52 | 53 | @cached_property 54 | def n(self): 55 | "slope factor (dimensionless)" 56 | return 0.025*self.sigma - ((1/90)*self.beta1_deg)**(1.2*self.sigma + 1)/(0.43*self.sigma + 1.5) - 0.06 57 | 58 | @cached_property 59 | def kti(self): 60 | "design incidence angle correction factor (dimensionless)" 61 | q = 0.28/(self.tbc**0.3 + 0.1) 62 | return (10*self.tbc)**q 63 | 64 | @cached_property 65 | def m(self): 66 | "deviation slope factor (dimensionless)" 67 | x = (1/100)*self.beta1_deg 68 | 69 | if self.airfoil_type == AirfoilType.NACA65: 70 | m1 = 0.333*x**2 - 0.0333*x + 0.17 71 | else: 72 | m1 = 0.316*x**3 - 0.132*x**2 + 0.074*x + 0.249 73 | 74 | b = -0.85*x**3 - 0.17*x + 0.9625 75 | return self.sigma**(-b)*m1 76 | 77 | @cached_property 78 | def Ktdelta(self): 79 | "design deviation angle correction factor (dimensionless)" 80 | return 37.5*self.tbc**2 + 6.25*self.tbc 81 | 82 | @cached_property 83 | def delta_star_0_10(self): 84 | "nominal deviation angle theta=0, tbc=0.10 (deg)" 85 | return 0.01*self.beta1_deg*self.sigma + ((0.74*self.sigma**1.9) + 3*self.sigma)*(self.beta1_deg/90)**(1.09*self.sigma + 1.67) 86 | 87 | @cached_property 88 | def i_star_0_10(self): 89 | "nominal incidence angle theta=0, tbc=0.10 (deg)" 90 | # seal pitch (dimensionless) 91 | p = (1/160)*self.sigma**3 + 0.914 92 | return ((self.beta1_deg**p)/(5 + 46*np.exp(-2.3*self.sigma))) - 0.1*self.sigma**3*np.exp((self.beta1_deg - 70)/4) 93 | 94 | def get_i_star_deg(self, theta_deg: Union[float, np.ndarray]): 95 | "nominal incidence angle (deg)" 96 | return theta_deg*self.n + self.kti*self.i_star_0_10*self.Ksh 97 | 98 | def get_delta_star_deg(self, theta_deg: Union[float, np.ndarray]): 99 | "nominal deviation angle (deg)" 100 | return self.Ksh*self.Ktdelta*self.delta_star_0_10 + theta_deg*self.m 101 | 102 | def get_metal_angle_offset(self, iterations: int): 103 | i_star_deg, delta_star_deg = 0, 0 104 | # TODO: make this more efficient with Numba 105 | for _ in range(iterations): 106 | metal_angles_deg = MetalAngles(self.beta1_deg, self.beta2_deg, i_star_deg, delta_star_deg) 107 | theta_deg = metal_angles_deg.theta 108 | i_star_deg = self.get_i_star_deg(theta_deg) 109 | delta_star_deg = self.get_delta_star_deg(theta_deg) 110 | 111 | i = np.radians(i_star_deg) * np.sign(self.beta1) 112 | delta = np.radians(delta_star_deg) * np.sign(self.beta2) 113 | return MetalAngleOffset(i, delta) 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | su2/ 2 | cfd/ 3 | generated/ 4 | simulations/ 5 | paraview 6 | docs 7 | bin/CQ-editor* 8 | bin/CQ-editor/ 9 | *.curaprofile 10 | ./cad 11 | *.step 12 | *.geo 13 | *.stl 14 | *.out 15 | *.mod 16 | *.pkl 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | db.sqlite3 79 | db.sqlite3-journal 80 | 81 | # Flask stuff: 82 | instance/ 83 | .webassets-cache 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | .pybuilder/ 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # IPython 99 | profile_default/ 100 | ipython_config.py 101 | 102 | # pyenv 103 | # For a library or package, you might want to ignore these files since the code is 104 | # intended to run in multiple environments; otherwise, check them in: 105 | # .python-version 106 | 107 | # pipenv 108 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 109 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 110 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 111 | # install all needed dependencies. 112 | #Pipfile.lock 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # General 158 | .DS_Store 159 | .AppleDouble 160 | .LSOverride 161 | 162 | # Icon must end with two \r 163 | Icon 164 | 165 | 166 | # Thumbnails 167 | ._* 168 | 169 | # Files that might appear in the root of a volume 170 | .DocumentRevisions-V100 171 | .fseventsd 172 | .Spotlight-V100 173 | .TemporaryItems 174 | .Trashes 175 | .VolumeIcon.icns 176 | .com.apple.timemachine.donotpresent 177 | 178 | # Directories potentially created on remote AFP share 179 | .AppleDB 180 | .AppleDesktop 181 | Network Trash Folder 182 | Temporary Items 183 | .apdisk 184 | 185 | *~ 186 | 187 | # temporary files which can be created if a process still has a handle open of a deleted file 188 | .fuse_hidden* 189 | 190 | # KDE directory preferences 191 | .directory 192 | 193 | # Linux trash folder which might appear on any partition or disk 194 | .Trash-* 195 | 196 | # .nfs files are created when an open file is removed but is still being accessed 197 | .nfs* 198 | 199 | # Windows thumbnail cache files 200 | Thumbs.db 201 | Thumbs.db:encryptable 202 | ehthumbs.db 203 | ehthumbs_vista.db 204 | 205 | # Dump file 206 | *.stackdump 207 | 208 | # Folder config file 209 | [Dd]esktop.ini 210 | 211 | # Recycle Bin used on file shares 212 | $RECYCLE.BIN/ 213 | 214 | # Windows Installer files 215 | *.cab 216 | *.msi 217 | *.msix 218 | *.msm 219 | *.msp 220 | 221 | # Windows shortcuts 222 | *.lnk 223 | -------------------------------------------------------------------------------- /turbodesigner/cad/blade.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from functools import cached_property 3 | import cadquery as cq 4 | import numpy as np 5 | from turbodesigner.blade.row import BladeRowCadExport 6 | from turbodesigner.cad.common import ExtendedWorkplane, FastenerPredicter 7 | 8 | 9 | @dataclass 10 | class BladeCadModelSpecification: 11 | include_attachment: bool = True 12 | "whether to include attachment (bool)" 13 | 14 | screw_length_padding: float = 0.00 15 | "screw length padding (dimensionless)" 16 | 17 | fastener_diameter_to_attachment_bottom_width: float = 0.25 18 | "blade attachment fastener to disk height (dimensionless)" 19 | 20 | 21 | @dataclass 22 | class BladeCadModel: 23 | blade_row: BladeRowCadExport 24 | "blade row" 25 | 26 | spec: BladeCadModelSpecification = field(default_factory=BladeCadModelSpecification) 27 | "blade cad model specification" 28 | 29 | @cached_property 30 | def lock_screw(self): 31 | return FastenerPredicter.predict_screw( 32 | target_diameter=self.heatset.thread_diameter, 33 | target_length=self.spec.screw_length_padding+self.heatset.nut_thickness 34 | ) 35 | 36 | @cached_property 37 | def heatset(self): 38 | return FastenerPredicter.predict_heatset( 39 | target_diameter=self.spec.fastener_diameter_to_attachment_bottom_width*self.blade_row.attachment_bottom_width, 40 | max_thickness=self.blade_row.attachment_height*0.75 41 | ) 42 | 43 | @cached_property 44 | def blade_assembly(self): 45 | base_assembly = cq.Assembly() 46 | fastener_assembly = cq.Assembly() 47 | 48 | start_airfoil = self.blade_row.airfoils[0] 49 | airfoil_vertical_offset = np.array([ 50 | (np.max(start_airfoil[:, 0]) + np.min(start_airfoil[:, 0]))/2, 51 | (np.max(start_airfoil[:, 1]) + np.min(start_airfoil[:, 1]))/2 52 | ]) 53 | 54 | # Hub Airfoil 55 | blade_profile = ( 56 | cq.Workplane("XY") 57 | .polyline(start_airfoil - airfoil_vertical_offset) 58 | .close() 59 | ) 60 | 61 | # Add all airfoil stations 62 | for i in range(0, len(self.blade_row.radii) - 1): 63 | blade_profile = ( 64 | blade_profile 65 | .transformed(offset=cq.Vector(0, 0, self.blade_row.radii[i+1]-self.blade_row.radii[i])) 66 | .polyline(self.blade_row.airfoils[i+1] - airfoil_vertical_offset) 67 | .close() 68 | ) 69 | 70 | if self.blade_row.is_rotating: 71 | hub_height_offset = self.blade_row.hub_radius*np.cos((2*np.pi / self.blade_row.number_of_blades) / 2)-self.blade_row.hub_radius 72 | else: 73 | hub_height_offset = 0 74 | 75 | blade_height = self.blade_row.tip_radius-self.blade_row.hub_radius 76 | path = ( 77 | cq.Workplane("XZ") 78 | .lineTo(hub_height_offset, blade_height) 79 | ) 80 | 81 | attachment_workplane = ExtendedWorkplane("YZ") 82 | if not self.blade_row.is_rotating: 83 | attachment_workplane = ( 84 | attachment_workplane 85 | .transformed(rotate=(0, 0, 180), offset=(0, blade_height, 0)) 86 | ) 87 | # Attachment Profile 88 | attachment_profile = ( 89 | attachment_workplane 90 | .polyline(self.blade_row.attachment) # type: ignore 91 | .close() 92 | .extrude(self.blade_row.disk_height*0.5, both=True) 93 | 94 | .faces("Z") 95 | .workplane() 96 | .insertHole(self.heatset, depth=self.heatset.nut_thickness*0.9, baseAssembly=fastener_assembly) 97 | ) 98 | 99 | blade_profile = ( 100 | blade_profile 101 | .sweep(path, multisection=True, makeSolid=True) 102 | ) 103 | 104 | if self.spec.include_attachment: 105 | blade_profile = ( 106 | blade_profile 107 | .add(attachment_profile) 108 | ) 109 | 110 | base_assembly.add(blade_profile, name="Blade") 111 | base_assembly.add(fastener_assembly, name="Fasteners") 112 | 113 | return base_assembly 114 | -------------------------------------------------------------------------------- /turbodesigner/cad/common.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional 2 | import cadquery as cq 3 | import cq_warehouse.extensions as cq_warehouse_extensions 4 | from cq_warehouse.fastener import SocketHeadCapScrew, HeatSetNut 5 | import numpy as np 6 | import re 7 | 8 | 9 | class ExtendedWorkplane(cq.Workplane): 10 | clearanceHole = cq_warehouse_extensions._clearanceHole 11 | tapHole = cq_warehouse_extensions._tapHole 12 | threadedHole = cq_warehouse_extensions._threadedHole 13 | insertHole = cq_warehouse_extensions._insertHole 14 | pressFitHole = cq_warehouse_extensions._pressFitHole 15 | 16 | def ring(self, radius: float, thickness: float, depth: float) -> "ExtendedWorkplane": 17 | return ( 18 | self 19 | .circle(radius) 20 | .circle(radius - thickness) 21 | .extrude(depth) 22 | ) 23 | 24 | def truncated_cone(self, start_radius: float, end_radius: float, height: float): 25 | plane_world_coords = self.plane.toWorldCoords((0, 0, 0)) 26 | path = cq.Workplane("XZ").moveTo(0, plane_world_coords.z).lineTo(0, height + plane_world_coords.z) 27 | 28 | return ( 29 | self 30 | .circle(start_radius) 31 | .transformed(offset=cq.Vector(0, 0, height)) 32 | .circle(end_radius) 33 | .sweep(path, multisection=True, makeSolid=True) 34 | .clean() 35 | ) 36 | 37 | def hollow_truncated_cone(self, inner_start_radius: float, inner_end_radius: float, height: float, start_thickness: float, end_thickness: float): 38 | outer_radius = inner_start_radius + start_thickness 39 | return ( 40 | self 41 | .truncated_cone(outer_radius, outer_radius, height) 42 | .cut( 43 | ExtendedWorkplane("XY") 44 | .truncated_cone(inner_start_radius, inner_end_radius, height) 45 | ) 46 | ) 47 | 48 | def mutatePoints(self, callback: Callable[[cq.Location], cq.Location]): 49 | for (i, loc) in enumerate(self.objects): 50 | if isinstance(loc, cq.Vertex) or isinstance(loc, cq.Vector): 51 | loc = cq.Location(self.plane, loc.toTuple()) 52 | 53 | assert isinstance(loc, cq.Location) 54 | mutated_loc = callback(loc) 55 | self.objects[i] = mutated_loc 56 | 57 | return self 58 | 59 | 60 | class FastenerPredicter: 61 | @staticmethod 62 | def get_nominal_size( 63 | target_diameter: float, 64 | nominal_size_range: List[str], 65 | ): 66 | for nominal_size in nominal_size_range: 67 | nominal_diameter = float(nominal_size.split("-")[0].replace("M", "")) 68 | if target_diameter <= nominal_diameter: 69 | return nominal_size 70 | 71 | raise ValueError(f"nominal size for target diameter {target_diameter} could not be found") 72 | 73 | @staticmethod 74 | def get_nominal_length( 75 | target_length: float, 76 | nominal_length_range: List[float], 77 | ): 78 | for nominal_length in nominal_length_range: 79 | if target_length <= nominal_length: 80 | return nominal_length 81 | raise ValueError(f"nominal length for target length {target_length} could not be found") 82 | 83 | 84 | @staticmethod 85 | def predict_heatset(target_diameter: float, max_thickness: Optional[float] = None, type: str = "Hilitchi"): 86 | nominal_size_range = HeatSetNut.sizes(type) 87 | last_acceptable_height_heatset: Optional[HeatSetNut] = None 88 | for nominal_size in nominal_size_range: 89 | heatset = HeatSetNut( 90 | size=nominal_size, 91 | fastener_type=type, 92 | simple=True, 93 | ) 94 | if max_thickness and heatset.nut_thickness <= max_thickness: 95 | last_acceptable_height_heatset = heatset 96 | if target_diameter <= heatset.nut_diameter : 97 | if max_thickness and heatset.nut_thickness > max_thickness: 98 | assert last_acceptable_height_heatset is not None, f"no heasets are valid for max height {max_thickness}, closest heatset {heatset.nut_diameter}" 99 | return last_acceptable_height_heatset 100 | return heatset 101 | raise ValueError(f"nominal size for target diameter {target_diameter} could not be found") 102 | 103 | 104 | @staticmethod 105 | def predict_screw(target_diameter: float, target_length: Optional[float] = None, type: str = "iso4762"): 106 | nominal_length_range = SocketHeadCapScrew.nominal_length_range[type] 107 | predicted_length = nominal_length_range[0] 108 | if target_length: 109 | predicted_length = FastenerPredicter.get_nominal_length(target_length, nominal_length_range) 110 | 111 | nominal_size_range = SocketHeadCapScrew.sizes(type) 112 | predicted_size = FastenerPredicter.get_nominal_size(target_diameter, nominal_size_range) 113 | 114 | return SocketHeadCapScrew(predicted_size, predicted_length, type) 115 | -------------------------------------------------------------------------------- /turbodesigner/stage.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | import numpy as np 5 | from turbodesigner.blade.row import BladeRow, BladeRowCadExport, MetalAngleMethods 6 | from turbodesigner.blade.vortex.free_vortex import FreeVortex 7 | from turbodesigner.flow_station import FlowStation 8 | from turbodesigner.units import MM 9 | 10 | 11 | @dataclass 12 | class StageBladeProperty: 13 | rotor: float 14 | stator: float 15 | 16 | 17 | @dataclass 18 | class StageCadExport: 19 | rotor: BladeRowCadExport 20 | "rotor blade row" 21 | 22 | stator: BladeRowCadExport 23 | "stator blade row" 24 | 25 | stage_height: float 26 | "stage height (mm)" 27 | 28 | stage_number: int 29 | "stage number" 30 | 31 | row_gap: float 32 | "stage gap (mm)" 33 | 34 | stage_gap: float 35 | "stage gap (mm)" 36 | 37 | 38 | @dataclass 39 | class Stage: 40 | "calculates turbomachinery stage" 41 | 42 | stage_number: int 43 | "stage number" 44 | 45 | Delta_Tt: float 46 | "stage stagnation temperature change between inlet and outlet (K)" 47 | 48 | R: float 49 | "stage reaction (dimensionless)" 50 | 51 | previous_flow_station: FlowStation 52 | "previous flow station (FlowStation)" 53 | 54 | eta_poly: float 55 | "polytropic efficiency (dimensionless)" 56 | 57 | N_stream: int 58 | "number of streams per blade (dimensionless)" 59 | 60 | AR: StageBladeProperty 61 | "aspect ratio (dimensionless)" 62 | 63 | sc: StageBladeProperty 64 | "spacing to chord ratio (dimensionless)" 65 | 66 | tbc: StageBladeProperty 67 | "max thickness to chord (dimensionless)" 68 | 69 | rgc: float 70 | "row gap to chord (dimensionless)" 71 | 72 | sgc: float 73 | "stage gap to chord (dimensionless)" 74 | 75 | metal_angle_method: MetalAngleMethods 76 | "metal angle method" 77 | 78 | next_stage: Optional["Stage"] = None 79 | "next turbomachinery stage" 80 | 81 | def __post_init__(self): 82 | assert isinstance(self.previous_flow_station.radius, float) 83 | self.rm = self.previous_flow_station.radius 84 | self.N = self.previous_flow_station.N 85 | 86 | @cached_property 87 | def Delta_ht(self) -> float: 88 | "enthalpy change between inlet and outlet (J/kg)" 89 | return self.Delta_Tt*self.previous_flow_station.Cp 90 | 91 | @cached_property 92 | def U(self): 93 | "mean blade velocity (m/s)" 94 | U = FlowStation.calc_U(self.N, self.rm) 95 | assert isinstance(U, float) 96 | return U 97 | 98 | @cached_property 99 | def phi(self): 100 | "flow coefficient (dimensionless)" 101 | return self.previous_flow_station.Vm/self.U 102 | 103 | @cached_property 104 | def psi(self): 105 | "loading coefficient (dimensionless)" 106 | return self.Delta_ht/self.U**2 107 | 108 | @cached_property 109 | def Tt2(self): 110 | "outlet total temperature (K)" 111 | return self.inlet_flow_station.Tt + self.Delta_Tt 112 | 113 | @cached_property 114 | def inlet_flow_station(self): 115 | "mid flow station between rotor and stator (FlowStation)" 116 | alpha1 = np.arctan((1 - self.R + -(1/2)*self.psi)/self.phi) 117 | return self.previous_flow_station.copyFlow( 118 | alpha=alpha1, 119 | ) 120 | 121 | @cached_property 122 | def mid_flow_station(self): 123 | "mid flow station between rotor and stator (FlowStation)" 124 | alpha2 = np.arctan((1 - self.R + (1/2)*self.psi)/self.phi) 125 | Pt2 = self.inlet_flow_station.Pt*self.PR 126 | return self.inlet_flow_station.copyFlow( 127 | Tt=self.Tt2, 128 | Pt=Pt2, 129 | alpha=alpha2, 130 | ) 131 | 132 | @cached_property 133 | def PR(self): 134 | "pressure ratio (dimensionless)" 135 | gamma = self.inlet_flow_station.gamma 136 | return self.TR**(self.eta_poly*gamma/(gamma - 1)) 137 | 138 | @cached_property 139 | def TR(self): 140 | "stagnation temperature ratio between stage outlet and inlet (dimensionless)" 141 | return self.Tt2/self.inlet_flow_station.Tt 142 | 143 | @cached_property 144 | def tau(self): 145 | "torque transmitted to stage (N*m)" 146 | return self.inlet_flow_station.mdot*self.rm*(self.mid_flow_station.Vtheta - self.inlet_flow_station.Vtheta) 147 | 148 | @cached_property 149 | def vortex(self): 150 | return FreeVortex( 151 | Um=self.U, 152 | Vm=self.previous_flow_station.Vm, 153 | Rm=self.R, 154 | psi_m=self.psi, 155 | rm=self.rm 156 | ) 157 | 158 | @cached_property 159 | def rotor(self): 160 | return BladeRow( 161 | stage_number=self.stage_number, 162 | stage_flow_station=self.inlet_flow_station, 163 | vortex=self.vortex, 164 | AR=self.AR.rotor, 165 | sc=self.sc.rotor, 166 | tbc=self.tbc.rotor, 167 | is_rotating=True, 168 | N_stream=self.N_stream, 169 | metal_angle_method=self.metal_angle_method, 170 | next_stage_flow_station=self.stator.flow_station 171 | ) 172 | 173 | @cached_property 174 | def stator(self): 175 | return BladeRow( 176 | stage_number=self.stage_number, 177 | stage_flow_station=self.mid_flow_station, 178 | vortex=self.vortex, 179 | AR=self.AR.stator, 180 | sc=self.sc.stator, 181 | tbc=self.tbc.stator, 182 | is_rotating=False, 183 | N_stream=self.N_stream, 184 | metal_angle_method=self.metal_angle_method, 185 | next_stage_flow_station=None if self.next_stage is None else self.next_stage.rotor.flow_station 186 | ) 187 | 188 | def to_cad_export(self): 189 | rotor = self.rotor.to_cad_export() 190 | stator = self.stator.to_cad_export() 191 | stage_height = rotor.disk_height+stator.disk_height 192 | return StageCadExport( 193 | stage_number=self.stage_number, 194 | rotor=rotor, 195 | stator=stator, 196 | stage_height=stage_height, 197 | stage_gap=self.sgc*self.rotor.c*MM, 198 | row_gap=self.rgc*self.rotor.c*MM 199 | ) 200 | -------------------------------------------------------------------------------- /turbodesigner/airfoils/DCA.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import cached_property 3 | from typing import Optional, Union 4 | import plotly.graph_objects as go 5 | import numpy as np 6 | from turbodesigner.airfoils.common import get_staggered_coords 7 | 8 | @dataclass 9 | class DCAAirfoil: 10 | c: float 11 | "chord length (length)" 12 | 13 | theta: float 14 | "camber angle (rad)" 15 | 16 | r0: float 17 | "double circular arc airfoil nose radius (length)" 18 | 19 | tb: float 20 | "max thickness (length)" 21 | 22 | xi: float = 0 23 | "stagger angle (rad)" 24 | 25 | arc_weight: float = 0.8 26 | "percentage of how much of x coordinates to load for arc" 27 | 28 | def __post_init__(self): 29 | if self.theta == 0: 30 | self.theta = 1E-5 31 | self.theta_mag = np.abs(self.theta) 32 | 33 | 34 | def get_camber_line(self, xc:Optional[Union[float, np.ndarray]] = None, is_staggered: bool = True, is_centered: bool = True, num_points = 20): 35 | """coordinates of camber line (length) 36 | 37 | num_points: int 38 | number of points 39 | """ 40 | # horizontal position of camber or chord line (length) 41 | if xc is None: 42 | xc = np.linspace(-self.c/2, self.c/2, num_points, endpoint=True) 43 | y_sign = np.sign(self.theta) 44 | # radius of curvature (length) 45 | Rc = (self.c/2) * (1/(np.sin(self.theta/2))) 46 | 47 | # camber line y-coordinate origin of radius (length) 48 | yc0 = -Rc * np.cos(self.theta/2) 49 | 50 | # vertical position of camber of chord line (length) 51 | yc = yc0 + np.sqrt(Rc**2 - xc**2) * y_sign 52 | 53 | camber_line = np.array([xc,yc]).T 54 | 55 | if is_centered: 56 | camber_line = camber_line - np.array([0,yc0 + np.sqrt(Rc**2) * y_sign]) 57 | 58 | if is_staggered: 59 | camber_line = get_staggered_coords(camber_line, self.xi) 60 | return camber_line 61 | 62 | def get_circle(self, is_left: bool, num_points: int): 63 | """coordinates of circle for DCA airfoil (length) 64 | 65 | is_left: bool 66 | whether it is the left circle of DCA airfoil 67 | 68 | num_points: int 69 | number of points 70 | """ 71 | 72 | x_sign = -1 if is_left else 1 73 | x_center = x_sign*(self.c/2 - self.r0*np.cos(self.theta_mag/2)) 74 | y_center = self.r0 * np.sin(self.theta_mag/2) 75 | 76 | center_to_end_distance = (x_sign*self.c/2) - x_center 77 | angle_offset = np.pi - np.arccos(center_to_end_distance/self.r0) + np.pi/2 78 | angle = np.linspace(0, np.pi, num_points, endpoint=True) + angle_offset 79 | 80 | x = self.r0 * np.cos(angle) + x_center 81 | y = (self.r0 * np.sin(angle) + y_center) * np.sign(self.theta) 82 | 83 | return np.array([x,y]).T 84 | 85 | 86 | def get_arc(self, is_lower = False, num_points=20): 87 | """y-coordinates of chord line (length) 88 | 89 | is_lower: bool = False 90 | whether arc is lower 91 | 92 | """ 93 | # negate tb and r0 if this a lower arc 94 | input_sign = -1 if is_lower else 1 95 | r0 = input_sign*self.r0 96 | tb = input_sign*self.tb 97 | 98 | x = np.linspace(-self.c*self.arc_weight/2, self.c*self.arc_weight/2, num=num_points) 99 | 100 | # camberline coordinate at mid coord (length) 101 | ym = (self.c/2)*np.tan(self.theta_mag/4) 102 | 103 | # radius of circular arc (length) 104 | d = ym + (tb / 2) - r0*np.sin(self.theta_mag/2) 105 | R = (d**2 - (r0**2) + ((self.c/2) - r0 * np.cos(self.theta_mag/2))**2)/(2*(d-r0)) 106 | 107 | # origin of circular arc (length) 108 | y0 = ym + (tb/2) - R 109 | 110 | y = np.sign(self.theta) * (y0 + np.sqrt(R**2 - x**2)) 111 | 112 | if is_lower and np.abs(y[0]) > self.tb*2: 113 | y = -np.sign(self.theta) * np.ones(num_points) * self.r0 114 | 115 | return np.array([x,y]).T 116 | 117 | def get_coords( 118 | self, 119 | num_arc_points: int = 20, 120 | num_circle_points: int = 10 121 | ): 122 | """double circular arc airfoil coordinates 123 | 124 | num_arc_points: int 125 | number of arc points 126 | 127 | num_circle_points: int 128 | number of circle points 129 | 130 | """ 131 | # Info: x coordinates are [:,0] and y coordinates are [:,1] 132 | 133 | # Circles 134 | left_circle = self.get_circle(is_left=True, num_points=num_circle_points) 135 | right_circle = self.get_circle(is_left=False, num_points=num_circle_points) 136 | 137 | # Arcs 138 | upper_arc = self.get_arc(is_lower=False, num_points=num_arc_points) 139 | lower_arc = self.get_arc(is_lower=True, num_points=num_arc_points) 140 | left_circle_start = np.array([left_circle[0]]) 141 | 142 | upper_cond = np.where(np.logical_and(upper_arc[:,0] > left_circle[0,0], upper_arc[:,0] < right_circle[-1,0])) 143 | lower_cond = np.where(np.logical_and(lower_arc[:,0] > left_circle[-1,0], lower_arc[:,0] < right_circle[0,0])) 144 | 145 | center = self.get_camber_line(0, is_staggered=False, is_centered=False) 146 | airfoil = np.concatenate( 147 | [ 148 | left_circle, 149 | lower_arc[lower_cond], 150 | right_circle, 151 | np.flip(upper_arc[upper_cond], axis=0), 152 | left_circle_start 153 | ] 154 | ) - center 155 | 156 | return get_staggered_coords(airfoil, self.xi) 157 | 158 | def visualize( 159 | self, 160 | fig=go.Figure(), 161 | show=True, 162 | num_arc_points: int = 20, 163 | num_circle_points: int = 10 164 | ): 165 | camber_xy = self.get_camber_line(num_points=num_arc_points) 166 | fig.add_trace(go.Scatter( 167 | x=camber_xy[:, 0], 168 | y=camber_xy[:, 1], 169 | )) 170 | 171 | xy = self.get_coords(num_arc_points, num_circle_points) 172 | fig.add_trace(go.Scatter( 173 | x=xy[:, 0], 174 | y=xy[:, 1], 175 | fill="toself" 176 | )) 177 | 178 | fig.layout.yaxis.scaleanchor="x" # type: ignore 179 | if show: 180 | fig.show() 181 | -------------------------------------------------------------------------------- /turbodesigner/turbomachinery.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from dataclasses import dataclass 3 | import json 4 | from typing import Literal, Union 5 | import numpy as np 6 | from turbodesigner.blade.row import MetalAngleMethods 7 | from turbodesigner.flow_station import FlowStation 8 | from turbodesigner.stage import Stage, StageBladeProperty, StageCadExport 9 | from dacite.core import from_dict 10 | Number = Union[int, float] 11 | 12 | 13 | @dataclass 14 | class TurbomachineryCadExport: 15 | stages: list[StageCadExport] 16 | "turbomachinery stages" 17 | 18 | 19 | @dataclass 20 | class Turbomachinery: 21 | gamma: float 22 | "ratio of specific heats (dimensionless)" 23 | 24 | cx: float 25 | "inlet flow velocity in the radial and axial plane (m/s)" 26 | 27 | N: float 28 | "rotational speed (rpm)" 29 | 30 | Rs: float 31 | "specific gas constant (J/(kg*K))" 32 | 33 | mdot: float 34 | "mass flow rate (kg/s)" 35 | 36 | PR: float 37 | "pressure ratio (dimensionless)" 38 | 39 | Pt: float 40 | "ambient total pressure (Pa)" 41 | 42 | Tt: float 43 | "ambient total temperature (K)" 44 | 45 | eta_isen: float 46 | "isentropic efficiency (dimensionless)" 47 | 48 | N_stg: int 49 | "number of stages (dimensionless)" 50 | 51 | B_in: Union[Number, list[Number]] 52 | "inlet blockage factor (dimensionless)" 53 | 54 | B_out: Union[Number, list[Number]] 55 | "outlet blockage factor (dimensionless)" 56 | 57 | ht: float 58 | "hub to tip ratio (dimensionless)" 59 | 60 | N_stream: int 61 | "number of streams per blade (dimensionless)" 62 | 63 | Delta_T0_stg: Union[list[float], Literal["equal"]] 64 | "array of stage stagnation temperature rises between inlet and outlet (K)" 65 | 66 | R_stg: Union[Number, list[Number]] 67 | "array of stage reaction rates (dimensionless)" 68 | 69 | rgc: Union[Number, list[Number]] 70 | "row gap to chord (dimensionless)" 71 | 72 | sgc: Union[Number, list[Number]] 73 | "stage gap to chord (dimensionless)" 74 | 75 | AR: Union[StageBladeProperty, list[StageBladeProperty]] 76 | "aspect ratios (dimensionless)" 77 | 78 | sc: Union[StageBladeProperty, list[StageBladeProperty]] 79 | "spacing to chord ratios (dimensionless)" 80 | 81 | tbc: Union[StageBladeProperty, list[StageBladeProperty]] 82 | "max thickness to chords (dimensionless)" 83 | 84 | metal_angle_method: MetalAngleMethods = "JohnsenBullock" 85 | "metal angle method" 86 | 87 | @cached_property 88 | def Tt2(self): 89 | "outlet stagnation temperature (K)" 90 | return self.Tt*self.PR**((self.gamma - 1)/(self.eta_poly*self.gamma)) 91 | 92 | @cached_property 93 | def Pt2(self): 94 | "stagnation outlet pressure (Pa)" 95 | return self.Pt*self.PR 96 | 97 | @cached_property 98 | def eta_poly(self): 99 | "polytropic efficiency (dimensionless)" 100 | return (self.gamma - 1)*np.log(self.PR)/(self.gamma*np.log((self.eta_isen + self.PR**((self.gamma - 1)/self.gamma) - 1)/self.eta_isen)) 101 | 102 | @cached_property 103 | def inlet_flow_station(self): 104 | "inlet flow station (FlowStation)" 105 | flow_station = FlowStation( 106 | gamma=self.gamma, 107 | Rs=self.Rs, 108 | Tt=self.Tt, 109 | Pt=self.Pt, 110 | Vm=self.cx, 111 | mdot=self.mdot, 112 | B=self.B_in[0] if isinstance(self.B_in, list) else self.B_in, 113 | N=self.N, 114 | ) 115 | flow_station.set_radius(self.ht) 116 | return flow_station 117 | 118 | @cached_property 119 | def outlet_flow_station(self): 120 | "outlet flow station (FlowStation)" 121 | return FlowStation( 122 | gamma=self.gamma, 123 | Rs=self.Rs, 124 | Tt=self.Tt2, 125 | Pt=self.Pt2, 126 | Vm=self.cx, 127 | mdot=self.mdot, 128 | B=self.B_out[-1] if isinstance(self.B_out, list) else self.B_out, 129 | N=self.N, 130 | radius=self.inlet_flow_station.radius 131 | ) 132 | 133 | @cached_property 134 | def Delta_T0(self): 135 | "stagnation temperature change between outlet and inlet (dimensionless)" 136 | return self.outlet_flow_station.Tt - self.inlet_flow_station.Tt 137 | 138 | @cached_property 139 | def TR(self): 140 | "stagnation temperature ratio between outlet and inlet (dimensionless)" 141 | return self.outlet_flow_station.Tt/self.inlet_flow_station.Tt 142 | 143 | @cached_property 144 | def stages(self): 145 | "turbomachinery stages (list[Stage])" 146 | if isinstance(self.Delta_T0_stg, list): 147 | assert len(self.Delta_T0_stg) == self.N_stg, "Delta_T0 quantity does not equal N_stg" 148 | else: 149 | assert self.Delta_T0_stg == "equal", f"'{self.Delta_T0_stg}' for Delta_T0_stg is invalid" 150 | 151 | if isinstance(self.R_stg, list): 152 | assert len(self.R_stg) == self.N_stg, "R quantity does not equal N_stg" 153 | assert self.R_stg[self.N_stg-1] == 0.5, "Last stage reaction only supports R=0.5" 154 | else: 155 | assert self.R_stg == 0.5, "Last stage reaction only supports R=0.5" 156 | 157 | if isinstance(self.AR, list): 158 | assert len(self.AR) == self.N_stg, "AR quantity does not equal N_stg" 159 | if isinstance(self.sc, list): 160 | assert len(self.sc) == self.N_stg, "sc quantity does not equal N_stg" 161 | if isinstance(self.tbc, list): 162 | assert len(self.tbc) == self.N_stg, "tbc quantity does not equal N_stg" 163 | 164 | previous_flow_station = self.inlet_flow_station 165 | stages: list[Stage] = [] 166 | # TODO: make this more efficient with Numba 167 | for i in range(self.N_stg): 168 | stage = Stage( 169 | stage_number=i+1, 170 | Delta_Tt=self.Delta_T0_stg[i] if isinstance(self.Delta_T0_stg, list) else self.Delta_T0/self.N_stg, 171 | R=self.R_stg[i] if isinstance(self.R_stg, list) else self.R_stg, 172 | previous_flow_station=previous_flow_station, 173 | eta_poly=self.eta_poly, 174 | N_stream=self.N_stream, 175 | AR=self.AR[i] if isinstance(self.AR, list) else self.AR, 176 | sc=self.sc[i] if isinstance(self.sc, list) else self.sc, 177 | tbc=self.tbc[i] if isinstance(self.tbc, list) else self.tbc, 178 | rgc=self.rgc[i] if isinstance(self.rgc, list) else self.rgc, 179 | sgc=self.sgc[i] if isinstance(self.sgc, list) else self.sgc, 180 | metal_angle_method=self.metal_angle_method 181 | ) 182 | previous_flow_station = stage.mid_flow_station 183 | if i > 0 and i < self.N_stg: 184 | stages[i-1].next_stage = stage 185 | stages.append(stage) 186 | 187 | return stages 188 | 189 | def to_cad_export(self): 190 | return TurbomachineryCadExport( 191 | stages=[ 192 | stage.to_cad_export() for stage in self.stages 193 | ] 194 | ) 195 | 196 | @staticmethod 197 | def from_dict(obj) -> "Turbomachinery": 198 | return from_dict(data_class=Turbomachinery, data=obj) 199 | 200 | @staticmethod 201 | def from_file(file_name: str) -> "Turbomachinery": 202 | with open(file_name, "r") as fp: 203 | obj = json.load(fp) 204 | return from_dict(data_class=Turbomachinery, data=obj) 205 | -------------------------------------------------------------------------------- /turbodesigner/attachments/firetree.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from typing import Optional 3 | import numpy as np 4 | from dataclasses import dataclass 5 | import plotly.graph_objects as go 6 | 7 | def get_line( 8 | y: np.ndarray, 9 | point1: np.ndarray, 10 | point2: np.ndarray, 11 | y_int: Optional[int] = None 12 | ): 13 | x2, y2 = point2[0], point2[1] 14 | x1, y1 = point1[0], point1[1] 15 | m = (y2 - y1) / (x2 - x1) 16 | b = y2 - m*x2 if y_int is None else 0 17 | x = (y-b)/m 18 | return np.array([x, y]).T 19 | 20 | 21 | def get_arc( 22 | lower_point: np.ndarray, 23 | upper_point: np.ndarray, 24 | radius: float, 25 | center: np.ndarray, 26 | num_points: int, 27 | is_clockwise: bool = True, 28 | endpoint: bool = True 29 | ): 30 | lower_distance = lower_point - center 31 | upper_distance = upper_point - center 32 | angle1 = np.arctan2(lower_distance[1], lower_distance[0]) 33 | angle2 = np.arctan2(upper_distance[1], upper_distance[0]) 34 | 35 | if not is_clockwise: 36 | angle1 = angle1 + 2*np.pi 37 | 38 | angle = np.linspace(angle1, angle2, num_points, endpoint=endpoint) 39 | return center + np.array([ 40 | radius * np.cos(angle), 41 | radius * np.sin(angle), 42 | ]).T 43 | 44 | 45 | @dataclass 46 | class FirtreeAttachment: 47 | gamma: float 48 | "angle of upper flank line (rad)" 49 | 50 | beta: float 51 | "angle of lower flank line (rad)" 52 | 53 | ll: float 54 | "lower flank line length (m)" 55 | 56 | lu: float 57 | "upper flank line length (m)" 58 | 59 | Ri: float 60 | "inner circle radius" 61 | 62 | Ro: float 63 | "outer circle radius" 64 | 65 | R_dove: float 66 | "dove circle radius" 67 | 68 | max_length: float 69 | "max length of attachment" 70 | 71 | num_stages: int 72 | "number of stages" 73 | 74 | disk_radius: float 75 | "disk radius of attachment" 76 | 77 | tolerance: float 78 | "attachment side tolerance" 79 | 80 | include_top_arc: bool = True 81 | "whether or not to include top arc" 82 | 83 | num_arc_points: int = 20 84 | "number of arc points" 85 | 86 | def __post_init__(self): 87 | self.origin = np.array([0, 0]) 88 | 89 | # Outer Circle 90 | self.outer_circle_lower_tangent = self.origin + ( 91 | np.array([self.ll*np.cos(self.beta), self.ll*np.sin(self.beta)]) 92 | ) 93 | self.outer_circle_upper_tangent = self.outer_circle_lower_tangent + ( 94 | np.array([0, 2*self.Ro*np.cos(self.gamma)]) 95 | ) 96 | self.outer_circle_tanget_intersect = self.outer_circle_lower_tangent + ( 97 | np.array([self.Ro*(1 - np.sin(self.gamma)**2)/np.sin(self.gamma), self.Ro*np.cos(self.gamma),]) 98 | ) 99 | self.outer_circle_center = self.outer_circle_lower_tangent + ( 100 | np.array([-self.Ro*np.sin(self.gamma), self.Ro*np.cos(self.gamma)]) 101 | ) 102 | 103 | # Inner Circle 104 | self.inner_circle_lower_tangent = self.outer_circle_upper_tangent + ( 105 | np.array([-self.lu*np.cos(self.gamma), self.lu*np.sin(self.gamma)]) 106 | ) 107 | self.inner_circle_upper_tangent = self.inner_circle_lower_tangent + ( 108 | np.array([0, 2*self.Ri*np.cos(self.gamma)]) 109 | ) 110 | self.inner_circle_center = self.inner_circle_lower_tangent + ( 111 | np.array([self.Ri*np.sin(self.beta), self.Ri*np.cos(self.beta)]) 112 | ) 113 | 114 | # Dove 115 | self.dove_circle_center = np.array([self.R_dove*np.sin(self.beta), -self.R_dove*np.cos(self.beta)]) 116 | self.dove_lower_point = self.dove_circle_center + np.array([0,-self.R_dove]) 117 | 118 | @cached_property 119 | def dove_arc(self): 120 | "calculates firtree dove arc" 121 | return get_arc(self.dove_lower_point, self.origin, self.R_dove, self.dove_circle_center, self.num_arc_points, is_clockwise=False) 122 | 123 | def get_top_shape(self, include_tolerance: bool): 124 | "calculates firtree top arc or line segement" 125 | max_length = self.max_length + self.tolerance if include_tolerance else self.max_length 126 | top_arc_left_point = self.left_side[-1] 127 | top_arc_right_point = top_arc_left_point + np.array([max_length, 0]) 128 | 129 | if self.include_top_arc: 130 | sector_angle = 2*np.arcsin((max_length/2)/self.disk_radius) 131 | top_arc_height = self.disk_radius - (max_length/2)/np.tan(sector_angle/2) 132 | disk_center = np.array([0,top_arc_left_point[1]-self.disk_radius+top_arc_height]) 133 | return get_arc(top_arc_left_point, top_arc_right_point, self.disk_radius, disk_center, self.num_arc_points) 134 | 135 | return np.concatenate([[top_arc_left_point], [top_arc_right_point]]) 136 | 137 | def get_stage(self, end_stage: bool = False): 138 | "calculates firtree single stage coordinates" 139 | # Flank Lines 140 | yl = np.linspace(self.origin[1], self.outer_circle_lower_tangent[1], 2, endpoint=False) 141 | lower_flank_line = get_line(yl, self.origin, self.outer_circle_lower_tangent) 142 | 143 | yu = np.linspace(self.outer_circle_upper_tangent[1], self.inner_circle_lower_tangent[1], 2, endpoint=False) 144 | upper_flank_line = get_line(yu, self.outer_circle_tanget_intersect, self.outer_circle_upper_tangent) 145 | 146 | # Arcs 147 | outer_arc = get_arc(self.outer_circle_lower_tangent, self.outer_circle_upper_tangent, self.Ro, self.outer_circle_center, self.num_arc_points, endpoint=False) 148 | inner_arc = get_arc(self.inner_circle_lower_tangent, self.inner_circle_upper_tangent, self.Ri, self.inner_circle_center, self.num_arc_points, is_clockwise=False) 149 | 150 | stage_elements = [lower_flank_line,outer_arc, upper_flank_line] 151 | if not end_stage: 152 | stage_elements.append(inner_arc) 153 | 154 | return np.concatenate(stage_elements) 155 | 156 | @cached_property 157 | def left_side(self) -> np.ndarray: 158 | "calculates firtree attachment left side coordinates" 159 | stage = self.get_stage() 160 | attachment_stage_side:Optional[np.ndarray] = None 161 | assert self.num_stages > 0, "num stages must greater than 0" 162 | # TODO: make this more efficient with Numba 163 | for i in range(self.num_stages): 164 | next_stage = stage 165 | if attachment_stage_side is not None: 166 | attachment_stage_side = np.concatenate([ 167 | attachment_stage_side[:-1], 168 | next_stage + attachment_stage_side[-1] 169 | ]) 170 | else: 171 | attachment_stage_side = next_stage 172 | 173 | # offset side to max length 174 | assert attachment_stage_side is not None 175 | attachment_center_offset = np.array([-attachment_stage_side[-1][0]-self.max_length/2, 0]) 176 | return np.concatenate([self.dove_arc[:-1], attachment_stage_side]) + attachment_center_offset 177 | 178 | 179 | def get_coords(self, include_tolerance: bool): 180 | "calculates firtree attachment coordinates" 181 | top_arc = self.get_top_shape(include_tolerance) 182 | left_side = self.left_side + np.array([-self.tolerance/2, 0]) if include_tolerance else self.left_side 183 | attachment_right_side = np.flip(left_side, axis=0) * np.array([-1, 1]) 184 | attachment = np.concatenate([left_side[:-1], top_arc[:-1], attachment_right_side, [left_side[0]]]) 185 | return attachment + np.array([0, -np.max(attachment[:,1])]) 186 | 187 | @cached_property 188 | def coords(self): 189 | return self.get_coords(include_tolerance=False) 190 | 191 | @cached_property 192 | def coords_with_tolerance(self): 193 | return self.get_coords(include_tolerance=True) 194 | 195 | @cached_property 196 | def height(self): 197 | return np.max(self.coords[:, 1]) - np.min(self.coords[:, 1]) # type: ignore 198 | 199 | @cached_property 200 | def bottom_width(self): 201 | return np.abs(self.left_side[0][0])*2 202 | 203 | def visualize(self): 204 | fig = go.Figure() 205 | fig.add_trace(go.Scatter( 206 | x=self.coords[:, 0], 207 | y=self.coords[:, 1], 208 | )) 209 | fig.layout.yaxis.scaleanchor = "x" 210 | fig.show() 211 | 212 | -------------------------------------------------------------------------------- /turbodesigner/flow_station.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from dataclasses import dataclass 3 | from typing import Optional, Union 4 | import numpy as np 5 | 6 | PROP_NON_STREAM_ERROR = "Property not allowed with streams" 7 | 8 | 9 | class FluidConstants: 10 | MU_REF = 1.73E-5 11 | "reference dynamic viscocity at sea level ((N*s)/m**2)" 12 | 13 | PT_REF = 101325 14 | "reference pressure at sea level (Pa)" 15 | 16 | T0_REF = 288.15 17 | "reference temperature at sea level (K)" 18 | 19 | C = 110.4 20 | "sutherland constant (K)" 21 | 22 | 23 | @dataclass 24 | class FlowStation: 25 | "calculates flow station for an ideal gas" 26 | 27 | gamma: float = np.nan 28 | "ratio of specific heats (dimensionless)" 29 | 30 | Rs: float = np.nan 31 | "specific gas constant (J/(kg*K))" 32 | 33 | Tt: float = np.nan 34 | "total temperature (K)" 35 | 36 | Pt: float = np.nan 37 | "total pressure (Pa)" 38 | 39 | Vm: float = np.nan 40 | "meridional flow velocity (m/s)" 41 | 42 | mdot: float = np.nan 43 | "mass flow rate (kg/s)" 44 | 45 | B: float = 0.0 46 | "blockage factor (dimensionless)" 47 | 48 | alpha: Union[float, np.ndarray] = np.nan 49 | "absolute flow angle (rad)" 50 | 51 | N: float = np.nan 52 | "rotational speed (rpm)" 53 | 54 | radius: Union[float, np.ndarray] = np.nan 55 | "flow radius (m)" 56 | 57 | mixture: str = "Air" 58 | "mixture" 59 | 60 | is_stream: bool = False 61 | "whether station is 1D stream" 62 | 63 | def copyFlow( 64 | self, 65 | Tt: Optional[float] = None, 66 | Pt: Optional[float] = None, 67 | Vm: Optional[float] = None, 68 | mdot: Optional[float] = None, 69 | alpha: Optional[Union[float, np.ndarray]] = None, 70 | radius: Optional[Union[float, np.ndarray]] = None, 71 | ): 72 | "copies all elements of FlowStation (FlowStation)" 73 | return FlowStation( 74 | gamma=self.gamma, 75 | Rs=self.Rs, 76 | Tt=self.Tt if Tt is None else Tt, 77 | Pt=self.Pt if Pt is None else Pt, 78 | Vm=self.Vm if Vm is None else Vm, 79 | mdot=self.mdot if mdot is None else mdot, 80 | B=self.B, 81 | alpha=self.alpha if alpha is None else alpha, 82 | N=self.N, 83 | radius=self.radius if radius is None else radius, 84 | mixture=self.mixture 85 | ) 86 | 87 | def copyStream( 88 | self, 89 | alpha: Optional[Union[float, np.ndarray]] = None, 90 | radius: Optional[Union[float, np.ndarray]] = None, 91 | ): 92 | """copies stream elements of FlowStation (FlowStation) 93 | 94 | excludes: 95 | * mdot - mass flow rate 96 | 97 | """ 98 | return FlowStation( 99 | gamma=self.gamma, 100 | Rs=self.Rs, 101 | Tt=self.Tt, 102 | Pt=self.Pt, 103 | Vm=self.Vm, 104 | alpha=self.alpha if alpha is None else alpha, 105 | N=self.N, 106 | radius=self.radius if radius is None else radius, 107 | is_stream=True, 108 | mixture=self.mixture 109 | ) 110 | 111 | @cached_property 112 | def h(self): 113 | "static enthalpy (J/kg*K)" 114 | return self.T*self.Cp 115 | 116 | @cached_property 117 | def ht(self): 118 | "total enthalpy (J/kg*K)" 119 | return self.h + (self.V**2)/2 120 | 121 | @cached_property 122 | def Cp(self): 123 | "specific heat at constant pressure (J/(kg*K))" 124 | return self.Rs*self.gamma/(self.gamma - 1) 125 | 126 | @cached_property 127 | def T(self): 128 | "static temperature (K)" 129 | return self.Tt - (self.V**2)/(2*self.Cp) 130 | 131 | @cached_property 132 | def Ttr(self): 133 | "total realtive temperature (K)" 134 | return self.Tt + (self.W**2 - self.V**2)/(2*self.Cp) 135 | 136 | @cached_property 137 | def P(self): 138 | "static pressure (Pa)" 139 | return self.Pt*(self.T/self.Tt)**(self.gamma/(self.gamma - 1)) 140 | 141 | @cached_property 142 | def Ptr(self): 143 | "total relative pressure (Pa)" 144 | return self.Pt*(self.Ttr/self.Tt)**(self.gamma/(self.gamma - 1)) 145 | 146 | @cached_property 147 | def rho(self): 148 | "density (kg/m**3)" 149 | return self.P/(self.T*self.Rs) 150 | 151 | @cached_property 152 | def q(self): 153 | "dynamic pressure (Pa)" 154 | return 0.5*self.rho*self.Vm**2 155 | 156 | @cached_property 157 | def a(self): 158 | "speed of sound in medium (m/s)" 159 | return np.sqrt(self.T*self.Rs*self.gamma) 160 | 161 | @cached_property 162 | def mu(self): 163 | "dynamic velocity using Sutherland's formula ((N*s)/m**2)" 164 | return FluidConstants.MU_REF * ((self.T / FluidConstants.T0_REF)**1.5) * ((FluidConstants.T0_REF + FluidConstants.C) / (self.T + FluidConstants.C)) 165 | 166 | @cached_property 167 | def MN(self): 168 | "mach number (dimensionless)" 169 | return self.Vm/self.a 170 | 171 | @cached_property 172 | def Vcr(self): 173 | "critical velocity (m/s)" 174 | return np.sqrt(((2*self.gamma)/(self.gamma+1)) * self.Rs*self.Tt) 175 | 176 | @cached_property 177 | def U(self): 178 | "blade velocity (m/s)" 179 | return FlowStation.calc_U(self.N, self.radius) 180 | 181 | @cached_property 182 | def omega(self): 183 | "blade angular velocity (rad/s)" 184 | return self.U/self.radius 185 | 186 | @cached_property 187 | def Vtheta(self): 188 | "absolute tangential velocity (m/s)" 189 | return self.Vm*np.tan(self.alpha) 190 | 191 | @cached_property 192 | def V(self): 193 | "absolute flow velocity (m/s)" 194 | if np.isnan(self.alpha).all(): 195 | return self.Vm 196 | return self.Vm/np.cos(self.alpha) 197 | 198 | @cached_property 199 | def Wtheta(self): 200 | "relative tangential flow velocity (m/s)" 201 | return self.Vtheta - self.U 202 | 203 | @cached_property 204 | def beta(self): 205 | "relative flow angle (rad)" 206 | return np.arctan(self.Wtheta/self.Vm) 207 | 208 | @cached_property 209 | def W(self): 210 | "relative flow velocity (m/s)" 211 | return self.Vm/np.cos(self.beta) 212 | 213 | # %% Annular Properties 214 | @cached_property 215 | def A_flow(self): 216 | "cross-sectional flow area (m**2)" 217 | assert not self.is_stream, PROP_NON_STREAM_ERROR 218 | return self.mdot/(self.rho*self.Vm) 219 | 220 | @cached_property 221 | def A_phys(self): 222 | "physical cross sectional area (m**2)" 223 | return self.A_flow*(self.B + 1) 224 | 225 | @cached_property 226 | def outer_radius(self): 227 | "flow outer radius (m)" 228 | assert not self.is_stream, PROP_NON_STREAM_ERROR 229 | return self.A_phys/(4*np.pi*self.radius) + self.radius 230 | 231 | @cached_property 232 | def inner_radius(self): 233 | "flow inner radius (m)" 234 | assert not self.is_stream, PROP_NON_STREAM_ERROR 235 | return 2*self.radius - self.outer_radius 236 | 237 | @staticmethod 238 | def calc_radius_from_ht(ht: float, A_phys: Union[float, np.ndarray]): 239 | """calculates radius from hub to tip ratio 240 | 241 | Parameters 242 | ========== 243 | 244 | ht: float 245 | hub to tip ratio (dimensionless) 246 | 247 | A_phys: float 248 | physical cross sectional area (m**2) 249 | 250 | """ 251 | 252 | outer_radius = np.sqrt(A_phys / (np.pi*(1-ht**2))) 253 | inner_radius = ht * outer_radius 254 | return (outer_radius + inner_radius) / 2 255 | 256 | @staticmethod 257 | def calc_U(N: float, radius: Union[float, np.ndarray]): 258 | """calculates blade velocity 259 | 260 | Parameters 261 | ========== 262 | 263 | N: float 264 | rotational speed (rpm) 265 | 266 | radius: float 267 | blade radius (m) 268 | 269 | """ 270 | 271 | return 2*np.pi*N*radius/60 272 | 273 | def set_radius(self, ht: float): 274 | """sets radius from hub to tip ratio 275 | 276 | Parameters 277 | ========== 278 | 279 | ht: float 280 | hub to tip ratio (dimensionless) 281 | """ 282 | self.radius = FlowStation.calc_radius_from_ht(ht, self.A_phys) 283 | -------------------------------------------------------------------------------- /turbodesigner/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | import numpy as np 3 | import pandas as pd 4 | from turbodesigner.stage import Stage 5 | from turbodesigner.turbomachinery import Turbomachinery 6 | from turbodesigner.units import DEG, BAR 7 | 8 | 9 | def get_hub_tip_dict_from_export( 10 | stage: Stage, 11 | table_dict: dict[str, dict], 12 | export_dict: dict, 13 | group_name: Optional[str] = None 14 | ): 15 | for (key, value) in export_dict.items(): 16 | group_name = group_name or f"Stage {stage.stage_number}" 17 | table_dict["Hub"][(group_name, key)] = value[0] if isinstance(value, np.ndarray) else value 18 | table_dict["Mean"][(group_name, key)] = np.median(value) if isinstance(value, np.ndarray) else value 19 | table_dict["Tip"][(group_name, key)] = value[-1] if isinstance(value, np.ndarray) else value 20 | return table_dict 21 | 22 | 23 | def get_hub_mean_tip_table( 24 | turbomachinery: Turbomachinery, 25 | to_export_dict: Callable[[Stage], dict], 26 | is_multi_row=False 27 | ): 28 | table = { 29 | "Hub": dict(), 30 | "Mean": dict(), 31 | "Tip": dict() 32 | } 33 | 34 | for stage in turbomachinery.stages: 35 | export_dict = to_export_dict(stage) 36 | if is_multi_row: 37 | table = get_hub_tip_dict_from_export(stage, table, export_dict["Rotor"], f"Stage {stage.stage_number} - Rotor") 38 | table = get_hub_tip_dict_from_export(stage, table, export_dict["Stator"], f"Stage {stage.stage_number} - Stator") 39 | else: 40 | table = get_hub_tip_dict_from_export(stage, table, export_dict) 41 | 42 | return pd.DataFrame(table) 43 | 44 | 45 | def get_rotor_stator_table(turbomachinery: Turbomachinery, to_export_dict: Callable[[Stage], dict]): 46 | table_dict = dict() 47 | for stage in turbomachinery.stages: 48 | export_dict = to_export_dict(stage) 49 | for (key, value) in export_dict.items(): 50 | if key not in table_dict: 51 | table_dict[key] = dict() 52 | table_dict[key][(f"Stage {stage.stage_number}", "Rotor")] = value["Rotor"] 53 | table_dict[key][(f"Stage {stage.stage_number}", "Stator")] = value["Stator"] 54 | return pd.DataFrame(table_dict) 55 | 56 | 57 | class TurbomachineryExporter: 58 | @staticmethod 59 | def turbomachinery_properties(turbomachinery: Turbomachinery): 60 | properties = { 61 | "gamma (dimensionless)": turbomachinery.gamma, 62 | "cx (m/s)": turbomachinery.cx, 63 | "N (rpm)": turbomachinery.N, 64 | "Rs (J/(kgK))": turbomachinery.Rs, 65 | "mdot (kg/s)": turbomachinery.mdot, 66 | "PR (dimensionless)": turbomachinery.PR, 67 | "Pt1 (bar)": turbomachinery.Pt*BAR, 68 | "Tt1 (K)": turbomachinery.Tt, 69 | "eta_isen (dimensionless)": turbomachinery.eta_isen, 70 | "eta_poly (dimensionless)": turbomachinery.eta_poly, 71 | "N_stg": turbomachinery.N_stg, 72 | "B_in (dimensionless)": turbomachinery.B_in, 73 | "B_out (dimensionless)": turbomachinery.B_out, 74 | "ht (dimensionless)": turbomachinery.ht, 75 | } 76 | return pd.DataFrame.from_dict(properties, orient='index') 77 | 78 | @staticmethod 79 | def stage_properties(turbomachinery: Turbomachinery): 80 | return pd.DataFrame( 81 | [ 82 | { 83 | "Stage": stage.stage_number, 84 | "Delta_Tt (K)": stage.Delta_Tt, 85 | "Delta_ht (J/kg)": stage.Delta_ht, 86 | "PR (dimensionless)": stage.PR, 87 | "R (dimensionless)": stage.R, 88 | "phi (dimensionless)": stage.phi, 89 | "psi (dimensionless)": stage.psi 90 | } 91 | for stage in turbomachinery.stages 92 | ], 93 | ) 94 | 95 | @staticmethod 96 | def stage_fluid_properties(turbomachinery: Turbomachinery): 97 | return pd.DataFrame( 98 | [ 99 | { 100 | "Stage": stage.stage_number, 101 | "Tt1 (K)": stage.inlet_flow_station.Tt, 102 | "Pt1 (bar)": stage.inlet_flow_station.Pt * BAR, 103 | "ht1 (J/kg*K)": stage.inlet_flow_station.ht, 104 | "T1 (K)": stage.inlet_flow_station.T, 105 | "P1 (bar)": stage.inlet_flow_station.P * BAR, 106 | "H1 (K)": stage.inlet_flow_station.h, 107 | "rho1 (kg/m^3)": stage.inlet_flow_station.rho, 108 | 109 | "Tt2 (K)": stage.mid_flow_station.Tt, 110 | "Pt2 (bar)": stage.mid_flow_station.Pt * BAR, 111 | "ht2 (J/kg*K)": stage.mid_flow_station.ht, 112 | "T2 (K)": stage.mid_flow_station.T, 113 | "P2 (bar)": stage.mid_flow_station.P * BAR, 114 | "H2 (K)": stage.mid_flow_station.h, 115 | "rho2 (kg/m^3)": stage.mid_flow_station.rho, 116 | } 117 | for stage in turbomachinery.stages 118 | ] 119 | ) 120 | 121 | @staticmethod 122 | def annulus(turbomachinery: Turbomachinery): 123 | return get_rotor_stator_table( 124 | turbomachinery, 125 | lambda stage: { 126 | "rh (m)": { 127 | "Rotor": stage.rotor.rh, 128 | "Stator": stage.stator.rh, 129 | }, 130 | "rt (m)": { 131 | "Rotor": stage.rotor.rt, 132 | "Stator": stage.stator.rt, 133 | }, 134 | "rm (m)": { 135 | "Rotor": stage.rotor.rm, 136 | "Stator": stage.stator.rm, 137 | } 138 | } 139 | ) 140 | 141 | @staticmethod 142 | def velocity_triangle(turbomachinery: Turbomachinery): 143 | return get_hub_mean_tip_table( 144 | turbomachinery, 145 | lambda stage: { 146 | "Vm (m/s)": stage.rotor.flow_station.Vm, 147 | "U (m/s)": stage.rotor.flow_station.U, 148 | "Vθ1 (m/s)": stage.rotor.flow_station.Vtheta, 149 | "V1 (m/s)": stage.rotor.flow_station.V, 150 | "Wθ1 (m/s)": stage.rotor.flow_station.Wtheta, 151 | "W1 (m/s)": stage.rotor.flow_station.W, 152 | "beta1 (deg)": stage.rotor.flow_station.beta * DEG, 153 | "alpha1 (deg)": stage.rotor.flow_station.alpha * DEG, 154 | "Vθ2 (m/s)": stage.stator.flow_station.Vtheta, 155 | "V2 (m/s)": stage.stator.flow_station.V, 156 | "Wθ2 (m/s)": stage.stator.flow_station.Wtheta, 157 | "W2 (m/s)": stage.stator.flow_station.W, 158 | "beta2 (deg)": stage.stator.flow_station.beta * DEG, 159 | "alpha2 (deg)": stage.stator.flow_station.alpha * DEG, 160 | } 161 | ) 162 | 163 | @staticmethod 164 | def blade_angles(turbomachinery: Turbomachinery): 165 | return get_hub_mean_tip_table( 166 | turbomachinery, 167 | lambda stage: { 168 | "Rotor": { 169 | "delta (deg)": stage.rotor.metal_angles.delta * DEG, 170 | "i (deg)": stage.rotor.metal_angles.i * DEG, 171 | "kappa1 (deg)": stage.rotor.metal_angles.kappa1 * DEG, 172 | "kappa2 (deg)": stage.rotor.metal_angles.kappa2 * DEG, 173 | "theta (deg)": stage.rotor.metal_angles.theta * DEG, 174 | "xi (deg)": stage.rotor.metal_angles.xi * DEG, 175 | }, 176 | "Stator": { 177 | "delta (deg)": stage.stator.metal_angles.delta * DEG, 178 | "i (deg)": stage.stator.metal_angles.i * DEG, 179 | "kappa1 (deg)": stage.stator.metal_angles.kappa1 * DEG, 180 | "kappa2 (deg)": stage.stator.metal_angles.kappa2 * DEG, 181 | "theta (deg)": stage.stator.metal_angles.theta * DEG, 182 | "xi (deg)": stage.stator.metal_angles.xi * DEG, 183 | } 184 | }, 185 | is_multi_row=True 186 | ) 187 | 188 | @staticmethod 189 | def blade_properties(turbomachinery: Turbomachinery): 190 | return get_rotor_stator_table( 191 | turbomachinery, 192 | lambda stage: { 193 | "sc (dimensionless)": { 194 | "Rotor": stage.rotor.sc, 195 | "Stator": stage.stator.sc, 196 | }, 197 | "AR (dimensionless)": { 198 | "Rotor": stage.rotor.AR, 199 | "Stator": stage.stator.AR, 200 | }, 201 | "tbc (dimensionless)": { 202 | "Rotor": stage.rotor.tbc, 203 | "Stator": stage.stator.tbc, 204 | }, 205 | "sigma (dimensionless)": { 206 | "Rotor": stage.rotor.sigma, 207 | "Stator": stage.stator.sigma, 208 | }, 209 | "c (m)": { 210 | "Rotor": stage.rotor.c, 211 | "Stator": stage.stator.c, 212 | }, 213 | "h (m)": { 214 | "Rotor": stage.rotor.h, 215 | "Stator": stage.stator.h, 216 | } 217 | } 218 | ) 219 | -------------------------------------------------------------------------------- /turbodesigner/blade/row.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from functools import cached_property 3 | from dataclasses import dataclass 4 | from typing import Literal, Optional 5 | from turbodesigner.airfoils import AirfoilType, DCAAirfoil 6 | from turbodesigner.blade.metal_angle_methods.johnsen_bullock import JohnsenBullockMetalAngleMethod 7 | from turbodesigner.blade.metal_angles import MetalAngles 8 | from turbodesigner.blade.vortex.common import Vortex 9 | from turbodesigner.flow_station import FlowStation 10 | from turbodesigner.attachments.firetree import FirtreeAttachment 11 | import numpy as np 12 | import numpy.typing as npt 13 | from turbodesigner.units import MM 14 | MetalAngleMethods = Literal["EqualsFlowAngles", "JohnsenBullock"] 15 | 16 | 17 | @dataclass 18 | class BladeRowCadExport: 19 | stage_number: int 20 | "stage number" 21 | 22 | disk_height: float 23 | "disk height (mm)" 24 | 25 | hub_radius: float 26 | "blade hub radius (mm)" 27 | 28 | tip_radius: float 29 | "blade hub radius (mm)" 30 | 31 | radii: npt.NDArray[np.float64] 32 | "blade station radius (mm)" 33 | 34 | airfoils: np.ndarray 35 | "airfoil coordinates for each blade radius (mm)" 36 | 37 | attachment: np.ndarray 38 | "attachment coordinates (mm)" 39 | 40 | attachment_with_tolerance: np.ndarray 41 | "attachment coordinates (mm)" 42 | 43 | attachment_height: float 44 | "attachment height (mm)" 45 | 46 | attachment_bottom_width: float 47 | "attachment bottom width (mm)" 48 | 49 | number_of_blades: int 50 | "number of blades" 51 | 52 | twist_angle: int 53 | "twist angle of blade" 54 | 55 | is_rotating: bool 56 | "whether blade is rotating or not" 57 | 58 | 59 | @dataclass 60 | class BladeRow: 61 | "calculates turbomachinery blade row" 62 | 63 | stage_number: int 64 | "stage number of blade row" 65 | 66 | stage_flow_station: FlowStation 67 | "blade stage flow station (FlowStation)" 68 | 69 | vortex: Vortex 70 | "blade vortex calculation for stagger angles" 71 | 72 | AR: float 73 | "aspect ratio (dimensionless)" 74 | 75 | sc: float 76 | "spacing to chord ratio (dimensionless)" 77 | 78 | tbc: float 79 | "max thickness to chord (dimensionless)" 80 | 81 | is_rotating: bool 82 | "whether blade is rotating or not" 83 | 84 | N_stream: int 85 | "number of streams per blade (dimensionless)" 86 | 87 | metal_angle_method: MetalAngleMethods 88 | "metal angle method" 89 | 90 | next_stage_flow_station: Optional["FlowStation"] = None 91 | "next blade row flow station" 92 | 93 | deviation_iterations: int = 20 94 | "nominal deviation iterations" 95 | 96 | def __post_init__(self): 97 | assert self.N_stream % 2 != 0, "N_stream must be an odd number" 98 | if self.is_rotating and self.next_stage_flow_station is None: 99 | self.next_stage_flow_station = self.stage_flow_station.copyStream( 100 | alpha=self.vortex.alpha(self.radii, is_rotating=False), 101 | radius=self.radii 102 | ) 103 | 104 | @cached_property 105 | def rt(self): 106 | "blade tip radius (m)" 107 | rt = self.stage_flow_station.outer_radius 108 | assert isinstance(rt, float) 109 | return rt 110 | 111 | @cached_property 112 | def rh(self): 113 | "blade hub radius (m)" 114 | rh = self.stage_flow_station.inner_radius 115 | assert isinstance(rh, float) 116 | return rh 117 | 118 | @cached_property 119 | def rm(self): 120 | "blade mean radius (m)" 121 | rm = self.stage_flow_station.radius 122 | assert isinstance(rm, float) 123 | return rm 124 | 125 | @cached_property 126 | def h(self): 127 | "height of blade (m)" 128 | return self.rt-self.rh 129 | 130 | @cached_property 131 | def h_disk(self): 132 | "disk height of blade row (m)" 133 | xi = self.metal_angles.xi[0] if self.is_rotating else self.metal_angles.xi[-1] 134 | return np.abs(self.c*np.cos(xi) * 1.25) 135 | 136 | @cached_property 137 | def c(self): 138 | "chord length (m)" 139 | return self.h/self.AR 140 | 141 | @cached_property 142 | def tb(self): 143 | "blade max thickness (m)" 144 | return self.tbc * self.c 145 | 146 | @cached_property 147 | def Z(self): 148 | "number of blades in row (dimensionless)" 149 | Z = np.ceil(2*np.pi*self.rm/(self.sc*self.c)) 150 | if not self.is_rotating and not Z % 2 == 0: 151 | Z -= 1 152 | return int(Z) 153 | 154 | @cached_property 155 | def s(self): 156 | "spacing between blades (m)" 157 | return 2*np.pi*self.rh/self.Z 158 | 159 | @cached_property 160 | def sh(self): 161 | "spacing to height (dimensionless)" 162 | return self.s/self.h 163 | 164 | @cached_property 165 | def sigma(self): 166 | "spacing between blades (dimensionless)" 167 | return 1 / self.sc 168 | 169 | @cached_property 170 | def deHaller(self): 171 | "deHaller factor (dimensionless)" 172 | return self.next_flow_station.W / self.flow_station.W 173 | 174 | @cached_property 175 | def Re(self): 176 | "Reynold's number of blade chord (dimensionless)" 177 | return self.stage_flow_station.rho * self.stage_flow_station.Vm * (self.c / self.stage_flow_station.mu) 178 | 179 | @cached_property 180 | def airfoil_type(self): 181 | # if self.stage_flow_station.MN < 0.7: 182 | # return AirfoilType.NACA65 183 | # elif self.stage_flow_station.MN >= 0.7 and self.stage_flow_station.MN <= 1.20: 184 | # return AirfoilType.DCA 185 | # raise ValueError("MN > 1.20 not currently supported") 186 | 187 | # TODO: only have support of DCA airfoil generation at the moment 188 | return AirfoilType.DCA 189 | 190 | @cached_property 191 | def metal_angles(self): 192 | if self.metal_angle_method == "JohnsenBullock": 193 | # beta1_rm: float = np.median(self.beta1) # type: ignore 194 | # beta2_rm: float = np.median(self.beta2) # type: ignore 195 | method_angle_method = JohnsenBullockMetalAngleMethod(self.beta1, self.beta2, self.sigma, self.tbc, self.airfoil_type) 196 | metal_angle_offset = method_angle_method.get_metal_angle_offset(self.deviation_iterations) 197 | return MetalAngles(self.beta1, self.beta2, metal_angle_offset.i, metal_angle_offset.delta) 198 | return MetalAngles(self.beta1, self.beta2, 0, 0) 199 | 200 | @cached_property 201 | def radii(self): 202 | "blade radii (m)" 203 | return np.linspace(self.rh, self.rt, self.N_stream, endpoint=True) 204 | 205 | @cached_property 206 | def flow_station(self): 207 | "flow station (FlowStation)" 208 | return self.stage_flow_station.copyStream( 209 | alpha=self.vortex.alpha(self.radii, self.is_rotating), 210 | radius=self.radii 211 | ) 212 | 213 | @cached_property 214 | def next_flow_station(self): 215 | "next flow station (FlowStation)" 216 | assert self.next_stage_flow_station is not None 217 | return self.next_stage_flow_station.copyStream( 218 | alpha=self.vortex.alpha(self.radii, self.is_rotating), 219 | radius=self.radii 220 | ) 221 | 222 | @cached_property 223 | def beta1(self): 224 | "blade inlet flow angle (rad)" 225 | if self.is_rotating: 226 | return self.flow_station.beta # beta1 227 | return self.flow_station.alpha # alpha2 228 | 229 | @cached_property 230 | def beta2(self): 231 | "blade outlet flow angle (rad)" 232 | if self.is_rotating: 233 | assert self.next_stage_flow_station is not None 234 | return self.next_stage_flow_station.beta # beta2 235 | 236 | assert self.next_stage_flow_station is not None or self.vortex.Rm == 0.5, "next_flow_station needs to be defined or Rc=0.5" 237 | if self.next_stage_flow_station is not None: 238 | return self.next_stage_flow_station.alpha # alpha3 239 | return self.vortex.alpha(self.radii, is_rotating=not self.is_rotating) # alpha3 240 | 241 | @cached_property 242 | def DF(self): 243 | "diffusion factor (dimensionless)" 244 | return 1-(np.cos(self.beta1)/np.cos(self.beta2))+(np.cos(self.beta1)/2)*self.sigma*(np.tan(self.beta1)-np.tan(self.beta2)) 245 | 246 | @cached_property 247 | def airfoils(self): 248 | r0 = self.tb * 0.15 249 | # TODO: optimize this with Numba 250 | return [ 251 | DCAAirfoil(self.c, self.metal_angles.theta[i], r0, self.tb, self.metal_angles.xi[i]) 252 | for i in range(self.N_stream) 253 | ] 254 | 255 | @cached_property 256 | def attachment(self): 257 | max_length = 0.75*self.s if self.is_rotating else 1*self.s 258 | attachment = FirtreeAttachment( 259 | gamma=np.radians(40), 260 | beta=np.radians(40), 261 | ll=0.15*self.s, 262 | lu=0.2*self.s, 263 | Ri=0.05*self.s, 264 | Ro=0.025*self.s, 265 | R_dove=0.05*self.s, 266 | max_length=max_length, 267 | num_stages=2, 268 | disk_radius=self.rh, 269 | tolerance=0.0006, # m, 0.5 mm 270 | include_top_arc=self.is_rotating 271 | ) 272 | return attachment 273 | 274 | def to_cad_export(self): 275 | return BladeRowCadExport( 276 | stage_number=self.stage_number, 277 | disk_height=self.h_disk * MM, 278 | hub_radius=self.rh * MM, 279 | tip_radius=self.rt * MM, 280 | radii=self.radii * MM, 281 | airfoils=np.array([airfoil.get_coords() for airfoil in self.airfoils]) * MM, 282 | attachment=self.attachment.coords * MM, 283 | attachment_with_tolerance=self.attachment.coords_with_tolerance * MM, 284 | attachment_height=self.attachment.height * MM, 285 | attachment_bottom_width=self.attachment.bottom_width * MM, 286 | number_of_blades=self.Z, 287 | twist_angle=np.degrees(self.metal_angles.xi[-1]-self.metal_angles.xi[0]), 288 | is_rotating=self.is_rotating, 289 | ) 290 | -------------------------------------------------------------------------------- /turbodesigner/cad/shaft.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from functools import cached_property 3 | from typing import Optional 4 | import cadquery as cq 5 | from turbodesigner.cad.common import ExtendedWorkplane, FastenerPredicter 6 | from turbodesigner.cad.blade import BladeCadModel, BladeCadModelSpecification 7 | from turbodesigner.stage import StageCadExport 8 | from turbodesigner.turbomachinery import TurbomachineryCadExport 9 | 10 | 11 | @dataclass 12 | class ShaftCadModelSpecification: 13 | is_simple: bool = False 14 | "whether to include attachment or fuse into shaft (bool)" 15 | 16 | stage_connect_length_to_heatset_thickness: float = 2.00 17 | "shaft stage connect to hub radius (dimensionless)" 18 | 19 | stage_connect_height_to_screw_head_diameter: float = 1.75 20 | "shaft stage connect to disk height (dimensionless)" 21 | 22 | stage_connect_padding_to_attachment_height: float = 1.25 23 | "shaft stage connect padding to attachment height (dimensionless)" 24 | 25 | stage_connect_heatset_diameter_to_disk_height: float = 0.05 26 | "shaft stage connect heatset diameter to disk height (dimensionless)" 27 | 28 | stage_connect_screw_quantity: int = 4 29 | "shaft stage connect screw quantity (dimensionless)" 30 | 31 | stage_connect_clearance: float = 0.5 32 | "shaft stage connect circular clearance (mm)" 33 | 34 | 35 | @dataclass 36 | class ShaftCadModel: 37 | stage: StageCadExport 38 | "turbomachinery stage" 39 | 40 | next_stage: Optional[StageCadExport] = None 41 | "next turbomachinery stage" 42 | 43 | spec: ShaftCadModelSpecification = field(default_factory=ShaftCadModelSpecification) 44 | "shaft cad model specification" 45 | 46 | def __post_init__(self): 47 | self.stage_connect_heatset = FastenerPredicter.predict_heatset( 48 | target_diameter=self.stage.rotor.disk_height*self.spec.stage_connect_heatset_diameter_to_disk_height, 49 | ) 50 | self.stage_connect_length = self.stage_connect_heatset.nut_thickness * self.spec.stage_connect_length_to_heatset_thickness 51 | 52 | self.blade_cad_model = BladeCadModel( 53 | self.stage.rotor, 54 | spec=BladeCadModelSpecification( 55 | not self.spec.is_simple, 56 | screw_length_padding=self.stage_connect_length 57 | ) 58 | ) 59 | 60 | # TODO: Just used to get head diameter, length doesn't matter 61 | self.stage_connect_screw = FastenerPredicter.predict_screw(target_diameter=self.stage_connect_heatset.thread_diameter) 62 | 63 | self.stage_connect_height = self.stage_connect_screw.head_diameter * self.spec.stage_connect_height_to_screw_head_diameter 64 | stage_connect_padding = self.stage.rotor.attachment_height * self.spec.stage_connect_padding_to_attachment_height 65 | self.stage_connect_outer_radius = self.stage.rotor.hub_radius-stage_connect_padding 66 | self.stage_connect_inner_radius = self.stage_connect_outer_radius-self.stage_connect_length 67 | 68 | if self.next_stage: 69 | self.next_stage_shaft_cad_model = ShaftCadModel(self.next_stage, spec=self.spec) 70 | self.next_stage_stage_connect_screw = FastenerPredicter.predict_screw( 71 | target_diameter=self.next_stage_shaft_cad_model.stage_connect_heatset.thread_diameter, 72 | target_length=self.next_stage_shaft_cad_model.stage_connect_heatset.nut_thickness + (self.stage.stator.hub_radius - self.next_stage_shaft_cad_model.stage_connect_outer_radius) 73 | ) 74 | 75 | @cached_property 76 | def shaft_stage_sector(self): 77 | sector_angle = 360 / self.stage.rotor.number_of_blades 78 | sector_cut_profile = ( 79 | cq.Workplane('XZ') 80 | .transformed(rotate=(0, sector_angle/2, 0)) 81 | .rect(self.stage.stator.hub_radius, self.stage.stage_height*2, centered=False) 82 | .revolve(sector_angle*(self.stage.rotor.number_of_blades-1), (0, 0, 0), (0, 1, 0)) 83 | ) 84 | 85 | shaft_profile = self.shaft_stage_assembly.objects["Stage Shaft"].obj 86 | assert shaft_profile is not None and isinstance(shaft_profile, cq.Workplane) 87 | shaft_sector_profile = ( 88 | shaft_profile 89 | .cut(sector_cut_profile) 90 | ) 91 | 92 | return shaft_sector_profile 93 | 94 | @cached_property 95 | def shaft_stage_assembly(self): 96 | base_assembly = cq.Assembly() 97 | blade_assembly = cq.Assembly() 98 | fastener_assembly = cq.Assembly() 99 | 100 | shaft_profile = ( 101 | ExtendedWorkplane("XY") 102 | # Stator Disk 103 | .circle(self.stage.stator.hub_radius) 104 | .extrude(self.stage.stator.disk_height+self.stage.stage_gap) 105 | 106 | # Row Gap Transition Disk 107 | .faces(">Z") 108 | .workplane() 109 | .truncated_cone( 110 | start_radius=self.stage.stator.hub_radius, 111 | end_radius=self.stage.rotor.hub_radius, 112 | height=self.stage.row_gap 113 | ) 114 | 115 | # Rotor Disk 116 | .faces(">Z") 117 | .workplane() 118 | .circle(self.stage.rotor.hub_radius) 119 | .extrude(self.stage.rotor.disk_height) 120 | ) 121 | 122 | if not self.spec.is_simple: 123 | shaft_profile = ( 124 | shaft_profile 125 | 126 | # Cut Attachments - TODO: make this operation faster 127 | .faces(">Z") 128 | .workplane() 129 | .polarArray(1.0001*self.stage.rotor.hub_radius, 0, 360, self.stage.rotor.number_of_blades) 130 | .eachpoint( 131 | lambda loc: ( 132 | cq.Workplane("XY") 133 | .polyline(self.stage.rotor.attachment_with_tolerance) # type: ignore 134 | .close() 135 | .rotate((0, 0, 0), (0, 0, 1), 270) 136 | ).val().located(loc), True) # type: ignore 137 | .cutBlind(-self.stage.rotor.disk_height) 138 | 139 | # Shaft Male Connect 140 | .faces(">Z") 141 | .workplane() 142 | .circle(self.stage_connect_outer_radius) 143 | .extrude(self.stage_connect_height) 144 | 145 | # Shaft Connect Hole 146 | .faces(">Z") 147 | .workplane() 148 | .circle(self.stage_connect_inner_radius*1.001) 149 | .cutThruAll() 150 | 151 | # Blade Lock Screws 152 | .faces(">Z") 153 | .workplane(offset=-self.stage_connect_height-self.blade_cad_model.lock_screw.head_diameter*1.5) 154 | .polarArray(self.stage_connect_inner_radius, 0, 360, self.stage.rotor.number_of_blades) 155 | .mutatePoints(lambda loc: loc * cq.Location(cq.Vector(0, 0, 0), cq.Vector(0, 1, 0), -90)) 156 | .clearanceHole(self.blade_cad_model.lock_screw, fit="Loose", baseAssembly=fastener_assembly) 157 | 158 | # Shaft Connect Heatsets 159 | .faces(">Z") 160 | .workplane(offset=-self.stage_connect_height/2) 161 | .polarArray(self.stage_connect_outer_radius, 0, 360, self.spec.stage_connect_screw_quantity) 162 | .mutatePoints(lambda loc: loc * cq.Location(cq.Vector(0, 0, 0), cq.Vector(0, 1, 0), 90)) 163 | .insertHole(self.stage_connect_heatset, fit="Loose", baseAssembly=fastener_assembly, depth=self.stage_connect_heatset.nut_thickness) 164 | ) 165 | if self.next_stage_shaft_cad_model: 166 | shaft_profile = ( 167 | # Next Shaft Female Connect 168 | shaft_profile 169 | .faces("Z") 108 | .workplane() 109 | .truncated_cone( 110 | start_radius=self.stage.stator.tip_radius, 111 | end_radius=self.stage.rotor.tip_radius, 112 | height=self.stage.row_gap 113 | ) 114 | 115 | # Rotor Disk 116 | .faces(">Z") 117 | .workplane() 118 | .circle(self.stage.rotor.tip_radius) 119 | .extrude(self.stage.rotor.disk_height) 120 | ) 121 | 122 | casing_profile = ( 123 | ExtendedWorkplane("XY") 124 | .circle(self.first_stage.rotor.tip_radius + self.casing_thickness) 125 | .extrude(self.half_connect_height) 126 | ) 127 | 128 | if not self.spec.is_simple: 129 | casing_profile = ( 130 | casing_profile 131 | 132 | # Stage Shaft Connect 133 | .faces("Z") 197 | .workplane() 198 | .circle(self.previous_stage_casing_cad_model.stage_connect_outer_radius) 199 | .circle(self.previous_stage_casing_cad_model.stage_connect_inner_radius) 200 | .cutBlind(-self.previous_stage_casing_cad_model.stage_connect_height) 201 | 202 | # Previous Stage Shaft Connect Heatsets 203 | .faces(">Z") 204 | .workplane(offset=-self.previous_stage_casing_cad_model.stage_connect_height/2) 205 | .transformed(rotate=(0, 0, 45)) 206 | .polarArray(self.previous_stage_casing_cad_model.stage_connect_inner_radius, 0, 360, self.spec.stage_connect_screw_quantity) 207 | .mutatePoints(lambda loc: loc * cq.Location(cq.Vector(0, 0, 0), cq.Vector(0, 1, 0), 90)) 208 | .insertHole(self.previous_stage_casing_cad_model.stage_connect_heatset, fit="Loose", baseAssembly=fastener_assembly, depth=self.previous_stage_casing_cad_model.stage_connect_heatset.nut_thickness) 209 | ) 210 | 211 | if not self.spec.is_simple: 212 | left_casing_profile = ( 213 | casing_profile 214 | .transformed(rotate=(90, -45, 0)) 215 | .split(keepBottom=True) 216 | .faces("