Fractal Softworks Forum

Please login or register.

Login with username, password and session length
Pages: 1 ... 16 17 [18] 19 20 ... 32

Author Topic: Optimizing the Conquest: a Mathematical Model of Space Combat  (Read 25095 times)

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #255 on: December 12, 2022, 09:54:55 AM »

I meant the code I posted above that's written in a "dumb" (ie. as simple as possible in terms of readability) way to be easily understandable in what it does. I'll get it for you asap but am currently prevented from going to the computer. If need it urgently you can run the code posted above in R but with

damage <- 400
 hitstrength <- 400
 startingarmor <- 1500
hitstrengthcalc <- function(hitstrength, armor) return(max(0.15,hitstrength/(hitstrength+max(armor,0.05*startingarmor))))
 shipcells <- 12
 probabilities <- c(1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12)

For example. It'll print the wrong "equivalent armor hp" because the 14 should be changed to 16 there but that's a trash value anyway.
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #256 on: December 12, 2022, 11:42:26 AM »

{
    "Shot.damage_armor_grid":{
        "test_config":{
            "decimal_places":2
        },
        "shot_spec":{
            "base_damage":200.0,
            "base_armor_damage":400.0,
            "base_shield_damage":100.0,
            "strength":400.0,
            "flux_hard":false
        },
        "armor_grid_spec":{
            "armor_rating":1500.0,
            "cell_size":10.0,
            "width":12
        },
        "armor_grid_cell_values":{
            "initial":[[100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00]],
            "after_shots":[[[100.00, 99.77, 99.53, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.53, 99.77, 100.00],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [100.00, 99.77, 99.53, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.53, 99.77, 100.00]],
            [[100.00, 99.53, 99.06, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 99.06, 99.53, 100.00],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [100.00, 99.53, 99.06, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 99.06, 99.53, 100.00]],
            [[100.00, 99.29, 98.58, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 98.58, 99.29, 100.00],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [100.00, 99.29, 98.58, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 98.58, 99.29, 100.00]]]
        }
    }
}
Number of firings to destroy ship
Calculated: 93
Average Simulated: 94

Average hull value
Calculated: 10128
Simulated: 10056
I don't know how to calculate hull damage.  Is the hull damage the sum across the entire armor grid of the amount whereby armor damage reduces the value of each cell below zero—or is the sum across just the third through third last cells of the second row?  I used the former above, and even 90 hits seems excessive for a Dominator especially because you told me it should have been around 60, but the latter approach required over 200 hits.

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #257 on: December 12, 2022, 12:10:34 PM »

Why is the damage 200 though?

Hull damage should be the amount which cells are reduced below zero across the whole armor grid. Padding cells can't be hit directly, but they can take damage indirectly - possibly leading to hull damage. At least that's how I believe it goes.

Note that damage must also be scaled back. So, for a HE shot, calculate damage using a 2x multiplier, then divide the overkill by 2. Now seeing damage=200 there makes me wonder - since these are supposed to be energy shots damage vs hull should be same as damage vs armor so no scaling.

I'll check this in more depth when I get back to the computer, probably tomorrow. Something is also possibly a little fishy about the simulated vs model numbers, they are suspiciously close - making me wonder if the timepoints are applied correctly (the hull residual calculated from what the simulated have left when the model dies, and the model's time to kill fixed when the model hits 0 hull even though the simulated combats continue). Also I wonder about the 10k hp, shouldn't a dominator have 14k? But one thing at a time.

Still, getting in the right order of magnitude is encouraging.
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #258 on: December 12, 2022, 03:27:57 PM »

Oh, the code uses base_armor_damage rather than the number in the base_damage field; nevertheless, the number I put in base_damage was erroneous.  The Shot class has several fields because I had thought to save the steps of checking damage type and multiplying the damage number by precomputing all the type-and-target-modified damage numbers and then using each one (and the base number) where needed.  I have now changed the Shot constructor to do this step and save an armor damage factor to divide overflow damage by later.
test_combat_entities_data.json
{
    "Shot.damage_armor_grid":{
        "test_config":{
            "decimal_places":2
        },
        "shot_spec":{
            "damage":400.0,
            "damage_type":"ENERGY",
            "beam":false,
            "flux_hard":false
        },
        "armor_grid_spec":{
            "armor_rating":1500.0,
            "cell_size":10.0,
            "width":12
        },
        "armor_grid_cell_values":{
            "initial":[[100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00],
             [100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00, 100.00]],
            "after_shots":[[[100.00, 99.77, 99.53, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.53, 99.77, 100.00],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [99.77,  99.30, 98.83, 98.36, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.13, 98.36, 98.83, 99.30, 99.77],
             [100.00, 99.77, 99.53, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.30, 99.53, 99.77, 100.00]],
            [[100.00, 99.53, 99.06, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 99.06, 99.53, 100.00],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [99.53,  98.59, 97.65, 96.71, 96.24, 96.23, 96.23, 96.23, 96.23, 96.23, 96.23, 96.24, 96.71, 97.65, 98.59, 99.53],
             [100.00, 99.53, 99.06, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 98.59, 99.06, 99.53, 100.00]],
            [[100.00, 99.29, 98.58, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 98.58, 99.29, 100.00],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [99.29,  97.88, 96.46, 95.04, 94.32, 94.31, 94.31, 94.31, 94.31, 94.31, 94.31, 94.32, 95.04, 96.46, 97.88, 99.29],
             [100.00, 99.29, 98.58, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 97.87, 98.58, 99.29, 100.00]]]
        }
    }
}
[close]
combat_entities.py
Code
import numpy as np
import copy
"""
Damage computing module.

methods:
- hit_probability
- main

classes:
- ArmorGrid
- Ship
- Shot
- Weapon
"""
class ArmorGrid:
    """
    A Starsector ship armor grid.
   
    - Represents a horizontal frontal row of armor on a ship.
    - Includes 2 rows of vertical padding above; 2 below; 2 columns of
      left horizontal padding; 2 right.
   
    constants:
    _MINIMUM_ARMOR_FACTOR - multiply by armor rating to determine
                            minimum effective value of pooled armor for
                            damage calculations
    _MINIMUM_DAMAGE_FACTOR - least factor whereby this ArmorGrid can
                             cause incoming damage to be multiplied
    ARMOR_RATING_PER_CELL_FACTOR - multiply by armor rating to determine
                                   initial value of each cell of an
                                   ArmorGrid
    WEIGHTS - multiply by damage to the central cell to determine damage
              to surrounding ones
             
    variables:
    _minimum_armor - minimum effective value of armor for damage
                     calculations
    bounds - right bound of each cell in the middle row, except the two
             padding cells on both sides
    cells - 2d array of armor grid cell values
   
    methods:
    - _pooled_values
    - damage_factors
    """
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    POOLING_WEIGHTS = np.array([[0.0, 0.5, 0.5, 0.5, 0.0],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.0, 0.5, 0.5, 0.5, 0.0]])
    DAMAGE_DISTRIBUTION = ARMOR_RATING_PER_CELL_FACTOR * POOLING_WEIGHTS
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        """
        armor_rating - armor rating of the ship to which this ArmorGrid
                       belongs
        cell_size - size, in pixels, of each armor cell, which is square
        width - width of the armor grid in cells
        """
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        armor_per_cell = armor_rating * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR
        self.cells = np.full((5,width+4), armor_per_cell)
        self.bounds = np.arange(0, width) * cell_size

    def _pool(self, index):
        """
        Return the armor value pooled around a cell at this index.

        The cell must be after the second and before the second to last
        one of the middle row.
        """
        return np.sum(ArmorGrid.POOLING_WEIGHTS * self.cells[0:5,index:index+5])
       
    def _pooled_values(self) -> object:
        """
        Return the armor value pooled around each cell of the middle
        row, from third through third-last.
        """
        return np.maximum(self._minimum_armor,
                          np.array([self._pool(i) for i, _ in
                                    enumerate(self.cells[2,2:-2])]))
                 
    def damage_factors(self, hit_strength: float) -> object:
        """
        Return the factor whereby to multiply the damage of each
        non-padding cell of the grid.
        """
        return np.maximum(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                          1 / (1 + self._pooled_values() / hit_strength))
       
       
class Ship:
    """
    A Starsector ship.
   
    methods:
    - will_overload
    - overloaded
    - shield_up
   
    variables:
    weapons - container of the weapons of the ship, with structure to be
              determined
    armor_grid - ArmorGrid of the ship
    hull - amount of hitpoints the ship has
    flux_capacity - how much flux would overload the ship
    flux_dissipation - how much flux the ship can expel every second when
                       not actively venting
    hard_flux - hard flux amount
    soft_flux - soft flux amount
    """
    def __init__(self,
            weapons: object,
            armor_grid: object,
            hull: float,
            flux_capacity: float,
            flux_dissipation: float):
        """
        weapons - container of the weapons of the ship, with structure
                  to be determined
        armor_grid - ArmorGrid of the ship
        hull - amount of hitpoints the ship has
        flux_capacity - amount of flux to overload the ship
        flux_dissipation - how much flux the ship can expel every second
                           without actively venting
        """
        self.weapons = weapons
        self.armor_grid = armor_grid
        self.hull = hull
        self.flux_capacity = flux_capacity
        self.flux_dissipation = flux_dissipation
        self.hard_flux, self.soft_flux = 0, 0
   
    @property
    def will_overload(self):
        """
        Return whether the ship will now overload.
       
        A ship will overload if soft or hard flux exceeds
        the flux capacity of the ship.
        """
        return (self.hard_flux > self.flux_capacity
                or self.soft_flux > self.flux_capacity)
   
    @property
    def overloaded(self):
        """
        Return whether the ship is in the overloaded state.
       
        Ignores the duration of overloaded.
        -TODO: Implement overload duration
        """
        return self.will_overload
       
    @property
    def shield_up(self):
        """
        Return whether the shield is up or down.
       
        Presumes the shield to be up if the ship is not overloaded,
        ignoring smart tricks the AI can play to take kinetic damage
        on its armor and save its shield for incoming high explosive
        damage.
        """
        return not self.overloaded


class Shot:
    """
    A shot fired at a row of armor cells protected by a shield.

    Calculates the expectation value of the damage of a shot with a
    spread to a ship with a shield, armor grid, and random positional
    deviation.
   
    variables:
    damage - amount listed under damage in weapon_data.csv
    shield_damage - damage amount to be inflictedon on a shield
    armor_damage - damage amount to be inflicted on armor
    strength - strength against armor for armor damage calculation
    flux_hard - whether the flux damage against shields is hard or not
    probabilities - chance to hit each cell of an armor grid at which
                    this Shot is targeted after being instantiated
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    DAMAGE_TYPE_DAMAGE_FACTORS = {
        "KINETIC" : {
            "shield" : 2.0,
            "armor" : 0.5,
        },
        "HIGH_EXPLOSIVE" : {
            "shield" : 0.5,
            "armor" : 2.0
        },
        "FRAGMENTATION" : {
            "shield" : 0.25,
            "armor" : 0.25
        },
        "ENERGY" : {
            "shield" : 1.0,
            "armor" : 1.0
        }
    }
   
    def __init__(
            self,
            damage: float,
            damage_type: str,
            beam: bool,
            flux_hard: bool):
        """
        damage - amount listed under damage in weapon_data.csv
        damage_type - string listed under damage type in weapon_data.csv
        beam - whether the weapon is a beam or not
        flux_hard - whether flux damage to shields is hard or not
        """
        self._damage = damage
        self._armor_damage_factor = Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["armor"]
        self._shield_damage = damage * Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["shield"]
        self._armor_damage = damage * self._armor_damage_factor
        self._strength = self._armor_damage * (0.5 if beam else 1)
        self._flux_hard = flux_hard
        self._probabilities = None
        self._expected_armor_damage = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability and, accordingly, expected distribution of
        base armor damage over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a row and
        save the probabilities and consequent expected armor damage for later
        calculation.
        """
        self._probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
        self._expected_armor_damage = self._armor_damage * self._probabilities
       
    def damage_armor_grid(self, armor_grid: object, damage: float, i: int):
        """
        Distribute across this ArmorGrid this amount of damage at
        this index.

        Note: may reduce armor cell values below zero.

        armor_grid - ArmorGrid instance
        """
        armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.DAMAGE_DISTRIBUTION

    def damage_ship(self, ship: object):
        """
        Apply the expected values of shield, armor, and hull damage to a
        ship.

        ship - Ship instance
        """
        if False: pass #TODO: implement shield check
        damage_distribution = (
            self._expected_armor_damage
            * ship.armor_grid.damage_factors(self._strength))
        for i, damage in enumerate(damage_distribution):
            self.damage_armor_grid(ship.armor_grid, damage, i)
        hull_damage = (np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
                       / self._armor_damage_factor)
        ship.hull = max(0, ship.hull + hull_damage)
        ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)
                       
                       
class Weapon:
    """
    A weapon for a Starsector ship in simulated battle.
   
    Is carried by a Ship instance, contains a shot with some
    distribution, and fires that shot at a target.
    """
    def __init__(self, shot: object, distribution: object):
        """
        shot - projectile, missile, or beam tick of the weapon
        distribution - function returning the probability that
                       of the shot to hit between two bounds
        """
        self.shot = shot
        self.distribution = distribution
       
    def fire(self, ship: object):
        """
        Fire the shot of this weapon at that ship.
       
        ship - a Ship instance
        """
        self.shot.distribute(ship, self.distribution)
        #TODO: implement general hit ship method on Shot
        self.shot.damage_ship(ship)
        return #future code below
        if ship.shield_up:
            pass #TODO: implement shield damage, overloading, etc.
        else: self.shot.damage_armor_grid(ship)
[close]
test_combat_entities.py
Code
import combat_entities
import numpy as np
import pytest
import json

       
def _data():
    with open('test_combat_entities_data.json') as f:
        return json.load(f)["Shot.damage_armor_grid"]


def _decimal_places(data):
    return data["test_config"]["decimal_places"]


def _hit_probability(bound: float): return 1 / 12#hardcoded dummy for test


def _armor_grid(data):
    return combat_entities.ArmorGrid(data["armor_grid_spec"]["armor_rating"],
                                     data["armor_grid_spec"]["cell_size"],
                                     data["armor_grid_spec"]["width"])


def _big_energy_shot(data):
    return combat_entities.Shot(data["shot_spec"]["damage"],
                                data["shot_spec"]["damage_type"],
                                data["shot_spec"]["beam"],
                                data["shot_spec"]["flux_hard"])

def _dominator(data):
    spec = (14_000,#hull
            1_000,#flux_capacity
            100)#flux_dissipation)
    return combat_entities.Ship([], _armor_grid(data), *spec)


def test_armor_grid_constructor():
    data = _data()
    armor_grid = _armor_grid(data)
    rounded_grid = np.round(armor_grid.cells, _decimal_places(data))
    expected_grid = np.array(data["armor_grid_cell_values"]["initial"])
    condition = (rounded_grid == expected_grid).all()
    assert condition, "Initial armor grid does not equal expected one."

   
def test_damage_armor_grid():
    data = _data()
    armor_grid = _armor_grid(data)
    shot = _big_energy_shot(data)
    ship = _dominator(data)
    shot.distribute(ship, _hit_probability)
    decimal_places = _decimal_places(data)
    expected_armor_grids = data["armor_grid_cell_values"]["after_shots"]
   
    for i, expected_armor_grid in enumerate(expected_armor_grids):
        damage_distribution = (shot._expected_armor_damage
                               * armor_grid.damage_factors(shot._strength))
        for j, damage in enumerate(damage_distribution):
            shot.damage_armor_grid(armor_grid, damage, j)
        ship.armor_grid.cells = np.maximum(0, armor_grid.cells)
        assert (np.round(armor_grid.cells, decimal_places)
                == np.array(expected_armor_grid)).all(), (
                "Armor grid after shot", i, "does not equal expected one.")
       

def _simulate_hit(shot: object, ship: object, index: int):
    pooled_armor = max(ship.armor_grid._minimum_armor,
                       ship.armor_grid._pool(index))
    damage_factor = max(combat_entities.ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                        1 / (1 + pooled_armor / shot._strength))
    damage = shot._armor_damage * damage_factor
    shot.damage_armor_grid(ship.armor_grid, damage, index)
    hull_damage = np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
    ship.hull = max(0, ship.hull + hull_damage)
    ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)


def test_calculation_vs_simulation():
    #setup
    data = _data()
    shot = _big_energy_shot(data)
    calculated_ship = _dominator(data)
    simulated_ship = _dominator(data)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 100

    #calculate
    calculated_firings = 0
    calculated_hull = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_hull += calculated_ship.hull
        calculated_firings += 1

    #simulate
    simulated_firings = 0
    simulated_hull = 0
    for trial in range(trials):
        firing = 0
        hull = 0
        while simulated_ship.hull > 0:
            index = np.random.randint(0, len(simulated_ship.armor_grid.bounds))
            _simulate_hit(shot, simulated_ship, index)
            hull += simulated_ship.hull
            firing += 1
        simulated_hull += hull / firing
        simulated_ship.armor_grid = _armor_grid(data)
        simulated_ship.hull = 14_000#hardcoded Dominator value
        simulated_firings += firing

    print()
    print("Number of firings to destroy ship")
    print("Calculated:", calculated_firings)
    print("Average Simulated:", round(simulated_firings / trials))
    print()
    print("Average hull value")
    print("Calculated:", round(calculated_hull / calculated_firings))
    print("Simulated:", round(simulated_hull / trials))
[close]
Number of firings to destroy ship
Calculated: 92
Average Simulated: 93

Average hull value
Calculated: 10209
Simulated: 10135

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #259 on: December 12, 2022, 09:35:31 PM »

All right Liral, the problem was on my end for the shot number. I rigged the dumb model to produce a complete annotated damage series vs a Dominator (armor 1500, hull hp 14000, 12 cells) with a uniform probability distribution (1/12th chance to hit each cell), in such a way that it also displays hull damage on the armor grid (it pools only values above 0 and calculates hull damage from values below 0 - this approach will work only for energy weapons but also demonstrates where the damage is going through; for comparisons consider cells with a value below 0 to have value 0). The full annotated output with all of the 92 armor grids: https://pastebin.com/ZtMNrx2p

Code:
Code
sink(file = "dominator_output.txt")


damage <- 400
hitstrength <- 400
startingarmor <- 1500
hitstrengthcalc <- function(hitstrength, armor) return(max(0.15,hitstrength/(hitstrength+max(armor,0.05*startingarmor))))
shipcells <- 12
probabilities <- c(1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12,1/12)
norounds <- 3


weights <- matrix(c(0,0.5,0.5,0.5,0,0.5,1,1,1,0.5,0.5,1,1,1,0.5,0.5,1,1,1,0.5,0,0.5,0.5,0.5,0),5,5)
shot <- 0

poolarmor <- function(armormatrix, index) {
  sum <- 0
  for(i in 1:5)for(j in 1:5) sum <- sum + max(0,weights[i,j]*armormatrix[i,index-3+j])
  return(sum)
}

hulldamage <- function(armormatrix){
  sum <- 0
  for(i in 1:length(armormatrix[,1])) for (j in 1:length(armormatrix[1,])) if(armormatrix[i,j] < 0) sum <- sum -armormatrix[i,j]
  return(sum)
}

print("Starting armor matrix")
print(armormatrix <- matrix(startingarmor / 15, 5, shipcells+4))

hulldamagevalue <- 0

while(hulldamagevalue < 14000){

shot <- shot+1
print("Shot:")
print(shot)
 
armordamagereductions <- vector(shipcells, mode="double")
for (x in 1:length(armordamagereductions)) {
  armordamagereductions[x] <- hitstrengthcalc(hitstrength,poolarmor(armormatrix,x+2))
}

armordamagesatmiddlecells <- armordamagereductions*damage

print("Probability adjusted armor damage at middle cells:")
for(x in 1:length(armordamagesatmiddlecells)) {
  armordamagesatmiddlecells[x] <- armordamagesatmiddlecells[x]*probabilities[x]
}
print(armordamagesatmiddlecells)
print("Total armor damage incoming at middle cells:")
print(sum(armordamagesatmiddlecells))

for (i in 1:length(armordamagesatmiddlecells)){
  for (j in 1:5){
    for (k in 1:5){
      armormatrix[j,i+k-1] <- armormatrix[j,i+k-1] - armordamagesatmiddlecells[i]*weights[j,k]/15
    }
  }
}
print("New armor matrix:")
print((armormatrix))

hulldamagevalue <- hulldamage(armormatrix)
print("Hull damage:")
print(hulldamagevalue)
}

sink(file = NULL)



So it does take this model 92 shots to kill the Dominator. What went wrong? Well, I just misremembered things, sorry. Intrinsic_parity's graph was this:
(from https://fractalsoftworks.com/forum/index.php?topic=25536.msg382015#msg382015) . But note that the hull hp is actually 10000, not 14000! also, the distribution was "pretty wide" and the armor was 1000.

Meanwhile this is my output: (from https://fractalsoftworks.com/forum/index.php?topic=25536.msg382471#msg382471). But this was 500 damage shots using a realistic convolved normal distribution (something like 10 spread I think!) and we know the code contains an error somewhere due to the plots I did later about chi drift.

These two apparently became conflated in my mind due to giving the same value but in fact the assumptions were quite different and your model gave the correct result which I can independently verify based on the "dumb" model.

Now the thing that's different though is that you get very similar results for the model and avgs in hull unlike I and intrinsic_parity (the shots are expected to be quite close and your code seems correct there).

I am wondering about this line:
Code
    print("Simulated:", round(simulated_hull / trials))

does this mean that you're getting the hull value from the end of each trial? Because then they will of course be the same since that is the point at which the ship dies. To get the hull residual you are supposed to calculate the hull left on the simulated ships at the point when the model dies, not when the simulations die as it's supposed to be "how much hull do simulations have left on average when the model dies".
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #260 on: December 12, 2022, 11:39:32 PM »

I average the hull value over each trial and then average the averages.  I have changed the code to (slightly roughly) calculate the standard deviation of the hull value from zero at the firing calculated to destroy the ship.
trials: 1000

Number of firings to destroy ship
Calculated: 92
Average Simulated: 93

Standard deviation of simulated hull from zero upon firing calculated to destroy ship: 186
test_combat_entities.py
Code
...


def test_calculation_vs_simulation():
    #setup
    data = _data()
    shot = _big_energy_shot(data)
    calculated_ship = _dominator(data)
    simulated_ship = _dominator(data)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 1_000

    #calculate
    calculated_firings = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_firings += 1

    #simulate
    simulated_firings = 0
    simulated_hull_variance = 0
    for trial in range(trials):
        firing = 0
        while simulated_ship.hull > 0:
            index = np.random.randint(0, len(simulated_ship.armor_grid.bounds))
            _simulate_hit(shot, simulated_ship, index)
            if firing == calculated_firings:
                simulated_hull_variance += simulated_ship.hull ** 2
            firing += 1
        simulated_ship.armor_grid = _armor_grid(data)
        simulated_ship.hull = 14_000#hardcoded Dominator value
        simulated_firings += firing

    print()
    print("trials:", trials)
    print()
    print("Number of firings to destroy ship")
    print("Calculated:", calculated_firings)
    print("Average Simulated:", round(simulated_firings / trials))
    print()
    print("Standard deviation of simulated hull from zero upon firing "
          "calculated to destroy ship:",
          round(np.sqrt(simulated_hull_variance / trials)))
[close]
« Last Edit: December 12, 2022, 11:58:39 PM by Liral »
Logged

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #261 on: December 13, 2022, 12:14:45 AM »

Yeah that's it. So it looks like your code is correct and even the % matches ours. We officially have a working Python armor damage function.

I'm having some trouble getting a simple sum based error correction to work. I'm wondering if there's something wrong with the math after all, specifically in the E(g(x))Pr{X=x} part.

Given how small the error is in absolute terms (just look at it, 186 hull, of course it will become over 1% when looked at in relative terms but it is so little and our model is bound to have larger deviations from reality in, say, that it does not consider ship movement or turret rotation at all) I suggest we put the rest of the code together now with this function and get back to the error correction when I can write a simplified reference implementation of it.

The next step would be to either build the code for shields (logic: use shield to block if it would not overload you (increase soft + hard flux over max flux). if you do, increase flux by damage to shields and flux type times shield efficacy. dissipate soft flux only, with rate flux dissipation - shield upkeep / sec. if you did not block, deal damage to armor and dissipate first soft, then if all soft flux is gone then hard, with rate flux dissipation) or realistic firing sequences (use the shots we produce from the shot time sequence function, increment time in seconds, every second for guns loop over number of shots and deal damage for each shot, for beams do 1 shot with damage based on beam ticks during that second and hit strength = base dps / 2).

Come to think of it firing sequences first might be the reasonable thing, since the logic is supposed to be that you can only have shield on or off during a given second, so first pre-compute damage to shields during that whole second and then flick shields on or off depending on whether you can block without overloading. It would be unrealistic to be able to, say, not block a gun shot incoming while blocking a number of beam ticks, hence this way.
« Last Edit: December 13, 2022, 12:31:01 AM by CapnHector »
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #262 on: December 13, 2022, 11:22:14 AM »

While translating the hit sequence code per your above instructions, I have noticed a small numerical instability.
Code
print(123 // (1 / 45))
print(int(45 * 123))
print(45 * 123)
print(123 / (1 / 45))
print(1 / 45)
5534.0
5535
5535
5535.0
0.022222222222222223
I suspect the cause to be the slightly-inaccurate representation of decimals as floating points.  I will use the second expression, int(45 * 123).

combat_entities.py
Code
import numpy as np
import copy
"""
Damage computing module.

methods:
- hit_probability
- main

classes:
- ArmorGrid
- Ship
- Shot
- Weapon
"""
class ArmorGrid:
    """
    A Starsector ship armor grid.
   
    - Represents a horizontal frontal row of armor on a ship.
    - Includes 2 rows of vertical padding above; 2 below; 2 columns of
      left horizontal padding; 2 right.
   
    constants:
    _MINIMUM_ARMOR_FACTOR - multiply by armor rating to determine
                            minimum effective value of pooled armor for
                            damage calculations
    _MINIMUM_DAMAGE_FACTOR - least factor whereby this ArmorGrid can
                             cause incoming damage to be multiplied
    ARMOR_RATING_PER_CELL_FACTOR - multiply by armor rating to determine
                                   initial value of each cell of an
                                   ArmorGrid
    WEIGHTS - multiply by damage to the central cell to determine damage
              to surrounding ones
             
    variables:
    _minimum_armor - minimum effective value of armor for damage
                     calculations
    bounds - right bound of each cell in the middle row, except the two
             padding cells on both sides
    cells - 2d array of armor grid cell values
   
    methods:
    - _pooled_values
    - damage_factors
    """
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    POOLING_WEIGHTS = np.array([[0.0, 0.5, 0.5, 0.5, 0.0],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.0, 0.5, 0.5, 0.5, 0.0]])
    DAMAGE_DISTRIBUTION = ARMOR_RATING_PER_CELL_FACTOR * POOLING_WEIGHTS
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        """
        armor_rating - armor rating of the ship to which this ArmorGrid
                       belongs
        cell_size - size, in pixels, of each armor cell, which is square
        width - width of the armor grid in cells
        """
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        armor_per_cell = armor_rating * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR
        self.cells = np.full((5,width+4), armor_per_cell)
        self.bounds = np.arange(0, width) * cell_size

    def _pool(self, index):
        """
        Return the armor value pooled around a cell at this index.

        The cell must be after the second and before the second to last
        one of the middle row.
        """
        return np.sum(ArmorGrid.POOLING_WEIGHTS * self.cells[0:5,index:index+5])
       
    def _pooled_values(self) -> object:
        """
        Return the armor value pooled around each cell of the middle
        row, from third through third-last.
        """
        return np.maximum(self._minimum_armor,
                          np.array([self._pool(i) for i, _ in
                                    enumerate(self.cells[2,2:-2])]))
                 
    def damage_factors(self, hit_strength: float) -> object:
        """
        Return the factor whereby to multiply the damage of each
        non-padding cell of the grid.
        """
        return np.maximum(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                          1 / (1 + self._pooled_values() / hit_strength))
       
       
class Ship:
    """
    A Starsector ship.
   
    methods:
    - will_overload
    - overloaded
    - shield_up
   
    variables:
    weapons - container of the weapons of the ship, with structure to be
              determined
    armor_grid - ArmorGrid of the ship
    hull - amount of hitpoints the ship has
    flux_capacity - how much flux would overload the ship
    flux_dissipation - how much flux the ship can expel every second when
                       not actively venting
    hard_flux - hard flux amount
    soft_flux - soft flux amount
    """
    def __init__(self,
            weapons: object,
            armor_grid: object,
            hull: float,
            flux_capacity: float,
            flux_dissipation: float):
        """
        weapons - container of the weapons of the ship, with structure
                  to be determined
        armor_grid - ArmorGrid of the ship
        hull - amount of hitpoints the ship has
        flux_capacity - amount of flux to overload the ship
        flux_dissipation - how much flux the ship can expel every second
                           without actively venting
        """
        self.weapons = weapons
        self.armor_grid = armor_grid
        self.hull = hull
        self.flux_capacity = flux_capacity
        self.flux_dissipation = flux_dissipation
        self.hard_flux, self.soft_flux = 0, 0
   
    @property
    def will_overload(self):
        """
        Return whether the ship will now overload.
       
        A ship will overload if soft or hard flux exceeds
        the flux capacity of the ship.
        """
        return (self.hard_flux > self.flux_capacity
                or self.soft_flux > self.flux_capacity)
   
    @property
    def overloaded(self):
        """
        Return whether the ship is in the overloaded state.
       
        Ignores the duration of overloaded.
        -TODO: Implement overload duration
        """
        return self.will_overload
       
    @property
    def shield_up(self):
        """
        Return whether the shield is up or down.
       
        Presumes the shield to be up if the ship is not overloaded,
        ignoring smart tricks the AI can play to take kinetic damage
        on its armor and save its shield for incoming high explosive
        damage.
        """
        return not self.overloaded


class Shot:
    """
    A shot fired at a row of armor cells protected by a shield.

    Calculates the expectation value of the damage of a shot with a
    spread to a ship with a shield, armor grid, and random positional
    deviation.
   
    variables:
    damage - amount listed under damage in weapon_data.csv
    shield_damage - damage amount to be inflictedon on a shield
    armor_damage - damage amount to be inflicted on armor
    strength - strength against armor for armor damage calculation
    flux_hard - whether the flux damage against shields is hard or not
    probabilities - chance to hit each cell of an armor grid at which
                    this Shot is targeted after being instantiated
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    DAMAGE_TYPE_DAMAGE_FACTORS = {
        "KINETIC" : {
            "shield" : 2.0,
            "armor" : 0.5,
        },
        "HIGH_EXPLOSIVE" : {
            "shield" : 0.5,
            "armor" : 2.0
        },
        "FRAGMENTATION" : {
            "shield" : 0.25,
            "armor" : 0.25
        },
        "ENERGY" : {
            "shield" : 1.0,
            "armor" : 1.0
        }
    }
   
    def __init__(
            self,
            damage: float,
            damage_type: str,
            beam: bool,
            flux_hard: bool):
        """
        damage - amount listed under damage in weapon_data.csv
        damage_type - string listed under damage type in weapon_data.csv
        beam - whether the weapon is a beam or not
        flux_hard - whether flux damage to shields is hard or not
        """
        self._damage = damage
        self._armor_damage_factor = Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["armor"]
        self._shield_damage = damage * Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["shield"]
        self._armor_damage = damage * self._armor_damage_factor
        self._strength = self._armor_damage * (0.5 if beam else 1)
        self._flux_hard = flux_hard
        self._probabilities = None
        self._expected_armor_damage = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability and, accordingly, expected distribution of
        base armor damage over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a row and
        save the probabilities and consequent expected armor damage for later
        calculation.
        """
        self._probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
        self._expected_armor_damage = self._armor_damage * self._probabilities
       
    def damage_armor_grid(self, armor_grid: object, damage: float, i: int):
        """
        Distribute across this ArmorGrid this amount of damage at
        this index.

        Note: may reduce armor cell values below zero.

        armor_grid - ArmorGrid instance
        """
        armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.DAMAGE_DISTRIBUTION

    def damage_ship(self, ship: object):
        """
        Apply the expected values of shield, armor, and hull damage to a
        ship.

        ship - Ship instance
        """
        if False: pass #TODO: implement shield check
        damage_distribution = (
            self._expected_armor_damage
            * ship.armor_grid.damage_factors(self._strength))
        for i, damage in enumerate(damage_distribution):
            self.damage_armor_grid(ship.armor_grid, damage, i)
        hull_damage = (np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
                       / self._armor_damage_factor)
        ship.hull = max(0, ship.hull + hull_damage)
        ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)


class AmmoTracker:
    """
    Holds and regenerates weapon ammunition.
    """
    def __init__(self, weapon: object):
        self.ammo = weapon.ammo
        self.max_ammo = weapon.ammo
        self.ammo_regen = weapon.ammo_regen
        self.reload_size = weapon.reload_size
        self.ammo_regen_time = 1 / weapon.ammo_regen
        self.ammo_regenerated = 0
        self.ammo_regen_timer = 0
        self.regenerating_ammo = False
   
    def should_regenerate_ammo(self, time):
        return time - self.ammo_regen_timer > self.ammo_regen_time
   
    def regenerate_ammo(self, time):
        amount = int(self.ammo_regen * (time - self.ammo_regen_timer))
        self.ammo_regenerated += amount
        self.ammo_regen_timer += amount / self.ammo_regen
        if self.ammo_regenerated >= self.reload_size:
            self.ammo += self.ammo_regenerated
            self.ammo_regenerated = 0
        if self.ammo >= self.max_ammo:
            self.ammo = self.ammo
            self.regenerating_ammo = False
       
                       
class Weapon:
    """
    A weapon for a Starsector ship in simulated battle.
   
    Is carried by a Ship instance, contains a shot with some
    distribution, and fires that shot at a target.

    constants:
    MINIMUM_REFIRE_DELAY - Starsector keeps weapons from firing more
                           than once every 0.05 seconds
    """
    MINIMUM_REFIRE_DELAY = 0.05
   
    def __init__(self, data: object, shot: object, distribution: object):
        """
        data - relevant game file information
        shot - projectile, missile, or beam tick of the weapon
        distribution - function returning the probability that
                       of the shot to hit between two bounds
        """
        self.mode = "GUN"
        self.charge_up = data["charge up"]
        self.charge_down = data["charge down"]
        self.burst_size = data["burst size"]
        self.burst_delay = data["burst delay"]
        if self.burst_delay > 0 or self.mode == "BEAM":
            self.burst_delay = max(self.burst_delay,
                                   Weapon.MINIMUM_REFIRE_DELAY)
        if self.mode == "GUN":
            self.charge_down = max(self.charge_down,
                                   Weapon.MINIMUM_REFIRE_DELAY)
        self.ammo = data["ammo"] if data["ammo"] else "UNLIMITED"
        self.ammo_regen = data["ammo regen"] if data["ammo regen"] else 0
        self.reload_size = data["reload size"] if data["reload size"] else 0
        self.speed = data["speed"]
       
        self.hit_sequence = None
        self.shot = shot
        self.distribution = distribution
       
    def fire(self, ship: object):
        """
        Fire the shot of this weapon at that ship.
       
        ship - a Ship instance
        """
        self.shot.distribute(ship, self.distribution)
        #TODO: implement general hit ship method on Shot
        self.shot.damage_ship(ship)
        return #future code below
        if ship.shield_up:
            pass #TODO: implement shield damage, overloading, etc.
        else: self.shot.damage_armor_grid(ship)

    def _hit_sequence(self, distance):
        """
        Return a hit sequence for a weapon against some target at a
        distance.
           
        times in seconds, self.ammo_regen is in ammo / second
        """
        #this vector will store all the hit time coordinates
        #current time
        #add a very small fraction here to round time correctly
        time = 0.001
        time_limit = 100
        time_interval = 1
        time_intervals = time_limit // time_interval
        travel_time = self.speed / distance
        ammo_tracker = AmmoTracker(self)
         
        if self.mode == "GUN":
            hits = []
            while time < time_limit:
                time += self.charge_up
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
                if self.burst_delay == 0:
                    for i in range(self.burst_size):
                        if ammo_tracker.ammo == 0: continue
                        hits.append(time + travel_time)
                        ammo_tracker.ammo -= 1
                        if not ammo_tracker.regenerating_ammo:
                            ammo_tracker.ammo_regen_timer = time
                            ammo_tracker.regenerating_ammo = True
                else:
                    for i in range(self.burst_size):
                        if ammo_tracker.ammo == 0: continue
                        hits.append(hits, time + travel_time)
                        time += self.burst_delay
                        ammo_tracker.ammo -= 1
                        if not ammo_tracker.regenerating_ammo:
                            ammo_tracker.ammo_regen_timer = time
                            ammo_tracker.regenerating_ammo = True
                        if ammo_tracker.should_regenerate_ammo(time):
                            ammo_tracker.regenerate_ammo(time)
                   
                time += self.charge_down
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
                       
            return (
                [len([hit for hit in hits if 0 <= hit <= time_interval])]
                + [len([hit for hit in hits
                        if (i - 1) * time_interval < hit <= i * time_interval])
                   for i in range(1, time_intervals)])
           
        if self.mode == "CONTINUOUS_BEAM":
            chargeup_ticks = self.charge_up / beam_tick
            charge_down_ticks = self.charge_down / beam_tick
            burst_ticks = self.burst_size / beam_tick
            intensities = []
            #for i in range(chargeup_ticks):
                #beam intensity scales quadratically while charging up
            while time < time_limit:
                times.append(time)
                intensities.append(1)
                time += beam_tick
            return [sum([intensity for i, intensity in enumerate(intensities)
                    if t - 1 < times[i] < t]) for t in range(time_intervals)]
       
        if self.mode == "BURST_BEAM":
            charge_up_ticks = self.charge_up // beam_tick
            charge_down_ticks = self.charge_down // beam_tick
            burst_ticks = self.burst_size // beam_tick
            times = []
            intensities = []
            while time < time_limit:
                if ammo_tracker.ammo == 0:
                    time += global_minimum_time
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                    continue
               
                ammo_tracker.ammo -= 1
                for i in range(charge_up_ticks):
                    times.append(time)
                    intensities.append((i * beam_tick) ** 2)
                    time += beam_tick
                    if not ammo_tracker.regenerating_ammo:
                        ammo_tracker.ammo_regen_timer = time
                        ammo_tracker.regenerating_ammo = True
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                                       
                for _ in range(burst_ticks):
                    times.append(time)
                    intensities.append(1)
                    time += beam_tick
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                       
                for i in range(charge_down_ticks):
                    times.append(time)
                    intensities.append(
                        ((charge_down_ticks - i) * beam_tick) ** 2)
                    time += beam_tick
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                                   
                time += self.burst_delay
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
               
            return [sum([intensity for i, intensity in enumerate(intensities)
                    if t - 1 < times[i] < t]) for t in range(time_intervals)]
[close]
test_combat_entites.py
Code
import combat_entities
import numpy as np
import pytest
import json

       
def _data():
    with open('test_combat_entities_data.json') as f:
        return json.load(f)["Shot.damage_armor_grid"]


def _decimal_places(data):
    return data["test_config"]["decimal_places"]


def _hit_probability(bound: float): return 1 / 12#hardcoded dummy for test


def _armor_grid(data):
    return combat_entities.ArmorGrid(data["armor_grid_spec"]["armor_rating"],
                                     data["armor_grid_spec"]["cell_size"],
                                     data["armor_grid_spec"]["width"])


def _big_energy_shot(data):
    return combat_entities.Shot(data["shot_spec"]["damage"],
                                data["shot_spec"]["damage_type"],
                                data["shot_spec"]["beam"],
                                data["shot_spec"]["flux_hard"])

def _dominator(data):
    spec = (14_000,#hull
            1_000,#flux_capacity
            100)#flux_dissipation)
    return combat_entities.Ship([], _armor_grid(data), *spec)


def test_armor_grid_constructor():
    data = _data()
    armor_grid = _armor_grid(data)
    rounded_grid = np.round(armor_grid.cells, _decimal_places(data))
    expected_grid = np.array(data["armor_grid_cell_values"]["initial"])
    condition = (rounded_grid == expected_grid).all()
    assert condition, "Initial armor grid does not equal expected one."

   
def test_damage_armor_grid():
    data = _data()
    armor_grid = _armor_grid(data)
    shot = _big_energy_shot(data)
    ship = _dominator(data)
    shot.distribute(ship, _hit_probability)
    decimal_places = _decimal_places(data)
    expected_armor_grids = data["armor_grid_cell_values"]["after_shots"]
   
    for i, expected_armor_grid in enumerate(expected_armor_grids):
        damage_distribution = (shot._expected_armor_damage
                               * armor_grid.damage_factors(shot._strength))
        for j, damage in enumerate(damage_distribution):
            shot.damage_armor_grid(armor_grid, damage, j)
        ship.armor_grid.cells = np.maximum(0, armor_grid.cells)
        assert (np.round(armor_grid.cells, decimal_places)
                == np.array(expected_armor_grid)).all(), (
                "Armor grid after shot", i, "does not equal expected one.")
       

def _simulate_hit(shot: object, ship: object, index: int):
    pooled_armor = max(ship.armor_grid._minimum_armor,
                       ship.armor_grid._pool(index))
    damage_factor = max(combat_entities.ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                        1 / (1 + pooled_armor / shot._strength))
    damage = shot._armor_damage * damage_factor
    shot.damage_armor_grid(ship.armor_grid, damage, index)
    hull_damage = np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
    ship.hull = max(0, ship.hull + hull_damage)
    ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)


def test_calculation_vs_simulation():
    #setup
    data = _data()
    shot = _big_energy_shot(data)
    calculated_ship = _dominator(data)
    simulated_ship = _dominator(data)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 1_000

    #calculate
    calculated_firings = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_firings += 1

    #simulate
    simulated_firings = 0
    simulated_hull_variance = 0
    for trial in range(trials):
        firing = 0
        while simulated_ship.hull > 0:
            index = np.random.randint(0, len(simulated_ship.armor_grid.bounds))
            _simulate_hit(shot, simulated_ship, index)
            if firing == calculated_firings:
                simulated_hull_variance += simulated_ship.hull ** 2
            firing += 1
        simulated_ship.armor_grid = _armor_grid(data)
        simulated_ship.hull = 14_000#hardcoded Dominator value
        simulated_firings += firing

    print()
    print("trials:", trials)
    print()
    print("Number of firings to destroy ship")
    print("Calculated:", calculated_firings)
    print("Average Simulated:", round(simulated_firings / trials))
    print()
    print("Standard deviation of simulated hull from zero upon firing "
          "calculated to destroy ship:",
          round(np.sqrt(simulated_hull_variance / trials)))


def test_hit_sequence():
    data = {
        "charge up" : 0,
        "charge down" : 0.1,
        "burst size" : 1,
        "burst delay" : 0,
        "ammo" : 10,
        "ammo regen" : 1,
        "reload size" : 0,
        "speed" : 500
    }

    weapon = combat_entities.Weapon(data, None, None)

    distance = 1000
    weapon.hit_sequence = weapon._hit_sequence(distance)
    print()
    print("Expected hit sequence")
    print(weapon.hit_sequence)
   
[close]
Expected hit sequence
[5, 5, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
« Last Edit: December 13, 2022, 03:17:54 PM by Liral »
Logged

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #263 on: December 13, 2022, 10:29:18 PM »

Code is looking beautiful! But I am getting a different result from the original code using the same values that I think you had.


> hits(chargeup=0,chargedown=0.1,burstsize=1,burstdelay=0,ammo=10,ammoregen=1,reloadsize=0,traveltime=2,mode=GUN)
  [1]  0  0 10  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
 [35]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
 [69]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[103]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[137]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[171]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[205]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[239]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[273]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[307]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[341]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[375]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[409]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[443]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1
[477]  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1  1


I should think this is the correct result because
- if shot speed is 500 and distance is 1000, then it should take 2 seconds for the first shot to arrive
- if burst size is 1, and chargeup is 0, then refire delay is determined by chargedown up to the ammo limit which is 0.1 in this case, so it should fire 10 shots during the first second
- after that it should take 1 second to regenerate 1 ammo, with no clip size so all reloaded ammo is available immediately, so it should fire 1 shot / sec.


For further testing here is a more challenging problem
- a beam with a chargeup over 1 second, travel time 1 second, chargedown of 1 second, burst size of 1 second, stores 7 ammo, after that regenerates 1 ammo / 7 sec in clips of 3


> hits(chargeup=1,chargedown=1,burstsize=1,burstdelay=0,ammo=7,ammoregen=1/7,reloadsize=3,traveltime=1,mode=BEAM)
  [1]  3.85 10.00  2.85  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.96  8.94  6.71  0.69  8.30
 [18]  7.55  0.60  7.55  8.30  0.69  6.71  8.94  0.96  5.80  9.45  1.41  4.84  9.81  2.04  0.00  0.00  0.00
 [35]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80
 [52]  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81
 [69]  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85
 [86] 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[103]  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00
[120]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05
[137]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84
[154]  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00
[171]  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[188]  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00
[205]  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00
[222]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41
[239]  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85
[256]  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[273]  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00
[290]  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00
[307]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45
[324]  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04
[341]  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[358]  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[375]  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00
[392]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80
[409]  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81
[426]  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85
[443] 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00
[460]  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05  0.00  0.00  0.00  0.00
[477]  0.00  0.00  0.00  0.00  0.00  0.00  0.00  2.85 10.00  3.85  2.04  9.81  4.84  1.41  9.45  5.80  0.05
[494]  0.00  0.00  0.00  0.00  0.00  0.00  0.00


Let's manually check the first numbers
1. takes 1 second for beam to arrive - this is incorrect in my code, I notice that the beam is missing a check for travel time so travel time is only applied for guns. The fix is quite simple, add a "+ traveltime" to the time coordinate. However, I'll present the original output here.
2. During the first second, ticks 10 times during chargeup with quadratically increasing intensity, so the intensity adjusted ticks during the first second should be sum{i from 1 to 10} (i/10)^2, so 3.85
3. Then fires 1 second at full intensity, so 10 ticks.
4. Then from the next tick starts charging down, so sum{i from 9 to 0}(i/10)^2, so 2.85 ticks.
5. Then the next chargeup is delayed by 1 tick, so we have sum{i from 9 to 0}(i/10)^2 = 2.85 ticks during the next second, then the beam bursts for 10 ticks, then charges down with the 1 extra tick so 3.85 ticks during the chargedown.

I am not actually sure that this is how it works. The quadratic intensity during chargeup and chargedown was empirically determined by Vanshilar from combat data, not something we know the exact math of from the code, although the difference is only 1 tick. But it could be that chargedown should also be 3.85 ticks (first tick is at full intensity) or that chargeup should also be 2.85 ticks (first tick is at 0 intensity). The way it is now is an average of the two possibilities.

If anybody knows, let us know the exact math.
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #264 on: December 14, 2022, 06:20:01 AM »

combat_entities.py
Code
import numpy as np
import copy
import decimal
from decimal import Decimal
"""
Damage computing module.

methods:
- hit_probability
- main

classes:
- ArmorGrid
- Ship
- Shot
- Weapon
"""
decimal.getcontext().prec = 6


class ArmorGrid:
    """
    A Starsector ship armor grid.
   
    - Represents a horizontal frontal row of armor on a ship.
    - Includes 2 rows of vertical padding above; 2 below; 2 columns of
      left horizontal padding; 2 right.
   
    constants:
    _MINIMUM_ARMOR_FACTOR - multiply by armor rating to determine
                            minimum effective value of pooled armor for
                            damage calculations
    _MINIMUM_DAMAGE_FACTOR - least factor whereby this ArmorGrid can
                             cause incoming damage to be multiplied
    ARMOR_RATING_PER_CELL_FACTOR - multiply by armor rating to determine
                                   initial value of each cell of an
                                   ArmorGrid
    WEIGHTS - multiply by damage to the central cell to determine damage
              to surrounding ones
             
    variables:
    _minimum_armor - minimum effective value of armor for damage
                     calculations
    bounds - right bound of each cell in the middle row, except the two
             padding cells on both sides
    cells - 2d array of armor grid cell values
   
    methods:
    - _pooled_values
    - damage_factors
    """
    _MINIMUM_ARMOR_FACTOR = 0.05
    _MINIMUM_DAMAGE_FACTOR = 0.15
    ARMOR_RATING_PER_CELL_FACTOR = 1 / 15
    POOLING_WEIGHTS = np.array([[0.0, 0.5, 0.5, 0.5, 0.0],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.5, 1.0, 1.0, 1.0, 0.5],
                                [0.0, 0.5, 0.5, 0.5, 0.0]])
    DAMAGE_DISTRIBUTION = ARMOR_RATING_PER_CELL_FACTOR * POOLING_WEIGHTS
   
    def __init__(self, armor_rating: float, cell_size: float, width: int):
        """
        armor_rating - armor rating of the ship to which this ArmorGrid
                       belongs
        cell_size - size, in pixels, of each armor cell, which is square
        width - width of the armor grid in cells
        """
        self._minimum_armor = ArmorGrid._MINIMUM_ARMOR_FACTOR * armor_rating
        armor_per_cell = armor_rating * ArmorGrid.ARMOR_RATING_PER_CELL_FACTOR
        self.cells = np.full((5,width+4), armor_per_cell)
        self.bounds = np.arange(0, width) * cell_size

    def _pool(self, index):
        """
        Return the armor value pooled around a cell at this index.

        The cell must be after the second and before the second to last
        one of the middle row.
        """
        return np.sum(ArmorGrid.POOLING_WEIGHTS * self.cells[0:5,index:index+5])
       
    def _pooled_values(self) -> object:
        """
        Return the armor value pooled around each cell of the middle
        row, from third through third-last.
        """
        return np.maximum(self._minimum_armor,
                          np.array([self._pool(i) for i, _ in
                                    enumerate(self.cells[2,2:-2])]))
                 
    def damage_factors(self, hit_strength: float) -> object:
        """
        Return the factor whereby to multiply the damage of each
        non-padding cell of the grid.
        """
        return np.maximum(ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                          1 / (1 + self._pooled_values() / hit_strength))
       
       
class Ship:
    """
    A Starsector ship.
   
    methods:
    - will_overload
    - overloaded
    - shield_up
   
    variables:
    weapons - container of the weapons of the ship, with structure to be
              determined
    armor_grid - ArmorGrid of the ship
    hull - amount of hitpoints the ship has
    flux_capacity - how much flux would overload the ship
    flux_dissipation - how much flux the ship can expel every second when
                       not actively venting
    hard_flux - hard flux amount
    soft_flux - soft flux amount
    """
    def __init__(self,
            weapons: object,
            armor_grid: object,
            hull: float,
            flux_capacity: float,
            flux_dissipation: float):
        """
        weapons - container of the weapons of the ship, with structure
                  to be determined
        armor_grid - ArmorGrid of the ship
        hull - amount of hitpoints the ship has
        flux_capacity - amount of flux to overload the ship
        flux_dissipation - how much flux the ship can expel every second
                           without actively venting
        """
        self.weapons = weapons
        self.armor_grid = armor_grid
        self.hull = hull
        self.flux_capacity = flux_capacity
        self.flux_dissipation = flux_dissipation
        self.hard_flux, self.soft_flux = 0, 0
   
    @property
    def will_overload(self):
        """
        Return whether the ship will now overload.
       
        A ship will overload if soft or hard flux exceeds
        the flux capacity of the ship.
        """
        return (self.hard_flux > self.flux_capacity
                or self.soft_flux > self.flux_capacity)
   
    @property
    def overloaded(self):
        """
        Return whether the ship is in the overloaded state.
       
        Ignores the duration of overloaded.
        -TODO: Implement overload duration
        """
        return self.will_overload
       
    @property
    def shield_up(self):
        """
        Return whether the shield is up or down.
       
        Presumes the shield to be up if the ship is not overloaded,
        ignoring smart tricks the AI can play to take kinetic damage
        on its armor and save its shield for incoming high explosive
        damage.
        """
        return not self.overloaded


class Shot:
    """
    A shot fired at a row of armor cells protected by a shield.

    Calculates the expectation value of the damage of a shot with a
    spread to a ship with a shield, armor grid, and random positional
    deviation.
   
    variables:
    damage - amount listed under damage in weapon_data.csv
    shield_damage - damage amount to be inflictedon on a shield
    armor_damage - damage amount to be inflicted on armor
    strength - strength against armor for armor damage calculation
    flux_hard - whether the flux damage against shields is hard or not
    probabilities - chance to hit each cell of an armor grid at which
                    this Shot is targeted after being instantiated
   
    methods:
    - distribute
    - _damage_distribution
    - damage_armor_grid
    """
    DAMAGE_TYPE_DAMAGE_FACTORS = {
        "KINETIC" : {
            "shield" : 2.0,
            "armor" : 0.5,
        },
        "HIGH_EXPLOSIVE" : {
            "shield" : 0.5,
            "armor" : 2.0
        },
        "FRAGMENTATION" : {
            "shield" : 0.25,
            "armor" : 0.25
        },
        "ENERGY" : {
            "shield" : 1.0,
            "armor" : 1.0
        }
    }
   
    def __init__(
            self,
            damage: float,
            damage_type: str,
            beam: bool,
            flux_hard: bool):
        """
        damage - amount listed under damage in weapon_data.csv
        damage_type - string listed under damage type in weapon_data.csv
        beam - whether the weapon is a beam or not
        flux_hard - whether flux damage to shields is hard or not
        """
        self._damage = damage
        self._armor_damage_factor = Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["armor"]
        self._shield_damage = damage * Shot.DAMAGE_TYPE_DAMAGE_FACTORS[
            damage_type]["shield"]
        self._armor_damage = damage * self._armor_damage_factor
        self._strength = self._armor_damage * (0.5 if beam else 1)
        self._flux_hard = flux_hard
        self._probabilities = None
        self._expected_armor_damage = None

    def distribute(self, ship: object, distribution: object):
        """
        Spread hit probability and, accordingly, expected distribution of
        base armor damage over each armor cell of a ship.
       
        Calculate the probability to hit each armor cell of a row and
        save the probabilities and consequent expected armor damage for later
        calculation.
        """
        self._probabilities = np.vectorize(distribution)(ship.armor_grid.bounds)
        self._expected_armor_damage = self._armor_damage * self._probabilities
       
    def damage_armor_grid(self, armor_grid: object, damage: float, i: int):
        """
        Distribute across this ArmorGrid this amount of damage at
        this index.

        Note: may reduce armor cell values below zero.

        armor_grid - ArmorGrid instance
        """
        armor_grid.cells[0:5,i:i+5] -= damage * ArmorGrid.DAMAGE_DISTRIBUTION

    def damage_ship(self, ship: object):
        """
        Apply the expected values of shield, armor, and hull damage to a
        ship.

        ship - Ship instance
        """
        if False: pass #TODO: implement shield check
        damage_distribution = (
            self._expected_armor_damage
            * ship.armor_grid.damage_factors(self._strength))
        for i, damage in enumerate(damage_distribution):
            self.damage_armor_grid(ship.armor_grid, damage, i)
        hull_damage = (np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
                       / self._armor_damage_factor)
        ship.hull = max(0, ship.hull + hull_damage)
        ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)


class AmmoTracker:
    """
    Holds and regenerates weapon ammunition.
    """
    def __init__(self, weapon: object):
        self.ammo = weapon.ammo
        self.max_ammo = weapon.ammo
        self.ammo_regen = Decimal(weapon.ammo_regen)
        self.reload_size = weapon.reload_size
        self.ammo_regen_time = Decimal(1 / weapon.ammo_regen)
        self.ammo_regenerated = Decimal(0)
        self.ammo_regen_timer = Decimal(0)
        self.regenerating_ammo = False
   
    def should_regenerate_ammo(self, time):
        return time - self.ammo_regen_timer >= self.ammo_regen_time
   
    def regenerate_ammo(self, time):
        amount = int(self.ammo_regen * (time - self.ammo_regen_timer))
        self.ammo_regenerated += amount
        self.ammo_regen_timer += amount / self.ammo_regen
        if self.ammo_regenerated >= self.reload_size:
            self.ammo += self.ammo_regenerated
            self.ammo_regenerated = 0
        if self.ammo >= self.max_ammo:
            self.ammo = self.max_ammo
            self.regenerating_ammo = False
       
                       
class Weapon:
    """
    A weapon for a Starsector ship in simulated battle.
   
    Is carried by a Ship instance, contains a shot with some
    distribution, and fires that shot at a target.

    constants:
    MINIMUM_REFIRE_DELAY - Starsector keeps weapons from firing more
                           than once every 0.05 seconds
    """
    MINIMUM_REFIRE_DELAY = Decimal(0.05)
   
    def __init__(self, data: object, shot: object, distribution: object):
        """
        data - relevant game file information
        shot - projectile, missile, or beam tick of the weapon
        distribution - function returning the probability that
                       of the shot to hit between two bounds
        """
        self.mode = "GUN"
        self.charge_up = Decimal(data["charge up"])
        self.charge_down = Decimal(data["charge down"])
        self.burst_size = int(data["burst size"])
        self.burst_delay = Decimal(data["burst delay"])
        if self.burst_delay > 0 or self.mode == "BEAM":
            self.burst_delay = max(self.burst_delay,
                                   Weapon.MINIMUM_REFIRE_DELAY)
        if self.mode == "GUN":
            self.charge_down = max(self.charge_down,
                                   Weapon.MINIMUM_REFIRE_DELAY)
        self.ammo = int(data["ammo"]) if data["ammo"] else "UNLIMITED"
        self.ammo_regen = (Decimal(data["ammo regen"]) if data["ammo regen"]
                          else Decimal(0))
        self.reload_size = (Decimal(data["reload size"]) if data["reload size"]
                           else Decimal(0))
        self.speed = Decimal(data["speed"])
       
        self.hit_sequence = None
        self.shot = shot
        self.distribution = distribution
       
    def fire(self, ship: object):
        """
        Fire the shot of this weapon at that ship.
       
        ship - a Ship instance
        """
        self.shot.distribute(ship, self.distribution)
        #TODO: implement general hit ship method on Shot
        self.shot.damage_ship(ship)
        return #future code below
        if ship.shield_up:
            pass #TODO: implement shield damage, overloading, etc.
        else: self.shot.damage_armor_grid(ship)

    def _hit_sequence(self, distance):
        """
        Return a hit sequence for a weapon against some target at a
        distance.
           
        times in seconds, self.ammo_regen is in ammo / second
        """
        #this vector will store all the hit time coordinates
        #current time
        #add a very small fraction here to round time correctly
        time = Decimal(0)#0.001
        time_limit = Decimal(100)
        time_interval = Decimal(1)
        time_intervals = int(time_limit / time_interval)
        travel_time = Decimal(distance / self.speed)
        ammo_tracker = AmmoTracker(self)
         
        if self.mode == "GUN":
            times = []
            while time < time_limit:
                time += self.charge_up
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
                if self.burst_delay == 0:
                    for i in range(self.burst_size):
                        if ammo_tracker.ammo == 0: continue
                        times.append(time + travel_time)
                        ammo_tracker.ammo -= 1
                        if not ammo_tracker.regenerating_ammo:
                            ammo_tracker.ammo_regen_timer = time
                            ammo_tracker.regenerating_ammo = True
                else:
                    for i in range(self.burst_size):
                        if ammo_tracker.ammo == 0: continue
                        times.append(time + travel_time)
                        time += self.burst_delay
                        ammo_tracker.ammo -= 1
                        if not ammo_tracker.regenerating_ammo:
                            ammo_tracker.ammo_regen_timer = time
                            ammo_tracker.regenerating_ammo = True
                        if ammo_tracker.should_regenerate_ammo(time):
                            ammo_tracker.regenerate_ammo(time)
                   
                time += self.charge_down
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
            return (
                [len([t for t in times if 0 <= int(t) <= time_interval])]
                + [len([t for t in times if (i - 1) * time_interval < int(t)
                                            <= i * time_interval])
                       for i in range(1, time_intervals)])
           
        if self.mode == "CONTINUOUS_BEAM":
            chargeup_ticks = self.charge_up / beam_tick
            charge_down_ticks = self.charge_down / beam_tick
            burst_ticks = self.burst_size / beam_tick
            intensities = []
            #for i in range(chargeup_ticks):
                #beam intensity scales quadratically while charging up
            while time < time_limit:
                times.append(time + travel_time)
                intensities.append(1)
                time += beam_tick
            return [sum([intensity for i, intensity in enumerate(intensities)
                            if t - 1 < times[i] < t])
                         for t in range(time_intervals)]
       
        if self.mode == "BURST_BEAM":
            charge_up_ticks = self.charge_up // beam_tick
            charge_down_ticks = self.charge_down // beam_tick
            burst_ticks = self.burst_size // beam_tick
            times = []
            intensities = []
            while time < time_limit:
                if ammo_tracker.ammo == 0:
                    time += global_minimum_time
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                    continue
               
                ammo_tracker.ammo -= 1
                for i in range(charge_up_ticks):
                    times.append(time + travel_time)
                    intensities.append((i * beam_tick) ** 2)
                    time += beam_tick
                    if not ammo_tracker.regenerating_ammo:
                        ammo_tracker.ammo_regen_timer = time
                        ammo_tracker.regenerating_ammo = True
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                                       
                for _ in range(burst_ticks):
                    times.append(time)
                    intensities.append(1)
                    time += beam_tick
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                       
                for i in range(charge_down_ticks):
                    times.append(time)
                    intensities.append(
                        ((charge_down_ticks - i) * beam_tick) ** 2)
                    time += beam_tick
                    if ammo_tracker.should_regenerate_ammo(time):
                        ammo_tracker.regenerate_ammo(time)
                                   
                time += self.burst_delay
                if ammo_tracker.should_regenerate_ammo(time):
                    ammo_tracker.regenerate_ammo(time)
               
            return [sum([intensity for i, intensity in enumerate(intensities)
                            if t - 1 < times[i] < t])
                         for t in range(time_intervals)]
[close]
test_combat_entities.py
Code
import combat_entities
import numpy as np
import pytest
import json
import time

       
def _data():
    with open('test_combat_entities_data.json') as f:
        return json.load(f)["Shot.damage_armor_grid"]


def _decimal_places(data):
    return data["test_config"]["decimal_places"]


def _hit_probability(bound: float): return 1 / 12#hardcoded dummy for test


def _armor_grid(data):
    return combat_entities.ArmorGrid(data["armor_grid_spec"]["armor_rating"],
                                     data["armor_grid_spec"]["cell_size"],
                                     data["armor_grid_spec"]["width"])


def _big_energy_shot(data):
    return combat_entities.Shot(data["shot_spec"]["damage"],
                                data["shot_spec"]["damage_type"],
                                data["shot_spec"]["beam"],
                                data["shot_spec"]["flux_hard"])

def _dominator(data):
    spec = (14_000,#hull
            1_000,#flux_capacity
            100)#flux_dissipation)
    return combat_entities.Ship([], _armor_grid(data), *spec)


def test_armor_grid_constructor():
    data = _data()
    armor_grid = _armor_grid(data)
    rounded_grid = np.round(armor_grid.cells, _decimal_places(data))
    expected_grid = np.array(data["armor_grid_cell_values"]["initial"])
    condition = (rounded_grid == expected_grid).all()
    assert condition, "Initial armor grid does not equal expected one."

   
def test_damage_armor_grid():
    data = _data()
    armor_grid = _armor_grid(data)
    shot = _big_energy_shot(data)
    ship = _dominator(data)
    shot.distribute(ship, _hit_probability)
    decimal_places = _decimal_places(data)
    expected_armor_grids = data["armor_grid_cell_values"]["after_shots"]
   
    for i, expected_armor_grid in enumerate(expected_armor_grids):
        damage_distribution = (shot._expected_armor_damage
                               * armor_grid.damage_factors(shot._strength))
        for j, damage in enumerate(damage_distribution):
            shot.damage_armor_grid(armor_grid, damage, j)
        ship.armor_grid.cells = np.maximum(0, armor_grid.cells)
        assert (np.round(armor_grid.cells, decimal_places)
                == np.array(expected_armor_grid)).all(), (
                "Armor grid after shot", i, "does not equal expected one.")
       

def _simulate_hit(shot: object, ship: object, index: int):
    pooled_armor = max(ship.armor_grid._minimum_armor,
                       ship.armor_grid._pool(index))
    damage_factor = max(combat_entities.ArmorGrid._MINIMUM_DAMAGE_FACTOR,
                        1 / (1 + pooled_armor / shot._strength))
    damage = shot._armor_damage * damage_factor
    shot.damage_armor_grid(ship.armor_grid, damage, index)
    hull_damage = np.sum(ship.armor_grid.cells[ship.armor_grid.cells<0])
    ship.hull = max(0, ship.hull + hull_damage)
    ship.armor_grid.cells = np.maximum(0, ship.armor_grid.cells)
   

def test_calculation_vs_simulation():
    #setup
    data = _data()
    shot = _big_energy_shot(data)
    calculated_ship = _dominator(data)
    simulated_ship = _dominator(data)
    shot.distribute(calculated_ship, _hit_probability)
    trials = 1_000

    start = time.perf_counter()
    #calculate
    calculated_firings = 0
    while calculated_ship.hull > 0:
        shot.damage_ship(calculated_ship)
        calculated_firings += 1
    calculation_duration = time.perf_counter() - start
   
    start = time.perf_counter()
    #simulate
    simulated_firings = 0
    simulated_hull_variance = 0
    for trial in range(trials):
        firing = 0
        while simulated_ship.hull > 0:
            index = np.random.randint(0, len(simulated_ship.armor_grid.bounds))
            _simulate_hit(shot, simulated_ship, index)
            if firing == calculated_firings:
                simulated_hull_variance += simulated_ship.hull ** 2
            firing += 1
        simulated_ship.armor_grid = _armor_grid(data)
        simulated_ship.hull = 14_000#hardcoded Dominator value
        simulated_firings += firing
    simulation_duration = time.perf_counter() - start
    speedup = simulation_duration / calculation_duration

    print()
    print()
    print("test_calculation_vs_simulation")
    print("simulation trials:", trials)
    print("number of firings to destroy ship")
    print("calculated:", calculated_firings)
    print("average Simulated:", round(simulated_firings / trials))
    print("standard deviation of simulated hull from zero upon firing "
          "calculated to destroy ship:",
          round(np.sqrt(simulated_hull_variance / trials)))
    print("Calculation duration:", calculation_duration)
    print("Simulation duration:", simulation_duration)
    print("Calculation is:", speedup, "times faster.")


def test_hit_sequence():
    distance = 1000
    gun_data = {
        "charge up" : 0,
        "charge down" : 0.1,
        "burst size" : 1,
        "burst delay" : 0,
        "ammo" : 10,
        "ammo regen" : 1,
        "reload size" : 1,
        "speed" : 500
    }
    beam_data = {
        "charge up" : 1,
        "charge down" : 1,
        "burst size" : 1,
        "burst delay" : 0,
        "ammo" : 7,
        "ammo regen" : 1 / 7,
        "reload size" : 3,
        "speed" : 1000
    }
   
    gun = combat_entities.Weapon(gun_data, None, None)
    beam = combat_entities.Weapon(beam_data, None, None)
    gun.hit_sequence = gun._hit_sequence(distance)
    beam.hit_sequence = beam._hit_sequence(distance)
   
    print()
    print()
    print("test_hit_sequence")
    print("Expected hit sequence")
    print("Gun")
    print(gun.hit_sequence)
    print("Beam")
    print(beam.hit_sequence)
[close]

test_combat_entities.py ..

test_calculation_vs_simulation
simulation trials: 1000
number of firings to destroy ship
calculated: 92
average Simulated: 93
standard deviation of simulated hull from zero upon firing calculated to destroy ship: 187
Calculation duration: 0.03467702100000003
Simulation duration: 5.872141774999999
Calculation is: 169.3381266804895 times faster.
.

test_hit_sequence
Expected hit sequence
Gun
[0, 0, 10, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Beam
[0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
.
test_database.py ('WARNING: mod at', '/Applications/Starsector.app/mods/SS-armaa-2.1.2b', 'not loaded because its.csv or .ship files cause errors.')
('WARNING: mod at', '/Applications/Starsector.app/mods/combat_math', 'not loaded because its.csv or .ship files cause errors.')
('WARNING: mod at', '/Applications/Starsector.app/mods/Gundam_UC', 'not loaded because its.csv or .ship files cause errors.')
('WARNING: mod at', '/Applications/Starsector.app/mods/SCY', 'not loaded because its.csv or .ship files cause errors.')
.

============================== slowest durations ===============================
5.91s call     test_combat_entities.py::test_calculation_vs_simulation
0.44s call     test_database.py::test_loading_database
0.02s call     test_combat_entities.py::test_hit_sequence
Calculating how long one weapon takes to destroy a Dominator armor grid and hull takes under a twentieth of a second, a duration well over 150 times than shorter than that of simulating the same process, combining the two speedups of 50x and 100x that you had hoped for from C++.  Here's a conservative estimate of how long calculating an average 'real' combat would be, followed by the implications for practical use. 

Estimate
The equation for the time T to test n ships of v average variants against each other with w workers and t test duration is:

T = (t / w)(nv)^2

Alternatively, the number of ships testable in time T with w workers and t test duration:

n = sqrt(Tw / t) / v

We need find only t to solve this equation because the other numbers are our choice; however, we can only estimate t.

To conservatively estimate t, I will first round the measure time of ~0.037s to 0.05s.  Calculating the combat of two ships might take twice as long as calculating that of one, so I will double the time to 0.1s, and I figure an average ship has about 5 weapons firing, so I will multiply the duration by 5, to 0.5s.  Finally, to account for shields, I will double the duration to 1s.   

With t conservatively estimated, we can solve the equation for various choices of the other variables.

Ships versus Workers

Minute
t = 1
2 | 1
3 | 2
4 | 4
5 | 8
8 | 16
11 | 32

Hour
15 | 1
21 | 2
30 | 4
42 | 8
60 | 16
85 | 32

Overnight
42 | 1
60 | 2
85 | 4
120 | 8
170 | 16
240 | 32

t = .1

Minute
6 | 1
9 | 2
12 | 4
17 | 8
24 | 16
35 | 32

Hour
47 | 1
67 | 2
95 | 4
134 | 8
190 | 16
268 | 32

Overnight
134 | 1
190 | 2
268 | 4
379 | 8
537 | 16
759 | 32

[close]
« Last Edit: December 14, 2022, 03:13:08 PM by Liral »
Logged

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #265 on: December 14, 2022, 02:19:33 PM »

I appreciate the concern for potatoes, seeing as I don't own computers of any other kind in the near future. Multithreading and multiprocessing sounds very good. I think there's also low hanging mathematical fruit to be looked for. This is turning out great overall.

Here are two low hanging mathematical fruit things:
1. do not calculate armor if armor matrix is at 0, instead have a logical switch to switch pooling off for that middle cell and just use minimum armor, when pooled armor is less than 0.05 of starting armor value since to my knowledge it never regenerates
2. only compute damage up to the middle cell (for a ship with 6 cells, to cell 3, for a ship with 7 cells, to cell 4). We do not have any kind of movement involved and we are dealing in probabilistic damage so damage and armor is always symmetrical, so the rest are irrelevant. You could just use this half of a ship and double the hull damage incoming from the existing half to get the equivalent full ship, but if you feel like the rest should be modeled then mirror the armor state instead of calculating it.

I think this means error correction is also no longer on the menu as a factor of even 10 is not acceptable to correct an error of 1-2% with these processing times.

The big drain is surely armor calculations, so food for thought whether there exists a known linear relationship between the rows. I think it is at least the case that the middle row is the same as the one below and one above, and the edge rows are the same, so you could essentially just have the middle row and the edge row with just appropriate multipliers for pooling? Now including only half of a ship we are down to modeling 1/5th of a ship for ships with an even number of armor cells.

Edit: here's another important one. Consult intrinsic_parity about optimizing testing instead of brute forcing when dealing with large numbers of combinations.

Is there such a thing that expressing it using matrices or tensors so you have products of arrays instead of loops would make it faster for the computer?
« Last Edit: December 14, 2022, 03:14:18 PM by CapnHector »
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

Liral

  • Admiral
  • *****
  • Posts: 718
  • Realistic Combat Mod Author
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #266 on: December 14, 2022, 04:34:30 PM »

I appreciate the concern for potatoes, seeing as I don't own computers of any other kind in the near future. Multithreading and multiprocessing sounds very good. I think there's also low hanging mathematical fruit to be looked for. This is turning out great overall.

Please read my post again to see an update I made to the estimate.

Quote
Here are two low hanging mathematical fruit things:
1. do not calculate armor if armor matrix is at 0, instead have a logical switch to switch pooling off for that middle cell and just use minimum armor, when pooled armor is less than 0.05 of starting armor value since to my knowledge it never regenerates

What do you mean exactly by armor matrix at 0 and by switch pooling off?  Also, every additional Python command in a tight loop adds much more overhead than the equivalent command in the highly-optimized C/C++ of NumPy, so sometimes one brute-force call to that library can beat a smart set of calls to it.

Quote
2. only compute damage up to the middle cell (for a ship with 6 cells, to cell 3, for a ship with 7 cells, to cell 4). We do not have any kind of movement involved and we are dealing in probabilistic damage so damage and armor is always symmetrical, so the rest are irrelevant. You could just use this half of a ship and double the hull damage incoming from the existing half to get the equivalent full ship, but if you feel like the rest should be modeled then mirror the armor state instead of calculating it.

Ah, but that's not true, is it?  Ships have uneven, asymmetrical loadouts, some weapons of which have limited such traverse arcs that they cannot all simultaneously face one direction.  The Wayfarer, for example, can point only 3 of its 5 weapons at one point at once, and even then must turn at a slight angle.  Some other weapons are hardpoint mounted (e.g., the large energy weapon aboard a Sunder or thermal pulse cannons aboard an Onslaught) and therefore depend on the angle of the ship and have very limited (10 degree) traverse arcs.  We must account for these facts somehow.

Quote
I think this means error correction is also no longer on the menu as a factor of even 10 is not acceptable to correct an error of 1-2% with these processing times.

Error correction makes it ten times longer?  :o

Quote
The big drain is surely armor calculations, so food for thought whether there exists a known linear relationship between the rows. I think it is at least the case that the middle row is the same as the one below and one above, and the edge rows are the same, so you could essentially just have the middle row and the edge row with just appropriate multipliers for pooling? Now including only half of a ship we are down to modeling 1/5th of a ship for ships with an even number of armor cells.

I have tried cutting the armor grid down, but multiplying a 5x5 armor grid by 5x5 weights almost always took less time than multiplying a 5x2 armor grid by 5x2 weights.
Code
Code
import numpy as np
import time


def small_pool(small_armor_grid, small_pool_weights):
    return np.sum(small_armor_grid * small_pool_weights)


def pool(armor_grid, pool_weights):
    return np.sum(armor_grid * pool_weights)


def main():
    pool_weights = np.array([[0.0, 0.5, 0.5, 0.5, 0.0],
                             [0.5, 1.0, 1.0, 1.0, 0.5],
                             [0.5, 1.0, 1.0, 1.0, 0.5],
                             [0.5, 1.0, 1.0, 1.0, 0.5],
                             [0.0, 0.5, 0.5, 0.5, 0.0]])
                               
    small_pool_weights = np.array([[1.5, 3.0, 3.0, 3.0, 1.5],
                                  [0.0, 1.0, 1.0, 1.0, 0.0]])
                               
    armor_rating = 10_000
    width = 15
    armor_grid = np.array([[armor_rating / 15 for _ in range(width)] for _ in range(5)])
    small_armor_grid = np.array([[armor_rating / 15 for _ in range(width)] for _ in range(2)])
   
    trials = 1_000_000
   
    #small pool     
    start = time.perf_counter()
    for _ in range(trials): small_pool(small_armor_grid, small_pool_weights)
    small_pool_time = time.perf_counter() - start
   
    #pool
    start = time.perf_counter()
    for _ in range(trials): pool(armor_grid, pool_weights)
    pool_time = time.perf_counter() - start
   
    print("pooled armor")
    print("small pool:", small_pool(small_armor_grid, small_pool_weights))
    print("pool:", pool(armor_grid, pool_weights))
    print("time with", trials, "trials")
    print("small pool:", small_pool_time)
    print("pool:", pool_time)
   
main()
[close]
pooled armor
small pool: 9999.999999999998
pool: 10000.0
time with 1000000 trials
small pool: 4.8033748020000075
pool: 4.64754984700005
Quote
Edit: here's another important one. Consult intrinsic_parity about optimizing testing instead of brute forcing when dealing with large numbers of combinations.

That would help once we'd get to testing more than four loadouts per ship.

Quote
Is there such a thing that expressing it using matrices or tensors so you have products of arrays instead of loops would make it faster for the computer?

Expressing what as matrices or tensors?
« Last Edit: December 14, 2022, 04:40:15 PM by Liral »
Logged

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #267 on: December 14, 2022, 07:36:27 PM »

Well, for the time being all distributions are, in fact, symmetrical, unless we want to start adding asymmetrical distributions. It's possible of course, just doesn't exist now.

That reminds me: hardpoints should be added. I never considered them due to only studying conquest. They should have half spread.

 Even then, the pooling/distribution matrices only have 2 real rows that are formatted like this:
R1
R2
R2
R2
R1

So this must also be the case for the rows of the armor matrix at all times since one thing we are not doing AFAIK is adding more complicated armor shapes. Of course we could, if we wanted to. It would just be a case of figuring out the projection of the armor shape to the circle (or tangent, to be crude) at the range and then using the points corresponding to cell edges with the prob dist function for the hit probabilities for cells, and either a map of how the cells pool armor when the matrix is projected to a linear shape, or a padded matrix of several rows corresponding to the armor shape. Anyway.

Then pooling armor could be done just like: 3x pool cells of R2, 2x pool cells of R1. Distribution should be the exact same as it is now except only 2 rows. And hull damage the same as now except multiplied by 2 for R1 and 1 for R2.

I do not understand how it could be slower to compute a sum of 2 rows over a sum of 5 rows? But I guess I don't know a lot about computers.

By matrices I meant that it is quite easily possible to formulate these in any number of ways using vectors, matrices etc. For example we could compute damage to a cell as the elementwise product of vectors, like I did before (ie damage to 1 cell from 1 shot is shot damage times pooling row/15 dot (elementwise product of (sub-vector of adrs and sub-vector of probabilities))) or as a matrix multiplication operation using diagonal matrices. And I was thinking I could look at tensor products and such. But can switching to such operations make it faster, or will it make it slower?

I mean for a human calculating by hand it would be a terrible choice to define armor damage using matrix multiplication rather than sums, much less something like a Kronecker product but since I really don't know about computers they could have special parts or algorithms for dealing with such things for all I know.

You could try profiling

                  [ a1  a2  a3  a4  a5 ]  [ 1/2 ]
                  [ a6  a7  a8  a9  a10]  [ 1   ]
[ 1/2 1 1 1 1/2 ] [ a11 a12 a13 a14 a15]  [ 1   ] - mean(a1,a5,a21,a25)
                  [ a16 a17 a18 a19 a20]  [ 1   ]
                  [ a21 a22 a23 a24 a25]  [ 1/2 ]


to see if it's faster than the sum operation (note: mean because 4 values multiplied by 1/4, but I assume it is more optimized than 1/4*sum. if not so, replace with 0.25*sum(a1,a5,a21,a25))

If you were to use vector operations then you could loop over cells only once for dealing damage (instead of dealing damage to it 3 to 5 times separately) so I wonder if that could speed things up?

To handle the entire calculation using matrix multiplication, which I should imagine would be the most optimized thing due to its importance, and no sub-vectors, which I imagine are costly, define

Say that the ship has n hittable cells ("ship cells"). Then define

P is a diagonal matrix of dimensions n+4 x n+4, where element p[i+2,i+2] = probability to hit ship cell i and all others are 0
ADR a diagonal matrix of dimensions n+4 x n+4, where element adr[i+2,i+2] = armor damage reduction at ship cell i and all others are 0
D is the matrix multiplication product P ADR
w_i is a vector 1 row, n+4 column matrix that has 1 at indices from i-1 to i+1, 1/2 at i-2 and i+2, and 0 otherwise, and these vectors correspond to ship cells i  (or -1 to 0 or n+1 to n+2 for padding) (special case: at the edges only consider indices that are defined for the above definition)
damage_edge_i is a 1 column, n+4  matrix with values 0 1/30 1/30 1/30 0
at indices i-2 to i+2 and 0 otherwise , special case likewise, with i corresponding to ship cell (or -1 to 0 or n+1 to n+2 for padding)

damage_center_i is a 1 column, n+4row matrix with values 1/30 1/15 1/15 1/15 1/30 at indices i-2 to i+2 and 0 otherwise, special case likewise, with i corresponding to ship cell. (or -1 to 0 or n+1 to n+2 for padding)

Then unless I am badly mistaken (I typed this on mobile while walking somewhere, will be away from keyboard for a while, maybe few days except intermittently) we can compute damage for edge cells as, letting d=damage(scalar)

d(w_i D damage_edge_i), referring to matrix multiplication, where i is the ship cell index (or -1 to 0 or n+1 to n+2 for padding)

and for the central cells it is

d(w_i D damage_center_i), i likewise.

While seemingly laborious all the matrices except D are constant and depend only on ship size so only computed once (or even pre-defined).
 So I was thinking even though it is 2 matrices for each cell, maybe it's faster for the computer due to fewer loops? Since using this you would compute the matrix D first, then loop over all armor cells doing above calculation at each cell once to determine incoming damage. By contrast distributing damage from each middle cell separately leads to 1 cell calculating damage 5 times. Additionally you could use the w_i for pooling: pooled armor at central cell i should be w_i A w_i^t - mean(a_(1,i-2),a_(1,i+2),a_(5,i-2),a_(5,i+2)) where A is the ship's armor matrix and ^t is transposition and a_ij is the armor cell at i,j.

Also note that you can again just compute 1 edge row and 1 central row and when you are done, set the other rows to equal them as appropriate, so you really loop over only 2/5th of the armor (1/5th if you do the half ship thing)

This can also, of course, be implemented by referencing - compute adrs at each of the central cells, then loop over the 2/5th of armor and compute damage*(adr_at_i-2*weight _i-2*probability_i-2 + adr_at_i-1*weight _i-1*probability_i-1+...+adr_at_i+2*weight _i+2*probability_i+2). Again loop over 2 rows and compute rest with equals operations.

(Edit: i edited the wi definition to permit pooling).

Edit: 1 more idea. Let armor be a complex number. then you can pool armor with

                  [ a1  a2  a3  a4  a5 ]  [ i/2 ]
                  [ a6  a7  a8  a9  a10]  [ 1   ]
[ 1/2 1 1 1 i/2 ] [ a11 a12 a13 a14 a15]  [ 1   ]
                  [ a16 a17 a18 a19 a20]  [ 1   ]
                  [ a21 a22 a23 a24 a25]  [ i/2 ]


without any need for referencing or manipulating the elements, so long as at the end you count the pooled armor as sum of both the imaginary part and the real part of the sum.

Logic: the first multiplication turns the top edge into a real with half the value and bottom edge into an imaginary number with half the value (while summing the columns, of course). Then the second multiplication turns the left edge and right edges into imaginary numbers with half the value, except the corner cells at the bottom which are turned into real numbers with a negative value of one quarter of the original and subtracted from the armor (while summing the row). The corner cells on the top edge on the other hand are turned into imaginary numbers with a quarter of the original value. Because top and bottom edges are symmetrical, this cancels out the corners exactly while summing the rest with the appropriate weight, so long as at the end you compute pooled armor = Re(sum)+Im(sum).
« Last Edit: December 15, 2022, 04:50:21 AM by CapnHector »
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge

intrinsic_parity

  • Admiral
  • *****
  • Posts: 3071
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #268 on: December 15, 2022, 10:50:31 AM »

We have been working with an armor grid that is essentially just a line of hittable cells with padding, but real ships aren't like that. They have corners and weird geometry which would ruin all this symmetry. We definitely don't want to bake in that really basic armor grid model and preclude actual ship models.

In terms of performance, matrix multiplication is an implicit inner loop over the elements of the matrix, so I don't think you are reducing the theoretical number of calculations by re-formulating in that way, just hiding them in a different operation. However, in python, it could very well be faster to do it that way because the linear algebra library is likely implemented very efficiently in a lower level language, while a high level loop in python is not. It's hard to say what will be best just looking at equations on paper.

There's lots of other fine details to speed in programming. For instance, memory is not free. It takes time to allocate and access memory, and sometimes, longer than to just do some extra calculations. Eliminating or pre-allocating temporary/intermediate variables is often a big performance booster.

Another non-intuitive one is that when dealing with multi-dimensional arrays, iterating over columns vs rows can result in different performance. This is because the data is actually stored in linear memory, so elements adjacent in the array are not necessarily adjacent in memory https://en.wikipedia.org/wiki/Row-_and_column-major_order. It's even more fun when you find out it is language dependent which way the data is stored. I'm pretty sure our arrays are going to be small enough for it to not make a huge difference though.

In my opinion, the best way to optimize code is to first write code that does what you want, then profile it to see what parts/functions/lines etc. are taking the most time, then find ways to improve those things.
Logged

CapnHector

  • Admiral
  • *****
  • Posts: 1056
    • View Profile
Re: Optimizing the Conquest: a Mathematical Model of Space Combat
« Reply #269 on: December 15, 2022, 11:20:31 AM »

Thanks, interesting stuff! This is an area where I have relatively little to contribute unfortunately. But now that we basically have / are very close to having the code in Python, but with limitations in performance (see Liral's post - hence hunting for optimizations), do you have ideas about how to improve the strategy for testing, say, ship variants against each other, or a very large number of weapons versus a set of ships in order to rank them by some attribute?
Logged
5 ships vs 5 Ordos: Executor · Invictus · Paragon · Astral · Legion · Onslaught · Odyssey | Video LibraryHiruma Kai's Challenge
Pages: 1 ... 16 17 [18] 19 20 ... 32