diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a933576..7032581 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [o-murphy] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +#github: [o-murphy] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] #patreon: o-murphy open_collective: o-murphy # Replace with a single Open Collective username ko_fi: o_murphy diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..5139ed9 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,25 @@ +name: Mypy typing check + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev,charts] + python -m pip install pandas-stubs matplotlib-stubs + + - name: Analysing the code with mypy + run: | + mypy ./py_ballisticcalc diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 6804391..779ee47 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -17,8 +17,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pylint + python -m pip install -e .[dev] + - name: Analysing the code with pylint run: | - #pylint $(git ls-files '*.py') pylint ./py_ballisticcalc diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4fa6cb5..0670fdc 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -27,7 +27,8 @@ jobs: sudo apt-get install build-essential -y python -m pip install --upgrade pip - python -m pip install setuptools pytest cython + python -m pip install setuptools cython + python -m pip install -e .[dev] - name: Check Python version and install tomli if less than 3.11 run: | diff --git a/.github/workflows/python-publish-test.yml b/.github/workflows/python-publish-test.yml index e5ecf5d..34836ee 100644 --- a/.github/workflows/python-publish-test.yml +++ b/.github/workflows/python-publish-test.yml @@ -29,7 +29,8 @@ jobs: sudo apt-get install build-essential -y python -m pip install --upgrade pip - python -m pip install setuptools pytest cython + python -m pip install setuptools cython + python -m pip install -e .[dev] - name: Run unittest tests in pure python mode run: | @@ -71,17 +72,18 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine + python -m pip install -e .[dev] + python -m pip install twine - name: Build package run: python -m build - - name: Publish package to Test PyPI + - name: Publish package to PyPI run: | - python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* --skip-existing --verbose --non-interactive + python -m twine upload dist/* --skip-existing --verbose --non-interactive env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} deploy-exts: needs: build @@ -103,7 +105,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine + python -m pip install -e .[dev] + python -m pip install twine - name: Build extensions package run: | @@ -111,9 +114,9 @@ jobs: python -m build --outdir ../dist cd .. - - name: Publish package to Test PyPI + - name: Publish package to PyPI run: | - python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* --skip-existing --verbose --non-interactive + python -m twine upload dist/* --skip-existing --verbose --non-interactive env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index c30231b..b1c584f 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -40,7 +40,8 @@ jobs: sudo apt-get install build-essential -y python -m pip install --upgrade pip - python -m pip install setuptools pytest cython + python -m pip install setuptools cython + python -m pip install -e .[dev] - name: Run unittest tests in pure python mode run: | @@ -82,17 +83,17 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine + python -m pip install -e .[dev] + python -m pip install twine - name: Build package run: python -m build - - name: Publish package to PyPI - run: | - python -m twine upload dist/* --skip-existing --verbose --non-interactive - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + - name: Publish package to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} deploy-exts: needs: [build, deploy] @@ -114,7 +115,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine + python -m pip install -e .[dev] + python -m pip install twine - name: Build extensions package run: | @@ -122,9 +124,8 @@ jobs: python -m build --outdir ../dist cd .. - - name: Publish package to PyPI - run: | - python -m twine upload dist/* --skip-existing --verbose --non-interactive - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + - name: Publish package to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/Manifest.in b/Manifest.in index 8abf500..166d01f 100644 --- a/Manifest.in +++ b/Manifest.in @@ -2,3 +2,5 @@ prune py_ballisticcalc_exts prune tests include py.typed include Example.ipynb +include LICENSE +include README.md diff --git a/examples/kanik_50bmg_aid_shooting.py b/examples/kanik_50bmg_aid_shooting.py new file mode 100644 index 0000000..52306d4 --- /dev/null +++ b/examples/kanik_50bmg_aid_shooting.py @@ -0,0 +1,92 @@ +"""Example of library usage""" +import math + +from py_ballisticcalc import * + + +# set global library settings +PreferredUnits.velocity = Velocity.MPS +PreferredUnits.adjustment = Angular.Mil +PreferredUnits.temperature = Temperature.Celsius +PreferredUnits.distance = Distance.Meter +PreferredUnits.sight_height = Distance.Centimeter +PreferredUnits.drop = Distance.Centimeter + + +# set_global_use_powder_sensitivity(True) # enable muzzle velocity correction my powder temperature + +# define params with default prefer_units +length = Distance.Inch(2.3) + +weapon = Weapon(sight_height=Unit.Centimeter(9.5), twist=15) +dm = DragModel(0.62, TableG1, 661, 0.51) +ammo = Ammo(dm, 900) + +zero_atmo = Atmo( + altitude=Unit.Meter(150), + pressure=Unit.hPa(1000), + temperature=Unit.Celsius(15), + humidity=50 +) +zero = Shot(weapon=weapon, ammo=ammo, atmo=zero_atmo) +zero_distance = Distance.Meter(500) + +calc = Calculator() +calc.set_weapon_zero(zero, zero_distance) + +shot = Shot(look_angle=Unit.Degree(20), weapon=weapon, ammo=ammo, atmo=zero_atmo) +shot_result = calc.fire(shot, Distance.Meter(1000), Distance.Meter(100)) + +from pprint import pprint +fieldsss = TrajectoryData._fields + +# for p in shot_result: +# +# # table = [{fieldsss[i]: it} for i, it in enumerate(p.in_def_units())] +# # +# # pprint(table) +# print(p.in_def_units(), ",") + + +def calculate_lead_angle(target_speed, target_size, bullet_speed, initial_distance, time_of_flight): + """ + Розрахунок кута упередження маючи швидкість і розмір цілі, швидкість кулі, початкову відстань до цілі. + + :param target_speed: Швидкість цілі (м/с) + :param target_size: Довжина цілі (м) + :param bullet_speed: Швидкість кулі (м/с) + :param initial_distance: Початкова відстань до цілі (м) + :return: Кут упередження в градусах + """ + # Час польоту кулі + # time_of_flight = initial_distance / bullet_speed + + # Відстань, яку пройде ніс цілі + # distance_nose_travel = target_speed * time_of_flight + target_size + distance_nose_travel = target_speed * time_of_flight + target_size + + # Обчислення кута упередження + lead_angle = math.atan(distance_nose_travel / initial_distance) + + # Перетворення кута в градуси + lead_angle_degrees = math.degrees(lead_angle) + + return lead_angle_degrees + +target_speed = 50 +target_size = 3 +max_lead_angle = 6.21 + + +for p in shot_result: + + table = [{fieldsss[i]: it} for i, it in enumerate(p.formatted())] + + pprint(table) + +# for p in shot_result[1:]: +# values = p.in_def_units() +# a = calculate_lead_angle(target_speed, target_size, values[2], values[1], values[0]) +# # if a > max_lead_angle: +# # continue +# print(f"{a:.2f}°, {values[1]:.0f}m, {values[2]:.0f}m/s") diff --git a/py_ballisticcalc/__init__.py b/py_ballisticcalc/__init__.py index 85fd01e..5d47eb0 100644 --- a/py_ballisticcalc/__init__.py +++ b/py_ballisticcalc/__init__.py @@ -9,26 +9,26 @@ __credits__ = ["o-murphy", "dbookstaber"] import os +import sys +from typing import Dict, Union, Optional from .backend import * -from .drag_tables import * +from .conditions import * from .drag_model import * +from .drag_tables import * from .interface import * from .logger import logger -from .trajectory_data import * -from .conditions import * from .munition import * +from .trajectory_data import * from .unit import * -from typing import Dict, Union, Optional -try: - import tomllib -except ImportError: +if sys.version_info[:2] < (3, 11): import tomli as tomllib +else: + import tomllib def _load_config(filepath=None): - def find_pybc_toml(start_dir=os.getcwd()): """ Search for the pyproject.toml file starting from the specified directory. @@ -94,7 +94,6 @@ def _basic_config(filename=None, max_calc_step_size: Optional[Union[float, Distance]] = None, use_powder_sensitivity: bool = False, preferred_units: Optional[Dict[str, Unit]] = None): - """ Method to load preferred units from file or Mapping """ @@ -140,7 +139,6 @@ def _basic_config(filename=None, 'Ammo', 'Sight', 'Unit', - 'UnitType', 'UnitAliases', 'UnitAliasError', 'UnitTypeError', @@ -156,9 +154,18 @@ def _basic_config(filename=None, 'Pressure', 'Energy', 'Weight', - 'Dimension', 'PreferredUnits', - 'get_drag_tables_names' + 'get_drag_tables_names', ] -__all__ += ["TableG%s" % n for n in (1, 7, 2, 5, 6, 8, 'I', 'S')] +# __all__ += ["TableG%s" % n for n in (1, 7, 2, 5, 6, 8, 'I', 'S')] +__all__ += [ + 'TableG1', + 'TableG7', + 'TableG2', + 'TableG5', + 'TableG6', + 'TableG8', + 'TableGI', + 'TableGS' +] diff --git a/py_ballisticcalc/backend.py b/py_ballisticcalc/backend.py index 52f4476..b95a79f 100644 --- a/py_ballisticcalc/backend.py +++ b/py_ballisticcalc/backend.py @@ -5,7 +5,7 @@ # try to use cython based backend try: - from py_ballisticcalc_exts import (TrajectoryCalc, + from py_ballisticcalc_exts import (TrajectoryCalc, # type: ignore get_global_max_calc_step_size, get_global_use_powder_sensitivity, set_global_max_calc_step_size, @@ -14,7 +14,7 @@ logger.debug("Binary modules found, running in binary mode") except ImportError as error: - from .trajectory_calc import (TrajectoryCalc, + from .trajectory_calc import (TrajectoryCalc, # type: ignore get_global_max_calc_step_size, get_global_use_powder_sensitivity, set_global_max_calc_step_size, diff --git a/py_ballisticcalc/conditions.py b/py_ballisticcalc/conditions.py index c834cea..2d1fd11 100644 --- a/py_ballisticcalc/conditions.py +++ b/py_ballisticcalc/conditions.py @@ -1,14 +1,11 @@ """Classes to define zeroing or current environment conditions""" import math -from dataclasses import dataclass, field -from typing import List, Union, Optional, Tuple +from dataclasses import dataclass +from typing_extensions import List, Union, Optional, Tuple -from .munition import Weapon, Ammo -# from .settings import Settings as Set -from .unit import Distance, Velocity, Temperature, Pressure, Angular, Dimension, PreferredUnits - -__all__ = ('Atmo', 'Wind', 'Shot') +from py_ballisticcalc.munition import Weapon, Ammo +from py_ballisticcalc.unit import Distance, Velocity, Temperature, Pressure, Angular, PreferredUnits cStandardHumidity: float = 0.0 # Relative Humidity cPressureExponent: float = 5.255876 # =g*M/R*L @@ -36,33 +33,37 @@ @dataclass -class Atmo(PreferredUnits.Mixin): # pylint: disable=too-many-instance-attributes +class Atmo: # pylint: disable=too-many-instance-attributes """Atmospheric conditions and density calculations""" - altitude: Union[float, Pressure] = Dimension(prefer_units="distance") - pressure: Union[float, Pressure] = Dimension(prefer_units="pressure") - temperature: Union[float, Temperature] = Dimension(prefer_units="temperature") - - humidity: float = 0.0 # Relative humidity [0% to 100%] - density_ratio: float = field(init=False) # Density / cStandardDensity - mach: Velocity = field(init=False) # Mach 1 in reference atmosphere - _mach1: float = field(init=False) # Mach 1 in reference atmosphere in fps - _a0: float = field(init=False) # Initial reference altitude (ft) - _t0: float = field(init=False) # Temperature given at reference altitude °F - _p0: float = field(init=False) # Barometric pressure (sea level) - _ta: float = field(init=False) # Standard temperature at reference altitude °F - - def __post_init__(self) -> None: + altitude: Distance + pressure: Pressure + temperature: Temperature + humidity: float # Relative humidity [0% to 100%] + + density_ratio: float + mach: Velocity + _mach1: float + _a0: float + _t0: float + _p0: float + _ta: float + + def __init__(self, + altitude: Optional[Union[float, Distance]] = None, + pressure: Optional[Union[float, Pressure]] = None, + temperature: Optional[Union[float, Temperature]] = None, + humidity: float = 0.0): + + self.humidity = humidity or 0.0 if self.humidity > 1: - self.humidity = self.humidity / 100.0 + self.humidity = humidity / 100.0 if not 0 <= self.humidity <= 1: self.humidity = 0.0 - if not self.altitude: - self.altitude = Distance.Foot(0) - if not self.temperature: - self.temperature = Atmo.standard_temperature(self.altitude) - if not self.pressure: - self.pressure = Atmo.standard_pressure(self.altitude) + + self.altitude = PreferredUnits.distance(altitude or 0) + self.pressure = PreferredUnits.pressure(pressure or Atmo.standard_pressure(self.altitude)) + self.temperature = PreferredUnits.temperature(temperature or Atmo.standard_temperature(self.altitude)) self._t0 = self.temperature >> Temperature.Fahrenheit self._p0 = self.pressure >> Pressure.InHg @@ -192,28 +193,32 @@ def get_density_factor_and_mach_for_altitude(self, altitude: float) -> Tuple[flo @dataclass -class Wind(PreferredUnits.Mixin): +class Wind: """ Wind direction and velocity by down-range distance. direction_from = 0 is blowing from behind shooter. direction_from = 90 degrees is blowing from shooter's left towards right. """ - velocity: Union[float, Velocity] = Dimension(prefer_units='velocity') - direction_from: Union[float, Angular] = Dimension(prefer_units='angular') - until_distance: Union[float, Distance] = Dimension(prefer_units='distance') - MAX_DISTANCE_FEET = 1e8 + velocity: Velocity + direction_from: Angular + until_distance: Distance + MAX_DISTANCE_FEET: float = 1e8 - def __post_init__(self) -> None: - if not self.until_distance: - self.until_distance = Distance.Foot(Wind.MAX_DISTANCE_FEET) - if not self.direction_from or not self.velocity: - self.direction_from = 0 - self.velocity = 0 + def __init__(self, + velocity: Optional[Union[float, Velocity]] = None, + direction_from: Optional[Union[float, Angular]] = None, + until_distance: Optional[Union[float, Distance]] = None, + *, + max_distance_feet: Optional[float] = 1e8): + self.MAX_DISTANCE_FEET = max_distance_feet or 1e8 + self.velocity = PreferredUnits.velocity(velocity or 0) + self.direction_from = PreferredUnits.angular(direction_from or 0) + self.until_distance = PreferredUnits.distance(until_distance or Distance.Foot(Wind.MAX_DISTANCE_FEET)) @dataclass -class Shot(PreferredUnits.Mixin): +class Shot: """ Stores shot parameters for the trajectory calculation. @@ -227,14 +232,32 @@ class Shot(PreferredUnits.Mixin): from the vertical plane into the horizontal plane by sine(cant_angle) """ - look_angle: Union[float, Angular] = Dimension(prefer_units='angular') - relative_angle: Union[float, Angular] = Dimension(prefer_units='angular') - cant_angle: Union[float, Angular] = Dimension(prefer_units='angular') - - weapon: Optional[Weapon] = field(default=None) - ammo: Optional[Ammo] = field(default=None) - atmo: Optional[Atmo] = field(default=None) - winds: List[Wind] = field(default=None) + look_angle: Angular + relative_angle: Angular + cant_angle: Angular + + weapon: Weapon + ammo: Ammo + atmo: Atmo + winds: List[Wind] + + def __init__(self, + weapon: Weapon, + ammo: Ammo, + look_angle: Optional[Union[float, Angular]] = None, + relative_angle: Optional[Union[float, Angular]] = None, + cant_angle: Optional[Union[float, Angular]] = None, + + atmo: Optional[Atmo] = None, + winds: Optional[List[Wind]] = None + ): + self.look_angle = PreferredUnits.angular(look_angle or 0) + self.relative_angle = PreferredUnits.angular(relative_angle or 0) + self.cant_angle = PreferredUnits.angular(cant_angle or 0) + self.weapon = weapon + self.ammo = ammo + self.atmo = atmo or Atmo.icao() + self.winds = winds or [Wind()] # NOTE: Calculator assumes that winds are sorted by Wind.until_distance (ascending) @@ -253,14 +276,5 @@ def barrel_azimuth(self) -> Angular: * ((self.weapon.zero_elevation >> Angular.Radian) + (self.relative_angle >> Angular.Radian))) - def __post_init__(self) -> None: - if not self.look_angle: - self.look_angle = 0 - if not self.relative_angle: - self.relative_angle = 0 - if not self.cant_angle: - self.cant_angle = 0 - if not self.atmo: - self.atmo = Atmo.icao() - if not self.winds: - self.winds = [Wind()] + +__all__ = ('Atmo', 'Wind', 'Shot') diff --git a/py_ballisticcalc/drag_model.py b/py_ballisticcalc/drag_model.py index 12e85c8..f532cb5 100644 --- a/py_ballisticcalc/drag_model.py +++ b/py_ballisticcalc/drag_model.py @@ -2,11 +2,9 @@ import math from dataclasses import dataclass, field -from typing import Union, List +from typing_extensions import Union, List, Dict, Tuple, Optional -from .unit import Weight, Distance, Velocity, PreferredUnits, Dimension - -__all__ = ('DragModel', 'DragDataPoint', 'BCPoint', 'DragModelMultiBC') +from py_ballisticcalc.unit import Weight, Distance, Velocity, PreferredUnits cSpeedOfSoundMetric = 340.0 # Speed of sound in standard atmosphere, in m/s @@ -21,20 +19,34 @@ class DragDataPoint: @dataclass(order=True) class BCPoint: """For multi-bc drag models, designed to sort by Mach ascending""" - BC: float = field(compare=False) # Ballistic Coefficient at the given Mach number - Mach: float = field(default=-1, compare=True) # Velocity in Mach units - # Velocity only referenced if Mach number not supplied - V: Velocity = Dimension(prefer_units='velocity', compare=False) - - def __post_init__(self): - # If Mach not defined then convert V using standard atmosphere - if self.Mach < 0: - self.Mach = (self.V >> Velocity.MPS) / cSpeedOfSoundMetric - if self.BC <= 0: + + BC: float = field(compare=False) + Mach: float = field(compare=True) + V: Optional[Velocity] = field(compare=False) + + def __init__(self, + BC: float, + Mach: Optional[float] = None, + V: Optional[Union[float, Velocity]] = None): + + if BC <= 0: raise ValueError('Ballistic coefficient must be positive') + if Mach and V: + raise ValueError("You cannot specify both 'Mach' and 'V' at the same time") -DragTableDataType = [list[dict[str, float]], list[DragDataPoint]] + if not Mach and not V: + raise ValueError("One of 'Mach' and 'V' must be specified") + + self.BC = BC + self.V = PreferredUnits.velocity(V or 0) + if V: + self.Mach = (self.V >> Velocity.MPS) / cSpeedOfSoundMetric + elif Mach: + self.Mach = Mach + + +DragTableDataType = Union[List[Dict[str, float]], List[DragDataPoint]] class DragModel: @@ -59,7 +71,7 @@ def __init__(self, bc: float, if len(drag_table) <= 0: # TODO: maybe have to require minimum size, cause few values don't give a valid result raise ValueError('Received empty drag table') - elif bc <= 0: + if bc <= 0: raise ValueError('Ballistic coefficient must be positive') self.drag_table = make_data_points(drag_table) @@ -84,11 +96,17 @@ def _get_sectional_density(self) -> float: return sectional_density(w, d) -def make_data_points(drag_table: DragTableDataType) -> list[DragDataPoint]: +def make_data_points(drag_table: DragTableDataType) -> List[DragDataPoint]: """Convert drag table from list of dictionaries to list of DragDataPoints""" - if isinstance(drag_table[0], DragDataPoint): - return drag_table - return [DragDataPoint(point['Mach'], point['CD']) for point in drag_table] + try: + return [ + point if isinstance(point, DragDataPoint) else DragDataPoint(point['Mach'], point['CD']) + for point in drag_table + ] + except (KeyError, TypeError) as exc: + raise TypeError( + "All items in drag_table must be of type DragDataPoint or dict with 'Mach' and 'CD' keys" + ) from exc def sectional_density(weight: float, diameter: float) -> float: @@ -134,9 +152,9 @@ def DragModelMultiBC(bc_points: List[BCPoint], return DragModel(bc, drag_table, weight, diameter, length) -def linear_interpolation(x: Union[list[float], tuple[float]], - xp: Union[list[float], tuple[float]], - yp: Union[list[float], tuple[float]]) -> Union[list[float], tuple[float]]: +def linear_interpolation(x: Union[List[float], Tuple[float]], + xp: Union[List[float], Tuple[float]], + yp: Union[List[float], Tuple[float]]) -> Union[List[float], Tuple[float]]: """Piecewise linear interpolation :param x: List of points for which we want interpolated values :param xp: List of existing points (x coordinate), *sorted in ascending order* @@ -168,3 +186,6 @@ def linear_interpolation(x: Union[list[float], tuple[float]], if left == right: y.append(yp[left]) return y + + +__all__ = ('DragModel', 'DragDataPoint', 'BCPoint', 'DragModelMultiBC') diff --git a/py_ballisticcalc/drag_tables.py b/py_ballisticcalc/drag_tables.py index 62f49c6..413c5eb 100644 --- a/py_ballisticcalc/drag_tables.py +++ b/py_ballisticcalc/drag_tables.py @@ -670,8 +670,17 @@ def get_drag_tables_names(): - return ["TableG%s" % n for n in (1, 7, 2, 5, 6, 8, 'I', 'S')] + return [f"TableG{n}" for n in (1, 7, 2, 5, 6, 8, 'I', 'S')] __all__ = ['get_drag_tables_names'] -__all__ += get_drag_tables_names() +__all__ += [ + 'TableG1', + 'TableG7', + 'TableG2', + 'TableG5', + 'TableG6', + 'TableG8', + 'TableGI', + 'TableGS' +] diff --git a/py_ballisticcalc/example.py b/py_ballisticcalc/example.py index a331d41..f649a27 100644 --- a/py_ballisticcalc/example.py +++ b/py_ballisticcalc/example.py @@ -14,7 +14,7 @@ # Define ammunition parameters weight, diameter = 168, 0.308 # Numbers will be assumed to use default Settings.Units -length = Distance.Inch(1.282) # Or declare prefer_units explicitly +length: Distance = Distance.Inch(1.282) # Or declare prefer_units explicitly dm = DragModel(0.223, TableG7, weight, diameter, length) ammo = Ammo(dm, 2750, 15) ammo.calc_powder_sens(2723, 0) diff --git a/py_ballisticcalc/interface.py b/py_ballisticcalc/interface.py index 2eee955..6c1f34b 100644 --- a/py_ballisticcalc/interface.py +++ b/py_ballisticcalc/interface.py @@ -1,22 +1,19 @@ """Implements basic interface for the ballistics calculator""" from dataclasses import dataclass, field -from typing import Union, Optional +from typing_extensions import Union -from .conditions import Shot -# pylint: disable=import-error,no-name-in-module,wildcard-import,unused-wildcard-import -from .backend import * -from .trajectory_data import HitResult -from .unit import Angular, Distance, PreferredUnits - - -__all__ = ('Calculator',) +from py_ballisticcalc.conditions import Shot +# pylint: disable=import-error,no-name-in-module,wildcard-import +from py_ballisticcalc.backend import TrajectoryCalc +from py_ballisticcalc.trajectory_data import HitResult +from py_ballisticcalc.unit import Angular, Distance, PreferredUnits @dataclass class Calculator: """Basic interface for the ballistics calculator""" - _calc: Optional[TrajectoryCalc] = field(init=False, repr=False, compare=False, default=None) + _calc: TrajectoryCalc = field(init=False, repr=False, compare=False) @property def cdm(self): @@ -65,3 +62,6 @@ def fire(self, shot: Shot, trajectory_range: Union[float, Distance], self._calc = TrajectoryCalc(shot.ammo) data = self._calc.trajectory(shot, trajectory_range, step, extra_data) return HitResult(shot, data, extra_data) + + +__all__ = ('Calculator',) diff --git a/py_ballisticcalc/logger.py b/py_ballisticcalc/logger.py index c3c21fd..6ad8966 100644 --- a/py_ballisticcalc/logger.py +++ b/py_ballisticcalc/logger.py @@ -2,8 +2,6 @@ import logging -__all__ = ('logger',) - formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s") console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) @@ -11,3 +9,5 @@ logger = logging.getLogger('py_balcalc') logger.addHandler(console_handler) logger.setLevel(logging.INFO) + +__all__ = ('logger',) diff --git a/py_ballisticcalc/munition.py b/py_ballisticcalc/munition.py index 9f3d837..32f672e 100644 --- a/py_ballisticcalc/munition.py +++ b/py_ballisticcalc/munition.py @@ -1,50 +1,73 @@ """Module for Weapon and Ammo properties definitions""" import math -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import IntEnum -from typing import NamedTuple, Union, Optional +from typing_extensions import NamedTuple, Union, Optional, Any -from .drag_model import DragModel -from .unit import Velocity, Temperature, Distance, Angular, PreferredUnits, Dimension, AbstractUnitType - -__all__ = ('Weapon', 'Ammo', 'Sight') +from py_ballisticcalc.drag_model import DragModel +from py_ballisticcalc.unit import Velocity, Temperature, Distance, Angular, PreferredUnits, AbstractUnitType +TrajectoryData: Any @dataclass -class Sight(PreferredUnits.Mixin): +class Sight: + """Sight data for sight specific adjustment calculation""" + class FocalPlane(IntEnum): + """FocalPlane enum""" + FFP = 1 # First focal plane SFP = 2 # Second focal plane LWIR = 10 # LWIR based device with scalable reticle # and adjusted click size to it's magnification class ReticleStep(NamedTuple): + """Reticle step""" + vertical: Angular horizontal: Angular class Clicks(NamedTuple): + """Clicks tuple""" + vertical: float horizontal: float - focal_plane: FocalPlane = field(default=FocalPlane.FFP) - scale_factor: Union[float, Distance] = Dimension(prefer_units='distance') - h_click_size: Union[float, Angular] = Dimension(prefer_units='adjustment') - v_click_size: Union[float, Angular] = Dimension(prefer_units='adjustment') + focal_plane: FocalPlane + scale_factor: Distance + h_click_size: Angular + v_click_size: Angular + + # def __post_init__(self): + def __init__(self, + focal_plane: FocalPlane = FocalPlane.FFP, + scale_factor: Optional[Union[float, Distance]] = None, + h_click_size: Optional[Union[float, Angular]] = None, + v_click_size: Optional[Union[float, Angular]] = None): - def __post_init__(self): - if self.focal_plane not in Sight.FocalPlane.__members__.values(): + if focal_plane not in Sight.FocalPlane.__members__.values(): raise ValueError("Wrong focal plane") - if not self.scale_factor and self.focal_plane == Sight.FocalPlane.SFP: + + if not scale_factor and focal_plane == Sight.FocalPlane.SFP: raise ValueError('Scale_factor required for SFP sights') + if ( - not isinstance(self.h_click_size, Angular) - or not isinstance(self.v_click_size, Angular) + not isinstance(h_click_size, (Angular, float, int)) + or not isinstance(v_click_size, (Angular, float, int)) ): raise TypeError("type Angular expected for 'h_click_size' and 'v_click_size'") + + self.focal_plane = focal_plane + self.scale_factor = PreferredUnits.distance(scale_factor or 1) + self.h_click_size = PreferredUnits.adjustment(h_click_size) + self.v_click_size = PreferredUnits.adjustment(v_click_size) + if self.h_click_size.raw_value <= 0 or self.v_click_size.raw_value <= 0: raise TypeError("'h_click_size' and 'v_click_size' have to be positive") def _adjust_sfp_reticle_steps(self, target_distance: Union[float, Distance], magnification: float) -> ReticleStep: + """Calculates the SFP reticle steps for a target distance and magnification""" + assert self.focal_plane == Sight.FocalPlane.SFP, "SFP focal plane required" # adjust reticle scale relative to target distance and magnification @@ -65,6 +88,7 @@ def get_sfp_step(click_size: Union[Angular, AbstractUnitType]): def get_adjustment(self, target_distance: Distance, drop_adj: Angular, windage_adj: Angular, magnification: float): + """Calculate adjustment for target distance and magnification""" if self.focal_plane == Sight.FocalPlane.SFP: steps = self._adjust_sfp_reticle_steps(target_distance, magnification) @@ -72,12 +96,12 @@ def get_adjustment(self, target_distance: Distance, drop_adj.raw_value / steps.vertical.raw_value, windage_adj.raw_value / steps.horizontal.raw_value ) - elif self.focal_plane == Sight.FocalPlane.FFP: + if self.focal_plane == Sight.FocalPlane.FFP: return Sight.Clicks( drop_adj.raw_value / self.v_click_size.raw_value, windage_adj.raw_value / self.h_click_size.raw_value ) - elif self.focal_plane == Sight.FocalPlane.LWIR: + if self.focal_plane == Sight.FocalPlane.LWIR: # adjust clicks to magnification return Sight.Clicks( drop_adj.raw_value / (self.v_click_size.raw_value / magnification), @@ -86,6 +110,8 @@ def get_adjustment(self, target_distance: Distance, raise AttributeError("Wrong focal_plane") def get_trajectory_adjustment(self, trajectory_point: 'TrajectoryData', magnification: float) -> Clicks: + """Calculate adjustment for target distance and magnification for `TrajectoryData` instance""" + return self.get_adjustment(trajectory_point.distance, trajectory_point.drop_adj, trajectory_point.windage_adj, @@ -93,7 +119,7 @@ def get_trajectory_adjustment(self, trajectory_point: 'TrajectoryData', magnific @dataclass -class Weapon(PreferredUnits.Mixin): +class Weapon: """ :param sight_height: Vertical distance from center of bore line to center of sight line. :param twist: Distance for barrel rifling to complete one complete turn. @@ -101,22 +127,25 @@ class Weapon(PreferredUnits.Mixin): :param zero_elevation: Angle of barrel relative to sight line when sight is set to "zero." (Typically computed by ballistic Calculator.) """ - sight_height: Union[float, Distance] = Dimension(prefer_units='sight_height') - twist: Union[float, Distance] = Dimension(prefer_units='twist') - zero_elevation: Union[float, Angular] = Dimension(prefer_units='angular') - sight: Optional[Sight] = field(default=None) - def __post_init__(self): - if not self.sight_height: - self.sight_height = 0 - if not self.twist: - self.twist = 0 - if not self.zero_elevation: - self.zero_elevation = 0 + sight_height: Distance + twist: Distance + zero_elevation: Angular + sight: Optional[Sight] + + def __init__(self, + sight_height: Optional[Union[float, Distance]] = None, + twist: Optional[Union[float, Distance]] = None, + zero_elevation: Optional[Union[float, Angular]] = None, + sight: Optional[Sight] = None): + self.sight_height = PreferredUnits.sight_height(sight_height or 0) + self.twist = PreferredUnits.twist(twist or 0) + self.zero_elevation = PreferredUnits.angular(zero_elevation or 0) + self.sight = sight @dataclass -class Ammo(PreferredUnits.Mixin): +class Ammo: """ :param dm: DragModel for projectile :param mv: Muzzle Velocity @@ -125,14 +154,21 @@ class Ammo(PreferredUnits.Mixin): Can be computed with .calc_powder_sens(). Only applies if: Settings.USE_POWDER_SENSITIVITY = True """ - dm: DragModel = field(default=None) - mv: Union[float, Velocity] = Dimension(prefer_units='velocity') - powder_temp: Union[float, Temperature] = Dimension(prefer_units='temperature') - temp_modifier: float = field(default=0) - def __post_init__(self): - if not self.powder_temp: - self.powder_temp = Temperature.Celsius(15) + dm: DragModel + mv: Velocity + powder_temp: Temperature + temp_modifier: float + + def __init__(self, + dm: DragModel, + mv: Union[float, Velocity], + powder_temp: Optional[Union[float, Temperature]] = None, + temp_modifier: float = 0): + self.dm = dm + self.mv = PreferredUnits.velocity(mv or 0) + self.powder_temp = PreferredUnits.temperature(powder_temp or Temperature.Celsius(15)) + self.temp_modifier = temp_modifier or 0 def calc_powder_sens(self, other_velocity: Union[float, Velocity], other_temperature: Union[float, Temperature]) -> float: @@ -158,7 +194,7 @@ def calc_powder_sens(self, other_velocity: Union[float, Velocity], self.temp_modifier = v_delta / t_delta * (15 / v_lower) # * 100 return self.temp_modifier - def get_velocity_for_temp(self, current_temp: [float, Temperature]) -> Velocity: + def get_velocity_for_temp(self, current_temp: Union[float, Temperature]) -> Velocity: """Calculates muzzle velocity at temperature, based on temp_modifier. :param current_temp: Temperature of cartridge powder :return: Muzzle velocity corrected to current_temp @@ -169,3 +205,6 @@ def get_velocity_for_temp(self, current_temp: [float, Temperature]) -> Velocity: t_delta = t1 - t0 muzzle_velocity = self.temp_modifier / (15 / v0) * t_delta + v0 return Velocity.MPS(muzzle_velocity) + + +__all__ = ('Weapon', 'Ammo', 'Sight') diff --git a/py_ballisticcalc/trajectory_calc.py b/py_ballisticcalc/trajectory_calc.py index 95c5c36..03839eb 100644 --- a/py_ballisticcalc/trajectory_calc.py +++ b/py_ballisticcalc/trajectory_calc.py @@ -4,22 +4,13 @@ import math from dataclasses import dataclass -from typing import NamedTuple, Union +from typing_extensions import NamedTuple, Union, List -from .drag_model import DragDataPoint -from .conditions import Atmo, Shot, Wind -from .munition import Ammo -from .trajectory_data import TrajectoryData, TrajFlag -from .unit import Distance, Angular, Velocity, Weight, Energy, Pressure, Temperature, PreferredUnits - -__all__ = ( - 'TrajectoryCalc', - 'get_global_max_calc_step_size', - 'get_global_use_powder_sensitivity', - 'set_global_max_calc_step_size', - 'set_global_use_powder_sensitivity', - 'reset_globals' -) +from py_ballisticcalc.drag_model import DragDataPoint +from py_ballisticcalc.conditions import Atmo, Shot, Wind +from py_ballisticcalc.munition import Ammo +from py_ballisticcalc.trajectory_data import TrajectoryData, TrajFlag +from py_ballisticcalc.unit import Distance, Angular, Velocity, Weight, Energy, Pressure, Temperature, PreferredUnits cZeroFindingAccuracy = 0.000005 cMinimumVelocity = 50.0 @@ -40,12 +31,14 @@ def get_global_use_powder_sensitivity() -> bool: def reset_globals() -> None: + # pylint: disable=global-statement global _globalUsePowderSensitivity, _globalMaxCalcStepSize _globalUsePowderSensitivity = False _globalMaxCalcStepSize = Distance.Foot(0.5) def set_global_max_calc_step_size(value: Union[float, Distance]) -> None: + # pylint: disable=global-statement global _globalMaxCalcStepSize if (_value := PreferredUnits.distance(value)).raw_value <= 0: raise ValueError("_globalMaxCalcStepSize have to be > 0") @@ -53,6 +46,7 @@ def set_global_max_calc_step_size(value: Union[float, Distance]) -> None: def set_global_use_powder_sensitivity(value: bool) -> None: + # pylint: disable=global-statement global _globalUsePowderSensitivity if not isinstance(value, bool): raise TypeError(f"set_global_use_powder_sensitivity {value=} is not a boolean") @@ -72,62 +66,62 @@ class Vector: y: float z: float - def magnitude(self): + def magnitude(self) -> float: return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z) - def mul_by_const(self, a: float): + def mul_by_const(self, a: float) -> 'Vector': return Vector(self.x * a, self.y * a, self.z * a) - def mul_by_vector(self, b: 'Vector'): + def mul_by_vector(self, b: 'Vector') -> float: return self.x * b.x + self.y * b.y + self.z * b.z - def add(self, b: 'Vector'): + def add(self, b: 'Vector') -> 'Vector': return Vector(self.x + b.x, self.y + b.y, self.z + b.z) - def subtract(self, b: 'Vector'): + def subtract(self, b: 'Vector') -> 'Vector': return Vector(self.x - b.x, self.y - b.y, self.z - b.z) - def negate(self): + def negate(self) -> 'Vector': return Vector(-self.x, -self.y, -self.z) - def normalize(self): + def normalize(self) -> 'Vector': m = self.magnitude() if math.fabs(m) < 1e-10: return Vector(self.x, self.y, self.z) return self.mul_by_const(1.0 / m) - def __add__(self, other: 'Vector'): + def __add__(self, other: 'Vector') -> 'Vector': return self.add(other) - def __radd__(self, other: 'Vector'): + def __radd__(self, other: 'Vector') -> 'Vector': return self.add(other) - def __iadd__(self, other: 'Vector'): + def __iadd__(self, other: 'Vector') -> 'Vector': return self.add(other) - def __sub__(self, other: 'Vector'): + def __sub__(self, other: 'Vector') -> 'Vector': return self.subtract(other) - def __rsub__(self, other: 'Vector'): + def __rsub__(self, other: 'Vector') -> 'Vector': return self.subtract(other) - def __isub__(self, other: 'Vector'): + def __isub__(self, other: 'Vector') -> 'Vector': return self.subtract(other) - def __mul__(self, other: Union[int, float, 'Vector']): + def __mul__(self, other: Union[int, float, 'Vector']) -> Union[float, 'Vector']: if isinstance(other, (int, float)): return self.mul_by_const(other) if isinstance(other, Vector): return self.mul_by_vector(other) raise TypeError(other) - def __rmul__(self, other: Union[int, float, 'Vector']): + def __rmul__(self, other: Union[int, float, 'Vector']) -> Union[float, 'Vector']: return self.__mul__(other) - def __imul__(self, other): + def __imul__(self, other: Union[int, float, 'Vector']) -> Union[float, 'Vector']: return self.__mul__(other) - def __neg__(self): + def __neg__(self) -> 'Vector': return self.negate() @@ -135,9 +129,9 @@ class TrajectoryCalc: """All calculations are done in units of feet and fps""" def __init__(self, ammo: Ammo): - self.ammo = ammo - self._bc = self.ammo.dm.BC - self._table_data = ammo.dm.drag_table + self.ammo: Ammo = ammo + self._bc: float = self.ammo.dm.BC + self._table_data: List[DragDataPoint] = ammo.dm.drag_table self._curve = calculate_curve(self._table_data) self.gravity_vector = Vector(.0, cGravityConstant, .0) @@ -224,18 +218,18 @@ def _trajectory(self, shot_info: Shot, maximum_range: float, step: float, :param step: Frequency (in feet down range) to record TrajectoryData :return: list of TrajectoryData, one for each dist_step, out to max_range """ - ranges = [] # Record of TrajectoryData points to return - ranges_length = int(maximum_range / step) + 1 - time = 0 - previous_mach = .0 - drag = 0 + ranges: List[TrajectoryData] = [] # Record of TrajectoryData points to return + ranges_length: int = int(maximum_range / step) + 1 + time: float = 0 + previous_mach: float = .0 + drag: float = 0 # region Initialize wind-related variables to first wind reading (if any) len_winds = len(shot_info.winds) current_wind = 0 current_item = 0 next_range_distance = .0 - next_wind_range = Wind.MAX_DISTANCE_FEET + next_wind_range: float = Wind.MAX_DISTANCE_FEET if len_winds < 1: wind_vector = Vector(.0, .0, .0) else: @@ -247,9 +241,11 @@ def _trajectory(self, shot_info: Shot, maximum_range: float, step: float, velocity = self.muzzle_velocity # x: downrange distance, y: drop, z: windage range_vector = Vector(.0, -self.cant_cosine * self.sight_height, -self.cant_sine * self.sight_height) - velocity_vector = Vector(math.cos(self.barrel_elevation) * math.cos(self.barrel_azimuth), - math.sin(self.barrel_elevation), - math.cos(self.barrel_elevation) * math.sin(self.barrel_azimuth)) * velocity + velocity_vector: Vector = Vector( + math.cos(self.barrel_elevation) * math.cos(self.barrel_azimuth), + math.sin(self.barrel_elevation), + math.cos(self.barrel_elevation) * math.sin(self.barrel_azimuth) + ) * velocity # type: ignore # endregion # With non-zero look_angle, rounding can suggest multiple adjacent zero-crossings @@ -326,7 +322,7 @@ def _trajectory(self, shot_info: Shot, maximum_range: float, step: float, # Drag is a function of air density and velocity relative to the air drag = density_factor * velocity * self.drag_by_mach(velocity / mach) # Bullet velocity changes due to both drag and gravity - velocity_vector -= (velocity_adjusted * drag - self.gravity_vector) * delta_time + velocity_vector -= (velocity_adjusted * drag - self.gravity_vector) * delta_time # type: ignore # Bullet position changes by velocity times the time step delta_range_vector = Vector(self.calc_step, velocity_vector.y * delta_time, @@ -533,3 +529,13 @@ def calculate_by_curve(data: list, curve: list, mach: float) -> float: m = mhi curve_m = curve[m] return curve_m.c + mach * (curve_m.b + curve_m.a * mach) + + +__all__ = ( + 'TrajectoryCalc', + 'get_global_max_calc_step_size', + 'get_global_use_powder_sensitivity', + 'set_global_max_calc_step_size', + 'set_global_use_powder_sensitivity', + 'reset_globals' +) diff --git a/py_ballisticcalc/trajectory_data.py b/py_ballisticcalc/trajectory_data.py index e561bb4..befc5e1 100644 --- a/py_ballisticcalc/trajectory_data.py +++ b/py_ballisticcalc/trajectory_data.py @@ -1,34 +1,38 @@ """Implements a point of trajectory class in applicable data types""" import logging import math -import typing from dataclasses import dataclass, field -from enum import Flag -from typing import NamedTuple, Optional, Union +from enum import IntFlag +from typing_extensions import NamedTuple, Optional, Union, Any -from .unit import Angular, Distance, Weight, Velocity, Energy, AbstractUnit, Unit, PreferredUnits -from .conditions import Shot +from py_ballisticcalc.conditions import Shot +from py_ballisticcalc.unit import Angular, Distance, Weight, Velocity, Energy, AbstractUnit, Unit, PreferredUnits + +pandas: Any +DataFrame: Any +matplotlib: Any +Polygon: Any +Axes: Any try: - import pandas as pd + import pandas except ImportError as error: logging.warning("Install pandas to convert trajectory to dataframe") - pd = None + pandas = None try: import matplotlib - from matplotlib import patches + from matplotlib.patches import Polygon except ImportError as error: logging.warning("Install matplotlib to get results as a plot") matplotlib = None - -__all__ = ('TrajectoryData', 'HitResult', 'TrajFlag') + Polygon = None PLOT_FONT_HEIGHT = 72 PLOT_FONT_SIZE = 552 / PLOT_FONT_HEIGHT -class TrajFlag(Flag): +class TrajFlag(IntFlag): """Flags for marking trajectory row if Zero or Mach crossing Also uses to set a filters for a trajectory calculation loop """ @@ -81,7 +85,7 @@ class TrajectoryData(NamedTuple): drag: float energy: Energy ogw: Weight - flag: typing.Union[TrajFlag, int] + flag: Union[TrajFlag, int] def formatted(self) -> tuple: """ @@ -153,7 +157,7 @@ def __str__(self) -> str: def overlay(self, ax: 'Axes', label: Optional[str] = None): """Highlights danger-space region on plot""" - if matplotlib is None: + if matplotlib is None or not Polygon: raise ImportError("Install matplotlib to get results as a plot") cosine = math.cos(self.look_angle >> Angular.Radian) @@ -173,8 +177,8 @@ def overlay(self, ax: 'Axes', label: Optional[str] = None): (begin_dist, begin_drop), (end_dist, begin_drop), (end_dist, end_drop), (begin_dist, end_drop) ) - polygon = patches.Polygon(vertices, closed=True, - edgecolor='none', facecolor='r', alpha=0.3) + polygon = Polygon(vertices, closed=True, + edgecolor='none', facecolor='r', alpha=0.3) ax.add_patch(polygon) if label is None: # Add default label label = f"Danger space\nat {self.at_range.distance << PreferredUnits.distance}" @@ -234,7 +238,7 @@ def get_at_distance(self, d: Distance) -> TrajectoryData: def danger_space(self, at_range: Union[float, Distance], target_height: Union[float, Distance], - look_angle: Union[float, Angular] = None + look_angle: Optional[Union[float, Angular]] = None ) -> DangerSpace: """ Assume that the trajectory hits the center of a target at any distance. @@ -300,14 +304,14 @@ def dataframe(self, formatted: bool = False) -> 'DataFrame': :param formatted: False for values as floats; True for strings with prefer_units :return: the trajectory table as a DataFrame """ - if pd is None: + if pandas is None: raise ImportError("Install pandas to get trajectory as dataframe") col_names = list(TrajectoryData._fields) if formatted: trajectory = [p.formatted() for p in self] else: trajectory = [p.in_def_units() for p in self] - return pd.DataFrame(trajectory, columns=col_names) + return pandas.DataFrame(trajectory, columns=col_names) def plot(self, look_angle: Optional[Angular] = None) -> 'Axes': """:return: graph of the trajectory""" @@ -370,3 +374,6 @@ def plot(self, look_angle: Optional[Angular] = None) -> 'Axes': ax.set_facecolor([0, 0, 0, 0]) return ax + + +__all__ = ('TrajectoryData', 'HitResult', 'TrajFlag') diff --git a/py_ballisticcalc/unit.py b/py_ballisticcalc/unit.py index 0c0befd..e441213 100644 --- a/py_ballisticcalc/unit.py +++ b/py_ballisticcalc/unit.py @@ -2,37 +2,30 @@ Useful types for prefer_units of measurement conversion for ballistics calculations """ -import sys -from abc import ABC, abstractmethod -from dataclasses import dataclass, MISSING, Field +import re +from dataclasses import dataclass from enum import IntEnum from math import pi, atan, tan -from typing import NamedTuple, Union, TypeVar, Optional, Any, Dict, Tuple -import re -from py_ballisticcalc.logger import logger +from typing_extensions import NamedTuple, Union, TypeVar, Optional, Dict, Tuple, Self -__all__ = ('Unit', 'UnitType', - 'AbstractUnit', 'AbstractUnitType', - 'UnitProps', 'UnitAliases', - 'UnitPropsDict', 'Distance', - 'Velocity', 'Angular', 'Temperature', 'Pressure', - 'Energy', 'Weight', 'Dimension', 'PreferredUnits', - 'UnitAliasError', 'UnitTypeError', 'UnitConversionError') +from py_ballisticcalc.logger import logger -UnitType = TypeVar('UnitType', bound='Unit') AbstractUnitType = TypeVar('AbstractUnitType', bound='AbstractUnit') class UnitTypeError(TypeError): + """Unit type error""" pass class UnitConversionError(UnitTypeError): + """Unit conversion error""" pass class UnitAliasError(ValueError): + """Unit alias error""" pass @@ -113,18 +106,16 @@ def symbol(self) -> str: def __repr__(self) -> str: return UnitPropsDict[self].name - def __call__(self: UnitType, value: Optional[Union[int, float, AbstractUnitType]] = None) -> AbstractUnitType: + def __call__(self: Self, value: Union[int, float, AbstractUnitType]) -> AbstractUnitType: """Creates new unit instance by dot syntax :param self: unit as Unit enum :param value: numeric value of the unit :return: AbstractUnit instance """ - # if value is None: - # return self - obj: Any + obj: AbstractUnit if isinstance(value, AbstractUnit): - return value << self + return value << self # type: ignore if 0 <= self < 10: obj = Angular(value, self) elif 10 <= self < 20: @@ -141,60 +132,7 @@ def __call__(self: UnitType, value: Optional[Union[int, float, AbstractUnitType] obj = Weight(value, self) else: raise UnitTypeError(f"{self} Unit is not supported") - return obj - - @staticmethod - def find_unit_by_alias(string_to_find: str, aliases: Dict[Tuple[str], UnitType]) -> Optional[UnitType]: - # Iterate over the keys of the dictionary - for aliases_tuple in aliases.keys(): - # Check if the string is present in any of the tuples - # if any(string_to_find in alias for alias in aliases_tuple): - if string_to_find in (each.lower() for each in aliases_tuple): - return aliases[aliases_tuple] - return None # If not found, return None or handle it as needed - - @staticmethod - def parse_unit(input_: str) -> UnitType: - input_ = input_.strip().lower() - if not isinstance(input_, str): - raise TypeError(f"type str expected for 'input_', got {type(input_)}") - if hasattr(PreferredUnits, input_): - return getattr(PreferredUnits, input_) - try: - return Unit[input_] - except KeyError: - return Unit.find_unit_by_alias(input_, UnitAliases) - - @staticmethod - def parse_value(input_: Union[str, float, int], preferred: Optional[Union[UnitType, str]]) -> AbstractUnitType: - - def create_as_preferred(value): - if isinstance(preferred, Unit): - return preferred(float(value)) - if isinstance(preferred, str): - if units := Unit.parse_unit(preferred): - return units(float(value)) - raise UnitAliasError(f"Unsupported {preferred=} unit alias") - - if isinstance(input_, (float, int)): - return create_as_preferred(input_) - - if not isinstance(input_, str): - raise TypeError(f"type, [str, float, int] expected for 'input_', got {type(input_)}") - - input_string = input_.replace(" ", "") - if match := re.match(r'^-?(?:\d+\.\d*|\.\d+|\d+\.?)$', input_string): - value = match.group() - return create_as_preferred(value) - - if match := re.match(r'(^-?(?:\d+\.\d*|\.\d+|\d+\.?))(.*$)', input_string): - value, alias = match.groups() - if units := Unit.parse_unit(alias): - return units(float(value)) - else: - raise UnitAliasError(f"Unsupported unit {alias=}") - - raise UnitAliasError(f"Can't parse unit {input_=}") + return obj # type: ignore class UnitProps(NamedTuple): @@ -353,13 +291,13 @@ def __le__(self, other): def __ge__(self, other): return float(self) >= other - def __lshift__(self, other: Unit): + def __lshift__(self, other: Unit) -> Self: return self.convert(other) - def __rshift__(self, other: Unit): + def __rshift__(self, other: Unit) -> float: return self.get_in(other) - def __rlshift__(self, other: Unit): + def __rlshift__(self, other: Unit) -> Self: return self.convert(other) def _validate_unit_type(self, value: float, units: Unit): @@ -392,7 +330,7 @@ def from_raw(self, value: float, units: Unit) -> float: """ return self._validate_unit_type(value, units) - def convert(self, units: Unit) -> AbstractUnitType: + def convert(self, units: Unit) -> Self: """Returns new unit instance in specified prefer_units :param units: Unit enum type :return: new unit instance in specified prefer_units @@ -759,55 +697,24 @@ class PreferredUnits(metaclass=PreferredUnitsMeta): # pylint: disable=too-many- target_height: Unit = Unit.Inch twist: Unit = Unit.Inch - @dataclass - class Mixin(ABC): # pylint: disable=too-few-public-methods - """ - TODO: move it to Units, use it instead of TypedUnits - Abstract class to apply auto-conversion values to - specified prefer_units by type-hints in inherited dataclasses - """ - - def __setattr__(self, key, value): - """ - converts value to specified prefer_units by type-hints in inherited dataclass - """ - - _fields = self.__getattribute__('__dataclass_fields__') - - if (_field := _fields.get(key)) and value is not None and not isinstance(value, AbstractUnit): - - if units := _field.metadata.get('prefer_units'): - - if isinstance(units, Unit): - value = units(value) - elif isinstance(units, str): - if _units := Unit.parse_unit(units): - value = _units(value) - else: - raise UnitTypeError(f"Unsupported unit or dimension, use one of {PreferredUnits}") - else: - raise UnitTypeError(f"Unsupported unit or dimension, use one of {PreferredUnits}") - - super().__setattr__(key, value) - @classmethod - def defaults(self): + def defaults(cls): """resets preferred units to defaults""" - self.angular = Unit.Degree - self.distance = Unit.Yard - self.velocity = Unit.FPS - self.pressure = Unit.InHg - self.temperature = Unit.Fahrenheit - self.diameter = Unit.Inch - self.length = Unit.Inch - self.weight = Unit.Grain - self.adjustment = Unit.Mil - self.drop = Unit.Inch - self.energy = Unit.FootPound - self.ogw = Unit.Pound - self.sight_height = Unit.Inch - self.target_height = Unit.Inch - self.twist = Unit.Inch + cls.angular = Unit.Degree + cls.distance = Unit.Yard + cls.velocity = Unit.FPS + cls.pressure = Unit.InHg + cls.temperature = Unit.Fahrenheit + cls.diameter = Unit.Inch + cls.length = Unit.Inch + cls.weight = Unit.Grain + cls.adjustment = Unit.Mil + cls.drop = Unit.Inch + cls.energy = Unit.FootPound + cls.ogw = Unit.Pound + cls.sight_height = Unit.Inch + cls.target_height = Unit.Inch + cls.twist = Unit.Inch @classmethod def set(cls, **kwargs): @@ -818,7 +725,7 @@ def set(cls, **kwargs): if isinstance(value, Unit): setattr(PreferredUnits, attribute, value) elif isinstance(value, str): - if _unit := Unit.parse_unit(value): + if _unit := _parse_unit(value): setattr(PreferredUnits, attribute, _unit) else: logger.warning(f"{value=} not a member of Unit") @@ -828,42 +735,83 @@ def set(cls, **kwargs): logger.warning(f"{attribute=} not found in preferred_units") -# pylint: disable=redefined-builtin,too-few-public-methods,too-many-arguments -class Dimension(Field): - """ - Definition of measure units specified field for - PreferredUnits.Mixin based dataclasses - """ - - def __init__(self, prefer_units: Union[str, Unit], init=True, repr_=True, - hash_=None, compare=True, metadata=None): - - if metadata is None: - metadata = {} - metadata['prefer_units'] = prefer_units - - major, minor = sys.version_info.major, sys.version_info.minor - - if major >= 3 and minor > 9: - extra = {'kw_only': MISSING} - elif major >= 3 and minor == 9: - extra = {} - else: - raise RuntimeError("Unsupported python version") - - super().__init__(default=None, default_factory=MISSING, - init=init, repr=repr_, - hash=hash_, compare=compare, - metadata=metadata, **extra) - - @property - def raw_value(self): - raise NotImplementedError - - @abstractmethod - def __rshift__(self, other): - ... - - @abstractmethod - def __lshift__(self, other): - ... +def _find_unit_by_alias(string_to_find: str, aliases: Dict[Tuple[str, ...], Unit]) -> Optional[Unit]: + """Find unit type by string and aliases dict""" + + # Iterate over the keys of the dictionary + for aliases_tuple in aliases.keys(): + # Check if the string is present in any of the tuples + # if any(string_to_find in alias for alias in aliases_tuple): + if string_to_find in (each.lower() for each in aliases_tuple): + return aliases[aliases_tuple] + return None # If not found, return None or handle it as needed + + +def _parse_unit(input_: str) -> Optional[Unit]: + """Parse the unit type from string""" + + input_ = input_.strip().lower() + if not isinstance(input_, str): + raise TypeError(f"type str expected for 'input_', got {type(input_)}") + if hasattr(PreferredUnits, input_): + return getattr(PreferredUnits, input_) + try: + return Unit[input_] + except KeyError: + return _find_unit_by_alias(input_, UnitAliases) + + +def _parse_value(input_: Union[str, float, int], + preferred: Optional[Union[Unit, str]]) -> Optional[AbstractUnit]: + """Parse the unit value and return 'AbstractUnit'""" + + def create_as_preferred(value_): + if isinstance(preferred, Unit): + return preferred(float(value_)) + if isinstance(preferred, str): + if units_ := _parse_unit(preferred): + return units_(float(value_)) + raise UnitAliasError(f"Unsupported {preferred=} unit alias") + + if isinstance(input_, (float, int)): + return create_as_preferred(input_) + + if not isinstance(input_, str): + raise TypeError(f"type, [str, float, int] expected for 'input_', got {type(input_)}") + + input_string = input_.replace(" ", "") + if match := re.match(r'^-?(?:\d+\.\d*|\.\d+|\d+\.?)$', input_string): + value = match.group() + return create_as_preferred(value) + + if match := re.match(r'(^-?(?:\d+\.\d*|\.\d+|\d+\.?))(.*$)', input_string): + value, alias = match.groups() + if units := _parse_unit(alias): + return units(float(value)) + raise UnitAliasError(f"Unsupported unit {alias=}") + + raise UnitAliasError(f"Can't parse unit {input_=}") + + +__all__ = ( + 'Unit', + 'AbstractUnit', + 'AbstractUnitType', + 'UnitProps', + 'UnitAliases', + 'UnitPropsDict', + 'Distance', + 'Velocity', + 'Angular', + 'Temperature', + 'Pressure', + 'Energy', + 'Weight', + 'PreferredUnits', + 'UnitAliasError', + 'UnitTypeError', + 'UnitConversionError', + # opt + '_parse_unit', + '_parse_value' +) diff --git a/py_ballisticcalc_exts/Manifest.in b/py_ballisticcalc_exts/Manifest.in index 925062c..42d173b 100644 --- a/py_ballisticcalc_exts/Manifest.in +++ b/py_ballisticcalc_exts/Manifest.in @@ -1,3 +1,5 @@ recursive-include py_ballisticcalc_exts *.pyx recursive-include py_ballisticcalc_exts *.pyi include py.typed +include LICENSE +include README.md \ No newline at end of file diff --git a/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyi b/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyi new file mode 100644 index 0000000..cb3433c --- /dev/null +++ b/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyi @@ -0,0 +1,58 @@ +from py_ballisticcalc.conditions import Atmo, Shot +from py_ballisticcalc.munition import Ammo +from py_ballisticcalc.unit import Angular, Distance +from _typeshed import Incomplete +from dataclasses import dataclass +from typing import NamedTuple + +__all__ = ['TrajectoryCalc', 'get_global_max_calc_step_size', 'get_global_use_powder_sensitivity', 'set_global_max_calc_step_size', 'set_global_use_powder_sensitivity', 'reset_globals'] + +def get_global_max_calc_step_size() -> Distance: ... +def get_global_use_powder_sensitivity() -> bool: ... +def reset_globals() -> None: ... +def set_global_max_calc_step_size(value: float | Distance) -> None: ... +def set_global_use_powder_sensitivity(value: bool) -> None: ... + +class CurvePoint(NamedTuple): + a: float + b: float + c: float + +@dataclass +class Vector: + x: float + y: float + z: float + def magnitude(self) -> float: ... + def mul_by_const(self, a: float) -> Vector: ... + def mul_by_vector(self, b: Vector) -> float: ... + def add(self, b: Vector) -> Vector: ... + def subtract(self, b: Vector) -> Vector: ... + def negate(self) -> Vector: ... + def normalize(self) -> Vector: ... + def __add__(self, other: Vector) -> Vector: ... + def __radd__(self, other: Vector) -> Vector: ... + def __iadd__(self, other: Vector) -> Vector: ... + def __sub__(self, other: Vector) -> Vector: ... + def __rsub__(self, other: Vector) -> Vector: ... + def __isub__(self, other: Vector) -> Vector: ... + def __mul__(self, other: int | float | Vector) -> float | Vector: ... + def __rmul__(self, other: int | float | Vector) -> float | Vector: ... + def __imul__(self, other: int | float | Vector) -> float | Vector: ... + def __neg__(self) -> Vector: ... + def __init__(self, x, y, z) -> None: ... + +class TrajectoryCalc: + ammo: Incomplete + gravity_vector: Incomplete + def __init__(self, ammo: Ammo) -> None: ... + @staticmethod + def get_calc_step(step: float = 0): ... + def trajectory(self, shot_info: Shot, max_range: Distance, dist_step: Distance, extra_data: bool = False): ... + barrel_azimuth: float + barrel_elevation: Incomplete + twist: int + def zero_angle(self, shot_info: Shot, distance: Distance) -> Angular: ... + def drag_by_mach(self, mach: float) -> float: ... + def spin_drift(self, time) -> float: ... + def calc_stability_coefficient(self, atmo: Atmo) -> float: ... diff --git a/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyx b/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyx index f81ae87..b68eb94 100644 --- a/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyx +++ b/py_ballisticcalc_exts/py_ballisticcalc_exts/trajectory_calc.pyx @@ -27,31 +27,27 @@ cdef object _globalMaxCalcStepSize = Distance.Foot(0.5) def get_global_max_calc_step_size() -> Distance: return _globalMaxCalcStepSize - def get_global_use_powder_sensitivity() -> bool: return bool(_globalUsePowderSensitivity) - def set_global_max_calc_step_size(value: [object, float]) -> None: global _globalMaxCalcStepSize - if (_value := PreferredUnits.distance(value)).raw_value <= 0: + cdef double _value = PreferredUnits.distance(value).raw_value + if _value <= 0: raise ValueError("_globalMaxCalcStepSize have to be > 0") _globalMaxCalcStepSize = PreferredUnits.distance(value) - def set_global_use_powder_sensitivity(value: bool) -> None: global _globalUsePowderSensitivity if not isinstance(value, bool): - raise TypeError(f"set_global_use_powder_sensitivity {value=} is not a boolean") + raise TypeError(f"set_global_use_powder_sensitivity value={value} is not a boolean") _globalUsePowderSensitivity = int(value) - def reset_globals() -> None: global _globalUsePowderSensitivity, _globalMaxCalcStepSize _globalUsePowderSensitivity = False _globalMaxCalcStepSize = Distance.Foot(0.5) - cdef struct CurvePoint: double a, b, c @@ -65,7 +61,6 @@ cdef enum CTrajFlag: ZERO = ZERO_UP | ZERO_DOWN ALL = RANGE | ZERO_UP | ZERO_DOWN | MACH | DANGER - cdef class Vector: cdef double x cdef double y @@ -179,7 +174,7 @@ cdef class TrajectoryCalc: dist_step = Distance.Foot(0.2) filter_flags = CTrajFlag.ALL - self._init_trajectory(shot_info) + self._init_trajectory(shot_info) return self._trajectory(shot_info, max_range >> Distance.Foot, dist_step >> Distance.Foot, filter_flags) cdef _init_trajectory(self, shot_info: Shot): @@ -216,7 +211,7 @@ cdef class TrajectoryCalc: self.barrel_azimuth = 0.0 self.barrel_elevation = atan(height_at_zero / zero_distance) self.twist = 0 - maximum_range -= 1.5*self.calc_step + maximum_range -= 1.5 * self.calc_step # x = horizontal distance down range, y = drop, z = windage while zero_finding_error > cZeroFindingAccuracy and iterations_count < cMaxIterations: @@ -263,12 +258,11 @@ cdef class TrajectoryCalc: velocity = self.muzzle_velocity # x: downrange distance, y: drop, z: windage - range_vector = Vector(.0, -self.cant_cosine*self.sight_height, -self.cant_sine*self.sight_height) + range_vector = Vector(.0, -self.cant_cosine * self.sight_height, -self.cant_sine * self.sight_height) velocity_vector = Vector(cos(self.barrel_elevation) * cos(self.barrel_azimuth), sin(self.barrel_elevation), cos(self.barrel_elevation) * sin(self.barrel_azimuth)) * velocity - # With non-zero look_angle, rounding can suggest multiple adjacent zero-crossings seen_zero = CTrajFlag.NONE # Record when we see each zero crossing so we only register one if range_vector.y >= 0: @@ -353,9 +347,9 @@ cdef class TrajectoryCalc: # If filter_flags == 0 then all we want is the ending value if not filter_flags: ranges.append(create_trajectory_row( - time, range_vector, velocity_vector, - velocity, mach, self.spin_drift(time), self.look_angle, - density_factor, drag, self.weight, _flag)) + time, range_vector, velocity_vector, + velocity, mach, self.spin_drift(time), self.look_angle, + density_factor, drag, self.weight, _flag)) return ranges cdef double drag_by_mach(self, double mach): @@ -377,7 +371,7 @@ cdef class TrajectoryCalc: cdef int sign if self.twist != 0: sign = 1 if self.twist > 0 else -1 - return sign * (1.25 * (self.stability_coefficient + 1.2) * pow(time, 1.83) ) / 12 + return sign * (1.25 * (self.stability_coefficient + 1.2) * pow(time, 1.83)) / 12 return 0 cdef double calc_stability_coefficient(self, object atmo): @@ -420,10 +414,10 @@ cdef create_trajectory_row(double time, Vector range_vector, Vector velocity_vec drop_adj=Angular.Radian(drop_adjustment - (look_angle if range_vector.x else 0)), windage=Distance.Foot(windage), windage_adj=Angular.Radian(windage_adjustment), - look_distance= Distance.Foot(range_vector.x / cos(look_angle)), + look_distance=Distance.Foot(range_vector.x / cos(look_angle)), angle=Angular.Radian(trajectory_angle), - density_factor = density_factor-1, - drag = drag, + density_factor=density_factor - 1, + drag=drag, energy=Energy.FootPound(calculate_energy(weight, velocity)), ogw=Weight.Pound(calculate_ogv(weight, velocity)), flag=flag diff --git a/py_ballisticcalc_exts/pyproject.toml b/py_ballisticcalc_exts/pyproject.toml index b72b729..38ae487 100644 --- a/py_ballisticcalc_exts/pyproject.toml +++ b/py_ballisticcalc_exts/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "py_ballisticcalc.exts" -version = "2.0.3" +version = "2.0.4rc1" authors = [ { name="o-murphy", email="thehelixpg@gmail.com" }, @@ -54,3 +54,5 @@ include = ["py_ballisticcalc_exts*"] # alternatively: `exclude = ["additional*" #[tool.setuptools.dynamic] #version = {attr = "py_ballisticcalc_exts.__version__"} +[project.optional-dependencies] +dev = ['cython', 'build', 'setuptools'] diff --git a/pyproject.toml b/pyproject.toml index d269567..062b56a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "py_ballisticcalc" -version = "2.0.3" +version = "2.0.4rc1" authors = [ { name="o-murphy", email="thehelixpg@gmail.com" }, @@ -33,7 +33,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["tomli; python_version<'3.11'"] +dependencies = ['typing_extensions>=4.12', "tomli; python_version<'3.11'"] #dynamic = ["version"] [project.urls] @@ -54,6 +54,6 @@ exclude = ["py_ballisticcalc_exts*"] [project.optional-dependencies] -exts = ['py_ballisticcalc.exts==2.0.3'] -lint = ['pylint', 'flake8'] +exts = ['py_ballisticcalc.exts==2.0.4rc1'] charts = ['matplotlib', 'pandas'] +dev = ['pylint', 'flake8', 'mypy', 'pytest', 'setuptools', 'build', 'pandas-stubs', 'matplotlib-stubs'] diff --git a/tests/test_units.py b/tests/test_units.py index 4e63206..7ac498f 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,5 +1,6 @@ import unittest from dataclasses import dataclass +from typing import Optional, Union from py_ballisticcalc.unit import * @@ -10,29 +11,6 @@ def back_n_forth(test, value, units): test.assertAlmostEqual(v, value, 7, f'Read back failed for {units}') -class TestPrefUnits(unittest.TestCase): - - def test_pref(self): - @dataclass - class TestClass(PreferredUnits.Mixin): - as_metadata_str: [float, Distance] = Dimension(prefer_units='sight_height') - as_metadata_unit: [float, Distance] = Dimension(prefer_units=Unit.Meter) - - tc1 = TestClass(1, 1) - self.assertEqual(tc1.as_metadata_str.units, Unit.Inch) - self.assertEqual(tc1.as_metadata_unit.units, Unit.Meter) - - tc2 = TestClass(Unit.Meter(1), Unit.Meter(1)) - self.assertEqual(tc2.as_metadata_str.units, Unit.Meter) - self.assertEqual(tc2.as_metadata_unit.units, Unit.Meter) - - PreferredUnits.sight_height = Unit.Centimeter - - tc3 = TestClass(1, 1) - self.assertEqual(tc3.as_metadata_str.units, Unit.Centimeter) - self.assertEqual(tc3.as_metadata_unit.units, Unit.Meter) - - class TestUnitsParser(unittest.TestCase): def test_parse_values(self): @@ -43,28 +21,28 @@ def test_parse_values(self): for case in valid_cases: with self.subTest(case): - ret = Unit.parse_value(case, Unit.FootPound) + ret = _parse_value(case, Unit.FootPound) self.assertIsInstance(ret, Energy) self.assertEqual(ret.units, Unit.FootPound) with self.subTest(case): - ret = Unit.parse_value(case, 'footpound') + ret = _parse_value(case, 'footpound') self.assertIsInstance(ret, Energy) self.assertEqual(ret.units, Unit.FootPound) with self.subTest(case): - ret = Unit.parse_value(case, 'ft*lb') + ret = _parse_value(case, 'ft*lb') self.assertIsInstance(ret, Energy) self.assertEqual(ret.units, Unit.FootPound) with self.subTest(case): - ret = Unit.parse_value(case, 'energy') + ret = _parse_value(case, 'energy') self.assertIsInstance(ret, Energy) self.assertEqual(ret.units, Unit.FootPound) def test_parse_units(self): - ret = Unit.parse_unit('ft*lb') + ret = _parse_unit('ft*lb') self.assertIsInstance(ret, Unit)