From 3b89620ace8377ebe5fb56e014d252ae60f21cb9 Mon Sep 17 00:00:00 2001 From: Murphy Date: Thu, 12 Oct 2023 18:59:35 +0300 Subject: [PATCH] * added ShotTrajectory class for shot results --- README.md | 30 +++--- py_ballisticcalc/example.py | 11 +-- py_ballisticcalc/interface.py | 141 ++++++++-------------------- py_ballisticcalc/trajectory_data.py | 82 +++++++++++++++- tests/plot.py | 11 +-- tests/test_all.py | 44 +++++---- 6 files changed, 167 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 09d1030..5b96bac 100644 --- a/README.md +++ b/README.md @@ -79,19 +79,21 @@ Distance.Meter(100) > 10 # >>> True, compare unit with float by raw value #### An example of calculations ```python -from py_ballisticcalc import * +from py_ballisticcalc import Velocity, Temperature, Distance +from py_ballisticcalc import DragModel, TableG7 +from py_ballisticcalc import Ammo, Atmo, Wind +from py_ballisticcalc import Weapon, Shot, Calculator from py_ballisticcalc import Settings as Set + # set global library settings -Set.Units.Mach = Velocity.FPS +Set.Units.velocity = Velocity.FPS Set.Units.temperature = Temperature.Celsius -Set.Units.distance = Distance.Meter +# Set.Units.distance = Distance.Meter Set.Units.sight_height = Distance.Centimeter -# set maximum inner Calculator step size, larger is faster, but accuracy is going down -Set.set_max_calc_step_size(Distance.Foot(1)) # same as default -# enable muzzle velocity correction by powder temperature -Set.USE_POWDER_SENSITIVITY = True # default False +Set.set_max_calc_step_size(Distance.Foot(1)) +Set.USE_POWDER_SENSITIVITY = True # enable muzzle velocity correction my powder temperature # define params with default units weight, diameter = 168, 0.308 @@ -104,21 +106,19 @@ dm = DragModel(0.223, TableG7, weight, diameter) ammo = Ammo(dm, length, 2750, 15) ammo.calc_powder_sens(2723, 0) -zero_atmo = Atmo.icao() +zero_atmo = Atmo.icao(100) # defining calculator instance calc = Calculator(weapon, ammo, zero_atmo) -calc.update_elevation() shot = Shot(1500, 100) +current_atmo = Atmo(110, 1000, 15, 72) +current_winds = [Wind(2, 90)] -current_atmo = Atmo(100, 1000, 15, 72) -winds = [Wind(2, Angular.OClock(3))] - -data = calc.trajectory(shot, current_atmo, winds) +shot_result = calc.fire(shot, current_atmo, current_winds) -for p in data: - print(p.formatted()) +for p in shot_result: + print(p.formatted()) ``` #### Example of the formatted output: ```shell diff --git a/py_ballisticcalc/example.py b/py_ballisticcalc/example.py index cf9fd80..add0d5a 100644 --- a/py_ballisticcalc/example.py +++ b/py_ballisticcalc/example.py @@ -42,17 +42,12 @@ # defining calculator instance calc = Calculator(weapon, ammo, zero_atmo) -calc.update_elevation() shot = Shot(1500, 100) -print(shot.max_range) -print(zero_atmo.temperature) - current_atmo = Atmo(110, 1000, 15, 72) -winds = [Wind(2, 90)] -print(weapon.sight_height) +current_winds = [Wind(2, 90)] -data = calc.trajectory(shot, current_atmo, winds) +shot_result = calc.fire(shot, current_atmo, current_winds) -for p in data: +for p in shot_result: print(p.formatted()) diff --git a/py_ballisticcalc/interface.py b/py_ballisticcalc/interface.py index e5b62b5..43867f5 100644 --- a/py_ballisticcalc/interface.py +++ b/py_ballisticcalc/interface.py @@ -1,14 +1,12 @@ """Implements basic interface for the ballistics calculator""" -import math from dataclasses import dataclass, field from .conditions import Atmo, Wind, Shot from .munition import Weapon, Ammo -from .settings import Settings as Set # pylint: disable=import-error,no-name-in-module from .trajectory_calc import TrajectoryCalc -from .trajectory_data import TrajectoryData, TrajFlag -from .unit import Angular, Distance, is_unit +from .trajectory_data import ShotTrajectory +from .unit import Angular __all__ = ('Calculator',) @@ -19,12 +17,15 @@ class Calculator: weapon: Weapon ammo: Ammo - zero_atmo: Atmo + zero_atmo: Atmo = field(default_factory=Atmo.icao) _elevation: Angular = field(init=False, repr=True, compare=False, default_factory=lambda: Angular.Degree(0)) _calc: TrajectoryCalc = field(init=False, repr=True, compare=False, default=None) + def __post_init__(self): + self.calculate_elevation() + @property def elevation(self): """get current barrel elevation""" @@ -35,112 +36,48 @@ def cdm(self): """returns custom drag function based on input data""" return self._calc.cdm - def update_elevation(self): + def calculate_elevation(self): """Recalculates barrel elevation for weapon and zero atmo""" self._calc = TrajectoryCalc(self.ammo) self._elevation = self._calc.zero_angle(self.weapon, self.zero_atmo) - def trajectory(self, shot: Shot, current_atmo: Atmo, winds: list[Wind]) -> list: + def fire(self, shot: Shot, current_atmo: Atmo = None, + current_winds: list[Wind] = None) -> ShotTrajectory: """Calculates trajectory with current conditions :param shot: shot parameters :param current_atmo: current atmosphere conditions - :param winds: current winds list + :param current_winds: current winds list :return: trajectory table """ - self._calc = TrajectoryCalc(self.ammo) - if not self._elevation and not shot.zero_angle: - self.update_elevation() - shot.zero_angle = self._elevation - data = self._calc.trajectory(self.weapon, current_atmo, shot, winds) - return data - - def zero_given_elevation(self, shot: Shot, - winds: list[Wind] = None) -> TrajectoryData: - """Find the zero distance for a given barrel elevation""" - self._calc = TrajectoryCalc(self.ammo) - if not winds: - winds = [Wind()] - - data = self._calc.trajectory( - self.weapon, self.zero_atmo, shot, winds, - filter_flags=(TrajFlag.ZERO_UP | TrajFlag.ZERO_DOWN).value) - if len(data) < 1: - raise ArithmeticError("Can't found zero crossing points") - return data - - @staticmethod - def danger_space(trajectory: TrajectoryData, target_height: [float, Distance]) -> Distance: - """Given a TrajectoryData row, we have the angle of travel - of bullet at that point in its trajectory, which is at distance *d*. - "Danger Space" is defined for *d* and for a target of height - `targetHeight` as the error range for the target, meaning - if the trajectory hits the center of the target when - the target is exactly at *d*, then "Danger Space" is the distance - before or after *d* across which the bullet would still hit somewhere on the target. - (This ignores windage; vertical only.) - - :param trajectory: single point from trajectory table - :param target_height: error range for the target - :return: danger space for target_height specified - """ + if current_atmo is None: + current_atmo = self.zero_atmo - target_height = (target_height if is_unit(target_height) - else Set.Units.target_height(target_height)) >> Distance.Yard - traj_angle_tan = math.tan(trajectory.angle >> Angular.Radian) - return Distance.Yard(-(target_height / traj_angle_tan)) + if current_winds is None: + current_winds = [Wind()] - @staticmethod - def to_dataframe(trajectory: list[TrajectoryData]): - import pandas as pd - col_names = TrajectoryData._fields - trajectory = [p.in_def_units() for p in trajectory] - return pd.DataFrame(trajectory, columns=col_names) - - def show_plot(self, shot, current_atmo, winds): - import matplotlib - import matplotlib.pyplot as plt - - matplotlib.use('TkAgg') self._calc = TrajectoryCalc(self.ammo) - data = self._calc.trajectory(self.weapon, current_atmo, shot, winds, - TrajFlag.ALL.value) # Step in 10-yard increments to produce smoother curves - df = self.to_dataframe(data) - ax = df.plot(x='distance', y=['drop'], ylabel=Set.Units.drop.symbol) - - for p in data: - - if TrajFlag(p.flag) & TrajFlag.ZERO: - ax.plot([p.distance >> Set.Units.distance, p.distance >> Set.Units.distance], - [df['drop'].min(), p.drop >> Set.Units.drop], linestyle=':') - if TrajFlag(p.flag) & TrajFlag.MACH: - ax.plot([p.distance >> Set.Units.distance, p.distance >> Set.Units.distance], - [df['drop'].min(), p.drop >> Set.Units.drop], linestyle='--', label='mach') - ax.text(p.distance >> Set.Units.distance, df['drop'].min(), " Mach") - - # # scope line - x_values = [0, df.distance.max()] # Adjust these as needed - y_values = [0, 0] # Adjust these as needed - ax.plot(x_values, y_values, linestyle='--', label='scope line') - ax.text(df.distance.max() - 100, -100, "Scope") - - # # # barrel line - # elevation = self.elevation >> Angular.Degree - # - # y = sh / math.cos(elevation) - # x0 = sh / math.sin(elevation) - # x1 = sh * math.sin(elevation) - # x_values = [0, x0] - # y_values = [-sh, 0] - # ax.plot(x_values, y_values, linestyle='-.', label='barrel line') - - df.plot(x='distance', xlabel=Set.Units.distance.symbol, - y=['velocity'], ylabel=Set.Units.velocity.symbol, - secondary_y=True, - ylim=[0, df['velocity'].max()], ax=ax) - plt.title = f"{self.weapon.sight_height} {self.weapon.zero_distance}" - - plt.show() - # ax = df.plot(y=[c.tableCols['Drop'][0]], ylabel=UNIT_DISPLAY[c.heightUnits].units) - # df.plot(y=[c.tableCols['Velocity'][0]], ylabel=UNIT_DISPLAY[c.bullet.velocityUnits].units, secondary_y=True, - # ylim=[0, df[c.tableCols['Velocity'][0]].max()], ax=ax) - # plt.show() + if not shot.zero_angle: + shot.zero_angle = self._elevation + data = self._calc.trajectory(self.weapon, current_atmo, shot, current_winds) + return ShotTrajectory(data) + + # @staticmethod + # def danger_space(trajectory: TrajectoryData, target_height: [float, Distance]) -> Distance: + # """Given a TrajectoryData row, we have the angle of travel + # of bullet at that point in its trajectory, which is at distance *d*. + # "Danger Space" is defined for *d* and for a target of height + # `targetHeight` as the error range for the target, meaning + # if the trajectory hits the center of the target when + # the target is exactly at *d*, then "Danger Space" is the distance + # before or after *d* across which the bullet would still hit somewhere on the target. + # (This ignores windage; vertical only.) + # + # :param trajectory: single point from trajectory table + # :param target_height: error range for the target + # :return: danger space for target_height specified + # """ + # + # target_height = (target_height if is_unit(target_height) + # else Set.Units.target_height(target_height)) >> Distance.Yard + # traj_angle_tan = math.tan(trajectory.angle >> Angular.Radian) + # return Distance.Yard(-(target_height / traj_angle_tan)) diff --git a/py_ballisticcalc/trajectory_data.py b/py_ballisticcalc/trajectory_data.py index 694840d..55183d3 100644 --- a/py_ballisticcalc/trajectory_data.py +++ b/py_ballisticcalc/trajectory_data.py @@ -1,11 +1,26 @@ """Implements a point of trajectory class in applicable data types""" +import logging +from dataclasses import dataclass from enum import Flag from typing import NamedTuple from .settings import Settings as Set from .unit import Angular, Distance, Weight, Velocity, Energy, AbstractUnit, Unit -__all__ = ('TrajectoryData', 'TrajFlag') +try: + import pandas as pd +except ImportError as error: + logging.warning("Install pandas to convert trajectory as dataframe") + pd = None + +try: + import matplotlib + from matplotlib import pyplot as plt +except ImportError as error: + logging.warning("Install matplotlib to get results as a plot") + matplotlib = None + +__all__ = ('TrajectoryData', 'ShotTrajectory', 'TrajFlag') class TrajFlag(Flag): @@ -58,6 +73,7 @@ def formatted(self) -> tuple: """ :return: matrix of formatted strings for each value of trajectory in default units """ + def _fmt(v: AbstractUnit, u: Unit): """simple formatter""" return f"{v >> u:.{u.accuracy}f} {u.symbol}" @@ -96,3 +112,67 @@ def in_def_units(self) -> tuple: self.ogw >> Set.Units.ogw, TrajFlag(self.flag) ) + + +@dataclass +class ShotTrajectory: + """Results of the shot""" + _trajectory: list[TrajectoryData] + + def __iter__(self): + for row in self._trajectory: + yield row + + def __getitem__(self, item): + return self._trajectory[item] + + @staticmethod + def to_dataframe(shot_result: 'ShotTrajectory') -> 'DataFrame': + """:return: the trajectory table as a DataFrame""" + if pd is None: + raise ImportError("Install pandas to convert trajectory as dataframe") + + col_names = TrajectoryData._fields + trajectory = [p.in_def_units() for p in shot_result] + return pd.DataFrame(trajectory, columns=col_names) + + @staticmethod + def plot(shot_result: 'ShotTrajectory') -> 'plot': + """:return: the graph of the trajectory""" + + if matplotlib is None: + raise ImportError("Install matplotlib to get results as a plot") + + matplotlib.use('TkAgg') + df = ShotTrajectory.to_dataframe(shot_result) + ax = df.plot(x='distance', y=['drop'], ylabel=Set.Units.drop.symbol) + + for p in shot_result: + + if TrajFlag(p.flag) & TrajFlag.ZERO: + ax.plot([p.distance >> Set.Units.distance, p.distance >> Set.Units.distance], + [df['drop'].min(), p.drop >> Set.Units.drop], linestyle=':') + if TrajFlag(p.flag) & TrajFlag.MACH: + ax.plot([p.distance >> Set.Units.distance, p.distance >> Set.Units.distance], + [df['drop'].min(), p.drop >> Set.Units.drop], linestyle='--', label='mach') + ax.text(p.distance >> Set.Units.distance, df['drop'].min(), " Mach") + + # # scope line + x_values = [0, df.distance.max()] # Adjust these as needed + y_values = [0, 0] # Adjust these as needed + ax.plot(x_values, y_values, linestyle='--', label='scope line') + ax.text(df.distance.max() - 100, -100, "Scope") + + df.plot(x='distance', xlabel=Set.Units.distance.symbol, + y=['velocity'], ylabel=Set.Units.velocity.symbol, + secondary_y=True, + ylim=[0, df['velocity'].max()], ax=ax) + + return plt + + def zero_given_elevation(self) -> list[TrajectoryData]: + """Find the zero distance for a given barrel elevation""" + data = [row for row in self._trajectory if row.flag & TrajFlag.ZERO.value] + if len(data) < 1: + raise ArithmeticError("Can't found zero crossing points") + return data diff --git a/tests/plot.py b/tests/plot.py index b986f34..d9aae69 100644 --- a/tests/plot.py +++ b/tests/plot.py @@ -5,12 +5,11 @@ dm = DragModel(0.22, TableG7, 168, 0.308) ammo = Ammo(dm, 1.22, Velocity(2600, Velocity.FPS)) -atmosphere = Atmo.icao() weapon = Weapon(4, 100, 11.24) -calc = Calculator(weapon, ammo, atmosphere) -calc.update_elevation() -atmo = Atmo.icao() +calc = Calculator(weapon, ammo) +calc.calculate_elevation() -shot = Shot(1200, Distance.Foot(0.2), zero_angle=calc.elevation, relative_angle=Angular.MOA(0)) -calc.show_plot(shot, atmo, [Wind()]) +shot = Shot(1200, Distance.Foot(0.2), zero_angle=calc.elevation, relative_angle=Angular.Mil(0)) +shot_results = calc.fire(shot) +shot_results.plot(shot_results).show() diff --git a/tests/test_all.py b/tests/test_all.py index 29246f1..e10ccb0 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -93,9 +93,10 @@ def print_output(data, at_elevation): with self.subTest(zero=reference_distance, sh=sh): try: - calc.update_elevation() + calc.calculate_elevation() shot = Shot(1000, Distance.Foot(0.2), zero_angle=calc.elevation) - zero_crossing_points = calc.zero_given_elevation(shot) + shot_result = calc.fire(shot) + zero_crossing_points = shot_result.zero_given_elevation() print_output(zero_crossing_points, calc.elevation) except ArithmeticError as err: if err == "Can't found zero crossing points": @@ -103,30 +104,33 @@ def print_output(data, at_elevation): with self.subTest(zero=reference_distance, sh=sh, elev=0): try: + calc.calculate_elevation() shot = Shot(1000, Distance.Foot(0.2), zero_angle=0) - zero_crossing_points = calc.zero_given_elevation(shot) + shot_result = calc.fire(shot) + zero_crossing_points = shot_result.zero_given_elevation() print_output(zero_crossing_points, 0) except ArithmeticError as err: if err == "Can't found zero crossing points": pass - # @unittest.skip(reason="Fixme: danger_space") - def test_danger_space(self): - zero_distance = Distance.Yard(100) - weapon = Weapon(Distance.Inch(4), zero_distance, 11.24) - calc = Calculator(weapon, self.ammo, self.atmosphere) - calc.update_elevation() - shot = Shot(1000, Distance.Foot(0.2), zero_angle=calc.elevation) - zero_given_elevation = calc.zero_given_elevation(shot) - if len(zero_given_elevation) > 0: - zero = [p for p in zero_given_elevation if abs( - (p.distance >> Distance.Yard) - (zero_distance >> Distance.Yard) - ) <= 1e7][0] - else: - raise ArithmeticError - print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Meter(1.7)) << Distance.Meter) - print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Meter(1.5)) << Distance.Meter) - print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Inch(10)) << Distance.Yard) + # # @unittest.skip(reason="Not implemented: danger_space") + # def test_danger_space(self): + # zero_distance = Distance.Yard(100) + # weapon = Weapon(Distance.Inch(4), zero_distance, 11.24) + # calc = Calculator(weapon, self.ammo, self.atmosphere) + # calc.calculate_elevation() + # shot = Shot(1000, Distance.Foot(0.2), zero_angle=calc.elevation) + # shot_result = calc.fire(shot) + # zero_given_elevation = shot_result.zero_given_elevation() + # if len(zero_given_elevation) > 0: + # zero = [p for p in zero_given_elevation if abs( + # (p.distance >> Distance.Yard) - (zero_distance >> Distance.Yard) + # ) <= 1e7][0] + # else: + # raise ArithmeticError + # print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Meter(1.7)) << Distance.Meter) + # print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Meter(1.5)) << Distance.Meter) + # print(zero.distance << Distance.Yard, calc.danger_space(zero, Distance.Inch(10)) << Distance.Yard) class TestTrajectory(unittest.TestCase):