Skip to content

Commit

Permalink
* added ShotTrajectory class for shot results
Browse files Browse the repository at this point in the history
  • Loading branch information
o-murphy committed Oct 12, 2023
1 parent 62bc7db commit 3b89620
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 152 deletions.
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 3 additions & 8 deletions py_ballisticcalc/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
141 changes: 39 additions & 102 deletions py_ballisticcalc/interface.py
Original file line number Diff line number Diff line change
@@ -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',)

Expand All @@ -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"""
Expand All @@ -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))
82 changes: 81 additions & 1 deletion py_ballisticcalc/trajectory_data.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
11 changes: 5 additions & 6 deletions tests/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading

0 comments on commit 3b89620

Please sign in to comment.